生于非阻塞,死于日志
metrics 上报在高并发时会带来性能问题,为了解决问题,有时反而又会带来别的问题。
举个例子,一般的 metrics 上报代码可能是下面这样:
// when request in
metrics.Req(caller, count, latency)
内部实现一般也就是个 UDP 调用,可能碰到的是 fd 锁的问题,在 这篇 里已经写过了。
为了优化这个锁带来的阻塞问题,有些系统会把 metrics 上报改为非阻塞逻辑:
func Req(caller string, count int, latency int) {
select {
case ch<- struct {caller, count, latency}:
default:
log.Print("ignore") // 注意这里
}
}
这种非阻塞的发送,可以在 channel 的 consumer 端启动多个 goroutine 进行发送,并且每个 goroutine 单独占有一个 udp client。增加了系统整体的处理能力,所以会比原先的大家抢锁阻塞发送提升一定的吞吐量。同时按照 go 的 select default 特性,如果 channel 对端没有 ready 的 g,实在处理不过来了,我们就打一条日志以示我知道了。
这个设计显然是有一些问题的。当上报量进一步提升,超出了 consumer 端的处理能力时,大部分 select 会直接进 default。本质上整个系统因为 log.Print
退化回了抢锁逻辑上。
在某次节假日高峰期,这个问题最终还是爆发了,某个系统直接启动了 90w goroutine,并且在 goroutine 堆积后无法进行服务,只能靠重启续命。
因为该服务非重要服务,所以事后甚至都没有一个复盘。
作为公共组件,还是应该多考虑一下各种异常情况,不能头痛医头脚痛医脚。如果提前有一些预见性,做好针对性压测,那就不会让你的用户在关键时刻靠重启续命了。
当然,碰到问题要思考总结,如果我们来编写这样的 metrics 库,有哪些思路来进行改进:
- 简单粗暴,还是增加 consumer 端的处理能力,10 个 consumer 不够用,就 20 个
- 减少 metrics 的上报量,当前 metrics 实现简单粗暴,应该在应用内进行简单聚合再打包发送,哪怕是优化到一个请求发一次 metrics 都可以大大节省 cpu 占用,counter 类简单用 atomic 内存聚合即可
- 不管怎么说,metrics 这种基础组件,应该要优化到在业务 QPS 单机 10w 时,也不成为瓶颈才行,平常的压测必须要做好