每隔几周就有人说某个页面很慢,而每一次第一个被怀疑的都是服务器。我的服务器是无辜的——而我用了两次错误的修复才证明这一点。
这是一个 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 的人都能获得这项改进,而我也能删掉那个叠加层。
如果没落地,至少性能分析器现在在我的工具箱里有了永久位置——而下一次有人说“服务器感觉好慢”时,我会先问一个问题:你在浏览器里测量过吗?

发表回复
要发表评论,您必须先登录。