一个大型聊天如何可能让我们的 API 宕机
9 月 28 日,一次发布之后大约 17 分钟,我们生产环境中的两台 API 服务器同时耗尽了内存并重启,有大约五分钟的时间,负载均衡器没有任何健康的服务器可以转发流量。我们的第一个修复是把 API 的内存翻倍。真正的原因在当天晚些时候才浮现:打开某一个特定的聊天时,API 会在一次请求中加载 53 MB 的消息。现在聊天历史会按大小以及数量分页加载,那个聊天会以 5 MB 的第一页打开,而不再崩溃。
出了什么问题
我们的 API 以容器的形式运行在两台服务器上,前面有一个负载均衡器。每个容器的内存上限是 384 MiB。在空闲时,它只使用 130–140 MiB,因此这个上限看起来留有充足的余量。
两个容器同时达到了这个上限,并以「JavaScript heap out of memory」崩溃。堆(heap)是 Node.js 进程用来存放其正在处理的对象的内存。由于两台服务器同时失败,已经没有地方可以路由请求了。对于两台服务器的架构来说,这是最糟糕的一类故障:只有当故障不会同时发生时,冗余才有作用。
第一个修复:更多空间
时间点指向了发布。发布之后,被发布中断的代理回合会恢复运行,并再次保存它们的转录内容,这一阵突发的工作看起来是内存峰值最明显的来源。
因此,第一个改动把 API 的上限从 384 MiB 提高到 768 MiB。我们当天就在两台服务器上实时应用了这项改动,并把它提交到部署配置中,以免下一次部署悄悄地把它改回 384。手动在服务器上做的修复,如果下一次部署又把它撤销,这正是同样的故障再次出现的常见方式。
更多的内存是一个合理的安全余量,但它并不是原因的解释。
真正的原因:一次请求,71 MB
当天晚些时候,我们在一个聊天的副本上复现了这次崩溃,使用的是生产环境原来的内存上限。这个副本本身就能让 API 崩溃,与任何部署都无关。
当你打开一个项目时,Web 应用会向 API 请求该聊天的历史记录。这个端点返回最新的 30 条消息,且是完整的。对于大多数聊天来说,30 条消息的体积很小。而对于这个聊天,最新的 30 条消息在数据库中是 53 MB,响应则是 71 MB。代理的几条回复每条都有 4–10 MB,其中大部分是子代理的转录内容:子代理是主代理为并行处理部分任务而启动的辅助代理,它们的完整工作成果会随回复一起保存。
构建这一个响应所需的堆内存,超过了 API 所拥有的。而且,每次打开或刷新该项目都会再次发送这个请求。一个聊天就可能让它落到的那台服务器崩溃,又因为页面可以被重新加载,它还可能波及两台服务器。
「30 条消息」从来就不是对大小的真正限制,它只是对数量的限制,而对于普通聊天来说,这个数量恰好很小。
修复:按大小以及数量分页
现在,一页历史记录是 30 条消息,或大约 4 MB 的消息,以先到者为准。
它分两步工作:
- 先询问大小。 API 向数据库请求最新消息的 ID 及其存储大小,但不包括内容。这个查询无论消息有多大都很便宜。
- 只加载放得下的部分。 从最新到最旧依次累加消息,直到下一条消息会使这一页超过 4 MB。然后才完整加载这些行。
有两条规则保证这一机制是安全的:
- 最新的一条消息总是会放入该页,无论它有多大。 如果一页什么都不返回,聊天就会卡住,用户也就无法向上滚动。
- 不做任何截断。 没有任何消息会被截断或丢弃。聊天的其余部分通过「向上滚动查看更早的消息」控件加载,每次一页,这是较旧的消息原本就在使用的方式。
我们还发现了另一个形式相同的地方。当你停止一个回合时,API 会检查最近的几条回复,看是否有空的回复需要清理。它的做法是加载八条完整的消息。对于一个包含多兆字节回复的聊天来说,这就是同样问题的小型版本。现在它只向数据库提出一个是非题:这条消息是否有任何内容?而不加载内容本身。
我们如何验证
在那个聊天的副本上,以旧的 384 MiB 上限运行:
- 第一页是 5 MB。之前,同样的请求会使 API 崩溃。
- 同时打开该聊天的五个浏览器标签页,内存峰值为 154 MB。
- 向上滚动浏览整个聊天,返回全部 190 条消息,每条恰好一次,且按顺序排列。
分页选择的逻辑也有单元测试,其中一项是用该聊天最新消息的真实大小构造的。第一页只返回一条消息,并表明还有更多,下一页则从紧接其后的那条消息开始。
我们从中得到的经验
- 计数不等于大小限制。 任何返回「最新的 N 条」而这些内容又可能无限增长的端点,都需要一个字节预算。数据迟早会找到这个缺口。
- 最明显的嫌疑人不一定是真正的原因。 部署确实是额外工作的一个真实诱因,增加内存也是值得的。但崩溃在没有部署的情况下也能复现,如果不复现它,我们会止步于增加内存。
- 两台服务器会因为收到相同的请求而一起失败。 冗余能防止一台服务器宕机,却防不住一个请求杀死它所到达的任何服务器,尤其是用户很自然会通过刷新来重试的请求。
- 在配置中修复,而不只是在机器上修复。 内存增加的改动当天就被写入部署配置,因此下一次部署也保留了它。
这是我们近期第二个与代理输出有关的内存故事。第一个是一个会排队 690 MB 字符串的状态标签。如果你在 Mythex 上使用子代理,可以在文档中了解它们的工作方式。