想象你站在写字楼下的自动售货机前扫码买水。手机弹出“支付成功”,但货道卡住了,水没掉出来,网络也超时了。你不确定刚才有没有扣款成功,于是又扫了一次码。这时后台系统收到了两笔“订单号完全相同”的请求——真正的麻烦来了:如果系统傻乎乎地按订单执行两次扣款和出货,你就会被扣两次钱,还可能拿到两瓶水(或者一瓶水都没有,却扣了两次钱)。 为了避免这种惨剧,系统需要一套“防重复执行”的机制。这套机制在分布式系统里有个专门的名字——幂等性设计(Idempotency Design)。所谓幂等,就是“同一操作重复执行一次和一万次,最终结果都一样”。它像一只尽职的小管家:收到请求后先翻查手里的账本,如果这单已经办过了,就直接把上次的结果告诉你,绝不再办第二次。 接下来,我们会用小管家、出货记录册、订单号和防抖齿轮这些比喻,把幂等性设计的四大方案——唯一请求 ID、去重表、乐观锁、状态机——和实战选型一次讲透。

岗位关联

掌握幂等性设计,是分布式系统容错的必修课。它直接决定你的接口在“网络重试、消息重投、用户狂点”三重摧残下,能否守住“不重复、不超扣、不乱账”的生命线。架构师和高并发后端工程师都要用它来做设计决策与故障排查。

目录
  1. 可视化演示
  2. 幂等基础
  3. 唯一请求 ID
  4. 去重表方案
  5. 乐观锁机制
  6. 状态机方案
  7. 策略选型
  8. 实践复盘
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

动态展示幂等原理、唯一ID去重流程、四种实现策略与状态机约束机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

幂等基础

自动售货机的小管家是个非常较真的人。他每天收钱出货,但有一条铁律:同一张订单号,只出一次货。你可能会问:如果客人明明被扣了两次款,凭什么只出一次货?小管家的回答是:因为这是同一笔订单的重复通知,不是新生意。他要做的,是把多出来的那次扣款退回去,而不是再搬一瓶水。这就是幂等性的生活原型——同一件事,做一次和做多次,世界不该因此改变。

在技术世界里,我们把“做一次和做多次结果都一样”的操作称为幂等操作。数学上可以写成 f(f(x)) = f(x):不管对同一输入执行多少次操作函数 f,最终的结果都等于只执行一次。注意,幂等不等于“只执行一次”,而是“重复执行的副作用为零”。系统无法阻止请求重复到达,但它可以保证重复到达后不再重复生效。

为什么分布式系统特别需要幂等?因为分布式环境里没有可靠的“一次且仅一次”投递。网络会超时,客户端会重试,消息队列会重投,用户会手抖连点。任何一环都不是百分之百可靠,而幂等性是让整个链路在不确定性中保持业务一致性的“安全气囊”。它和“防重”也有区别:防重是并发场景下的去抖(比如 1 秒内只让一个请求通过),而幂等要对付的,是已经成功执行过后再次到达的“幽灵请求”。

回到小管家的账本,他判断“这单是不是重复”靠的不是记忆,而是规则:订单号相同,就是同一件事。第一笔真正出货,账本记下;后续相同的订单号进来,直接调出上次的结果回复客人——“您的水已经出过了,在取货口呢”。数学上等价于 f(f(x)) = f(x):无论同样的指令来多少次,货只出一瓶,结果一直是那瓶水。

f(f(x))=f(x)f(f(x)) = f(x)
这个公式是说:一个操作被执行两次的最终效果,必须和执行一次完全相同。凡是满足这条规律的操作,才配得上“幂等”二字。
  1. 操作函数 f代表任意业务动作,例如“扣款十元”或“发货一件”。输入 x 是操作的参数,输出是执行结果。
    f(x)f(x)
  2. 第一次执行的结果把第一次执行记作 y,表示业务操作产生了一笔真实变化。
    y=f(x)y = f(x)
  3. 对结果再执行一次把 y 当作输入再次调用同一操作,于是得到 f(f(x))。
    f(f(x))=f(y)f(f(x)) = f(y)
  4. 幂等成立条件第二次操作没有产生新的副作用,最终结果仍等于首次结果。重复无数次,等于一次。
    f(f(x))=f(x)f(f(x)) = f(x)

