生于非阻塞,死于日志

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 库,有哪些思路来进行改进:

  1. 简单粗暴,还是增加 consumer 端的处理能力,10 个 consumer 不够用,就 20 个
  2. 减少 metrics 的上报量,当前 metrics 实现简单粗暴,应该在应用内进行简单聚合再打包发送,哪怕是优化到一个请求发一次 metrics 都可以大大节省 cpu 占用,counter 类简单用 atomic 内存聚合即可
  3. 不管怎么说,metrics 这种基础组件,应该要优化到在业务 QPS 单机 10w 时,也不成为瓶颈才行,平常的压测必须要做好
Xargin

Xargin

If you don't keep moving, you'll quickly fall behind
Beijing
京ICP备15065353号-1