Idempotency 在实际生产中的几个实际问题
Textbook的"Unique Key" 答案只对了 30%——dedup race、跨系统状态、外部 API 撒谎
面试时我有道场景题:用户在结账页连点了 5 次”提交订单”按钮。前端只发了 5 个请求,但其中 3 个因为网络抖动没收到响应。你的后端怎么保证只生成一个订单?
90% 的同学第一反应是:加个 idempotency key 就行了。client 生成一个 UUID 当 key,server 在 dedup 表上加 unique constraint,重复请求 INSERT 失败,返回上次结果。
答得对吗?对。但只对了 30%。
但是在生产环境中,idempotency 这个概念,单条记录在单个数据库里确实是 unique key 就能搞定。一旦你的写入跨表、跨系统、或者依赖一个不真正 idempotent 的下游,你的”幂等保证”就开始漏。今天聊几个面试时往深里挖会让人栽的具体场景。
01 dedup 表自己也有 race
最朴素的答案:dedup 表上加 unique constraint,重复 INSERT 会失败,从失败上判断是重复,返回上次结果。
我面试里会追问:现在两个并发的相同请求 A 和 B 同时到。它们都查 dedup 表,都没找到记录,都决定走业务逻辑。然后都 INSERT。unique constraint 让 B 失败。B 知道是重复,要返回 A 的结果。B 怎么拿到 A 的结果?
候选人通常会说:再去查一次 dedup 表。
我接着问:你查的时候,A 的事务可能还没 commit。dedup 表里看不到那条记录。
他说:那我等一下重试?
我说:等多久?A 那边事务可能因为别的原因 hang 几十秒。你一直循环重试,什么时候放弃?放弃之后怎么处理?返回 500?那 client 又会重试一次,叠加更多并发。
到这里基本就答不上来了。
工业上处理这一层的标准答案是:dedup 表和业务结果在同一个事务里写入。重试请求 INSERT 失败后能直接读到完整结果,因为它们就在同一行。听起来又像 textbook 答案,但这要求你的”业务结果”得塞得进 dedup 表那一行——金额、订单号、reference 这种小数据 OK,巨大的 payload 不行。所以业界一般分两层:fast path 用一张 dedup-with-result 表,slow path 用 dedup + reference + async补偿。
我之前接手过一个支付网关,dedup 用的是 Redis 的 SET NX EX。逻辑是先 SET NX,成功就走业务,失败就读上次结果。第一次大流量出问题是黑五。某个商户的 webhook 推送一个 payment confirmation,因为网络抖动 3 秒内重试了 4 次。我们的服务那一刻刚好在做 Redis failover——主节点切换,failover 那 3 秒里 SET NX 的状态丢了一部分。结果是同一笔支付确认跑了 2 次业务逻辑,下游账户系统重复入账。
那次之后我们加了一层 MySQL unique constraint 做兜底——Redis 走快速路径,DB 走强保证路径。代价是每个请求多 5ms 的 P99 延迟。也能接受。
面试里能聊到这一层的,跟只会答”加 unique index”的,是两个 level。
02 跨系统的 idempotency
dedup 只解决了一件事:同样的请求来了第二次,我能识别出来。
真正难的是:识别出来之后,我能确保整个 side effect 链跟第一次完全一样吗?
我之前在一个广告归因系统里就栽过这一刀。一次click event会触发三个写入:MySQL 插入 attribution 记录,S3 写一份原始日志,调用下游计费服务 API。三个独立系统,没有全局事务。
第一次请求进来,带着 idempotency key K。MySQL 写成功了,attribution 记录已经落库;接着服务开始写 S3 raw log,但写到一半网络断了,进程 crash;计费 API 还没来得及调用。重试进来时,还是同一个 key K。
如果idempotency的逻辑只看 MySQL,就会出现状态分裂:MySQL 看到 K 已存在,认为是重复请求,跳过 attribution 插入;S3 如果没有使用稳定 object key 或条件写,重试可能再写一份 raw log;计费 API 第一次没调到,所以第二次继续调用。最后状态变成:MySQL 1 条 attribution,S3 2 份 raw log,计费 1 次。单看每一步都合理,整体却不一致。下游报表对账时,raw log、attribution、billing event 对不上。
跨三个系统时,一次故障重试大概是这样:
问题在于,idempotency key 只能告诉你“这是不是同一个请求”,但不能告诉你“这个请求上次执行到哪一步”。所以跨系统写入不能只靠一张 dedup 表。更稳的做法是把整个流程变成一条可恢复的状态机:先在 MySQL 里写 intent 记录,再按状态推进,比如 RECEIVED -> ATTRIBUTION_WRITTEN -> RAW_LOG_WRITTEN -> BILLING_RECORDED -> COMPLETED。每个状态转移都带自己的幂等检查,子步骤完成后回写状态。
重试时,不是重新跑完整业务逻辑,而是先读当前状态,从中断的位置继续。比如 attribution 已写,就继续确认或补写 raw log;raw log 已写,就继续调用 billing;如果某一步卡太久,就交给 reconciliation job 去查下游真实状态,再决定重试、补偿还是人工介入。
这套方案的代价是 state machine 本身成了新组件,要监控、告警、兼容 schema 变更。但它消除了最致命的问题:静默不一致。故障后我们能查出每一笔卡在哪一步,而不是等到事后对账才发现少了几百万条。
03 外部 API 自己撒谎
最后一个坑,也是最容易让人在面试里栽的:外部 API 自己声称 idempotent,但其实不可靠。
Stripe 的 charge API 接受 Idempotency-Key header,文档写得清清楚楚:同 key 24 小时内只会扣一次款。听起来完美。
但真实场景是这样的:你给它发请求,TCP 超时 30 秒。你不知道它收到没。你重试。Stripe 后台第一次其实成功了,但你没收到响应。第二次带同 key 进去,Stripe 返回上次的成功结果。看起来对了。
但中间这 30 秒,你的系统在做什么?
如果你的代码是调 Stripe → 拿到响应 → 写本地 ledger」,超时那一刻本地 ledger 没写。重试拿到响应再写,看起来对。
但如果在重试之前,触发了一次deploy,新进程启动,你不知道 Stripe 那笔到底成没成功,本地没有任何记录。系统假设它没成功,触发补偿流程:用户被扣了一次款,你的 ledger 显示”应该补扣”,又触发一次 charge。Stripe 返回上次结果(idempotent 嘛),你以为又扣成功了,对账系统看到两条 ledger 但只对应一笔实际扣款。
响应丢失 + 进程重启之间, 本地丢失了”我调过 Stripe”这个事实, 后续任何决定都是错的:
idempotency 不是只有调用方的责任,它是一份契约。你这边发出去的每一次请求,必须在本地有一份不依赖响应的 intent log。哪怕响应丢了、进程重启了、机器换了,只要 intent log 在,你就能基于这份意图重新对账。
业界上叫 outbox + reconciliation。我们当时给每笔外部调用先写一条 intent 记录到 MySQL(带 idempotency key 和当前状态),再发请求。响应回来更新 intent 状态。reconcile job 每分钟扫一遍状态停在 in-flight 的记录,主动查 Stripe 对账。
这套机制让我们的 stuck transaction 数从每天几百降到了个位数。
为什么这些问题决定面试结果
Coding 写得快、leetcode 刷得熟,是入场券。System Design 这一轮,面试官就是在问:你做过的系统,故障来的时候是默默丢数据,还是有清晰的恢复路径?
idempotency 是个特别好的考题,因为它分层很清楚:
答 unique key — junior 水平
答跨系统的 saga / outbox — mid-level,知道概念
能讲 state machine + intent log + reconciliation — Strong Hire 区间
这不是面试官想为难你。Tier-0 服务里,每一笔钱、每一次外部 API 调用,都要在故障下能讲清楚状态。这个能力上来了,level 自然上来。我之前带过的一个同学,两年 SDE 经验,面 Stripe 时把 idempotency 这一层完整讲透了,包括我们今天聊的三个场景,面试官当场给了 Strong Hire。拿到的 package 比同级别 high 出 30k。
这个 Gap 怎么补
靠自己摸索这套思维,需要进大厂、做过支付/广告/订单这类核心系统、被 on-call 过几次、写过几份 post-mortem。大概 3-5 年。
但现在的市场不给你 3-5 年。一次面试机会浪费了,可能就是半年冷冻期。OPT 倒计时不会等你。
学校不教这些。公司也不会等你学会了再给你 Offer。
这也是我做辅导时候的核心切入点之一。我不教刷题。我做的是:把你简历上的项目拆开,找出那些”生产环境一定会出问题”的地方——通常就是 idempotency、failure recovery、scale 这几个面,然后跟你一起重新设计。不是在简历上加 buzzword,是你能在面试里把每一笔状态、每一个 race window、每一个补偿路径讲清楚。
面试官评价表上能写”Strong Hire”的同学,跟只能写”Hire”的同学,差距通常就在这种地方。


