基本信息
阅读时间:约 11 分钟
字数:约 4284 字
摘要:同一条通知发了三遍?本文用幂等性讲清重复触发、乱序到达、失败补跑三种场景的处置,并给出可落地的幂等键设计与执行清单。
全文语音
中文
English
日本語
한국어
一条通知为什么发了三遍
周三早上七点五十分,杭州的运营小林被手机连续震了三次。三次都是同一封“订单已支付”的推送,连订单号都一模一样。她盯着屏幕愣了两秒,第一反应是系统是不是崩了,第二反应是客户会不会被扣三次钱。这种“同一条通知发了三遍”的场面,几乎每一个跑过自动化的人都在深夜里遇见过。

我第一次撞见它,是在帮一家网店搭库存同步的时候。那家店用了三套工具:店面是 Shopify 类平台,仓库是另一个系统,中间的桥接是一个类似 Zapier 的自动化。只要一笔订单状态变更,桥接就会触发一次同步。问题出在 webhook——同一个订单的“已支付”事件,因为网络抖动被源系统投递了三次。

我当时想得很简单:多跑两次同步又能怎样?反正最终库存数对就行。后来才发现,每一次同步都会顺手给客户发一条短信、给财务写一条流水、给物流打一个标。三遍通知,意味着三条短信、三条流水、三个物流标记,全部是重复的。客户收到三条短信以为自己付了三笔,财务月底对账多出一摞乱账。

这件事让我明白:自动化里最贵的不是“没跑起来”,而是“跑了太多遍”。当一条消息被投递多次,真正要解决的不是让它跑快一点,而是让它无论到几次,结果都只生效一次。这背后藏着一个听起来很学术、用起来很救命的词——幂等性。

幂等性到底是什么
幂等(idempotent)这个词来自数学,说的是某个操作连做两遍,结果和做一遍一样。放到自动化里,就是:同一份输入,无论被触发一次还是一百次,系统最终状态都相同。你按一百次电灯开关,灯要么是亮要么是灭,不会“更亮”;幂等要的就是这种稳。

很多人会把“幂等”和“不重复”搞混。不重复是消息层面——同一个事件只来一次;幂等是结果层面——就算来了十次,也只认第一次。消息可靠性没法百分之百保证(网络、重试、超时都会让同一事件重复到达),所以工程上真正的做法是:接受它会重复,但用设计让重复无害。

实现幂等的核心,是一把“幂等键”(idempotency key)。它像每张单据的身份证:每次请求都带上同一个键,系统在落库前先查“这个键处理过没有”。处理过就直接返回上次的结果,没处理过才真正执行。这样无论请求来几遍,第二次起都在门口被拦下。

还有一个容易混淆的点:幂等不等于“什么都不做”。它做的是“只做一次该做的事”。第一次把短信发出去了、把流水写进去了;后面几次进来,系统知道“这事办过了”,于是原样返回成功,但不再重复发短信。用户看到的结果,和只触发一次完全一样。

重复触发:同一事件被投递多次
第一种场景最常见,叫重复触发。它的成因很朴素:webhook 重试、消息队列的 at-least-once 投递、网关超时后的客户端重发,都会让同一个业务事件以“多份”的姿态到达。源系统以为自己失败了,于是很负责任地再发一遍,殊不知第一遍其实已经成功了。

处置重复触发的第一道闸,是入口去重。在自动化的最前端(比如接收 webhook 的函数),先校验幂等键。键可以从业务里取:订单号、支付流水号、消息唯一 ID。只要这个键在“已处理表”里存在,就直接返回,彻底不往下走。相当于在门口挂了个名单,来过的人一律免进。

第二道闸是存储兜底。很多团队会在数据库层给关键字段加唯一约束,比如“订单号 + 操作类型”建唯一索引。就算去重逻辑有疏漏,写入时也会因为违反唯一约束而失败,数据库替你把第二遍挡在门外。这是“宁可报错也不重复”的硬保险。

第三道闸是幂等键的留存时间。键不能永远留着,否则表会无限膨胀。通常按业务周期设过期,比如支付类留 24 小时(这正对应 Stripe 这类支付平台的官方做法,其文档明确幂等键默认保留 24 小时)。过期后即使重复到达,也已经落在窗口外,需要配合对账兜底,而不是无限依赖去重。

