大多数讲 Webhook 幂等性的文章,包括我们自己的,都是从数据库往外推演。你会多出一行重复数据、多发一张收据、计数器翻了一倍。都是真问题,但都很抽象。读者点点头,就翻过去了。
到了预约类集成,这个论证就不再抽象了。重复的不是一行数据。而是 10:00 的视频会议里有两个人,都认为这个时段是自己的,其中一个还为此请了一上午的假。
TL;DR
- 预约类 Webhook 的超时很宽松,这让它们感觉很安全
- 一次重试的后果,是一个需要人来解决的现实冲突,而不是一行你能清理掉的数据
- Calendly 用
Calendly-Webhook-Signature签名,内容是时间戳和签名的组合,对timestamp.body做 HMAC-SHA256 - 按预约本身的 URI 或 UUID 去重,而不是按请求体内容,因为改期看起来很像一个新预约
- 取消和改期乱序到达,才是真正会把日历搞乱的情况
为什么这一类感觉安全,其实不然
让其他类别的 Webhook 变得可怕的大部分因素,这里都没有。没有 Shopify 那样五秒的响应时限。不会因为一个糟糕的下午就有订阅被删除。对大多数产品来说,预约量都低到永远碰不到限流。
于是处理程序很快写完,用一个预约测试一下,就上线了。
风险在另一边。支付 Webhook 被处理两次时,你会在自己的数据里发现,并在任何外部的人受到影响之前修好。预约 Webhook 被处理两次时,第二份会立刻被客户看到,等你发现的时候,造成的损失已经是一次日程冲突,而不是数据不一致。
具体过程
一个受邀者预约了 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 的正确性不再只是工程问题,而是直接面向客户的问题。超时宽松到没有任何东西逼你去想它,直到两个人出现在同一个时段。
按预约标识符和事件类型一起去重。写入时用平台的时间戳做守卫,而不是你自己的。在时段上建唯一索引,让表结构兜住处理程序漏掉的情况。对待迟到的取消,要像对待迟到的退款一样,因为一个悄悄出错的日历,修起来比一行悄悄出错的数据代价更高。