通知发送之前是内联在请求链路里跑的。一上量就给写操作增加 200–800 ms 的延迟,SMTP 连接池一打满还会悄悄丢邮件。这个 PR 把投递挪到已有的 pg-boss 队列上,API 立即返回,失败的发送会按退避策略重试。
为什么要改
上个季度我们在评论里加了 @提及功能,每次提及都会在保存评论的同一个事务里触发最多三次发送(站内、邮件、Slack)。刚上线时还好,但现在一条任务更新会扇出给四十个关注者,这就撑不住了。
- 发送内联在写操作的处理器里
- SMTP 超时 = 评论本身报 500
- 不重试——丢了的邮件就是丢了
comments.create的 p99:1.4 s
- 处理器为每个收件人入队一个任务,立刻返回
- Worker 按指数退避重试 3 次
- 用尽重试后落入死信表,留作排查
comments.create的 p99:180 ms(预发环境)
逐文件过一遍
按阅读顺序排,不是按字母序。先看 worker——这是新引入的部分——再看入队调用点,最后是配套改动。
packages/notify/src/worker.ts 新增 +126
这个 PR 的核心。一个 pg-boss 订阅者,拉取 notify.deliver 任务,解析用户的渠道偏好,然后调用对应的适配器。重试按渠道分别配置——邮件重试三次,Slack 只重试一次,因为它的 API 在我们这边已经是幂等的。
boss.work('notify.deliver', { batchSize: 20 }, async (jobs) => { for (const job of jobs) { const { userId, event, channel } = job.data; const prefs = await getPrefs(userId); if (!prefs[channel]) return; // 用户屏蔽了该渠道 try { await adapters[channel].send(userId, event); } catch (err) { if (job.retryCount >= MAX_RETRY[channel]) { await deadLetter(job, err); // 不抛出 —— ack 后暂存 return; } throw err; // 让 pg-boss 重新调度 } } });
packages/api/src/routers/comments.ts 改动 +14 −62
收益落地的地方。这个写操作以前会直接调用 sendEmail、sendSlack 和 createInApp。现在它只插入评论、算出收件人,然后入队。一锅 try/catch 也没了。
const comment = await db.comments.insert(input); const recipients = await resolveWatchers(input.taskId, input.mentions); for (const r of recipients) { await sendEmail(r, comment); // 阻塞了响应 await sendSlack(r, comment); } await boss.insert(recipients.flatMap((r) => CHANNELS.map((ch) => ({ name: 'notify.deliver', data: { userId: r.id, channel: ch, event: toEvent(comment) }, singletonKey: `${comment.id}:${r.id}:${ch}`, // 幂等 })))); return comment;
packages/db/migrations/0051_dead_letter.sql 新增 +22
用尽重试的任务落到这张表。我故意没有设自动清理——在我们对新链路有信心之前,每周都得翻一翻。表里有完整的任务载荷和最后一次的错误信息。
packages/notify/src/adapters/{email,slack,inapp}.ts 改动 +88 −74
大部分是搬位置。每个适配器现在都实现统一的 Adapter 接口,并抛出带类型的 RetryableError 或 PermanentError,让 worker 知道要不要重试。邮件适配器同时去掉了自己的重试循环——重试现在归队列管,四月份那次重复邮件就是双重重试惹的祸。
apps/worker/src/index.ts, infra/fly.toml 改动 +31 −4
在已有的 worker 进程里注册新订阅者,并把并发数从 5 提到 20。不需要新的部署单元。
packages/notify/src/__tests__/worker.test.ts 新增 +137
覆盖了重试边界、死信路径、渠道屏蔽和 singleton key 去重。直接用真实的 pg-boss 跑在测试库上——上个季度那次 mock 队列测试都过了,结果生产环境的顺序却挂了,我们已经被坑过一次。
评审重点看哪里
worker.ts:31–44。捕获异常后我会检查 retryCount,要么暂存要么抛出。这段逻辑写错了,要么无限重试要么丢消息——而这两种故障模式正是这个 PR 要解决的。comments.ts:28。${commentId}:${userId}:${channel} 应该能让 API 处理器重试时的重复入队保持幂等。请帮我兜底确认一下,它在不同任务之间不会冲突。测试计划
放量节奏
用 notify_queue_v2 特性开关挡住。老的内联路径还留在代码库里,死着但保留一个版本,万一需要切回。