昨天处理了个极其下饭的排障工单。有个做私域代运营的研发哥们跑来找我查日志,说他们的客服机器人在半夜突然"发癫",给同一个 VVIP 客户连发了 8 遍一模一样的产品报价单,客户直接被烦到怒退群。这哥们咬死是网关抽风了,一直在重复发送。
作为每天在一线跟各类技术团队死磕 星云API(xingyapi.com) 接口联调的销售客服,我拉出底层的 Webhook 推送流水一看,当场给他怼了回去:网关确实推了 8 次,但原因是他自己的接收服务器在处理第一次推送时,跑去查了个奇慢无比的连表 SQL,导致 5 秒内没给网关返回 success!
这种因为没做"幂等处理(Idempotence)"引发的惨案,我简直看得眼睛都起茧子了。今天咱们不聊那些轻飘飘的理论,直接基于底层通信架构,把 Webhook 重复事件的幂等处理思路彻底盘透,帮你把系统的防洪堤筑死。
认清现实:企微网关的"暴脾气"
在分布式系统里,如果你仔细翻过 接口文档 里的 Webhook 回调机制说明,就会发现企微的推送永远遵循"至少投递一次(At-least-once)"原则。
网关把加密报文推给你的服务器,最多只等你 5 秒钟。如果 5 秒内没收到你明确返回的 HTTP 200 和纯文本 "success",网关就会无情地判定你的服务器"挂了"。为了防止关键业务消息丢失,它会毫不犹豫地按阶梯时间(比如 15秒、1分钟、5分钟)发起疯狂的超时重试。
如果你在接收端直接写死业务逻辑,稍微一点网络抖动或者数据库锁表,重试洪峰马上就会教你做人。
工业级幂等架构的三道防线
不要想着去阻止网关重试,你要做的是让你的系统"刀枪不入"------不管同样的一段 JSON 砸过来多少次,核心业务逻辑绝对只执行一次。
第一道防线:Redis 抢占式缓冲锁(拦截率 99%)
这是应对并发重试最快、最有效的方法。解密拿到报文后,第一件事就是提取消息的"唯一指纹"。
-
普通消息 :直接抠出报文里自带的
clientMsgId。 -
动作事件 :如果是退群、改标签等没有明文 MsgId 的事件,把
ChatId+Event+CreateTime拼接起来算一个 MD5 哈希。
拿着这个指纹,去 Redis 里执行 SETNX(如果不存在则设置),顺手设个 10 分钟的过期时间。 抢锁成功的,扔进 MQ 慢慢消费;抢锁失败的,说明绝对是重试报文。此时立刻中断业务 ,向网关果断返回 success 糊弄过去,绝对不让它流转到大模型或者下发逻辑里。
第二道防线:数据库唯一索引(兜底保障)
Redis 偶尔也会抖动,或者锁过期了网关又发起了一次极其滞后的重发,这时候必须靠底层存储来兜底。
在设计你们的业务明细表(比如聊天记录表、动作流水表)时,千万别只依赖自增 ID。一定要建一个基于上述消息指纹的 UNIQUE KEY(唯一索引)。 哪怕第一道防线被击穿,数据库在执行 INSERT 时也会触发唯一键冲突(Duplicate Entry)。代码里直接捕获这个异常并安静地吞掉,完美兜底。
第三道防线:状态机的乐观锁(针对变更事件)
对于修改客户资料、变更群成员状态这类事件,重复推送极容易造成数据覆盖的脏写。
在执行 UPDATE 前,必须带上前置状态检查。比如处理"退群事件"时,执行的 SQL 应该是: UPDATE t_customer SET status = 'LEFT' WHERE user_id = 'xxx' AND status = 'IN_GROUP'
如果网关重试了三次,第一次已经把状态改成了 LEFT,后面两次的 UPDATE 受影响行数直接为 0,业务逻辑天然免疫重复。
研发避坑:别拿肉身去抗高并发
幂等逻辑用嘴说起来简单,但在并发场景下,锁没写好或者事务没隔离好,极其容易引发死锁或者脏读。很多团队直接在线上业务去盲测,一到流量高峰就原形毕露。
动手写业务代码前,必须上工具强力 Mock!
强烈建议大家在重构这段架构时,打开 Apifox 或者 Apipost:
-
在本地环境起好这套带有 Redis 锁和唯一索引的接收网关。
-
在工具里捏造一段真实的 Webhook JSON(比如带着固定
clientMsgId的文本消息)。 -
利用测试工具的并发/性能测试功能,开 50 个线程,在 1 秒内把这个一模一样的请求疯狂轰炸你的本地接口。
-
盯着日志和数据库:如果只有 1 条成功落库并在群里触发了回复,另外 49 条全被拦截并平滑返回了 HTTP 200,恭喜你,你的幂等架构彻底立住了。
做接口开发,敬畏分布式网络的复杂性是第一课。把防重去重的护城河挖深,你的机器人才不会在关键时刻乱发神经。
