采样器与高性能写入路径

日志系统最怕的不是慢,而是被高频日志拖垮。zap 用两个组件解决这个问题:sampler 控制数量,bufferedWriteSyncer 控制写入频率。这一篇是系列的收尾,也是性能路径的总结。

sampler:计数采样

gozapcore/sampler.go(简化)
type sampler struct {
	core            Core
	// 每个级别的计数数组(固定大小,避免哈希)
	counts    [maxInteger]uint64
	ticks     uint64
	tick      uint64
	first     uint64
	rand      *rand.Rand
	// ...
}

采样算法要点:

  1. 按消息计数:每条日志按「级别 + 消息内容」哈希到固定槽位
  2. 时间窗口:每个窗口(默认 1 秒)内同一消息最多输出 N 条(默认 100)
  3. 突发放行:窗口内前 N 条全部放行,之后概率放行少量样本,并输出 Dropped 计数
text采样后的输出
{"level":"warn","msg":"slow query","count":100}
{"level":"warn","msg":"slow query dropped","count":3420}

Dropped 条目让「被丢弃的日志」也可被查询到——这是采样设计里最容易忽略、却最见功底的一点。

⚠️ 注意: 采样是有损的。审计类日志、计费日志永远不该走采样路径,应单独使用不采样的 Core。

高频路径的三级缓存

zap 对「高频写」的优化是分层级的:

层级 机制 解决的问题
编码层 字节级追加 + 类型分派 CPU 开销
写入层 bufferedWriteSyncer 系统调用次数
同步层 Sync() 合并 刷盘频率

bufferedWriteSyncer:攒一批再写

gozapcore/write_syncer.go(简化)
type bufferedWriteSyncer struct {
	ws             WriteSyncer
	size           int
	clock          Clock
	mutex          sync.Mutex
	stopc          chan struct{}
	// ...
}

核心机制:

  • 日志先写入内存缓冲(默认 256KB)
  • 缓冲满或到达刷新间隔(默认 5 秒)时,一次性 Write 到底层
  • 通过 channel 通知后台刷新协程,避免主路径阻塞

代价是延迟:日志可能最多滞后一个刷新周期才落盘。崩溃时未刷新的日志会丢失——这也是为什么 zap 提供 Sync() 让应用在退出前主动刷盘。

完整写入路径

Logger.Info(...)
  → core.Check()      // 级别过滤 + 采样,快速失败
  → core.Write()      // encoder 编码到 buffer
  → WriteSyncer.Write // buffered 合并写入
  → (后台协程) io.Write + Sync

整条路径上没有锁竞争(采样器除外)、没有反射、没有中间对象——这就是 zap 吞吐量高的全部秘密。

小结

回到系列第一篇的问题:zap 凭什么高性能又可扩展?现在答案完整了:

  1. 架构:双 API + 三层解耦,扩展靠组合
  2. 编码:强类型 Field + 字节级编码,零反射
  3. 写入:缓冲合并 + 后台刷新,减少系统调用
  4. 控制:原子级别 + 采样器,量价可控

如果你在设计自己的日志内核,这套组合拳是经过生产验证的范本。