你的日志可能吃掉了 40% 的一个 CPU 核
一个 10 万 QPS 的服务,每个请求打 5 条日志。实测这 50 万条/秒的日志要花多少 CPU:
erlang
写法 ns/条 10 万 QPS 时 占一个核的
------------------------------------------------------------------
Printf + %v (走 String()) 802.1 0.401 秒/秒 40.1%
Printf + 显式字段 230.9 0.115 秒/秒 11.5%
KV (interface{} 传参) 479.1 0.240 秒/秒 24.0%
Fast (零分配链式) 110.1 0.055 秒/秒 5.5%
最差的写法和最好的写法差了 7.3 倍------从 40% 的一个核,到 5.5%。
单条日志的成本拆解
输出到 /dev/null(排除磁盘影响),只看格式化本身:
scss
写法 ns/op B/op allocs/op
----------------------------------------------------------------------
Printf("order %d user %s amount %d") 430.3 32 3
Printf("%v", order) 走 String() 997.5 168 8
KV(msg, "id", id, "user", user) 819.0 216 10
Fast(msg).Int().Str().Int().Done() 282.2 0 0
成本的来源非常清楚------看 allocs/op 这一列:
① 可变参数要装箱。 Printf(format, args ...any) 里每个参数都要塞进一个 interface{}。int64 和 string 装箱都要堆分配。
② %v 会走反射或 String() 方法。 997 ns、8 次分配------因为 Order.String() 内部又是一次 Sprintf,而且 Items []string 要再格式化一遍。
③ 格式串要在运行时解析。 %d、%s 这些占位符每次都要重新扫一遍。
④ 结果字符串要分配。
而零分配版本做的事情是:往一个复用的 []byte 上 append ,类型在编译期就确定了(.Int() / .Str() 分开),不经过 interface{},不解析格式串。
go
scss
lg.Fast(INFO, "order placed").
Int("id", o.ID).
Str("user", o.User).
Int("amount", o.Amount).
Done()
这就是 zap / zerolog 的 API 为什么长这样。 它看起来啰嗦,但那个啰嗦正是性能的来源------每个字段的类型都是静态已知的。
被过滤掉的 DEBUG 日志,不一定免费
scss
写法 ns/op allocs/op
----------------------------------------------------------------
Printf(DEBUG, "%d %s", id, user) 40.1 2
Printf(DEBUG, "%v", order) 2.1 0
Printf(DEBUG, order.String()) 594.4 9 ← 坑
if Enabled(DEBUG) { ... } 1.9 0
Fast(DEBUG, ...) 链式在 nil 上短路 5.0 0
第三行是真正的陷阱:594 ns,是第四行的 313 倍。
go
c
log.Debug("order " + order.String()) // ❌ String() 一定会执行
log.Debug("order %v", order) // ✅ 只在真要输出时才格式化
函数参数在调用前就求值了。 日志级别的判断发生在函数内部 ------等它判断出「这条不该输出」的时候,order.String() 早就跑完了。
这是个非常隐蔽的坑:你在生产环境把级别设成 INFO,以为所有 DEBUG 日志都是零成本,实际上只要参数里有函数调用或字符串拼接,这部分开销一分不少。
顺带解释第一行和第二行的差异:"%d %s", o.ID, o.User 要把 int64 和 string 各装进一个 interface{}------2 次堆分配 ;而 "%v", o 里 o 本身就是指针,装箱时指针直接当数据存,不需要分配。
所以「被过滤的日志」的成本,取决于你传了什么参数。
写到哪里,差 7 倍
javascript
输出目标 ns/op
-----------------------------------------
/dev/null 268.9
直接写文件 (每条一次 write) 525.1
64KB 缓冲写 72.7 ← 最快
带缓冲写文件,比写 /dev/null 还快 3.7 倍。
因为 /dev/null 虽然不落盘,但每条日志依然是一次 write 系统调用。而 64KB 缓冲把几百条日志攒成一次系统调用------系统调用的固定开销被摊薄了几百倍。
这和之前那篇讲系统调用的文章是同一个结论:打扰内核的次数,比传输的数据量重要得多。
代价是崩溃时会丢失缓冲区里的日志。所以:
- ERROR 级别立即 flush(它们最可能和崩溃相关)
- INFO/DEBUG 走缓冲 + 定时 flush(比如每 100 ms)
- 关键路径用异步 writer,让业务线程只管往 channel 里塞
实践清单
1. 用结构化日志库的强类型 API。 zap 的 logger.Info("msg", zap.Int64("id", id))、zerolog 的 log.Info().Int64("id", id).Msg("msg")。别用 Sugar() / Printf 风格------那正是 interface{} 装箱的来源。
2. DEBUG 日志的参数里不要有计算。 要么用延迟求值(传闭包),要么显式判断:
go
c
if log.Enabled(DEBUG) {
log.Debug("state: " + expensiveDump(x))
}
3. 输出一定要带缓冲。 7 倍,而且几乎没有代价。
4. 高频日志要采样。 一个每秒打 10 万条的 WARN,前 100 条和后 99900 条提供的信息量完全一样。zap 自带 Sampler。
5. 别在日志里 %v 一个大对象。 997 ns 一条,而且输出的东西通常没人看。要打就打关键字段。
6. 定期检查日志量本身。 日志的下游成本(采集 agent 的 CPU、网络带宽、ES 的存储和索引)往往是写日志本身的几倍到几十倍。
最后
日志是唯一一种在系统正常时持续消耗资源、只在出事时才产生价值的东西。
正因如此它很容易被忽视------压测时大家关掉日志跑出漂亮数字,上线后日志全开,然后困惑为什么性能对不上。
而这次实测里,仅仅是把 Printf("%v", obj) 换成强类型字段,就省下了 35% 的一个 CPU 核。
那是实打实的机器钱。