乱序到达:后发的消息先到
第二种场景更隐蔽,叫乱序到达。消息队列、跨区复制、移动网络都会让“先发生的事件后到,后发生的事件先到”。比如订单先“已支付”后“已退款”,如果“已退款”先到,系统先把订单标成退款,紧接着“已支付”才到,又把它标回已支付——状态就被颠倒了。

处置乱序的关键是“定序”。给每个事件打一个单调递增的序号,或者带上业务里的时间戳、版本号。系统只接受“比当前已知版本更新”的事件,更旧的事件直接丢弃。就像看朋友圈,你不会因为刷到了一条旧动态,就认为那是当下最新的状态。

这里有个细节:版本号要来自业务本身,而不是消息到达的顺序。如果用“谁先到就算新”这种逻辑,乱序一来就翻车。正确做法是事件自带逻辑时钟——比如订单状态机从“待支付=1”到“已支付=2”到“已退款=3”,只认更大的数字,旧的覆盖不了新的。

在类似 Zapier 的多步骤自动化里,乱序还表现为“后续步骤跑在了前置步骤之前”。解法是在每一步记录前置依赖的完成标记,没拿到前置标记的步骤先挂起或进缓冲队列,等齐了再按正确顺序提交。顺序错,结论就错,定序是乱序的唯一解药。

失败补跑:上一次到底成功没有
第三种场景叫失败补跑,也叫重试。它和重复触发常常结伴出现:第一次请求超时了,调用方不知道服务端到底有没有处理完,于是发起重试。如果服务端其实已经成功,只是回包丢了,重试就会变成“重复触发”。补跑的核心难题,是“上一次成功没有”这件事,调用方往往并不知道。

处置补跑的黄金法则:把重试请求和原请求打上同一个幂等键。这样即便第一次成功、回包丢失,第二次带着相同键进来,服务端一看“这键办过了”,直接把上次的成果返回。调用方拿到成功,世界安宁,没有副作用。

如果服务端确实没成功(比如第一次在落库前就崩了),那第二次带着相同键进来,就当作一次全新处理。幂等键的存在,让“成功过的请求”和“没成功过的请求”走的是同一条安全通道,区别只是系统内部有没有记录。它把“不确定”变成“可重放且无害”。

补跑还有一个伴生问题:补偿。有些操作天生不可简单幂等,比如“给用户账户加一百元”。直接加两次就真多了一百。这时要用“记录型补偿”:先写一条“待确认加款”的流水,确认成功才真正入账;失败则冲销。把不可逆动作拆成“记账 + 确认”两步,补跑时只重复安全的那一步。

幂等键怎么设计才不踩坑
幂等键是整个设计的命门,键设计错了,前面所有闸都形同虚设。第一原则:唯一性。键必须能精确指代“这一次业务动作”,通常是订单号、交易号这类业务主键,而不是随机 UUID——随机值每次都不同,去重就失去了意义。

第二原则:稳定性。同一个业务动作,在所有重试里必须带同一个键。如果客户端每次重试都生成新键,服务端就会把它当成不同请求,去重彻底失效。键应当由发起方在第一次就定好,并一路透传。

第三原则:不可预测与隔离。在对外接口里,幂等键最好由调用方提供而非服务端猜,避免不同调用方撞键。对内自动化,键可以由“业务主键 + 操作类型 + 步骤序号”组合而成,比如“ORD-10086|SYNC_STOCK|1”,既唯一又可追溯。

第四原则:有效期。键不能无限留存,按业务窗口设 TTL。支付留 24 小时、库存同步留几分钟都合理。过期后去重失效应由对账与人工复核兜底,而不是指望一张永远膨胀的表。四原则缺一,幂等就会有缝隙。

专业分析
这一章用公开可查的工程事实,把幂等性的代价与做法钉死。先说量级:据 AWS 官方文档口径(来源类型:云厂商技术文档),SQS 标准队列采用 at-least-once 投递,消息在极端情况下可能重复送达,平台明确建议消费端自己做去重;Kafka 社区文档同样把“消息可能重复”列为 at-least-once 语义的固有属性。也就是说,“重复”不是 bug,而是分布式系统的默认假设。

