Wanzul Blog

Notes, opinions, and lessons I paid for.

Two overlapping panels on paper: the same inbox payload traversed a second time

Chatwoot 感觉很慢,但服务器是无辜的。

每隔几周就有人说某个页面很慢,而每一次第一个被怀疑的都是服务器。我的服务器是无辜的——而我用了两次错误的修复才证明这一点。

这是一个 Chatwoot 收件箱打开一段对话要花好几秒的故事,也是证明服务器清白的数据,以及一行把同样昂贵的工作做了两次的前端代码的故事。

从枯燥的测量开始

在动任何东西之前,我先把数据拉出来。对整个技术栈做了六小时的只读遥测:

  • Web 实例 CPU:平均 5.4%,峰值 13.2%。CPU credits 充足。
  • Worker:平均 4.7%,credits 未被触动。
  • 数据库:CPU 6%,读写延迟约 1 ms,15–20 个连接,246 MB 数据。
  • 缓存层:CPU 3%,零淘汰。
  • 负载均衡器处的应用响应时间:172–413 ms。没有一个请求超过半秒。

所以服务器没问题。网络也没问题。可是页面却像在思考五秒钟之后才肯做任何事。

这种落差——后端健康、人却不满意——正是工作发生在错误位置的典型特征。

那就从坐在服务器旁边的浏览器测量

从我自己的笔记本电脑测量说明不了什么,因为我的网络线路本身就是实验的一部分。于是我把调试会话附加到运行在同一数据中心内的 Chrome 实例上,打开完全相同的对话,并记录两次时间线:一次冷启动,一次热启动。

  • DOMContentLoaded:2.4–3.2 秒
  • 每次加载解码的 JavaScript 和 CSS:约 23.7 MB
  • 点击对话后的长任务:约 8.4 秒,最长的单个任务 5 秒
  • 前七秒内的外发请求:没有

正是最后一行让我重新理解了整件事。页面并不是在等服务器。它在向服务器发出任何请求之前就已经卡死了。

有帮助的扩容工作——以及证明我错了的部分

我还是先做了合理的底层设施工作,因为应用跑在规格不足的机器上确实是个真问题,即使它不是这次的问题:

  • 把 Web 实例从小型突发型换到中型(同样是两个 vCPU,内存翻倍)。
  • 设置 WEB_CONCURRENCY=2,让 Puma 真正用上第二个核心:2 个 worker × 5 个线程。
  • 把部署方式从一次性全量改为滚动部署,这样未来的发布不会再让整个环境下线。

八个并行的对话列表请求从 2.82–4.52 秒降到 1.17–2.24 秒。真实、可测量、值得保留。

接着我测试了显而易见的后续步骤,两项都没能让数字改善:

  • 把 Puma worker 从两个增加到三个:在波动范围内,没有收益。
  • 每个 worker 的线程数依次设为 5、10、15:慢的那条路径依然慢,而且最后一次运行还更糟。

增加线程救不了一个卡在主线程上的页面。那个实验花了我一个下午,却帮我省下了一整个月的错误假设。

我的第一次修复修错了地方

第一个假设很合理:一个收件箱 getter 在只需要收件箱数据时,却遍历了庞大的 WhatsApp 消息模板数据负载。我写了一个修复,在生产环境测量,得到这样的结果:

  • 深层遍历:5,548 ms → 4,669 ms
  • 点击后的长任务:9.5 秒 → 8.4 秒
  • 七秒内请求仍然没有发出。

纸面上更好,实际上没用。深层遍历仍然在发生——只是发生在同一条路径上的别处。我关掉了那个拉取请求,而不是假装一个微优化解决了什么问题。

与其猜测,不如对主线程做性能分析

于是我不再读代码,而是开始记录代码的运行。三个成本很低的工具:

  • 在 Array.prototype.map 上挂钩,只记录长度超过 500 项的数组。
  • 围绕一次点击采集的 CPU 性能分析。
  • 在 longtask 上使用 PerformanceObserver,这样我就能把卡顿看成一段持续时间,而不是一种感觉。

调用栈两次呈现出相同的结构:

Array.map (2,000+ entries)  ←  message templates
  ←  Vue reactive proxy
  ←  map-obj deep walk
  ←  map-obj deep walk        (yes, twice)

罪魁祸首是一个把收件箱记录转换成 camelCase 的 composable。它从一个已经将整条记录 camelCase 化的 getter 读取数据,然后又对结果运行 useCamelCase(..., { deep: true })。同一个对象被完整地深层遍历了两次,每次对话切换都发生,而且都在主线程上——因为页头、消息元数据和气泡都依赖那个计算值。

一行代码,做了两次

这个修复去掉了第二次遍历,同时保留原有行为。没什么聪明技巧,没有新依赖,也没有以后需要失效的缓存。就一句话:不要把同样昂贵的计算做两次。

部署构建后的静态资源后,在生产环境测得:

  • 点击对话后的长任务:8,368 ms → 1,984 ms
  • 最长的单个任务:5,007 ms → 447 ms
  • 深层遍历:从性能分析中消失
  • 超过 500 项的 map 调用:25 → 0

这就是“这个应用很慢”和“这个应用没问题”之间的差别。

在上游点头之前先上线

我不想等一个评审周期才让团队用上顺手的收件箱,所以这个修复目前以发布镜像之上的前端资源叠加层方式运行。后端和数据库完全不动——不同的只是构建后的 JavaScript。

这带来一个必须诚实面对的运维风险,我把它写进了内部运行手册:未来任何使用不同版本标签的部署都会悄悄替换掉那些资源,并无声地丢掉这个修复。无声的回归比响亮的回归更糟,所以运行手册写明了确切的标签以及移除该叠加层的条件。

上游

正确的修复是向 Chatwoot 本身提交一个拉取请求:perf: stop deep-camelCasing inbox records twice on every conversation change,并附上一个关联 issue,描述症状、根本原因以及如何复现。

我还把它与那个代码仓库里已经合并的拉取请求做了比较,并重写了自己的版本以符合他们的惯例——引用 issue、模板、测试步骤、检查清单。我的第一版按照我自己的结构来写,而那是很容易被忽略的做法。

目前这个拉取请求仍然开着,所有检查都通过,但还没有人评审。我还在一个症状同类(打开对话时卡死)的旧 issue 下留了评论,解释我们找到的原因以及任何人都能重复的性能分析方法,同时明确说明那边的触发原因可能不同。

所以目前的状态是:生产环境已经变快,修复已有文档记录,上游拉取请求已提交,我正在等它获批并合并。如果它成功落地,所有使用 Chatwoot 的人都能获得这项改进,而我也能删掉那个叠加层。

如果没落地,至少性能分析器现在在我的工具箱里有了永久位置——而下一次有人说“服务器感觉好慢”时,我会先问一个问题:你在浏览器里测量过吗?

 的头像

Written by 

Comments

发表回复