从未到来的完成事件:两次静默的丢弃
逐项完成事件关闭了整个流,并且客户端在作业启动后才订阅。这两种情况都导致通知被丢弃,而工作本身却成功了。
一位用户报告称,一个下载按钮始终没有完成。旋转图标一直在转。显而易见的猜测是渲染失败了,所以我去查找错误。但并没有错误。文件存在于对象存储中,数据库行也指向它,并且使用相同输入在本地重放时,也生成了字节完全相同的产物。这项工作成功了三次。只是通知丢失了,而且是以两种独立的方式丢失的。
日志的真实记录
这个流水线很普通:客户端发布一个任务,服务器返回 202 并在后台进行渲染,然后在一个发布/订阅通道上发布一个完成事件。一个服务器发送事件 (server-sent events) 端点会将该事件分发给所有已订阅的浏览器标签页。
我将发布时间戳与 SSE 连接窗口进行了比对。每个连接在关闭时都会记录其持续时间,因此开始时间就是关闭时间减去持续时间。
SSE window (server log) completion published received
13:36:04 - 13:36:33 -> 13:36:44 no subscriber
13:37:14 - 13:41:25 -> 13:41:32 no subscriber
13:45:59 - 13:49:41 -> 13:50:07 no subscriber
三次事件,三次都发向了一个空频道。Redis 的发布/订阅(pub/sub)机制不会为缺席的订阅者保留队列,因此消息在发布的那一刻就消失了。这一点在一小时内就弄清楚了。更耗时的是去接受这样一个事实:两个独立的 bug 产生了相同的症状。
同一份日志中的另一个细节,事后证明比事件丢失本身更重要:连接持续时间极不均衡。2.58 秒、28.59 秒、5.79 秒、35.2 秒。一个带有 20 秒心跳和 30 分钟会话上限的长连接流,不应该在 2.58 秒后就关闭。
丢弃一例:单个项目的完成状态关闭了整个流
此系统中的状态码是整数,其最后三位数字编码一个阶段。一个辅助函数用于判断某个状态是否为最终状态:
func isDone(s Status) bool {
r := s % 1000
return r == 500 || r == 600 || r == 800
}
SSE 循环使用该辅助函数来决定何时停止流式传输并返回。在此之前,曾为“一个轨道渲染完成”添加了一个新状态,其编号为 5600。没人注意到 5600 % 1000 的结果是 600。
因此,每一次成功的逐项完成都被归类为终止状态,处理程序随之返回。流关闭了。一项测试凸显了这种不对称性:
status=5600 (item done) isDone=true terminal=true stream closes
status=5910 (item failed) isDone=false terminal=false stream stays
失败会保持连接,成功则会终止连接。如果用户将九个项目加入队列,第一个成功项就会关闭流,浏览器会等待一秒钟的重连延迟,而在此时间窗口内发布的每个完成事件都会消失。这种不稳定的连接时长就是这个 bug 造成的,它在生产环境中已存在数周,看起来不过是网络不稳定而已。
修复方法很简单,重要的是后半部分:
func isSSETerminal(s Status) bool {
if s == StatusItemDone {
return false
}
return (isDone(s) && s >= StatusFirstPhaseDone) ||
s == StatusError || s == StatusFailed
}
我保留了 isDone 不变,因为其他调用者依赖于它。我编写了一个测试,遍历从 0 到 10000 的每一个整数,并断言新的谓词与旧的表达式匹配,只有一个值例外。将这个决策提取到一个命名的函数中,才使得该测试成为可能。最初它是一个内联的布尔表达式,位于一个 230 行的处理函数内部,该处理函数还掌管着 HTTP 响应写入器、数据库读取和速率限制器。
一般性的教训并不仅仅是关于一个错误的常量。从一个标识符的算术运算中派生出控制流,意味着每个新的标识符都有可能在某个遥远的地方悄悄地改变行为。在状态定义中添加一个 terminal bool 字段,本可以使这个错误无法被写出。
第二次丢包:在开始工作后订阅
第二次丢包发生在客户端。操作顺序是:提交作业,等待 202 响应,然后打开 SSE 连接并注册关注。
当渲染耗时较长时,这套流程是可行的。但当作业执行很快时,它就会失败。这一批次中有一个项目只有一个分段,其渲染在 1.5 秒内就完成了,远早于浏览器注册订阅。这个项目每次都失败,而有 30 或 200 个分段的项目几乎总能成功。这个 bug 看起来是数据相关的,这就是它能存活这么久的原因:它只在那个没人认为有何特别的输入上复现。
显而易见的修复方法是先订阅。我就是这么做的,但仅仅调整顺序还不够,其原因值得一提,因为它在实现过程中坑了我。客户端在其待处理集一清空后就关闭连接,并且这个检查在每条消息上都会运行。服务器在连接一打开时就会发送一个初始状态帧。因此,单单是提早打开连接,会产生如下结果:连接、接收初始帧、待处理集为空、关闭。竞态问题又回来了。提早订阅并保持订阅处于活动状态是同一个变更,而不是两个。
为何显而易见的回退方案会让情况变得更糟
由于完成事件可能会丢失,因此本能的反应是添加一个恢复路径。我设计了两次,但都放弃了。
第一种是客户端轮询:在提交请求后,每隔几秒轮询一次结果端点,直到出现答案。这看起来很稳健,直到你注意到轮询计时器和作业 ID 存在哪里。它们存在于浏览器内存中。最初的错误报告称,项目“即使刷新数次”也无法下载。刷新操作恰好会销毁回退方案所依赖的状态。这个回退方案覆盖了除报告中提到的那种情况之外的所有情况。
第二种是服务器端重放:将每个结果存储在一个以作业为键的哈希表中,当客户端连接时,读取该哈希表并重放其找到的任何结果。原则上,这能应对刷新操作。但在实践中,它造成了比它修复的问题更糟糕的故障。因为现在客户端在提交请求前就进行订阅,所以在连接时,该项目总是在待处理集合中。一个来自早期运行的过时结果会被重放,与那个待处理条目匹配,并被当作是尚未发送的请求的答案。用户会静默地收到一个旧的产物,并相信它是新的。一个无休止的加载动画是用户能看到的不良结果。一个看起来正确但实际错误的文件则更糟。
还有一个问题:SSE 处理程序与其他消费者共享,其中一个消费者会将任何以 failed 结尾的状态归类为它自身的失败。重放存储的逐项失败信息会导致不相关的操作显示错误横幅。
因此,这个回退方案被完全放弃了,精力被投入到从一开始就避免丢失事件上。在开始工作前订阅,在面板打开期间保持订阅开启,并停止在逐项完成时关闭流。一个比主路径更弱的恢复路径并非冗余。它是在相同条件下也会失败的、需要维护的第二套东西,并且还带来了一类新的错误。
仍未涵盖的内容
移除回退机制意味着接受一个更小的保证,与其假装不是这样,不如明确指出这一点。这是实时交付可靠性,而不是持久完成。如果服务器在渲染中途重启,或者发布/订阅链接短暂中断,或者浏览器在后台标签页中冻结的时间过长以至于错过了帧,通知仍然会丢失。
取代回退机制的是一个客户端超时。30 分钟后,项目会转为错误状态,用户可以重试。这种体验比自动恢复要差,但它也坦诚地说明了系统实际承诺的是什么。对于引发这一切的单段项目,重试需要 1.5 秒。
两项较小的调整也出自同一次审查。流的会话上限必须超过客户端超时,否则服务器会先挂断连接,并制造出导致事件丢失的重连间隙。并且,按用户键控的连接限制器的 TTL 比新的会话长度短,这意味着一个活动连接会因其计数器老化而失效,从而允许超出限制的额外连接。这两项都不在最初的计划中。它们都是通过询问超时更改还触及了哪些其他部分而发现的。
常见问题解答
这是反对使用发布/订阅(pub/sub)模型来发送完成通知的论据吗? 不是。对于“立即通知浏览器”这种场景,发布/订阅是一种合理的传输方式。错误在于将其视作存储。如果你需要答案在刷新后依然存在,那么答案必须存放在某个持久化的地方,并且可以通过身份标识来获取。这意味着需要一个带有可重构 ID 的真实端点,而不是一个根据客户端碰巧在等待的内容来键控的回放。
为什么不只修复客户端,而跳过服务端的更改? 因为这两个 bug 是相互独立的。提早订阅修复了快速作业的问题。但它对于流在首次成功后就关闭的问题毫无作用,而这正是导致批量处理失败的原因。单独修复其中任何一个,都会留下一个可复现的故障。
像“终端状态”那样的 bug 是如何发现的? 寻找与设计不符的持续时间。流被配置为存活 30 分钟,但却在 3 秒后就关闭了。这个差距一直存在于日志中,并被解读为网络不稳定。记录长连接关闭的原因,而不仅仅是记录它关闭了。
使用带有状态表的作业队列能避免所有这些问题吗? 很可能可以,而且这才是正确的长期架构:提交时返回一个作业 ID,状态可随时读取,推送通道作为一种优化,而不是了解结果的唯一途径。这比修复一个线上 bug 的改动要大得多,明确地推迟它,而不是在压力之下仓促地半途而废,是值得的。