监听文件变化常见做法是使用 fsnotify 这类库。但有些小工具不想引入依赖,只需要每隔几秒检查配置文件是否变了。这时轮询修改时间是一个简单可控的方案。它不够实时,但容易理解,也适合入门。
本文用配置热加载示例讲如何轮询文件变化,以及它的边界。
读取修改时间
func modTime(path string) (time.Time, error) {
info, err := os.Stat(path)
if err != nil {
return time.Time{}, err
}
return info.ModTime(), nil
}
轮询:
func WatchFile(ctx context.Context, path string, interval time.Duration, onChange func() error) error {
last, err := modTime(path)
if err != nil {
return err
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
now, err := modTime(path)
if err != nil {
return err
}
if now.After(last) {
last = now
if err := onChange(); err != nil {
log.Printf("reload failed: %v", err)
}
}
}
}
}
onChange 失败时只记录日志,不更新运行状态。真正替换配置前应该先完整解析和校验。
防止半写文件
如果编辑器或脚本正在写文件,轮询可能读到半截内容。应用侧要能接受 reload 失败并继续使用旧配置。发布配置的脚本也最好采用“写临时文件,再原子 rename”的方式:
cp config.json config.json.tmp
mv config.json.tmp config.json
同一文件系统内 rename 通常是原子的。这样 watcher 要么看到旧文件,要么看到新文件,看到半写文件的概率更低。
配置加载函数
func ReloadConfig(path string, holder *ConfigHolder) error {
cfg, err := LoadConfig(path)
if err != nil {
return err
}
holder.Store(cfg)
return nil
}
不要在 WatchFile 里写具体业务逻辑。Watcher 只负责发现变化,reload 函数负责加载、校验和发布。职责分开,测试更容易写。
轮询间隔怎么选
间隔太短会增加 stat 调用,太长则不够及时。配置热加载通常 1 到 5 秒已经够用。不要为了“实时”把轮询设成 10ms。真正需要实时和大量文件监听时,应该使用系统事件库。
轮询适合:
- 单个或少量配置文件
- 变化频率低
- 对实时性要求不高
- 希望避免额外依赖
不适合:
- 监听大量目录
- 需要毫秒级响应
- 需要跨平台复杂事件语义
测试 WatchFile
可以用临时文件和较短 interval:
func TestWatchFileDetectsChange(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
os.WriteFile(path, []byte(`{"debug":false}`), 0644)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
changed := make(chan struct{}, 1)
go WatchFile(ctx, path, 10*time.Millisecond, func() error {
changed <- struct{}{}
return nil
})
time.Sleep(20 * time.Millisecond)
os.WriteFile(path, []byte(`{"debug":true}`), 0644)
select {
case <-changed:
case <-time.After(time.Second):
t.Fatal("change not detected")
}
}
时间相关测试要留余量,避免 CI 上偶发失败。更严谨的设计可以把 ticker 抽象出来,但入门示例先保持简单。
删除和重建文件
很多编辑器保存文件时不是原地写入,而是先写临时文件,再重命名覆盖原文件。对轮询程序来说,这可能表现为文件短暂消失、修改时间变化、inode 变化。入门实现不要假设文件永远存在。
func statSignature(path string) (time.Time, int64, bool, error) {
info, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) {
return time.Time{}, 0, false, nil
}
if err != nil {
return time.Time{}, 0, false, err
}
return info.ModTime(), info.Size(), true, nil
}
返回 exists 比直接把不存在当成错误更方便。配置文件被运维替换时,程序可以等下一轮再加载,而不是立刻退出。
比较内容哈希
只看修改时间和大小通常够用,但不是绝对可靠。某些文件系统时间精度较低,或者部署脚本保留了时间戳。对配置较小的场景,可以计算内容哈希,确认文件真的变了。
func fileHash(path string) ([32]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
return [32]byte{}, err
}
return sha256.Sum256(b), nil
}
哈希方式不适合巨大文件轮询,因为每次都要读完整内容。它适合几 KB 到几百 KB 的配置、规则、模板。选择方法时要看文件大小和轮询频率,不要为了“严谨”让程序每秒读取几百 MB。
记录 reload 成功版本
文件变化不等于配置可用。reload 可能因为 JSON 语法错误、字段缺失、端口冲突而失败。一个稳妥策略是:新配置解析成功后再替换旧配置,失败时继续使用旧版本。
func loadConfig(path string) (Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
var cfg Config
if err := json.Unmarshal(b, &cfg); err != nil {
return Config{}, err
}
if cfg.Name == "" {
return Config{}, errors.New("name is required")
}
return cfg, nil
}
业务代码可以把当前配置放在 atomic.Value 里,reload 成功后整体替换。读取方每次拿到的都是完整配置,不会看到一半旧值一半新值。
var current atomic.Value // stores Config
func updateConfig(cfg Config) {
current.Store(cfg)
}
func getConfig() Config {
return current.Load().(Config)
}
这种方式比到处加锁简单,但要保证存进去的是不可变结构。不要把 map 暴露给调用方随意修改,必要时复制一份。
小结
文件变化轮询是简单热加载方案:定期 os.Stat,比较修改时间,变化后加载新配置。它适合少量低频文件,不适合大量文件和强实时需求。
实现时要处理 context 取消、半写文件、加载失败保留旧配置、轮询间隔和测试稳定性。简单方案只要边界清楚,也能在小服务里工作得很好。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。