面试题:接口幂等:重复支付、消息重投、请求重试,怎么保证同一操作只生效一次?
这是「每日一题」专栏的第十四篇。每天一道面试题,后端 + AI 混合路线,从原理到代码一次讲清。上一篇讲了 RAG 检索增强生成,今天按轮换回到后端/分布式方向,聊上一篇预告的题:接口幂等。
一、先搞清楚:什么是幂等,以及它不等于什么
面试官问幂等,十个人有八个背「一次和多次结果一致」——这是定义,但真正的考点在后面三连问:幂等、去重、并发控制是一回事吗?分别用什么手段解决?
| 概念 | 要解决的问题 | 典型手段 |
|---|---|---|
| 幂等 | 同一请求执行 N 次,效果等同于 1 次 | 幂等键 + 唯一约束 |
| 去重 | N 条相同消息只消费 1 条 | 消费记录表 + 唯一索引 |
| 并发控制 | 同一时刻多个请求竞争同一资源 | 分布式锁、乐观锁版本号 |
三者经常叠加出现,但手段不同。比如支付:网络重试要幂等(同一笔订单扣款一次),用户连点是并发控制(两个请求同时到达),MQ 重投是去重。一个健壮的系统,往往三层手段都要上。
先记住 HTTP 语义:GET、DELETE、PUT 天然幂等(PUT 是覆盖语义),POST 不幂等。所以业界标准做法是给 POST 请求带一个 Idempotency-Key(幂等键),这是 Stripe 的支付 API 带火的标准:
POST /api/v1/orders/123/pay
Idempotency-Key: 8f14e45fceea167a5a36dedd4bea2543
二、幂等键方案:请求怎么拦,响应怎么回
最通用的做法是「幂等键 + 幂等表」,链路分四步:
- 客户端生成幂等键:UUID,或业务可推导的键(如
userId + orderId + 操作类型)。注意:业务键必须能稳定复现,用户重试时带同一个键。 - 服务端先查后写:用
SELECT ... WHERE idempotency_key = ?查幂等表,命中直接返回上次结果。 - 并发兜底:查和写之间有竞态窗口,两个相同键的请求会同时查不到。所以幂等表主键就是幂等键,靠数据库唯一索引拦第二刀——谁先插入成功谁生效,后到的插入报 Duplicate Key,转去查已保存的结果。
- 响应缓存:幂等表不只存键,还要存首次请求的响应体(或状态)。重试时返回首次结果,保证「一次和多次结果一致」。
伪代码(服务端拦截器视角):
def process(request):
key = request.headers["Idempotency-Key"]
# 第一步:先查
record = idem_table.find(key)
if record:
return record.response # 命中:返回第一次的结果
# 第二步:并发兜底(唯一索引)
try:
idem_table.insert(key, status="PROCESSING")
except DuplicateKey:
# 并发兄弟请求赢了,等它完成再返回其结果
record = idem_table.wait_and_find(key)
return record.response
# 第三步:真正执行业务
try:
result = biz_execute(request)
except Exception:
idem_table.update(key, status="FAILED") # 失败可删可标记
raise
idem_table.update(key, status="DONE", response=result)
return result
注意一个细节:执行中(PROCESSING)状态的重试怎么办?两种流派——严格派直接返回「处理中,请稍后重试」;宽松派让重试请求阻塞等待结果。支付场景一般选前者,配合前端轮询。
三、三个高频场景逐一拆解
场景一:重复支付
支付是幂等最经典的场景,因为钱不能多扣也不能少扣。落地是「订单状态机 + 唯一约束」双保险:
- 订单表加
pay_status状态机:待支付 → 支付中 → 已支付 → 已退款,状态只能单向流转。回调来时,只有待支付/支付中才允许进入已支付,已支付的回调直接幂等返回成功。 - 支付流水表
pay_record建唯一索引(order_id, channel, out_trade_no)——第三方支付的回调、对账单、人工补单,任何一条路重复进来都被唯一索引挡住。 - 最后一道:对账。每天拉渠道对账单,跟本地流水逐笔比对,多扣的自动退款、漏扣的自动补扣。幂等是防君子,对账是防系统。
场景二:消息重投
Kafka 的 at-least-once 语义决定了消费端必须自己幂等——这是高频考点。标准答案:
CREATE TABLE msg_consume_log (
msg_id VARCHAR(64) PRIMARY KEY, -- 唯一约束是去重核心
biz_id VARCHAR(64) NOT NULL,
status TINYINT NOT NULL,
create_time DATETIME NOT NULL
);
消费流程:INSERT IGNORE msg_consume_log(msg_id) → 影响行数为 0 说明消费过,直接 ACK 跳过;插入成功才执行业务,业务与消费记录放同一事务(本地事务表方案)。这样消息重投再多次,唯一约束保证业务只执行一次。
如果对实时性要求高不想写库,也可以用 Redis SET msg_id 1 NX EX 86400 做轻量去重,但注意 Redis 的坑:没开持久化时宕机会丢锁,导致重投穿透——生产上要么开 AOF,要么数据库唯一索引兜底,二选一或都上。
场景三:RPC 超时重试
调用方视角:order-service 调 payment-service 超时了,是重试还是放弃?重试就可能造成重复扣款,不重试可能丢单。解法还是幂等键:
- 调用方每次业务操作生成
request_id,随 RPC 透传(header 或消息字段)。 - 被调方用
request_id建幂等表(或直接用它做状态机更新的条件)。 - 重试策略:指数退避 + 抖动(如
1s → 2s → 4s加随机 jitter),避免重试风暴把下游打挂;同时设最大重试次数和超时上限,超过后进补偿队列人工兜底。
一个反直觉的点值得提:重试要在「确认失败」后做,而不是「超时」后盲目做。超时可能是网络慢,请求其实成功了——所以重试必须带幂等键,这是重试机制能安全存在的前提。
四、存储选型对比:幂等表放哪?
面试官常追问:幂等表用 MySQL 还是 Redis?回答要分场景:
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| MySQL 唯一索引 | 强一致、不丢数据、天然支持事务 | 慢(毫秒级)、有主库压力 | 支付、订单等资金/核心链路 |
| Redis SETNX | 快(亚毫秒)、实现简单 | 可能丢(持久化问题)、无事务回滚 | 防重提交、限流类高频场景 |
| Redis + MySQL 双写 | 快且稳 | 实现复杂 | 高并发核心场景(先 Redis 挡流量,MySQL 兜底) |
推荐话术:「核心链路以数据库唯一索引为准,Redis 只做前置过滤挡流量;两者数据不一致时以数据库为准,靠对账兜底。」
五、两道追问怎么接
追问 1:幂等键过期了怎么办? 幂等表要有保留期(如 24 小时),过期键会被清理,同一业务键再来就是新请求。所以幂等键的时间窗口必须覆盖最大重试周期:客户端最长重试 N 小时,幂等键就得保留超过 N 小时。这是设计时最容易漏的细节。
追问 2:幂等和分布式锁有什么区别? 分布式锁保证同一时刻只有一个执行;幂等保证多次执行只生效一次。锁管并发、幂等管重复——所以前面说两者常叠加:用锁挡住并发(两个同时到达的请求只放行一个),用唯一索引兜底重复(串行到达的相同请求只生效一次)。
六、收尾
幂等设计的本质,是把「不确定性」变成「确定性」:网络是不可靠的,重试是必然发生的,所以每个写操作都要有唯一可识别的身份,并且系统对相同身份只生效一次。从幂等键生成、唯一约束兜底,到状态机流转、对账纠偏——四层防线缺一层,线上就可能出现一笔钱扣两次的事故。
明日预告:系统设计方向 ——「设计一个实时协作文档系统:多人同时编辑同一份文档怎么不冲突、OT 和 CRDT 各自怎么运作、怎么选?」