再看可辨识的真实案例。支付服务商 Stripe 在其公开 API 文档中,把“幂等键”(Idempotency-Key 请求头)作为所有写操作的标准推荐,并明确该键默认保留 24 小时(来源类型:厂商技术文档)。这意味着连全球级支付平台,也是靠“每个写请求带同一把键 + 服务端按键去重”来消灭重复扣款,而非靠消息队列零重复。另一例是大型电商的“订单状态机”实践:用单调递增的状态版本号(待支付→已支付→已发货→已退款)拒绝旧版本覆盖,是处理乱序到达的业界通法。

框架层面,我提出一个可复用的「幂等三场景处置框架(TRO 模型)」:T=Token(幂等键去重)、R=Record(状态/版本记录定序)、O=Order(补偿与对账兜底)。它把前面三章的场景收拢成一套动作。与之对照的是更朴素的「自然重试法」:失败就无脑重发、不挂键、不记版本。两者对比如下——自然重试法在重复触发时直接造成多次副作用,TRO 则在入口用幂等键拦下;自然重试法靠人工对账发现重复,TRO 用数据库唯一约束在写入层兜底;自然重试法用消息到达顺序判断新旧,TRO 用业务版本号只接受更新;自然重试法对不可逆动作直接执行,TRO 拆成记账加确认两步补偿。

可落地度量指标有四项:一、重复事件拦截率(被幂等键拦下的请求占全部重复请求的比例),目标不低于 99.9%;二、幂等键命中率(命中已有键的请求比例),用来验证重试是否真的带了相同键;三、乱序丢弃率(被版本号拒绝的旧事件占比),验证定序是否生效;四、补偿成功率(补偿流水最终一致的比例)。执行清单:一、给所有写接口加上幂等键参数;二、建“已处理键”表并设唯一索引与 TTL;三、给状态机加版本号拒绝旧值;四、把不可简单幂等的动作拆成“记账+确认”;五、每日跑对账扫描残留重复;六、在自动化平台(如 Zapier 类)的 webhook 入口前置去重步骤。把这张清单贴进发布前检查,幂等性就有了可验收的锚点。

把幂等性装进你的自动化流程
讲了这么多机制,落到日常用的自动化工具上该怎么做?如果你用的是 Zapier 类平台,关键是别把“接收事件”和“执行业务”放在同一个裸步骤里。先在触发步骤之后插一个“筛选/查重”步骤:用表单或代码步骤检查这个事件 ID 是否已经在你的记录里出现过。

具体做法:用一个轻量数据表(甚至一张 Google Sheet 或 Airtable)充当“已处理键”表。每次触发,先拿事件唯一 ID 去查;查到了就走“直接结束”分支,查不到才继续往下,并在执行后把 ID 写进表。这张表就是你的去重闸,零代码也能搭起来。

对于会乱序的多步骤流程,给每一步都写一个小标签记录“前置步骤已完成”。比如“同步库存”步骤开始前,先确认“支付确认”步骤的标记存在;不存在就暂停等待,而不是硬跑。这样即使平台把步骤顺序打乱,你的业务顺序依然自洽。

最后,把幂等性当成和“错误处理”“日志”同级的基础能力,而不是出事后再补。每一次新自动化上线前,问自己三个问题:它会被重复触发吗?事件会乱序吗?失败后会被补跑吗?三个里有一个是,就必须在入口埋好幂等键。这一步省下的,是未来无数个被三条重复短信吓到的凌晨。

幂等性不是银弹:它管什么、不管什么
把幂等性讲得这么重要,容易让人以为它是万能药。需要泼一盆冷水:幂等性只保证“同一输入多次到达,结果一致”,它不保证“输入本身是对的”。如果你的业务规则写错了,幂等只会让你的错误被稳定地执行一百遍,而不是一遍。

它也救不了“读”的问题。幂等针对的是写操作(发消息、改库存、记账)。如果一个自动化只是查询并展示数据,重复跑只是多耗点资源,不会污染状态,这种场景加幂等收益很低,优先级可以靠后。

