可靠性2026年8月6日7 分钟阅读

重复预约:一次 Webhook 重试在真人眼里是什么样子

讨论幂等性,通常以数据库里多出一行告终。到了预约类 Webhook,结局是两个人同时出现在同一个 10:00。

一次预约,投递了两次invitee.created200 太慢你的处理程序写入时段日历10:00 已占用重试同一个预约日历10:00 被占用两次两个人,一个时段冲突不在你的数据库里。它在某个人的早晨里。
一个预约,两次投递,两条日历记录。重复不在某张表里,而在某个人的上午。

大多数讲 Webhook 幂等性的文章,包括我们自己的,都是从数据库往外推演。你会多出一行重复数据、多发一张收据、计数器翻了一倍。都是真问题,但都很抽象。读者点点头,就翻过去了。

到了预约类集成,这个论证就不再抽象了。重复的不是一行数据。而是 10:00 的视频会议里有两个人,都认为这个时段是自己的,其中一个还为此请了一上午的假。

TL;DR

  • 预约类 Webhook 的超时很宽松,这让它们感觉很安全
  • 一次重试的后果,是一个需要人来解决的现实冲突,而不是一行你能清理掉的数据
  • Calendly 用 Calendly-Webhook-Signature 签名,内容是时间戳和签名的组合,对 timestamp.body 做 HMAC-SHA256
  • 按预约本身的 URI 或 UUID 去重,而不是按请求体内容,因为改期看起来很像一个新预约
  • 取消和改期乱序到达,才是真正会把日历搞乱的情况

为什么这一类感觉安全,其实不然

让其他类别的 Webhook 变得可怕的大部分因素,这里都没有。没有 Shopify 那样五秒的响应时限。不会因为一个糟糕的下午就有订阅被删除。对大多数产品来说,预约量都低到永远碰不到限流。

于是处理程序很快写完,用一个预约测试一下,就上线了。

风险在另一边。支付 Webhook 被处理两次时,你会在自己的数据里发现,并在任何外部的人受到影响之前修好。预约 Webhook 被处理两次时,第二份会立刻被客户看到,等你发现的时候,造成的损失已经是一次日程冲突,而不是数据不一致。

一次预约,投递了两次invitee.created200 太慢你的处理程序写入时段日历10:00 已占用重试同一个预约日历10:00 被占用两次两个人,一个时段冲突不在你的数据库里。它在某个人的早晨里。

具体过程

一个受邀者预约了 10:00。Calendly 触发 invitee.created。

你的处理程序写入这个时段,然后调用日历服务创建日程,再发一封确认邮件。那天上午日历接口很慢。整个过程耗时太长,这次投递被记为失败。

Calendly 重试。你的处理程序又跑了一遍。它没有任何办法知道第一次已经跑完,因为第一次完成时,响应已经丢了。两条日历记录,两封确认邮件,还有一个被你的可预约时间逻辑认为占用了两次的时段。

每一步单独看都没问题。这个处理程序没有任何单元测试能发现的 bug。

按预约去重,而不是按请求体

最直接的想法是对请求体做哈希,把哈希相同的当成重复。

应该用平台给预约本身分配的标识符。Calendly 的请求体里带有一个 URI,能唯一标识受邀者和已安排的日程。Cal.com 和 Acuity 也有类似的字段。同一次投递的多次重试中,这个标识符保持不变;真正不同的预约,标识符也不同。

CREATE TABLE processed_bookings (
  booking_uri TEXT NOT NULL,
  event_type  TEXT NOT NULL,
  occurred_at TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (booking_uri, event_type)
);
const { rowCount } = await db.query(
  `INSERT INTO processed_bookings (booking_uri, event_type, occurred_at)
   VALUES ($1, $2, $3) ON CONFLICT (booking_uri, event_type) DO NOTHING`,
  [body.payload.uri, body.event, body.created_at],
);
if (rowCount === 0) return new Response(null, { status: 200 });

注意这里用的是 (booking_uri, event_type) 组合键,而不只是 URI。同一个预约会正常地先产生 invitee.created,之后再产生 invitee.canceled,两个你都需要,而且各自恰好处理一次。

取消乱序到达是更糟的 bug

重复会造成一个马上有人注意到的冲突。顺序错误造成的,是一个悄悄出错的日历。

