配置热重载听起来很诱人:不用重启服务,改配置就生效。但不是所有配置都适合热重载。日志级别、开关、限流阈值通常可以;数据库连接地址、监听端口、加密密钥就要谨慎。入门阶段最重要的是先划清边界。
本文用一个 JSON 配置文件演示如何安全地加载、校验并用 atomic.Value 发布配置,同时说明哪些配置不该随便热重载。
定义运行时配置
type RuntimeConfig struct {
LogLevel string `json:"log_level"`
FeatureSearch bool `json:"feature_search"`
RateLimit int `json:"rate_limit"`
CacheTTL time.Duration `json:"-"`
CacheTTLText string `json:"cache_ttl"`
}
JSON 不能直接解 time.Duration 的 5s 字符串,可以加载后转换:
func (c *RuntimeConfig) Normalize() error {
d, err := time.ParseDuration(c.CacheTTLText)
if err != nil {
return fmt.Errorf("parse cache_ttl: %w", err)
}
c.CacheTTL = d
return nil
}
校验:
func (c RuntimeConfig) Validate() error {
if c.RateLimit <= 0 || c.RateLimit > 10000 {
return errors.New("rate_limit out of range")
}
if c.CacheTTL <= 0 {
return errors.New("cache_ttl must be positive")
}
return nil
}
热重载必须先校验。错误配置不能覆盖正在工作的旧配置。
加载配置文件
func LoadRuntimeConfig(path string) (RuntimeConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return RuntimeConfig{}, err
}
var cfg RuntimeConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return RuntimeConfig{}, err
}
if err := cfg.Normalize(); err != nil {
return RuntimeConfig{}, err
}
if err := cfg.Validate(); err != nil {
return RuntimeConfig{}, err
}
return cfg, nil
}
这里保持顺序:读取、解析、规范化、校验。任何一步失败,都返回错误。
用 atomic.Value 发布
type ConfigHolder struct {
value atomic.Value // stores RuntimeConfig
}
func NewConfigHolder(cfg RuntimeConfig) *ConfigHolder {
h := &ConfigHolder{}
h.value.Store(cfg)
return h
}
func (h *ConfigHolder) Get() RuntimeConfig {
return h.value.Load().(RuntimeConfig)
}
func (h *ConfigHolder) Store(cfg RuntimeConfig) {
h.value.Store(cfg)
}
atomic.Value 适合读多写少的配置。读请求不需要加锁,更新时一次性替换整个配置值。不要在配置结构里放可变 map 或切片后再到处修改。配置应该尽量是不可变快照。
定时重载
func WatchConfig(ctx context.Context, path string, holder *ConfigHolder, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
defer ticker.Stop()
for {
select {
case <-ticker.C:
cfg, err := LoadRuntimeConfig(path)
if err != nil {
log.Printf("reload config failed: %v", err)
continue
}
holder.Store(cfg)
log.Printf("config reloaded")
case <-ctx.Done():
return
}
}
}()
}
失败时继续使用旧配置。这是热重载的关键:新配置必须先证明自己合法,才能替换旧配置。不要把坏配置加载一半,导致服务进入未知状态。
Handler 中读取
func SearchHandler(holder *ConfigHolder) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg := holder.Get()
if !cfg.FeatureSearch {
http.Error(w, "search disabled", http.StatusServiceUnavailable)
return
}
// 使用 cfg.RateLimit 和 cfg.CacheTTL
}
}
每个请求读取当前快照。不要把配置读出来后长期保存到某个全局变量里,否则热重载不会生效。
哪些不该热重载
不建议随便热重载:
- HTTP 监听端口
- 数据库连接地址
- TLS 证书和私钥,除非有完整轮换设计
- 加密密钥
- 影响数据结构兼容性的配置
这些配置通常涉及资源生命周期。改数据库地址不只是换字符串,还要创建新连接池、健康检查、切换、关闭旧连接。可以做,但不是入门级简单热重载。
适合热重载:
- 日志级别
- 功能开关
- 限流阈值
- 缓存 TTL
- 某些展示文案或策略参数
判断标准:配置变化是否只影响之后的请求,是否不需要复杂资源迁移,错误时能否保留旧值。
记录配置版本
热重载后,日志里最好能看到配置版本或文件修改时间。否则线上排查时,你只知道某个开关变了,却不知道是哪次加载造成的。
可以在配置中加版本字段:
type RuntimeConfig struct {
Version string `json:"version"`
LogLevel string `json:"log_level"`
FeatureSearch bool `json:"feature_search"`
}
重载成功时打印:
log.Printf("config reloaded version=%s", cfg.Version)
如果配置来自文件,也可以记录文件路径和加载时间。不要打印敏感配置值。配置可观测性的目标是知道“哪份配置生效”,不是把所有配置内容写进日志。
测试热重载时,可以先加载一份合法配置,再尝试加载一份非法配置,确认 holder 里的旧配置没有被覆盖。这比只测成功路径更重要。
配置文件更新也可能不是原子写入。如果编辑器或脚本先截断文件再写入,重载任务可能刚好读到半份 JSON。稳妥做法是发布配置时先写临时文件,校验后再 rename 到目标路径。应用侧也要接受偶发读取失败,并继续使用旧配置。
小结
Go 配置热重载可以用“加载新配置、完整校验、atomic 一次性替换”的模式实现。atomic.Value 适合读多写少的运行时配置,失败重载应保留旧配置。
热重载的关键不是技术,而是边界。能重载的是简单运行时策略,不该轻易重载的是资源生命周期和安全边界。先把这些规则写清楚,再实现监听文件或定时加载,系统会稳很多。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。