23 · 事务待投递箱:追踪一条没有成功入队的待办#
小林创建工单,数据库保存成功,但那一刻 Redis 不在线。如果系统只在创建后调用一次 queue.add,这张工单的提醒就可能永远没有下文。小陈的导出也有同样的问题:接口说已受理,谁记得还有一个文件没有生成?
本章先找两个系统之间的失败窗口,再沿一条 BackgroundTask 追踪扫描、入队、确认与补投。Outbox 可理解为“先在可靠账本里记下待发事项”,但它没有让 PostgreSQL 和 Redis 合并成一个事务。
1. 先画出两种错误顺序#
先写库 → 提交 → 入队失败:有工单,没有后续调度依据
先入队 → Worker 消费 → 写库失败:队列可能处理一个并不存在的业务
当前:事务内写工单 + 历史 + 提醒待办 → 一起提交
Worker 扫描待办 → 入队 → 更新投递核对时间关键不是“晚一点再调用 Redis”,而是晚调用之前,待办已经与业务事实共同持久化。若业务事务失败,待办也不能单独存在;若投递失败,事务已经留下下次可扫描的行。
2. 从两个创建入口追到同一张表#
| 源码入口 | 要搜索什么 | 共同提交的内容 |
|---|---|---|
| 工单服务 | create、scheduleReminder | Ticket、CREATED 事件、REMINDER 待办 |
| outbox.ts | scheduleReminder | 结束旧提醒,按 dueAt 保存新提醒 |
| 任务服务 | createExport | 授权与数量检查后保存 EXPORT 待办 |
| 模型 | BackgroundTask | 同时承担账本与待投递箱 |
scheduleReminder 接收事务客户端 tx。先 invalidateReminders;没有 dueAt 就返回,有 dueAt 才创建新的 REMINDER,保存 ticketId、teamId、ownerId、reminderRevision 和 runAt。ownerId 取自传入对象的 creatorId,不是 BackgroundTask 上另有一个 creatorId 字段;调用者怎样构造它,见第 24 章。不要误以为这个函数里已经发了通知。
这里没有另一张名为 Outbox 的表,也没有先创建 BackgroundTask 再额外创建一条消息行。找不到独立 Outbox 模型,不代表缺少这套模式;要看待投递意图是否确实持久化在事务内。
3. dispatcher 怎样找下一批#
打开 dispatcher.ts,先读 findMany:
// 源码节选:扫描条件
where: {
status: 'WAITING',
OR: [
{ dispatchedAt: null },
{ dispatchedAt: { lt: new Date(Date.now() - 30000) } },
],
},排序先按 dispatchedAt 升序且 null 在前,再按 runAt 升序,每轮 take=100。新任务优先,已投递的旧任务按最久未核对优先。否则一大批明天才执行的提醒可能反复占住前 100 个位置,新导出一直没有机会。
扫描没有要求 runAt 小于现在。 明天的提醒也能今天入队,再由延迟队列等待。dispatchedAt 更接近“最近一次投递核对时间”,不是业务完成时间,也不是永久不再扫描的已发送标记。
4. 入队前后的真实时间线#
// 源码节选:dispatcher.ts
await queue.add(
'execute',
{ taskId: task.id, run: task.run },
{
jobId: taskJobId(task.id, task.run),
delay: Math.max(0, task.runAt.getTime() - Date.now()),
},
);成功后用 { id, run, status: 'WAITING' } 条件更新 dispatchedAt。如果队列很快执行完成,条件可能已不匹配;不能为了记一个时间把完成状态改回等待。
把同一任务 T 的第 0 轮放进时间线:
| 失败位置 | 留下什么 | 下次如何处理 |
|---|---|---|
| queue.getJob / add 前或期间失败 | WAITING,旧投递时间不前进 | 后续扫描仍能发现 |
| add 已成功,更新 dispatchedAt 前崩溃 | Redis 可能已有 T-0,账本仍待扫描 | 使用同一 jobId 再投递 |
| 投递时间已更新,Redis 后来丢了该 job | 账本 WAITING,不是已完成 | 超过 30 秒核对窗口后可补投 |
| 任务业务已经完成 | 账本 COMPLETED | 不在 WAITING 扫描集合里 |
这就是为何重复投递是必须处理的常态,而不是“偶尔重复,忽略就行”。固定 jobId 只在相应队列记录仍存在等条件下去重,真正能否生效还得看执行端。
5. 已有队列记录不是一律跳过#
投递器先 getJob(taskJobId(...))、getState():
| 队列状态与账本前提 | 当前代码动作 |
|---|---|
| job 为 failed,账本仍 WAITING | 按同 run + WAITING + executionId=null 条件写 FAILED,提示手动重试 |
| job 为 completed,账本仍 WAITING | remove 该队列记录,再 add,尝试恢复业务处理 |
| 没有 job,或其他尚可复用状态 | 调用 add,使用稳定 jobId |
Redis 的 completed 说的是队列处理结束;PostgreSQL 的 COMPLETED 说的是本项目业务结果。两份记录通常一致,但恢复流程就是为了处理它们不一致的时候。
Worker 生命周期 每约 3 秒 triggerTick;同一进程有未结束的 tickPromise 时不重叠启动。一次扫描抛错会退出这轮,后面的任务和清理可能尚未执行;tick 捕获后记录投递故障,之后再次尝试。不能将 3 秒解释为每项任务必定 3 秒内入队。
6. 扫描尾部还做了两种恢复#
dispatcher 在投递循环后,将超过 120000ms 没推进 updatedAt 的 RUNNING 改回 WAITING,并清 executionId、dispatchedAt;下一轮扫描再投递。这里不递增 run,属于同轮恢复,不是用户发起新轮次。
如果 Redis 已是 failed,而账本还 RUNNING,扫描不会立刻覆盖正在使用的租约。只有过期恢复先把它置 WAITING、清 executionId,下一轮才按同 run、WAITING、executionId=null 核对队列终态并置 FAILED。若新执行者已接手为 RUNNING,或已完成为 COMPLETED,这次条件更新不匹配。failed 回调只记录事实,不在旁边绕过这道门;具体交错见第 26 章。
它还清除已过 expiresAt 的 EXPORT.resultCsv,保留账本。下载入口独立检查过期,因而清理晚了不代表过期文件还能下载。
这两项依赖扫描走到末尾。若 Redis 一直故障导致循环中途抛错,不能期待清理在同一程序里还准时完成。遇到旧 RUNNING 时,应查扫描是否成功,而不只等待两分钟。
拓展阅读Outbox 提供的是恢复依据,不是无限成功保证。
账本解决“还有什么事没做”的记忆问题;权限撤销、永久业务拒绝、依赖长期离线仍可能让任务失败。队列层固定 ID、执行层租约、业务层版本和唯一约束各自解决一部分重复问题,不能用一个 Outbox 名称替代这些设计。
7. 动手验证两个窗口#
先做无需破坏依赖的实验:停止你自己的 Worker,提交一次导出;Prisma Studio 按任务 ID 检查 WAITING、run=0、dispatchedAt=null。启动 Worker,检查结果是否推进,下载内容是否与筛选相符。这个实验验证独立受理与后续处理,不是在证明真实 Redis 断线重连。
然后按第 27 章准备独立测试库,运行已有失败注入用例:
TEST_DATABASE_URL=postgresql://teamdesk:teamdesk@localhost:5433/teamdesk_stage5_test \
pnpm exec vitest run tests/tasks.integration.test.ts \
-t 'Redis 投递失败' --reporter=verbose该测试 构造 getJob 返回 null、add 抛“Redis 离线”的对象,传入真实 dispatchTasks。断言投递抛错、目标任务仍 WAITING。它没有真的断开 Redis,也没有在这个用例里恢复后再次投递、检查 dispatchedAt 或完成下载;那些是待补的验证。
练习 A:为什么不能先写 dispatchedAt,再 queue.add#
动手练习查看参考答案
参考答案:若之后入队失败,会把没有成功完成的投递记成近期已核对,至少推迟恢复。本项目先入队、再条件记时间;即使确认步骤失败,也可按同 jobId 重投。
练习 B:如何补强“投递失败后恢复”的测试#
动手练习查看参考答案
参考答案:用仅含一条待办的受控夹具,先让 add 失败并检查状态与投递时间;换成可记录调用的成功队列对象再次扫描,核对 taskId、run、jobId、delay 与投递时间。若要证明真实调度,再另用隔离 Redis / Worker 完成任务,不把桩调用成功当作执行完成。
练习 C:WAITING 且 dispatchedAt 不为空,是不是卡死#
动手练习查看参考答案
参考答案:可能只是延迟未到期,也可能在重试。先比较 runAt、队列状态和上次核对时间;dispatchedAt 不是完成证明。若已到期又长期不动,再沿投递、消费、账本恢复逐层排查。
链路:业务事务写待办 → 扫描 WAITING → 用 ID 与轮次入队 → 条件记录核对时间 → 重复核对和恢复 → 执行端判断是否仍应处理。