site logo

Marico's space

24小时的幂等性key会将崩溃变成丢弃的webhook,所以我们的从120秒开始

前端技术 2026-10-19 17:34:17 6

最近在处理 webhook 幂等性的问题,踩了一个很有意思的坑——24小时的 TTL(生存时间)看起来很安全,实际上会把程序崩溃变成永久丢失的事件。这篇把问题说清楚,也记录一下我们最终的选择。

Munchable 有两个 webhook 端点:一个是处理 Stripe 支付回调的,一个是处理入站邮件的。两个都是"至少一次"投递,都有可能产生不能重复执行的副作用,处理流程开头都一样:

const key = `webhook:${provider}:${eventId}`;
const claimed = await redis.set(key, Date.now().toString(), { nx: true, ex: ttlSeconds });
if (claimed === null) return { isFirstDelivery: false, degraded: false };

用 Redis 的 SET NX 加过期时间。第一次投递拿到 key,后续重试看到 key 就直接返回。TTL 只需要覆盖 provider 的重试窗口就行,所以用带过期时间的 Redis key 而不是一张只增不减的事件表。

有意思的问题是 ttlSeconds 填什么数字,而直觉给你的答案往往是错的。

直觉上的 TTL 会让崩溃变成永久丢失

Svix 的重试策略在一天之内就结束了,所以 24 小时是去重的极限范围。设置 24 小时会得到这种失败路径:

  1. 事件到达,抢到 key,这个 key 在接下来一天内都标记"已处理"。
  2. 处理函数死在半路。超时、OOM、平台 kill——任何让代码无法继续执行的情况。
  3. Provider 重试。Key 还在。重试直接返回。
  4. 后续所有重试都这样,然后重试停止。

事件就这么没了,没有报错,唯一的痕迹是一条 Redis key 声称"这件事做完了"——但实际上没做。用来防止重复工作的 key,变成了未完成工作的墓碑。

三个状态,而不是一个

所以初始 claim 要写短一点,然后成功时再升级:

/** Svix retries taper off well inside 24h, so that is the dedupe horizon. */
const DEFAULT_TTL_SECONDS = 24 * 60 * 60; /** Short window for the FIRST claim ... */
export const PROCESSING_TTL_SECONDS = 120;

路由逻辑驱动这三个状态转换:

const claim = await claimWebhookEvent('resend', svixId, PROCESSING_TTL_SECONDS);
if (!claim.isFirstDelivery) return NextResponse.json({ received: true, skipped: true }); try { await handleEmailReceived(data); await confirmWebhookEvent('resend', svixId); // promote to 24h return NextResponse.json({ received: true });
} catch (error) { await releaseWebhookEvent'resend', svixId); // delete, let the retry in return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 });
}

每种状态覆盖一种失败类型,分裂的原因是其中一种情况根本跑不了任何代码:

  • 成功:confirm 用完整的 24 小时 TTL 覆盖 key。那次写入不带 NX,所以总是能成功写入,而不是因为 key 已存在就静默什么都不做。
  • 捕获到的失败:release 删除 key,所以 500 之后的重试会被当作第一次投递而不是重复。这是快速恢复的路径。
  • 崩溃或超时:没有代码运行,所以没什么可删除的。短 TTL 是唯一的机制。两分钟后 key 消失,下一次重试就能通过。

第三种情况是初始 TTL 存在的原因,第二种情况是它不需要设计得很巧妙的原因。短窗口是崩溃路径的兜底,不是主要的恢复方式。

120 秒是怎么来的

不是根据处理函数的平均耗时。路由自己声明了一个上限:

// Allow up to 30s for the fetch, parse and forward pipeline.
export const maxDuration = 30;

claim 的 TTL 必须覆盖平台允许的最长运行时间,因为到达截止时间被 kill 的处理函数恰恰就是上面说的崩溃情况。120 秒是硬上限 30 秒的四倍,留了空间让上限提高而不至于让 claim 的有效期偷偷变得比它要覆盖的工作还短。把这个数字绑定到上限上,而不是 p99 上,就不再是一个需要反复调优的参数。

失败时开放,处理器必须幂等

没有配置 Redis,或者 Redis 抛异常,claim 会返回 isFirstDelivery: true:

// Deliberately fails OPEN when Redis is unavailable: dropping a real email is
// worse than forwarding a duplicate.

两个消费者都能接受重复投递,原因不同。

Stripe 处理器的写入是带顺序保护的 upsert,所以重放事件不会把权益往回移动:更新的 setWhere 只在存储的事件时间戳不比正在应用的新时才匹配。那个保护有它自己的故事,它让幂等性 key 变成了优化而不是正确性要求。

入站邮件处理器的重复是一封邮件在收件箱里出现两份,这只是个麻烦。另一个选项——一封从未转发过、以后也不会再有的消息——是丢失客户对话。你可以在 munchable.app/support 看到那条流水线的另一端:网页表单和入站邮件汇入同一个队列。

文档注释最后写着"处理器必须幂等",这句话是承重的。如果处理器不是幂等的,没有正确的 TTL:设短了崩溃会重复执行,设长了崩溃会丢失。Key 的选择只在处理器能承受被执行两次时才变得有意义。

一个没人读取的字段

返回类型携带了第二个字段:

export interface IdempotencyResult { isFirstDelivery: boolean; degraded: boolean;
}

degraded 在两个 fail-open 路径上都是 true:没有 Redis,或者 Redis 抛异常。在三个地方设置,零个地方读取。两个路由只看 isFirstDelivery,不看别的。

这个信息没有丢失,因为库在每个这样的路径上都会记录一条错误。但路由是它可以做点什么的地方:打一个 header、加一个计数器、决定支付事件的无保护重放和转发邮件的无保护重放应该区别对待。在它做这些事情之前,这个字段只是一个带类型注解的注释。我把它写在这里而不是删掉,因为我真正想要的是一个指标,而诚实的现状是这个字段目前还是愿景。

相关的内容,来自同样的两个端点:Resend webhook 订阅属于账户而不是域名,这是另一种把一个事件处理两次的方式;还有那个测量了一个字符串却存储了另一个的 Redis 缓存,那是 key 生命周期在另一个方向上出问题的故事。