有人预约了 10:00,四分钟后取消。invitee.created 的投递失败并重试。invitee.canceled 的投递先成功了。你的处理程序去取消一个还不存在的预约,记一条警告日志,然后继续。接着重试到达,创建了这个预约。

现在这个时段被一个没人会来的会议占着,而且任何地方都没有报错。去重在这里无关紧要:两个事件都没有重复到达。它们到达的顺序错了。

修法和订阅、CRM 记录用的一样,是单调递增的守卫条件,用的是预约本身的时间戳,而不是你的到达时间:

UPDATE bookings
SET status = $1, updated_at = $2
WHERE booking_uri = $3
  AND updated_at < $2;

对于这么小的状态机,显式检查状态转换更好。一个已经是 canceled 的预约,在迟到的创建事件到来时,应该拒绝变成 active,而且这个拒绝应该醒目地记进日志,而不是默默发生。通用的处理方法见同一个 Webhook 收到了两次。

签名验证

Calendly 会发送一个 Calendly-Webhook-Signature 请求头,里面是用逗号分隔的时间戳和签名。被签名的内容是时间戳、一个点号,再加上原始请求体,用创建订阅时拿到的签名密钥做 HMAC-SHA256。

如果这个结构看着眼熟,那是因为 Stripe 用的也是同样的构造(Webhook 平台参考列出了哪些平台采用同一种方式),它也带着同样的两个要求:对原始字节计算哈希,而不是对重新序列化的对象;用恒定时间比较。各平台的细节见验证 Stripe、GitHub 和 Shopify 的 Webhook 签名。

缺少签名请求头的请求要直接拒绝,而不是默认放行。一个接受未签名 POST 的预约端点,等于让互联网上的任何人都能把你的日历填满。

让日历本身成为约束

在这一类里,最靠得住的修法根本不在 Webhook 处理程序里。而是资源层面的唯一约束:

CREATE UNIQUE INDEX one_booking_per_slot
  ON bookings (resource_id, starts_at)
  WHERE status = 'active';

这样一来,即使其他每一层都失效,重复投递也不可能造成重复预约,因为数据库会拒绝它。处理程序捕获这个约束冲突,把它当作重复而不是错误来处理。

即使幂等已经做对了,这一步也值得做,因为它把保证从“我们的处理程序很小心”变成了“表结构让它不可能发生”。这是两种不同强度的保证,对于客户直接感受到的东西,你要的是后一种。

AnyHook 能做什么

重试是一种传输层行为,你的处理程序之所以看到两次,是因为第一次投递没能及时得到应答。

把 Webhook 订阅指向 in.anyhook.net/you/scheduling。AnyHook 在边缘节点完成持久化,约 1 秒应答,所以你这边的日历接口再慢,也不会造成投递失败,平台的重试也就永远不会触发,那个重复事件也永远不会到达你的处理程序。

  • 向你的端点投递时按指数退避重试,间隔拉开,让正在恢复的系统有喘息的空间,而不是承受压力
  • 每个预约事件都在投递之前保存,所以“我们收到取消了吗”可以从日志里找到答案,而不是从一个可能有错的日历去推断
  • 按时间范围重放,可以用修好的处理程序把出问题的那段时间重新跑一遍,对预约来说,这意味着修好日历,而不是发邮件请客户重新预约
  • 连续失败 1 次、5 次和 20 次时告警,对于这一类来说,区别在于你是从监控里发现问题,还是从到场的那个人那里发现问题

你的处理程序仍然需要去重键、版本守卫,最好再加上唯一索引。转发服务消除的是平台的重试,而不是重试发生时你必须正确处理的要求。

总结

在预约这一类里,Webhook 的正确性不再只是工程问题,而是直接面向客户的问题。超时宽松到没有任何东西逼你去想它,直到两个人出现在同一个时段。

按预约标识符和事件类型一起去重。写入时用平台的时间戳做守卫,而不是你自己的。在时段上建唯一索引,让表结构兜住处理程序漏掉的情况。对待迟到的取消,要像对待迟到的退款一样,因为一个悄悄出错的日历,修起来比一行悄悄出错的数据代价更高。

全部文章2026年8月6日 · 7 分钟

别再丢 Webhook 了。

只改一个 URL。获得重试、事件日志和一键重放。也可以先亲眼看看:注册后跑一次 30 秒的故障演练,看一个事件如何挺过你的服务器宕机。不需要服务器。