唯一请求 ID

小管家最怕两单生意长得一模一样。假如两个客人同时买了一款同样的水,付的钱也一样,光看内容根本分不清谁是谁。还好每张支付回执单上都有一个“订单号”——这是这笔交易从生到死都不变的身份证。凭借这串数字,小管家才能知道:哦,这单号我见过,这是刚才那笔的重复通知。

在系统里,这个身份证就是唯一请求 ID(Request ID / Idempotency Key)。它的核心作用是让所有下游节点都能识别“这是哪一笔操作”。如果请求里没有这个 ID,服务端就只能靠“参数是否完全一致”来猜测,面对参数相同的两笔合法请求就会误伤——所以唯一请求 ID 是幂等设计的基石。

唯一请求 ID 的生成要满足两个要求:全局唯一、尽量有序。业界常用方案有 UUID、数据库自增 ID,以及高并发下常见的雪花算法(Snowflake)。雪花算法把 ID 拆成三部分:时间戳 + 机器编号 + 序号,既能保证唯一,又能让 ID 大体递增,有利于数据库索引。生成后,ID 由客户端在发起请求时创建,放在请求头或消息体中一路透传到服务端;服务端拿到后,把它作为幂等键存入数据库或 Redis,配合后续的去重表、状态机一起使用。

有了订单号这枚“身份证”,小管家的账本才有意义。他不需要去比对两位客人的长相、买的水是不是同一个牌子——只看订单号,就能认出谁是新客户、谁又是同一笔订单换了身衣服又来敲门。这就是唯一请求 ID 在整个幂等体系里的位置:它是所有后续方案的前置基础。

ID=timestamp  ∥  machine_id  ∥  sequence\text{ID} = \text{timestamp} \;\|\; \text{machine\_id} \;\|\; \text{sequence}
这段公式展示了雪花算法的核心思想:把“时间、机器、自增序号”三块信息拼进同一个 ID,从源头保证不重号。这样服务端才能放心地拿它当幂等依据。
  1. 时间戳 timestamp记录 ID 生成时刻,让 ID 随时间递增,避免时间维度上的碰撞。
    timestamp\text{timestamp}
  2. 机器编号 machine_id标识生成 ID 的是哪台机器,防止不同服务实例在同一时刻生成相同 ID。
    machine_id\text{machine\_id}
  3. 序列号 sequence同一毫秒内在单台机器上从 0 递增的序号,保证超高并发下也不重复。
    sequence\text{sequence}
  4. 拼成完整 ID三段按位或按字符串拼接,形成一个紧凑的全局唯一标识。
    ID=timestamp  ∥  machine_id  ∥  sequence\text{ID} = \text{timestamp} \;\|\; \text{machine\_id} \;\|\; \text{sequence}

去重表方案

小管家管自己叫“记账型选手”。他每天上岗第一件事,就是把出货记录册摊开:每出一瓶水,就在册子上工工整整写一行“订单号 + 已出货 + 时间”。下一次再有订单号过来,他先翻册子——查到了就直接说“这单出过了”,查不到才动手出货。这本册子,就是去重表。

去重表方案是最直观的幂等实现:单独建一张“幂等记录表”,主键或唯一键就是请求 ID。每次请求到达时,先查这张表;如果已存在记录,说明是重复请求,直接返回首次处理的结果;如果不存在,则执行业务逻辑,同时向去重表插入一条记录。图省事的话也可以把幂等键直接挂在业务表上作为唯一索引,比如“订单号唯一”,让数据库来强制去重。

去重表最关键的机制是“插入时的唯一约束兜底”。为什么不能只做“先查再插”?因为在高并发下两个相同的请求可能同时通过查询,都发现“查无此单”,然后都去执行业务——重复就发生了。解决办法是给请求 ID 建唯一索引:两个请求同时插入时,数据库保证只有一个能成功,另一个插入直接报唯一键冲突,冲突方把这次操作视为“已被别人处理”,不再执行业务。这样即使业务层竞争失败了,数据层也绝不放水。去重表的缺点是记录会不断增长,所以需要定期清理(例如只保留最近 30 天),或者按日期分表归档。

