大多数 Node.js 教程都将错误处理视为事后才考虑的事情。你只需在路由处理程序中包裹一个 try/catch 块,记录堆栈跟踪,然后返回一个 500 错误。这种思维方式之所以存在,是因为 HTTP 请求的另一端有一个真实的人在等待。后台任务则完全不同。在队列系统中,没有需要回复的焦急客户端,也没有自动刷新的浏览器。只有一个 Worker、一个 Payload 以及一个在沉默中不断增加的重试计数器。当问题发生时,它们起初进展缓慢,随后会突然爆发。一个分类错误的错误可能会导致整个流水线停滞,或者在凌晨三点把工程师叫醒。
这种脱节的原因很简单。请求-响应周期失败得快且明显。而队列失败是无声的。一个 Worker 可能在数据库连接断开之前已经处理了数百个任务。如果没有明确的处理规则,Worker 会立即重试,猛烈冲击已经不堪重负的数据库,最终导致崩溃。由于没有人直接监控 Worker,问题的第一个迹象通常是级联积压或日志文件填满磁盘。你需要的不仅仅是 catch 块。你需要一种能够对不同类型的失败采取不同策略,并保护系统其余部分免受单个错误任务影响的策略。
两种类型的失败
首先,将每个错误分为两类。
可重试错误是瞬时的。例如第三方 API 的网络超时、429 速率限制响应,或者是数据库从库暂时落后于主库。这些是压力的症状,而不是 Bug。系统可能在 30 秒内自行恢复。可重试的任务值得再试一次,但必须在受控条件下进行。
永久性错误是失误。例如 Payload 中的无效 JSON、缺失的用户 ID、存储中不存在的必需文件。无论重试多少次,它们的结果都和第一次失败时一模一样。重试它们会消耗 CPU 周期,浪费队列槽位,并产生有害的背压,从而延迟正常的任务。永久性失败唯一有用的去处是日志、告警或死信队列(DLQ)。它们不应该留在重试循环中。
构建决策引擎
立即进行分类。不要把这个决定交给队列框架。在你捕获错误的那一刻,就决定它的命运。
在实践中,这意味着创建自定义错误类或包装函数,在错误向上抛出之前对其进行检查。如果数据库驱动程序抛出连接重置,你的处理程序应该将其标记为“可重试”。如果 Payload 验证器抛出模式不匹配(schema mismatch),则将其标记为“永久性”。许多任务处理器默认重试所有任务,这是代价最高昂的选择。立即拒绝永久性任务。要么丢弃它们,要么将它们重新路由到死信队列,以免它们污染主流水线。这种习惯比任何基础设施的改动都能更可靠地防止雪崩效应。
退避,但要更聪明
当你进行重试时,绝不要立即重试。如果数据库宕机了,一波 Worker 每秒都在猛烈冲击它,看起来就像是一场来自内部的拒绝服务(DoS)攻击。使用指数退避(exponential backoff)。先等待一分钟,然后是五分钟,再然后是十五分钟。给上游系统留出恢复的空间。
但仅有指数退避是不够的。如果一千个任务因为服务重启而同时失败,它们的重试时间表将会对齐。当服务重新上线时,它们会同时冲击该服务,可能再次导致其宕机。添加抖动(jitter):为每个延迟增加一个微小的随机偏移量。将重试分散在几秒钟内,可以防止同步冲击。数学原理很简单,但它带来的稳定性是巨大的。
保留证据
死信队列是你的审计追踪,而不是垃圾桶。当一个任务耗尽了最后一次重试机会时,不要简单地删除它。将整个 Payload 连同错误上下文和重试历史一起移至 DLQ。
这可以保留证据。人类可以检查该任务,修复 Bug,并在必要时手动重新执行。更重要的是,要监控 DLQ 的深度。死信任务的突然激增通常是部署错误、模式变更失败或外部供应商违反协议的最早预警。将 DLQ 的增长视为领先指标,而非滞后指标。如果你的 DLQ 正在填满,说明上游发生了变化,你的团队需要在积压扩散之前知晓。
为重试而设计
设计每一个任务时,都要假设它可能会运行两次,因为事实确实如此。Worker 可能在处理到一半时失败,被重新调度,然后再次执行。如果你的任务是向客户扣费、发送电子邮件或增加库存计数,那么简单的重试会导致重复操作。
解决方法是幂等性(idempotency)。在执行副作用之前,先检查它是否已经发生。使用任务负载(payload)中的唯一标识符作为幂等键(idempotency key)。将该键存储在短时缓存或具有唯一性约束的数据库表中。如果键已存在,则跳过该任务并返回成功。这能将重试从一种风险转变为无害的空操作(no-op)。虽然这需要多写几行代码,但它能让你免于向财务部门解释为什么收入在一夜之间翻了一倍。
保护进程
无限制的 Promise rejection 和异常可能会在毫无预警的情况下杀死 Node.js 进程。对于 Worker 而言,这意味着任务丢失,以及编排器(orchestrator)不得不仓促重启容器。
为 unhandledRejection 和 uncaughtException 注册全局处理器。它们的工作不是拯救应用程序,而是执行必要的最小清理工作,然后退出。让 Docker、Kubernetes 或 systemd 以干净的内存状态重启 Worker。在触发全局处理器后仍勉强维持运行,会引发内存泄漏和状态损坏。快速、干净地退出,比一个处理任务出错的缓慢僵尸进程更安全。信任你的编排器会把你拉回来,不要试图去挑战一个已经损坏的运行时(runtime)。
尊重信号
Worker 会在部署、扩缩容事件和节点轮转期间被关闭。如果你的进程在收到 SIGTERM 的瞬间就立即死亡,那么你就会中断当前正在进行的任何任务。该任务可能永远无法完成,甚至其重试计数器都还没有增加。
监听 SIGTERM 和 SIGINT。当信号到达时,停止从队列中拉取新任务。如果可能,完成当前任务。设置一个硬超时(例如 30 秒),超时后无论如何都要退出。这种优雅停机(graceful shutdown)尊重了队列并避免了虚假失败。你的部署流水线应该将干净退出的 Worker 视为健康,而崩溃的 Worker 则应触发警报。
核心总结
可靠的队列处理不在于捕获每一个错误,而在于针对每种失败模式做出深思熟虑的决策。耐心地重试那些瞬时错误(transient errors),迅速处理掉那些永久性错误。保护你的 Worker 免受突发流量冲击(stampedes),使用幂等键保护你的数据,并让即将结束的进程干净地退出。当每一种失败都有明确的处理路径时,凌晨三点也只不过是平常的一个小时。你的流水线会持续运转,而你的团队可以安稳入睡。
