Go 文件变化轮询入门:不用依赖也能做简单热加载

用配置文件热加载示例讲 Go 如何通过定时轮询文件修改时间检测变化,以及它和 fsnotify 的取舍。

监听文件变化常见做法是使用 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 取消、半写文件、加载失败保留旧配置、轮询间隔和测试稳定性。简单方案只要边界清楚,也能在小服务里工作得很好。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页