小管家的出货记录册有一个死规矩:册子上每一行订单号都只能用一次,谁也别想涂改。正因为册子背后有“一条订单号只能写一行”的铁律,他才敢在客人催单时说:“这单早出过了,您去取货口看看。”——账本不乱,生意就不乱。

id∈S⇒拒绝;id∉S⇒执行\text{id} \in S \Rightarrow \text{拒绝};\quad \text{id} \notin S \Rightarrow \text{执行}
这个公式等价于“查册子——在册就拒绝,不在册就执行”。最后的唯一索引是安全锁:就算两个小管家同时翻开册子都没查到,数据库也只会让其中一笔登记成功。
  1. 集合 S表示去重表中已处理成功的请求 ID 集合。
    S={已处理的请求 ID}S = \{\text{已处理的请求 ID}\}
  2. id 属于 S请求已在册,说明是重复请求,直接拒绝重复执行并返回旧结果。
    id∈S⇒拒绝\text{id} \in S \Rightarrow \text{拒绝}
  3. id 不属于 S首次到达,执行业务逻辑并向 S 插入该 ID。
    id∉S⇒执行\text{id} \notin S \Rightarrow \text{执行}
  4. 唯一索引兜底即使判断出现竞争,数据库的唯一索引也会强制插入只成功一个。
    UNIQUE(id)\text{UNIQUE}(\text{id})

乐观锁机制

后来售货机生意太好,一台机器配了两位小管家。麻烦来了:两位管家可能同时翻开记录册,同时看到“订单号 10086 还没有出货记录”,然后同时搬起饮料往出货口塞——两瓶水卡在一起,货道直接崩了。小管家的对策是在记录册上盖“版本号”印章:谁要改这一行,先记下当前版本号,落笔时如果版本号没变才允许写;如果发现别人已经改过了,这一笔就算白抢。

这就是乐观锁(Optimistic Lock)的思想。它不锁数据,而是靠“版本号比对”保证并发改写的安全性。业务表里加一个 version 字段,每次更新时带着当初读到的 version 作为条件:UPDATE … SET version = version + 1 WHERE version = 旧版本。如果更新的影响行数是 1,说明这期间没有人改过,操作成功;如果影响行数是 0,说明 version 已经变了,操作失败,需要重读后重试或放弃。

乐观锁非常适合“并发竞争少、冲突频率低”的场景,例如防止订单金额被重复扣减、库存被超卖。它与去重表的区别在于:去重表解决的是“同一请求来了两次”,乐观锁解决的是“不同请求同时改同一行数据”。二者经常组合使用:去重表挡住重复请求,乐观锁挡住并发竞争。需要注意,乐观锁不适合写冲突极高的热点数据——如果同一把库存每秒被抢 10 万次,乐观锁会大量失败重试,反而拖垮系统,这时应该换用分布式锁或队列化。

两位管家后来养成了好习惯:动手改记录册之前,先看一眼右上角的“版本号”。如果还是自己刚才记下的那个数字,就放心下笔;如果已经被人加过一横,就乖乖合上册子退一步。这份“核对再落笔”的默契,让两个管家能同时在店里忙碌,而账本永远不会乱。

UPDATE  t  SET  v=v+1  WHERE  k=id  AND  v=v0\mathrm{UPDATE}\;t\;\mathrm{SET}\;v=v+1\;\mathrm{WHERE}\;k=\mathrm{id}\;\mathrm{AND}\;v=v_0
乐观锁像“先到先得”的盖章机制:谁先落下版本号,谁就赢得这次修改;后来者发现版本号变了,就只能接受失败。它不做物理隔离,靠的是“核对版本号”这一个轻量动作。
  1. 表 t 与主键 k指定要更新的业务记录,k = id 精确定位到这一行。
    WHERE  k=id\mathrm{WHERE}\;k=\mathrm{id}
  2. 条件 v = v_0v_0 是读取时拿到的旧版本号,只有版本没变才允许更新。
    AND  v=v0\mathrm{AND}\;v=v_0
  3. 更新版本号 v+1每次成功修改,版本号递增一步,让后来者发现版本已变。
    SET  v=v+1\mathrm{SET}\;v=v+1
  4. 影响行数判定等于 1 表示抢写成功;等于 0 表示版本已过期,禁止覆盖他人结果。
    rows∈{0,1}\text{rows} \in \{0, 1\}

