CRM 的 Webhook 看起来是简单的那一类。不涉及资金流动,处理慢了也不会有订阅被删除。一个联系人变了,去更新你那份副本就行。
然后有客户告诉你,他们的生命周期阶段在你的应用里是 Lead,在 HubSpot 里却是 Customer,而且已经错了两周,而涉及的事件没有一个被处理过两次。
业务记录类 Webhook 的失败方式和支付 Webhook 不同。流量以更新为主,而不是以事件为主,同一个对象会在短时间内变更很多次,而顺序保证比任何看过请求体的人想象的都要弱。
TL;DR
- “HubSpot 不保证你会按照这些通知发生的顺序收到它们”
- 通知以数组形式到达,而不是单个对象。每批最多 100 条
- 响应时限是 5 秒,针对整批。其他发送方的情况见 Webhook 平台参考
- 失败的通知会在 24 小时内最多重试 10 次,这会拉大乱序的时间窗口
- 文档写明
eventId“不保证唯一”,所以它不适合做幂等键 - 按
occurredAt排序。用组合键去重。永远不要按到达时间排序
看起来像答案的那个字段
从 Stripe 过来的人,第一反应是找到事件 ID,在上面建个唯一索引,然后继续往下做。HubSpot 的请求体里正好就有 eventId。
文档是这么写的:eventId 是“触发这条通知的事件的 ID。这个值不保证唯一。”
这是一个少见的直白警告,却很容易一眼扫过去。在 eventId 上建唯一索引,测试时看起来没问题,但在生产环境的某个时刻,它要么拒掉一个你需要的事件,要么接收一个你已经处理过的事件。这两种失败都不会主动暴露出来。
你能依赖的两个字段是 occurredAt 和 attemptNumber。前者是“该事件发生的时间,以毫秒时间戳表示”,后者是“从 0 开始,表示这是第几次尝试向你的服务通知该事件”。
attemptNumber 是有用的诊断依据。只要大于 0,就说明这是一次重试,也就说明你的端点之前失败过,也就意味着事件现在已经不按发生的顺序到达了。
批量发送改变了处理程序的形态
HubSpot 不是每个请求发一条通知:“你应该预期在单个请求中收到一个对象数组。批次大小可能不同,但会少于 100 条通知。”
这带来三个后果。
你的处理程序是一个循环,而不是一个函数。5 秒的响应时限覆盖整个数组。每条记录一次 30 毫秒的数据库往返,单是这一项,大约 160 条就会超出时限;每条记录再附带其他工作的话,数组还没到 100 条就可能超出。
HTTP 里没有表达部分失败的词汇。如果 90 条里的第 40 条抛出异常,你没法告诉 HubSpot“前 39 条已经处理好了”。整批只能返回一个状态码。返回非 2xx,90 条会在重试时全部重来;返回 200,你还没处理到的那 50 条就没了。
这就逼出了和其他地方一样的结论:先持久化整个数组,返回 200,之后再处理。批量写入是一次插入而不是 90 次,这也顺便解决了时间问题。
export async function POST(req: Request) {
const raw = await req.text();
if (!verifyHubSpotSignature(raw, req.headers)) {
return new Response("unauthorized", { status: 401 });
}
const notifications = JSON.parse(raw); // always an array
await db.insertBatch(notifications); // one durable write
return new Response(null, { status: 200 }); // inside 5s regardless of size
}
顺序为什么会乱,去重为什么修不好
一个销售在九十秒内把一笔交易改了四次。四条通知。你的端点短暂异常,拒掉了第二条。HubSpot 会重试它,“最多 10 次”,这些重试“分散在接下来的 24 小时内,每次请求之间的间隔各不相同”。
于是第二条通知在第三条和第四条之后才到达。你的处理程序照样应用了它。这笔交易的阶段现在停留在第二次编辑时的状态,在有人再次改动这条记录之前,它会一直这样。
没有任何通知被处理两次。去重完全正常。数据还是错了。
关键的区别在这里:幂等性防的是同一个事件被处理两次。它完全防不了用旧状态覆盖新状态。这是两种不同的失败,修法也不同,而 CRM 事件流产生第二种失败的频率远高于支付事件流,因为同一个对象会被反复更新。
修法是在写入语句本身加一个单调递增的守卫条件。
UPDATE contacts
SET lifecycle_stage = $1, hs_updated_at = $2
WHERE hubspot_id = $3
AND hs_updated_at < $2; -- refuse to apply stale state
$2 用 occurredAt,不要用你自己的 NOW()。你的时钟记录的是你收到它的时间,而接收顺序恰恰是不可靠的那个东西。更完整的讨论,包括有真正不变量的状态机该怎么处理,见同一个 Webhook 收到了两次。
真正靠得住的去重键
既然 eventId 不能用,就用几个字段拼一个组合键,让它们一起标识一次变更:
CREATE TABLE processed_hubspot (
object_id BIGINT NOT NULL,
subscription_type TEXT NOT NULL,
occurred_at BIGINT NOT NULL,
PRIMARY KEY (object_id, subscription_type, occurred_at)
);
一个对象,一种变更,一毫秒。同一毫秒内对同一个字段的两次确实不同的编辑会冲突,对于人工操作的 CRM 编辑,这个风险真实存在但可以忽略;对于批量导入,这个风险是实打实的。对于属性变更订阅,把 propertyName 加进键里:不管变的是哪个属性,subscriptionType 都一样,所以不加它的话,同一毫秒内两个不同属性的变更也会冲突。
这一点值得说清楚,因为它比 Stripe 给你的要差一档。有平台签发的唯一事件 ID,去重是精确的。用组合键,去重只是一种很好的启发式方法。把这个选择写在表定义旁边,让下一个人知道这是有意为之。
批量导入就是压力测试
一个客户导入 50,000 个联系人,会在短时间内产生 50,000 条通知,按数组分批发来。这时上面说的所有问题会同时从理论变成现实:批次最大,数据库最忙,5 秒的响应时限最紧,而任何失败产生的重试都会在几小时后到达,和实时编辑交错在一起。
如果你只用手工构造的单个事件测试过,那你就没测到会出问题的场景。给自己发一批 100 条、时间戳故意打乱的通知,检查最终状态是不是 occurredAt 最新的那条,而不是最后到达的那条。
AnyHook 能做什么
顺序问题没有一部分属于传输层。如果 HubSpot 在第四条通知之后才发出第二条,没有任何转发服务能把它撤回,任何声称能做到的产品都是在过度宣传。
转发服务改变的,是它周围的一切。
把 HubSpot 指向 in.anyhook.net/you/hubspot,不管批次多大,AnyHook 都约 1 秒返回 200,5 秒的响应时限就不再限制你怎么处理。向你的端点投递时,按 AnyHook 自己的计划而不是 HubSpot 的来重试,所以一次短暂的宕机不会把乱序窗口拉宽 24 小时。
除此之外:
- 每一批在尝试投递之前都会连同请求头和请求体一起保存,所以“我们到底收到了什么、按什么顺序收到的”可以从日志里找到答案,而不是根据数据库的当前状态去反推
- 重放某个时间范围内所有失败的事件,就能用修好的处理程序重新跑一遍导入时丢掉的部分,而不用请客户再导入一次
attemptNumber大于 0 不再是你发现出问题的唯一线索,因为投递失败会被计数,并在第 1、5、20 次时告警
你的处理程序仍然需要版本守卫。这部分归你。
总结
CRM 这一类需要的本能反应,和支付类不一样。建任何索引之前,先读字段文档,因为 HubSpot 少见地坦白:它的事件 ID 撑不起你想压给它的分量。按 occurredAt 排序,用你有意选定的组合键去重,并为每次写入加上防止旧状态覆盖的守卫。
如果你的处理程序现在执行的是不带时间戳条件的 UPDATE ... WHERE id = $1,问题不在于它有没有写入过旧数据。问题在于有没有人注意到。