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

2022-10-30 更新:经过近半年的持续优化,应用内统计延迟降低到了 0.3ms,相关的技术优化工作会在不久的未来发布。

2023-02-05 更新:相关地理计算优化工作介绍见

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 的功劳)