状态机方案

小管家的出货记录册越写越厚,他发现光记“已出货”不够。比如客人付款后网络断了几秒,他这边已经扣了款、正在出货,这时订单来了,他该怎么说?总不能翻遍册子说“查无此单”然后再扣一次款。于是他把册子升级成了“状态条”:每笔订单从生到死,必须依次经历「待出货 → 出货中 → 已出货」三个阶段。

这个思路在系统设计中叫状态机(State Machine):把业务对象建模成一组有限状态,只有在规定的顺序里才能从一个状态跳到另一个状态。比如订单的状态依次是“待支付 → 已支付 → 已发货 → 已完成”,任何一步都不能跳级或倒流。重复请求到达时,状态机只需检查当前状态:如果订单已经在“已完成”这种终态,任何后续事件都不会改变它。

状态机天然具备幂等性,因为它的核心是“状态决定行为,而不是次数决定行为”。比如支付回调进来时先查订单状态:如果订单是“待支付”,就执行入账并把状态推进到“已支付”;如果订单已经是“已支付”或“已完成”,说明这笔回调是重复消息,直接返回成功,不再动账。终态尤其重要——终态一旦达到,必须拒绝一切流转,这样哪怕同一个事件被重投一万次,业务也纹丝不动。状态机的实现可以是数据库里的状态字段加校验逻辑,也可以引入专门的状态机框架。

有了状态条,小管家面对客户的连环追问再也不慌了。“出货中?稍等,正在掉货。”“已出货?去取货口拿吧。”最妙的是一旦订单盖上了“已出货”的终态章,哪怕客户把订单号报上一百遍,他也只会指着取货口说同一句话。状态机的魔法就在这:答案由状态决定,与询问次数无关。

s′=δ(s,e),δ(sterm,e)=sterms' = \delta(s, e),\quad \delta(s_{\text{term}}, e) = s_{\text{term}}
状态机公式的核心是最后一行:终态无论如何流转都停留在自身。这就像订单已完成后再收到一百次支付回调,它也只会回答“已经是完成状态”。
  1. 状态转移函数 δ给定当前状态 s 与到达事件 e,得到新状态 s'。
    s′=δ(s,e)s' = \delta(s, e)
  2. 非法事件被拒绝如果事件 e 不是从状态 s 出发的合法动作,转移可以被拦截,确保状态不乱跳。
    非法事件⇒拒绝\text{非法事件} \Rightarrow \text{拒绝}
  3. 终态自循环终态 s_term 收到任何事件后,结果仍是它自己,这就是重复请求落空的原因。
    δ(sterm,e)=sterm\delta(s_{\text{term}}, e) = s_{\text{term}}
  4. 幂等性的保证判断依据是“当前状态是什么”,而不是“事件来了多少次”。
    s′=δ(当前状态,e)s' = \delta(\text{当前状态}, e)

策略选型

同样是售货机,摆在早晚高峰的地铁站和摆在景区门口,脾气完全不同。地铁站的机器每分钟被扫几百次,顾客排队,网络经常抖动,重复回调像下雨一样密——小管家必须用最轻快的手段挡下大部分重复;景区门口的机器一天开不了几单,偶尔来一单重试,小管家有充分时间慢慢翻册子。

这就是幂等方案的选型问题:没有银弹,只有取舍。核心判断维度有三个:重复请求量有多大、写冲突有多激烈、能不能接受额外的存储成本。网络重试场景下,因为系统层面无法阻止重试,通常用“唯一请求 ID + 去重表”来兜底;消息队列场景下,很多 MQ 只能保证 at-least-once(至少一次)投递,消费端必须靠状态机或去重表自行去重;高并发场景下,要把压力最重的那条路用乐观锁和状态机顶住,把去重表的查询尽量前置到缓存。

