Go 配置热更新实战——不重启服务动态更新配置的完整实现
Go 配置热更新实战——不重启服务动态更新配置的完整实现
适读人群:需要在不停服的情况下修改服务配置的 Go 工程师 | 阅读时长:约15分钟 | 核心价值:从文件监听到配置中心,Go 配置热更新的完整技术方案和安全实践
为什么需要配置热更新
去年我们有一个功能上线,需要在特定时间段对某些用户群体开启限流——双十一期间白名单用户不限流,其他用户限流到 100 QPS。
最初的方案是把这个限流配置写在代码里,发布的时候改。结果双十一当天,产品要临时调整这个阈值(从 100 改成 50),然后又改回来,又改……发布了4次,每次都要走完整的发布流程,整个团队都在等着。
那天之后我就下定决心把限流配置做成热更新的。
配置热更新的几种方案
方案一:文件监听(适合中小规模)
用 fsnotify 监听配置文件变化,文件被修改时重新加载。
package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"sync"
"sync/atomic"
"time"
"github.com/fsnotify/fsnotify"
)
// Config 应用配置
type Config struct {
RateLimitQPS int `json:"rate_limit_qps"`
RateLimitWhitelist []string `json:"rate_limit_whitelist"`
FeatureFlags map[string]bool `json:"feature_flags"`
LogLevel string `json:"log_level"`
}
// HotReloadConfig 支持热更新的配置管理器
type HotReloadConfig struct {
filePath string
current atomic.Pointer[Config] // 原子指针,读操作无锁
mu sync.Mutex // 只在写时加锁
watchers []func(old, new *Config) // 配置变更回调
logger *slog.Logger
}
// NewHotReloadConfig 创建热更新配置管理器
func NewHotReloadConfig(filePath string, logger *slog.Logger) (*HotReloadConfig, error) {
cfg := &HotReloadConfig{
filePath: filePath,
logger: logger,
}
// 首次加载
if err := cfg.reload(); err != nil {
return nil, fmt.Errorf("initial config load: %w", err)
}
// 启动文件监听
if err := cfg.startWatcher(); err != nil {
return nil, fmt.Errorf("start watcher: %w", err)
}
return cfg, nil
}
// Get 获取当前配置(无锁读取,性能好)
func (c *HotReloadConfig) Get() *Config {
return c.current.Load()
}
// OnChange 注册配置变更回调
func (c *HotReloadConfig) OnChange(handler func(old, new *Config)) {
c.mu.Lock()
defer c.mu.Unlock()
c.watchers = append(c.watchers, handler)
}
func (c *HotReloadConfig) reload() error {
data, err := os.ReadFile(c.filePath)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
var newConfig Config
if err := json.Unmarshal(data, &newConfig); err != nil {
return fmt.Errorf("parse config: %w", err)
}
// 校验配置合法性(防止错误配置导致服务异常)
if err := validateConfig(&newConfig); err != nil {
return fmt.Errorf("config validation: %w", err)
}
old := c.current.Load()
c.current.Store(&newConfig)
// 通知所有 watcher
c.mu.Lock()
watchers := make([]func(*Config, *Config), len(c.watchers))
copy(watchers, c.watchers)
c.mu.Unlock()
for _, handler := range watchers {
handler(old, &newConfig)
}
return nil
}
func (c *HotReloadConfig) startWatcher() error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
if err := watcher.Add(c.filePath); err != nil {
return err
}
go func() {
defer watcher.Close()
// 防抖:短时间内多次写入文件只触发一次重载
var debounceTimer *time.Timer
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
// 防抖:延迟 200ms,避免文件写入一半时就读取
if debounceTimer != nil {
debounceTimer.Stop()
}
debounceTimer = time.AfterFunc(200*time.Millisecond, func() {
if err := c.reload(); err != nil {
c.logger.Error("config reload failed", "error", err)
// 重要:加载失败时不更新,继续用旧配置
} else {
c.logger.Info("config reloaded", "file", c.filePath)
}
})
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
c.logger.Error("watcher error", "error", err)
}
}
}()
return nil
}
func validateConfig(cfg *Config) error {
if cfg.RateLimitQPS < 0 {
return fmt.Errorf("rate_limit_qps must be >= 0, got %d", cfg.RateLimitQPS)
}
if cfg.RateLimitQPS > 100000 {
return fmt.Errorf("rate_limit_qps too high: %d (max 100000)", cfg.RateLimitQPS)
}
return nil
}使用热更新配置
package main
import (
"log/slog"
"net/http"
"os"
"github.com/myapp/config"
"github.com/myapp/ratelimit"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// 初始化热更新配置
cfg, err := config.NewHotReloadConfig("/etc/myapp/config.json", logger)
if err != nil {
logger.Error("failed to load config", "error", err)
os.Exit(1)
}
// 初始化限流器
limiter := ratelimit.New(cfg.Get().RateLimitQPS)
// 注册配置变更回调:配置更新时动态调整限流器
cfg.OnChange(func(old, new *config.Config) {
if old == nil || old.RateLimitQPS != new.RateLimitQPS {
logger.Info("updating rate limit",
slog.Int("old_qps", func() int {
if old != nil {
return old.RateLimitQPS
}
return 0
}()),
slog.Int("new_qps", new.RateLimitQPS),
)
limiter.SetRate(new.RateLimitQPS)
}
})
// HTTP handler 里直接用 cfg.Get(),每次获取最新配置
http.HandleFunc("/api/query", func(w http.ResponseWriter, r *http.Request) {
current := cfg.Get()
// 检查白名单
userID := r.Header.Get("X-User-ID")
isWhitelisted := contains(current.RateLimitWhitelist, userID)
if !isWhitelisted && !limiter.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
// 检查功能开关
if !current.FeatureFlags["new_feature_enabled"] {
http.Error(w, "feature not available", http.StatusNotFound)
return
}
// ... 正常处理
})
http.ListenAndServe(":8080", nil)
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}踩坑实录
坑一:文件写入过程中读到了损坏的配置
现象: 某次更新配置文件时,服务日志里出现了 JSON parse error,然后用旧配置继续运行——但这次比较"幸运",生产上之前有一次直接崩了(panic: nil pointer dereference)。
原因: 文件还没写完(vi 保存文件时是先写临时文件再重命名),fsnotify 就触发了 WRITE 事件,这时候读到的是不完整的 JSON。
解法: 两层防御:
- 防抖延迟(已在上面代码里实现)
- 严格的配置校验,parse 失败时不更新,继续用旧配置
坑二:热更新回调里修改了共享状态,没有加锁
现象: 有时候配置更新后,限流阈值没有立即生效,还是用着旧的值。排查发现是 race condition。
原因: 多个 goroutine 同时读写 limiter 的内部状态,没有做并发保护。
解法: 限流器内部用原子操作或 sync.Mutex 保护状态:
type RateLimiter struct {
rate atomic.Int64 // 用原子操作存储 QPS,无锁读取
// ...
}
func (l *RateLimiter) SetRate(qps int) {
l.rate.Store(int64(qps))
// 重建令牌桶(需要加锁)
}坑三:配置格式升级时的兼容性
现象: 在做配置格式升级时,新老版本同时在运行(滚动更新),新格式的配置被老版本读到,解析失败,老版本服务开始用默认配置(空配置)跑——限流全关了。
原因: 配置文件格式升级和服务升级不是原子的,存在窗口期。
解法: 配置格式升级必须向后兼容,所有新字段都要有默认值,老版本在读到新配置时应该忽略未知字段(JSON 的 UnmarshalJSON 默认就是忽略未知字段,所以这点 Go 天然有保障)。此外,配置变更要走测试环境→灰度→全量的发布流程,不能直接改生产配置文件。
方案二:配置中心(Etcd/Consul/Apollo)
对于多实例部署,文件监听有一个问题:你需要在每台机器上都更新文件。这时候应该用配置中心。
基本思路是一样的,只是数据来源从文件变成了 Etcd/Consul 的 Watch API。以 Etcd 为例:
// 监听 Etcd 中的配置 key 变化
func (c *EtcdConfig) startWatch(ctx context.Context) {
watchCh := c.etcdClient.Watch(ctx, "/config/myapp")
go func() {
for resp := range watchCh {
for _, event := range resp.Events {
if event.Type == clientv3.EventTypePut {
// 解析新配置
var newConfig Config
if err := json.Unmarshal(event.Kv.Value, &newConfig); err != nil {
c.logger.Error("parse etcd config failed", "error", err)
continue
}
c.updateConfig(&newConfig)
}
}
}
}()
}Go vs Java:配置热更新的差别
Spring Cloud Config + @RefreshScope 是 Java 生态的主流方案,配合 Spring Bus 可以做到"发送一个消息,所有实例同时刷新配置",挺方便的。
Go 没有 @RefreshScope 这种注解魔法,所以你要显式地处理"哪些组件需要在配置变更时更新"。这需要你认真思考应用里有哪些状态依赖配置,然后为每一个注册回调。
麻烦,但清晰。