还有一类它无能为力的:外部副作用不可回滚。比如你已经调用了第三方短信网关真正发出短信,这一下是物理发生的,幂等键能拦下“再次调用网关”,但拦不下“已经发出去的那条”。所以对外不可逆动作,要尽量放在去重闸之后,且做好“发了就认”的心理准备与对账。

最后,幂等性有成本:那张“已处理键”表要存、要查、要定期清理;版本号要维护;补偿逻辑要写。小项目可以先做最轻量的入口去重,等业务长到一定有重复痛了,再逐步补齐定序与补偿。它不是银弹,但它是自动化从“玩具”长成“工具”必经的一道门。

常见问题
1.问:我的自动化很简单,只有一步发消息,还需要幂等吗?答:只要这条消息的触发源是 webhook 或定时轮询,就可能重复。一步虽小,重复三次客户就收到三遍。哪怕只是加一张“已发过此 ID”的表,也能立刻止血。
2.问:幂等键用随机 UUID 行不行?答:不行。UUID 每次都不同,去重逻辑永远查不到“相同”的键,等于没做。键必须是能稳定指代同一次业务动作的业务值,比如订单号或支付流水号。
3.问:加了唯一索引就够了吗,还要幂等键吗?答:唯一索引是数据库层的最后兜底,但它在冲突时会抛错,未必能优雅返回“上次的结果”。幂等键在入口就拦下,体验更顺,两者是互补不是替代。
4.问:消息队列不是有 exactly-once 吗,为什么还要自己处理?答:exactly-once 通常指“生产到消费”这一段的语义保证,跨系统、跨步骤的自动化仍可能重复。把它当作加分项而非免死金牌,自己在入口去重最稳。
5.问:补跑时怎么判断上一次到底成功没有?答:靠幂等键。把原请求和重试打上同一个键,服务端查到键已处理就直接返回上次成果,查不到就当新请求处理。你不必真的知道答案,系统帮你记着。
图片来源:图1 tranmautritam / Pixabay (CC0) · 图2 CJMM / Pixabay (CC0) · 图3 CJMM / Pixabay (CC0) · 图4 ssoibeat / Pixabay (CC0) · 图5 Abdouj / Pixabay (CC0) · 图6 Hans / Pixabay (CC0) · 图7 Hans / Pixabay (CC0) · 图8 RosZie / Pixabay (CC0) · 图9 MartinHarry / Pixabay (CC0) · 图10 Pexels / Pixabay (CC0) · 图11 mirashin1 / Pixabay (CC0) · 图12 Pexels / Pixabay (CC0) · 图13 Fxq19910504 / Pixabay (CC0) · 图14 webreiziger / Pixabay (CC0) · 图15 Pexels / Pixabay (CC0) · 图16 messomx / Pixabay (CC0) · 图17 geralt / Pixabay (CC0) · 图18 Pexels / Pixabay (CC0) · 图19 HamedAsad / Pixabay (CC0) · 图20 geralt / Pixabay (CC0) · 图21 dimitrisvetsikas1969 / Pixabay (CC0) · 图22 dimitrisvetsikas1969 / Pixabay (CC0) · 图23 dimitrisvetsikas1969 / Pixabay (CC0) · 图24 Abdouj / Pixabay (CC0) · 图25 jarmoluk / Pixabay (CC0) · 图26 Pexels / Pixabay (CC0) · 图27 Pexels / Pixabay (CC0) · 图28 Pexels / Pixabay (CC0) · 图29 Abdouj / Pixabay (CC0) · 图30 Hans / Pixabay (CC0) · 图31 Hans / Pixabay (CC0) · 图32 652234 / Pixabay (CC0) · 图33 mrjerryjm / Pixabay (CC0) · 图34 suju-foto / Pixabay (CC0) · 图35 Abdouj / Pixabay (CC0) · 图36 WilliamCho / Pixabay (CC0) · 图37 MiraCosic / Pixabay (CC0) · 图38 Dimhou / Pixabay (CC0) · 图39 MiraCosic / Pixabay (CC0) · 图40 focusonpc / Pixabay (CC0)

评论(0)