组合运用是生产环境的常态。一个典型的订单接口会这样分层:第一层,前端和网关携带唯一请求 ID;第二层,Redis 缓存快速判断“这个 ID 最近是否见过”,挡住 99% 的重复请求;第三层,数据库去重表用唯一索引做最终裁决;第四层,订单表用版本号乐观锁防止两个不同请求同时改同一订单;第五层,订单状态机保证终态后再来的任何消息都进不了业务逻辑。层层设防,每一层都能独立兜底。另外,在引入“失败重试 n 次”策略前,一定要确认操作是幂等的——否则重试越多,账错得越离谱。

地铁站的小管家把防重复的活儿分给了三道工序:门口快速看一眼订单号(缓存)、柜台核销记录册(去重表)、出货口核对状态条(状态机)。景区的小管家则只需要一本册子慢慢翻。选型不是找最贵的兵器,而是摸清自己站台的客流强度,再决定把防线铺在第几层。

1−(1−p)n1 - (1 - p)^n
这个公式解释了为什么业务系统要设置重试:单次失败不可怕,可怕的是失败后不敢重试。有了幂等做后盾,重试越多,最终成功的概率越接近 100%。
  1. 单次失败概率 p一次请求因超时、宕机等原因失败的概率。
    pp
  2. n 次全部失败的概率假设每次失败相互独立,n 次都失败的概率是各次失败概率的连乘。
    (1−p)n(1-p)^n
  3. 至少一次成功的概率用 1 减去“全部失败”的概率,就得到重试 n 次后至少成功一次的概率。
    1−(1−p)n1-(1-p)^n
  4. 幂等让重试放飞正因为操作幂等,重试不会带来副作用,这个成功率才真正属于你。
    P(成功)=1−(1−p)nP(\text{成功}) = 1-(1-p)^n

实践复盘

月底,售货机厂商来巡查。老板撂下一摞投诉单:“这个月有 3 个客人说被扣了两次款,还有 5 个说水出了但没扣款。小管家,你的册子是怎么记的?”面对事故,小管家不慌,他有固定的排查三步:先找出这笔订单的订单号,再查记录册上有没有登记,最后看订单当前处于哪个状态。证据链一旦串起来,问题出在哪个环节一目了然。

生产环境的幂等故障排查,遵循同样的三步走:第一步确认请求是否携带了唯一请求 ID,以及这个 ID 在整条链路中有没有被透传;第二步查幂等记录(去重表、Redis 缓存)里有没有对应条目,检查插入是否命中唯一索引;第三步查业务对象的状态,看它停在了哪一步。顺序很重要——先确认“是不是同一笔”,再判断“有没有被执行过”,最后看“执行到什么程度”。

幂等性设计上线时就要配套监控告警:去重表命中率(重复请求占比)、唯一索引冲突次数、状态机非法流转次数、幂等拦截率的变化曲线。用幂等率 R 衡量有效性。一个典型的生产案例复盘:某支付系统在网关升级后,部分回调丢失了原始请求 ID,服务端用“子订单号 + 时间戳”重新拼了一个不够唯一的幂等键,导致同一笔订单产生了多条记录,重复入账——最后通过固定幂等键生成规则、增加状态机终态校验才彻底修复。

小管家把三个月的记录册摊在会议室桌上,用订单号做索引,一条一条核对状态,最后定位到问题:上周更换新记录册时,有一页纸没印版本号栏。他连夜补上这一栏,并给册子加了“终态红印”。厂商老板听完点了点头:“有订单号可查、有记录可对、有状态可验——这套账本,才是售货机真正值钱的东西。”

