同一笔请求重来三次,业务只成功一次:幂等性在技术面试里到底怎么讲
最近看中文技术社区的面试内容,有一个词又被频繁翻出来:幂等性。
它常常出现在消息队列、分布式事务、接口设计、支付回调、订单状态流转这些题里。很多文章会从"重复消费怎么办""消息至少一次投递如何保证业务正确""接口重复提交如何处理"讲起,最后落到一组熟悉答案:唯一键、去重表、Redis、数据库约束、状态机、乐观锁。
这些答案都能用,但在面试里只背工具名,容易把自己讲成一个"会搜方案"的人。
面试官真正想听的通常不是"幂等性是什么"。他更关心一个现场判断:
如果同一个动作因为网络抖动、用户连点、服务超时、MQ 重试、第三方回调重放,连续发生了三次,你能不能让业务世界里只发生一次正确结果。
比如:
- 用户点了两次"确认支付",钱不能扣两次。
- 下单接口超时,客户端重试,请求不能生成两个订单。
- MQ 消费者处理完订单后 ACK 失败,消息又投递一次,库存不能再扣一次。
- 第三方支付回调重复推送,订单状态不能从已退款又被改回已支付。
- 面试系统里一次代码提交被前端重放,评分任务不能排两份、扣两次额度、生成两份冲突结果。
你看,这不是一道概念题。它是一道业务事故预防题。
如果候选人把幂等性回答成"用 Redis setnx 加个锁",面试官大概率还会继续追问:
- 这个 key 怎么设计?
- 过期时间设多久?
- Redis 成功了,数据库失败了怎么办?
- 请求正在处理中,第二次请求应该返回什么?
- 如果第一次已经成功,但响应丢了,客户端重试时你返回成功还是重新执行?
- 去重记录和业务写入是不是在同一个事务里?
- 你怎么知道这个幂等方案真的挡住了重复扣款?
能接住这些追问,才是幂等性在技术面试里的真正价值。
一、先把"重复"当成系统默认状态
很多人讲接口设计时,下意识假设一次请求只会发生一次。真实系统很少这么温柔。
前端可能会重复提交。移动网络抖一下,用户以为没点上,又点一次。浏览器回退再提交一次。App 超时后自动重试。网关重试。服务之间调用超时。消息队列为了不丢消息选择至少一次投递。第三方平台为了保证通知到达,支付回调、物流回调、签约回调都可能重放。
所以面试里讲幂等性,第一句话可以这样说:
我不会先假设请求只来一次。我会先定义业务上"只允许发生一次"的结果,再把所有可能重复进入系统的入口都挡住。
这句话比"幂等就是多次执行结果一致"更有工程味。
因为它先抓住了业务不变量。
技术方案可以换,业务不变量不能模糊。支付题的不变量是"一笔支付单只能成功扣款一次"。库存题的不变量是"一次订单确认只能占用一次库存"。消息消费题的不变量是"同一个业务事件只能让下游状态前进一次"。面试评分题的不变量是"一次提交只能对应一次有效评分和一次额度扣减"。
只要不变量说清楚,后面的唯一键、状态机、事务、重试、日志都会有落点。
二、完整回答可以拆成三层:入口、业务状态、消息消费
面试里比较稳的讲法,是把幂等分成三层:入口幂等、业务幂等、消息幂等。
第一层是入口幂等。
它解决的是"同一个外部动作重复打进来"。常见做法是让客户端或服务端生成一个幂等键,比如 request_id、order_token、payment_intent_id。同一个动作带同一个 key,服务端先检查这个 key 的处理状态。
这里容易讲浅。很多人说"前端传个 token,后端存 Redis",就停了。
更完整的回答要说明这个 key 代表什么。
它应该绑定一个业务动作,而不只是一次 HTTP 请求的随机编号,比如"用户 A 对购物车 B 发起的一次下单尝试",或者"支付单 P 的一次确认扣款"。如果每次重试都生成新 token,那它只能追踪请求,挡不住重复业务。如果 key 设计得太粗,比如只按用户 ID 去重,又会误伤用户的下一笔正常订单。
入口幂等的关键不是 key 本身,而是 key 的业务粒度。
第二层是业务幂等。
它解决的是"请求进来了,业务状态不能乱跳"。这里最可靠的工具通常是数据库唯一约束、状态机和事务。
比如支付单状态可以设计为:
INIT -> PAYING -> PAID -> REFUNDING -> REFUNDED
同一笔支付单从 INIT 或 PAYING 进入 PAID 可以成功一次。已经是 PAID 时再次收到支付成功回调,不再重复扣款,也不再重复发券,只返回"已处理"。已经是 REFUNDED 时又收到迟到的支付成功回调,不能把状态倒回去,而要记录异常事件等待人工或补偿流程处理。
这就是状态机的价值:它让"哪些状态能前进、哪些状态不能回退"变得可检查。
如果再配合数据库唯一索引,比如 payment_id 唯一、business_event_id 唯一、order_id + event_type 唯一,就能把重复写入挡在存储层。应用代码可能有 bug,分布式锁可能过期,Redis 可能抖动,但数据库约束是最后一道很硬的闸门。
第三层是消息幂等。
它解决的是"上游已经发出事件,下游消费时可能重复"。消息队列常见语义是至少一次投递。至少一次的意思很直白:为了不丢,它宁愿重复。
所以消费者不能假设"拿到一条消息就代表第一次处理"。消费者应该拿消息里的业务唯一 ID 做去重,例如 event_id、order_id + event_type、payment_id + callback_type。处理前先查是否已完成,处理成功后把消费记录和业务结果一起落库。
这里有一个面试高频细节:去重记录和业务写入最好在同一个事务里。
如果先写去重记录,再扣库存,中间失败了,下一次重试会发现"已经处理过",实际库存却没扣。反过来,如果先扣库存,再写去重记录,中间失败了,下一次重试可能又扣一次。讲到这里,面试官会知道你不是只背了 MQ 重复消费,而是真的想过故障顺序。
三、一个"重复扣款"案例,能把幂等性讲完整
假设面试官给你一道题:
"设计一个支付确认接口。用户可能重复点击,客户端可能超时重试,支付渠道也可能重复回调。你怎么保证不会重复扣款?"
可以按这个顺序答。
第一步,定义业务不变量。
同一笔支付单只能进入一次"扣款成功"的业务结果。重复请求不能重复扣钱,不能重复发权益,不能重复写成功通知。
第二步,定义业务 ID。
支付单有 payment_id。用户发起支付确认时,客户端带 idempotency_key。这个 key 和 payment_id、用户 ID、金额、币种绑定。服务端收到请求后,先检查这个 key 是否已经有处理记录。
第三步,设计处理状态。
幂等记录不要只有"存在/不存在"。更稳的是记录状态:
PROCESSING:正在处理。SUCCEEDED:已经成功。FAILED_RETRYABLE:失败但可重试。FAILED_FINAL:失败且不可重试。
如果第二次请求看到 PROCESSING,可以返回"处理中"或短暂等待后返回当前状态。看到 SUCCEEDED,直接返回第一次成功结果。看到 FAILED_RETRYABLE,根据策略允许继续处理。看到 FAILED_FINAL,返回明确失败原因。
第四步,把状态机和数据库约束放进事务边界。
扣款成功、订单状态变更、权益发放、幂等记录更新,不能散在一堆没有关系的写操作里。至少要明确哪些写入必须原子,哪些可以通过 outbox 或补偿异步完成。
一个常见做法是:本地订单状态和幂等记录在同一个数据库事务里更新;对外通知、发邮件、埋点、异步消息通过 outbox 表记录后再投递。这样主业务结果先稳定,外围动作失败后可以补偿,不会影响"同一笔支付只成功一次"的核心不变量。
第五步,处理第三方回调。
第三方支付回调也要做幂等。回调里通常有渠道交易号、支付单号、事件类型、渠道回调 ID。你可以用 provider_event_id 或 payment_id + event_type 做唯一约束。重复回调只读出已有处理结果,不再推进状态。
第六步,加观测。
很多候选人会忘记这一层。面试官问幂等,不只是问你怎么写代码,也是在问出事后你怎么发现。
你至少要有:
- 重复请求命中次数。
- 幂等 key 冲突次数。
PROCESSING超时数量。- 重复消息消费次数。
- 状态机非法迁移次数。
- 同一支付单多次回调的渠道分布。
这些指标能告诉你幂等是不是在真实承压,而不是停留在代码注释里。
四、三个常见翻车回答
第一个翻车回答:直接说"加锁"。
锁能降低并发冲突,但锁不是完整幂等方案。锁会过期,服务会崩,网络会断,请求会重试。即使用 Redis setnx 抢到锁,也要回答锁释放失败怎么办、锁过期后业务还在跑怎么办、数据库写失败怎么办。只讲锁,说明你把问题看成并发互斥;幂等更关心重复业务结果。
第二个翻车回答:直接说"MQ 保证 exactly once"。
很多消息系统会在某些场景宣传 exactly-once 语义,但面试里最好别把业务正确性完全外包给中间件。消息投递、消费者处理、数据库写入、下游副作用是几段不同链路。哪怕消息系统帮你减少重复,消费者仍然要按业务 ID 做幂等,尤其是库存、扣款、发券、发送通知这类副作用。
第三个翻车回答:先查一下有没有,没查到就插入。
这句话最危险。两个请求并发进来,都查不到,然后都插入成功,问题就发生了。面试里要主动补一句:查询只能做优化,真正的唯一性必须靠唯一索引或事务性写入兜底。应用层判断不能替代存储层约束。
五、回答框架:一句话先稳住,再往下拆
如果你在面试现场被问到幂等性,可以用这一套回答:
我会先定义业务不变量:同一个业务动作重复发生,最终只能产生一次可见业务结果。然后从入口、业务状态和消息消费三层做幂等。入口层用业务粒度的 idempotency key 识别同一次动作;业务层用唯一索引、状态机和事务边界保证状态只能合法前进;消息层用 event_id 或业务唯一键做消费去重,并把去重记录和业务写入放在同一个事务或可恢复流程里。最后用重复命中、非法状态迁移和处理超时指标验证方案是否有效。
这段话可以在一分钟内讲完。面试官如果继续追问,你再展开支付、订单、库存、消息消费、回调重放中的一个案例。
更好的准备方式,是把你简历里的真实项目套进去。
比如你做过订单系统,就准备"重复下单如何防"。做过通知系统,就准备"重复发短信如何防"。做过任务调度,就准备"同一个任务被两台 worker 抢到如何防"。做过 AI 面试或代码评测工具,就准备"一次提交被重复消费时,如何只扣一次额度、只产出一次有效报告"。
技术面试最怕空。只要你把幂等性落到自己的项目,回答会立刻具体起来。
我自己会把这种准备拆成一张追问卡:业务不变量、重复入口、唯一键、状态机、事务边界、失败补偿、观测指标、真实事故。准备面试时,可以把 JD、项目经历和这张卡片放进 offer.cc,让它围绕你的项目继续追问,逼你把"知道方案"练成"能现场解释清楚"。如果你准备后端面试,可以先选一个自己项目里的扣款、任务、消息消费或评分场景,用这张卡片做一轮追问训练。
六、幂等性背后,其实是工程师对副作用的控制力
幂等性难,不是因为概念抽象,而是因为它要求你承认一个事实:系统会重复做事。
重复请求会来,重复消息会来,重复回调会来,用户会连点,网关会重试,消费者会崩,ACK 会丢,页面会刷新,移动网络会断。工程系统里,重复不是异常分支,而是正常世界的一部分。
高级一点的回答,不会只说"怎么去重"。它会说清楚:
- 哪个动作允许重复,哪个结果不允许重复。
- 哪个 ID 代表一次业务动作。
- 哪些状态可以前进,哪些状态不能回退。
- 哪些写入必须原子,哪些副作用可以异步补偿。
- 哪些重复应该安静返回,哪些重复应该报警。
- 哪些指标能证明方案正在工作。
如果你能把这些讲清楚,幂等性就不再是八股题。
它会变成一个很强的信号:你写代码时,不只想着 happy path;你知道系统会在网络、并发、重试和第三方回调里反复折腾;你能把这些混乱收进一个可解释、可恢复、可验证的设计里。
这也是技术面试真正想筛的人。
参考阅读
- 掘金:消息队列可靠投递与幂等消费相关讨论
juejin.cn/post/765783... - 掘金:设计消息队列时如何回答重复消费与幂等
juejin.cn/post/763405... - 掘金:面试中的幂等性问题如何回答
juejin.cn/post/747713... - 知乎:消息队列面试题中的重复消费与幂等问题
zhuanlan.zhihu.com/p/203640969... - 博客园:接口幂等性与重复提交方案
www.cnblogs.com/mikechensha...