重复投递不是企业微信 Webhook 的缺陷,是所有 HTTP 事件投递的固有语义 —— 投递方拿不到响应时,没有任何办法区分「没送到」和「送到了但回执丢了」。所以问题从来不是怎么让重复消失,而是怎么让重复不产生第二次副作用。这篇把幂等设计拆成四个必须自己拍板的决定:键怎么取、窗口多长、存哪里、存储挂了怎么办,例子按 wecomapi 的事件回调形态来写。
重复投递不是故障,是投递语义的必然
先接受一个前提:任何基于 HTTP 的事件投递都做不到「恰好一次」。投递方发出请求后如果没拿到响应,它无法区分三种情况 —— 请求根本没到你这、到了但你处理失败、你处理成功了而响应在回程丢了。这三种在投递方眼里长得一模一样,它唯一安全的选择就是重发。所以重复是设计出来的结果,不是坏掉的表现。
落到企业微信这类事件回调场景,真正制造重复的入口比大多数人预估的多:
- 响应超时:你在回调里顺手同步查了一次数据库,那次刚好慢了两秒,投递方判超时重发,而你其实已经处理完了
- 返回非 2xx:业务逻辑抛异常导致 500,投递方按失败重试,但异常很可能发生在副作用之后
- 中间层截断:网关超时、负载均衡断连、服务滚动重启,请求在你的接入层已经生效但响应没回去
- 你自己的队列:ACK 之后事件进了消息队列,消费者崩溃或超时未提交位点,同一条消息会被再投一次
- 人为重放:死信补偿、历史事件回灌、运维手滑,这类重复量最大,而且往往是几天以后才来
最后一条最容易被漏掉:就算平台侧一次都没重投,只要你内部用了消息队列,链路依然是 at-least-once。这决定了幂等的位置 —— 它属于业务处理入口,不属于 HTTP handler。写在 handler 里的去重,挡不住队列的重投。
幂等键取什么:三个维度决定成败
幂等键只有一个要求:同一个业务事件的每一次重复投递都要算出同一个键,不同事件不能撞。听着简单,但常见的三种取法里有一种直接是错的。
- 1平台下发的事件唯一标识 —— 首选。它由投递方生成,重投时保持不变,天然满足要求。从 wecomapi 的事件回调里取这个标识之前,先确认两件事:它在你关心的时间跨度内是否真的唯一,以及不同事件类型是否共用同一个编号空间。
- 2业务自然键拼接 —— 平台标识缺失,或者你要跨两条来源去重(回调加补偿拉取)时用。挑几个稳定字段拼,比如会话标识加消息序号,别把会变的字段拼进去。
- 3整包报文哈希 —— 看起来最省事,实际最脆。投递方只要在重试时多带一个投递次数或投递时间,同一事件两次投递的字节就不一样,哈希一变去重当场失效。不要用。
真正让人栽跟头的是第二个维度:幂等键必须带上消费者身份。一条从 wecomapi 收到的客户消息事件同时要写会话库、推 CRM、触发机器人回复,如果三个处理器共用一个键,第一个跑完就把这条事件标记成已处理,后两个全被当成重复丢掉。键的正确形态是「事件标识加处理器名」,处理器之间互不干扰。这个坑的可怕之处在于,它上线时不报错,等你加第二个消费者才炸。
第三个维度是键代表什么。「我收到过这条事件」和「这条事件的副作用已经完成」是两件事。只在收到时记录,进程在副作用中途崩了,重试会被误判为重复,事件永久丢失;只在完成后记录,两个并发的重复投递会同时通过检查,副作用做两遍。正确做法是一次原子占位、两阶段落定。
// 示意逻辑:字段名、事件类型与签名算法以 wecomapi 线上文档为准
function idemKey(evt, handler) {
// 优先用平台下发的事件唯一标识,缺失时退回稳定业务字段的组合
const base = platformEventId(evt) ?? stableBusinessKey(evt);
return `wecom:${handler}:${base}`; // 处理器名必须进 key
}
async function once(evt, handler, run) {
const key = idemKey(evt, handler);
// 第一阶段:原子占位。抢不到 = 别人处理过,或正在处理
if (!(await store.acquire(key, { ttl: HOT_WINDOW }))) return;
try {
await run(evt);
await store.commit(key, { ttl: COLD_WINDOW }); // 第二阶段:落定,续到冷窗口
} catch (e) {
await store.release(key); // 释放,允许重试重新抢占
throw e;
}
}占位的 TTL 要略大于单次处理的最长耗时,否则处理还没结束占位就过期了,重试会并发进来。这个值和去重窗口不是一回事,别复用同一个常量。
去重窗口按什么定
窗口长度最常见的定法是拍脑袋 —— 五分钟,或者一天。这两个数都不是推导出来的,所以出事时也说不清为什么不够。
窗口的下界应该由「重复最晚可能到达的时刻」决定,而它由三段叠加:投递方的自动重试跨度、你自己队列的重试与死信跨度、人工重放的操作窗口。前两段可以从日志里量出来 —— 把同一事件标识的首次到达与末次到达做差,取分布的 P99,比任何经验值都可靠。第三段量不出来,只能定规矩:明确「历史事件最多回灌多久以前的」,这个约定本身就是窗口的上界。
结论是两层窗口,而不是一个数。热窗口覆盖自动重试,尺度是分钟到小时,放在内存型存储里,命中快、成本低;冷窗口覆盖人工重放与对账,尺度是天到月,落在数据库唯一索引上。只做热窗口的系统迟早会在某次死信重放里,把三天前的客户消息重新发一遍 —— 这类事故的破坏力远大于偶尔重复一次。
反过来,窗口也不是越长越好。窗口越长状态越多、清理越贵,更麻烦的是它会误杀「故意的重发」:业务上确实需要把同一条通知再发一次时,系统会拒绝。所以要留一个显式的强制通道 —— 在键里拼进批次标识,换批次就是一条新事件,而不是让运维去手工删去重记录。批次标识要在业务侧生成、一路透传到调 wecomapi 发送接口的那一步,中途任何一层重新生成一次,这条链上的去重就整段失效 —— 而它不会报错,只表现为「偶尔多发一条」,等有人投诉时往往已经过了能查日志的窗口。
- 热窗口:覆盖投递方与队列的自动重试,按实测 P99 的两到三倍取
- 冷窗口:覆盖人工重放与对账周期,按业务约定的最长回灌期取
- 强制重发:靠键里的批次标识区分,不靠删记录
- 两层窗口都要有清理任务,清理任务本身也要能被观测
存储选型:四条路线的失效方式不一样
幂等存储只需要一个原子的「不存在则写入」,但四种实现的失效方式完全不同,选错了会在最不该出问题的时候出问题。
- 内存型键值存储(比如 Redis 的 SETNX 加 TTL):最快,TTL 天然就是窗口,适合承担热窗口。代价是它不保证持久 —— 主从切换、实例重启、故障转移都可能丢掉一段时间内的键,那段时间的幂等等于没有。
- 数据库唯一索引:把幂等键做成唯一约束,插入冲突即重复。慢一些,但有一个别的方案给不了的性质 —— 它能和业务写入放进同一个事务,去重与副作用要么一起成功要么一起回滚。涉及资金、计费、外发客户消息的场景应该选它。
- 业务表自身的状态机:不额外建表,用业务主键加状态流转(待处理到已完成)承担去重。最省,但只适用于「一个事件恰好对应一条业务记录」的情况,一旦一个事件要触发多个动作就退化了。
- 概率型结构(比如布隆过滤器):省内存,但假阳性意味着把真事件当成重复丢掉,对事件投递来说这是最不能接受的错误方向。只能当前置过滤器用,命中之后必须再查一次精确存储。
选完存储还有一个必须显式回答的问题:存储本身不可用时,放行还是拒绝。放行意味着这段时间的重复全部生效,拒绝意味着事件在这段时间全部堆积或失败重试。答案取决于哪种错误更贵:内部状态同步、缓存刷新这类可以放行,反正重复执行没有可见后果;给客户发消息、扣费、开工单必须拒绝,宁可积压等重试,也不能让客户连收三条一样的内容。这个开关要写进配置,而不是让它由某次异常的默认分支替你决定。
还有一条比选存储更划算:先看看能不能把操作本身改成天然幂等的。按键覆盖写而不是累加、把状态直接置成终态而不是做增量流转、用 upsert 而不是先查后插 —— 这类操作重复执行结果一样,根本不需要外挂去重表。能改造的先改造,去重表只留给真正不可撤销的副作用,比如已经发出去的那条消息。判断能不能改造,标准是这个副作用有没有被外部感知过:写自己的库、刷缓存、把状态置成终态都可以重来,一条已经通过 wecomapi 送到客户手机上的消息不能重来。两类混在一套规格里做,通常是为了迁就后者,让前者也背上本不需要的存储与延迟开销。
本文讲的是概念与工程权衡。事件标识字段、重试策略与签名约定以 wecomapi 线上接口文档为准,示意代码不要直接照抄上生产。
常见问题
- 幂等键用整包报文的哈希行不行?
- 不建议。投递方在重试时可能带上投递次数、投递时间一类会变化的内容,同一事件两次投递的报文字节并不相同,哈希一变去重就失效了。优先用平台下发的事件唯一标识,确实没有时再挑几个稳定业务字段自己拼,并把处理器名一起拼进去。
- 去重记录要保留多久才够?
- 至少覆盖「重复最晚可能到达的时刻」,它由 wecomapi 的自动重试跨度、你自己队列的死信跨度和人工重放约定三段叠加而成。实操上分两层:分钟到小时级的热窗口挡自动重试,天到月级的冷窗口挡人工重放与对账。前两段能从日志里量出 P99,第三段只能靠约定一个最长回灌期。
- 去重存储挂了应该放行还是拒绝?
- 按副作用能不能撤销来定,并且写进配置而不是留给默认分支。内部状态同步、缓存刷新这类重复执行无害的可以放行;外发客户消息、计费、开单这类不可撤销的必须拒绝,让事件走重试或积压,也好过客户连收三条相同内容。
准备好动手了?
精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。