R=N拦截N重复×100%R = \frac{N_{\text{拦截}}}{N_{\text{重复}}} \times 100\%
幂等率是套在幂等机制脖子上的仪表盘:只要它从 100% 往下掉,就意味着有漏网的重复请求正在穿透防线。没有度量,就没有改进。
  1. N_重复 理论重复请求数根据日志关联、MQ 重投统计等手段识别出的重复请求总量。
    N重复N_{\text{重复}}
  2. N_拦截 被拦截的请求数去重表、状态机等真正挡下并返回旧结果的请求数。
    N拦截N_{\text{拦截}}
  3. 拦截率 R两者相除再乘 100%,量化幂等机制的有效性。
    R=N拦截N重复×100%R = \frac{N_{\text{拦截}}}{N_{\text{重复}}} \times 100\%
  4. 目标值健康系统的 R 应长期维持在 100%;一旦跌落,立即触发告警启动排查。
    目标:R=100%\text{目标}: R = 100\%

AI 应用场景

支付回调幂等

阶段:分布式事务与接口容错设计

解决的问题:微信、支付宝等第三方支付平台会因网络原因重复推送同一笔支付结果。传统做法是“收到回调直接改订单状态”,这会导致一笔订单被重复入账。引入幂等性设计后,以商户订单号为幂等键,先去重表查询,再结合订单状态机判断,保证回调处理只生效一次。

具体案例:某电商平台每天处理百万级支付回调,通过“订单号唯一索引 + 状态机终态校验”,将重复回调拦截率提升到接近 100%,每年挽回重复入账损失数百万元。

消息队列重复消费

阶段:消息中间件与流处理容错

解决的问题:Kafka 在故障重启、消费者重平衡时可能重复投递消息,而 at-least-once 语义保证消息不丢但不保证不重。消费端直接处理会导致库存多扣、积分多发。解决方式:消费前根据消息内的业务 ID 进行幂等校验,使用状态机判断消息对应业务是否已处于终态。

具体案例:某电商订单创建后发送“扣库存”消息,消费端用订单状态机 + 乐观锁版本号双重校验,确保同一消息重复消费 100 次也不会重复扣减库存,支撑大促峰值每秒数万消息的稳定处理。

RPC 重试下的下单接口

阶段:微服务与高可用设计

解决的问题:Dubbo、Feign 等 RPC 框架在调用超时后会触发重试,非幂等接口在重试下会重复建单。方案是客户端生成全局唯一请求 ID 放入 RPC 请求头,服务端用分布式缓存防重 + 数据库唯一索引兜底,从源头阻断重复下单。

具体案例:某 O2O 平台在下单服务中接入幂等键与去重表,配合状态机,将重复订单率从 1.2% 降至接近 0,同时接口可用性提升到 99.99%。

Python 演示

python50 行
import threadingimport timeimport uuidfrom datetime import datetime  class IdempotentProcessor:    """幂等处理器:相同 request_id 只执行一次业务"""     def __init__(self):        self._store = {}        self._lock = threading.Lock()     def process(self, request_id, business_func):        with self._lock:            if request_id in self._store:                return self._store[request_id], False  # 命中幂等            result = business_func()                    # 真正执行一次            self._store[request_id] = result            return result, True  def main():    processor = IdempotentProcessor()    call_count = []     def deduct_balance():        """模拟扣款业务:真实环境里这里是改库操作"""        time.sleep(0.05)        call_count.append(1)        return "扣款成功"     request_id = str(uuid.uuid4())    # 两个线程同时用同一个 request_id 发起请求,模拟网络重试    threads = [        threading.Thread(target=lambda: processor.process(request_id, deduct_balance)),        threading.Thread(target=lambda: processor.process(request_id, deduct_balance)),    ]    for t in threads:        t.start()    for t in threads:        t.join()     print(f"实际执行业务次数: {len(call_count)} (期望 1)")    print(f"幂等存储记录数: {len(processor._store)} (期望 1)")    print(f"当前时间: {datetime.now().strftime('%H:%M:%S')}")  if __name__ == "__main__":    main()
目的:用字典模拟去重表,加锁模拟并发,演示同一请求 ID 并发到达时业务逻辑只执行一次。
  1. self._store = {}用字典模拟去重表,键是请求 ID,值是首次处理结果。
  2. if request_id in self._store:查重逻辑:命中说明请求已处理过,直接返回旧结果,不再执行业务。
  3. result = business_func()只有首次到达的请求才会真正调用业务逻辑,这份“第一次”是被幂等机制保护起来的。
  4. self._store[request_id] = result处理完后把请求 ID 记入“去重表”,后面的重复请求就能查到了。
  5. with self._lock:加锁避免两个线程同时查重都判断为“未处理”,从而造成重复执行。

