Go 在只读高并发场景下的优化

Posted on Mar 12, 2022


距离决定用 FastAPI 重写 Flask 应用已经过去了一年了, 年中的时候在 FastAPI 上尝试了很多性能优化方案,但机器的负载水平已经快到头了, 只能承认这条路走不通,需要换一个快得多语言来做。 决定用 Go 再重写,又经历了若干次的优化,目前已经稳定运行。 线上峰值 QPS 接近 10K,应用内统计延迟在 2ms 之内,依赖服务统计延迟在 13ms 左右。 记录下若干有意思的事情。

Singleflight 没预期那么好用

在生产环境用 Go 之前就已经知道了这个方法,当时觉得是改善性能的好工具。 在候选选项只有 2K 不到的情况下,线上 Singleflight 命中的请求量,最高值 5% 不到。 日常情况下更低,因为选项请求的分布没有想象中那么不均匀,存在某几个过热的 key。 至少在这个场景下,并没有提升/缓解某些性能问题。

In-memory Cache

这个 API 服务是只读的并且对数据一致性要求并不高,所以按照不同的数据类型做了一层 LRU 缓存。 key 里含有时间信息,实现 TTL 功能。

后来发现在某些情况下存在大约 10 秒的窗口,集体 cache miss 会有一个明显的延迟毛刺。

于是对某些候选项不大的数据用 sync.Map 做缓存,用异步 goroutine 定时更新 sync.Map 的内容, 将延迟控制在了 1ms 之内(有一些和业务逻辑相关的搜索功能需要一些时间)。

GC 与内存分配指标重要

从生产环境观察到的表现看,GC 次数越低实际性能越好,说明内存分配的次数很少(参考 in-memory cache)。

Error 不要 Panic

用的一个奇怪的 middleware 会导致非常频繁的 Panic,而 Panic 时会暂停程序运行, API 服务延迟瞬间猛涨到 300ms+。 后来去掉了,就没有这种问题了。

Sentry 在 Go 里的使用

之前在 Python 里使用 Sentry,可以很方便得到原始的 API 请求参数,每一层的变量值。 Go 里做到 Python 的粒度不是很方便,最开始用 errors.Wrapf 的方式将尽可能多的内容塞到错误信息里。 后来则开始使用 Sentry 的 Tags 将变量写到这一次的上报事件中,在 Sentry 上看报错时相对而言会方便一些。

唯一需要注意的是需要使用 sentry.CurrentHub().Clone() 避免并发报错相互污染。

指标统计要尽早添加

上面提到的结论其实都来自后台监控,越早做和核心性能相关的指标统计,排查故障就越方便。

Go 比 Python 快了多少?

这是一个很有趣的话题,在这个确定场景下:

  1. 机器数量从 7 台降低到了 4 台,并且 CPU 负载显著下降(为 FastAPI 时的 25%)
  2. 同等量级的 in-memory cache 在 Python 多进程场景下内存压力太大,做了数量限制,导致没有完全兜住,Go 绰绰有余
  3. API mean 延迟变成原来的三分之一
  4. 延迟毛刺几乎消失(in-memory cache 的功劳)