常见误区

误区我觉得幂等就是防止用户重复点击,只要前端把按钮在提交后置灰、加个 loading,就不会有重复请求了。

正解防重只是“并发瞬间的去抖”,它挡不住网络层超时重试、MQ 重投这类来源完全不受前端控制的重复请求。幂等是服务端的底线能力:无论重复请求来自哪里、间隔多久,系统都能识别并保证只生效一次。前端置灰只是礼貌,服务端幂等才是契约。

为何常见:日常开发里最先遇到的重复请求就是“用户手抖连点”,前端很容易解决;而网络重试、消息重投属于基础设施行为,看不到摸不着,容易忽略。

误区两个请求参数一模一样,那肯定是重复请求,直接拦截第二个就行。

正解参数相同不代表业务相同。比如“给同一个用户充值 10 元”,用户完全可能先后发起两次合法请求,每次都该生效。幂等的依据必须是业务语义上的唯一键,比如订单号、支付流水号,而不是参数的完全相等。用参数做幂等键会把合法请求误杀。

为何常见:在简单接口测试里,重复请求确实都长一个样;但真实业务里“参数相同”和“事件重复”是两回事,初学者很容易把表象当成本质。

误区去重表插入遇到唯一键冲突抛异常,我就直接把这个异常返回给前端,让他们重试。

正解唯一索引冲突恰恰是“已经有人处理过了”的信号,应该把这次冲突翻译为“幂等命中”,然后返回首次处理的结果,而不是当成错误抛给上游。处理冲突的标准姿势:先查去重表拿结果再决定返回什么;如果担心并发窗口,可以用“有则查、无则插”的原子操作。

为何常见:唯一索引冲突是个数据库异常,直觉上会当成故障处理;但在这个场景里它实际上是业务逻辑的一部分,需要换个角度看。

误区既然幂等这么重要,我把唯一请求 ID、去重表、乐观锁、状态机全给所有接口安排上,肯定万无一失。

正解过度设计本身就是一种风险:为纯查询接口加去重表会白白增加每次请求的写放大和高延迟;为低频写接口上乐观锁,冲突率接近零,收益甚微。正确做法是按接口分级:核心交易链路才需要层层设防,普通读接口只需保证天然幂等即可。

为何常见:学过一门技术后容易“手里拿着锤子看什么都像钉子”,希望把所有方案堆上去求安全感,却忽略了成本和复杂度。

误区订单只要到了“已完成”状态不继续变,状态机就完工了,中间的跳转不用管。

正解状态机必须约束每一跳。比如“已支付”订单就不能再回到“待支付”,“已发货”不能跳到“已支付”。如果只锁终态不锁中间态,重复回调可能把“已支付”重新置回“支付中”,或者把“已发货”又触发一次发货。终态是最后一道闸,但每一道闸门都要有名字。

为何常见:重复请求最常撞上的确实是终态,所以大家观察到的现象集中在“终态没拦”,一叶障目忽略了中间态也可能被重复事件打乱。

面试题

Q1. 对幂等性最准确的理解是?

单选题 · easy

  • A.接口只允许调用一次
  • B.重复执行结果与一次一致← 正确答案
  • C.所有请求都必须加锁
  • D.接口必须瞬间返回结果

答案:B。幂等的关键是“重复执行与单次执行结果一致”,而不是限制调用次数。A 把幂等误解为防重;C 和 D 与幂等无关。就像售货机重复收款但只出一瓶水,结果始终是那瓶水。

Q2. 唯一请求 ID 的作用是?

单选题 · easy

  • A.让接口调用更快
  • B.标识同一笔业务操作← 正确答案
  • C.代替密码完成鉴权
  • D.压缩请求数据体积

答案:B。唯一请求 ID 是业务的“身份证”,让服务端能认出同一笔操作,这是幂等设计的地基。A、D 是性能优化无关项;C 是安全范畴,与幂等无关。

Q3. 去重表方案实现幂等的关键保障是?

单选题 · mid

  • A.定期清理历史数据
  • B.数据库唯一索引约束← 正确答案
  • C.Redis 缓存预热
  • D.查询结果分页返回

答案:B。唯一索引是最终兜底:即使并发下两个请求都查不到记录,数据库也只会让一个插入成功。清理数据只是运维手段,缓存只是加速,分页与幂等无关。

Q4. 乐观锁更新时影响行数为 0,说明?

单选题 · mid

  • A.更新操作成功执行
  • B.版本号已被他人更新← 正确答案
  • C.数据库连接断开了
  • D.表结构发生变化

答案:B。影响行数为 0 意味着 WHERE 条件里 version = 旧版本没有命中,即这行数据已被别人改过。这就像两位管家抢着盖章,后到的人发现版本号已经变了,只能放弃。

Q5. 状态机保证幂等的根本是?

单选题 · hard

  • A.状态字段使用枚举
  • B.终态拒绝后续流转← 正确答案
  • C.状态表建立唯一索引
  • D.每次请求都记日志

答案:B。终态一旦到达,任何事件都无法改变它,所以重复请求即使再来一万次也落空。枚举类型只是实现细节,唯一索引是去重表的事,记日志不能防重复。

Q6. 第三方支付重复回调应优先以什么为幂等键?

单选题 · easy

  • A.回调服务器 IP 地址
  • B.回调数据的时间戳
  • C.原始商户订单号← 正确答案
  • D.回调数据包字节数

答案:C。商户订单号在业务语义上唯一,是天然的幂等键。IP 可能是负载均衡地址,时间戳可能重复,字节数更与业务无关。就像小管家认订单号,不认“谁送来的信”。

Q7. 消息队列 at-least-once 场景下,消费端幂等可用的手段有?

多选题 · mid

  • A.去重表拦截重复消息← 正确答案
  • B.状态机校验终态← 正确答案
  • C.乐观锁更新版本号← 正确答案
  • D.抛弃所有异常消息

答案:ABC。去重表、状态机、乐观锁都是成熟的幂等手段。D 会丢消息,违反可靠性原则。就像小管家面对重复通知是查册子、验状态,而不是把信扔进垃圾桶。

Q8. 高并发重复下单场景,合理的防线组合是?

多选题 · mid

  • A.请求头携带唯一 ID← 正确答案
  • B.服务端缓存快速查重← 正确答案
  • C.数据库唯一索引兜底← 正确答案
  • D.屏蔽所有重试请求

答案:ABC。唯一 ID + 缓存查重 + 唯一索引兜底是经典三层防线。D 是掩耳盗铃——重试是网络环境的客观存在,系统层面无法完全屏蔽。就像售货机不能怪客人多投币,只能提高自己防重复的本事。

Q9. 去重表记录膨胀的治理方式包括?

多选题 · hard

  • A.定期清理过期记录← 正确答案
  • B.按日期分表归档← 正确答案
  • C.缓存留存活跃键← 正确答案
  • D.永不清理保留全量

答案:ABC。定期清理、分表归档、把活跃键放缓存都合理。D 会让表无限膨胀,拖垮查询性能。就像记录册写满了要换新册子,旧册子归档到仓库,但不能永远堆在柜台上。

Q10. 生产重复入账事故,排查顺序正确做法是?

单选题 · extreme

  • A.查请求 ID 与幂等记录状态← 正确答案
  • B.直接修改数据库回滚金额
  • C.重启服务清空全部缓存
  • D.通知前端修改置灰逻辑

答案:A。正确顺序是先确认幂等链路:请求 ID 是否唯一、幂等记录是否被写入、订单停在哪个状态。直接改库是破坏数据完整性的危险操作,重启服务会清掉现场,改前端置灰治标不治本。