什么是幂等

同一个操作执行一次或执行多次,系统最终产生的业务效果相同,就称为幂等。HTTP GET、PUT、DELETE 在协议语义上通常被设计为幂等,但真实业务是否幂等仍取决于服务实现。

网络超时无法证明服务端没有执行。客户端、网关、消息队列都可能重试,因此订单创建、支付、库存扣减等关键写操作必须主动设计幂等。

先识别业务唯一性

最可靠的幂等依据通常是业务唯一键,例如支付请求号、订单号或消息 ID。键必须代表“同一次业务意图”,不能每次重试都生成新值,也不能把不同操作错误地复用同一个键。

数据库唯一约束

通过唯一索引让重复插入在数据库层原子失败,是最直接的方案:

CREATE UNIQUE INDEX uk_payment_request
ON payment(request_id);

收到唯一键冲突后,应查询并返回第一次请求的结果,而不是统一报系统错误。“先查询再插入”存在并发窗口,唯一索引才是最终保障。

幂等记录表

通用接口可以维护 idempotency_key、request_hash、status、result。第一次请求创建处理中记录,完成后保存响应;重复请求校验参数摘要,若参数不同应拒绝,若已完成则返回原结果。

处理中记录需要超时与恢复机制,否则服务崩溃会留下永久处理中状态。响应过大或含敏感信息时,不应无期限保存完整内容。

状态机条件更新

对订单、任务等有明确生命周期的数据,可使用条件更新:

UPDATE orders
SET status = 'PAID', version = version + 1
WHERE id = ? AND status = 'PENDING' AND version = ?;

受影响行数为 0 时,需要区分已经完成、版本冲突和对象不存在。状态机还必须定义合法迁移,避免状态倒退。

Redis 的使用边界

SET key value NX EX seconds 可以快速拦截短时间重复请求,但 Redis 故障、过期时间和数据库事务之间仍存在一致性窗口。对于资金等核心数据,Redis 适合削峰,数据库约束仍应作为最终防线。

消息消费幂等

消费者可将消息唯一 ID 与业务写入放在同一数据库事务中。若消息记录插入发生唯一键冲突,说明已处理过。仅在内存中记录或先写缓存再写数据库,都无法可靠覆盖进程重启和部分失败。

设计检查表

  1. 幂等键由谁生成,生命周期和作用域是什么?
  2. 相同键但不同参数如何处理?
  3. 第一次请求仍在执行时,重复请求等待、拒绝还是返回处理中?
  4. 服务在业务成功、记录结果前崩溃如何恢复?
  5. 幂等记录保存多久,过期后重试是否仍安全?
  6. 是否用并发请求和故障注入验证过,而不只是串行测试?

核心考点清单

  • 幂等键必须唯一表示同一次业务意图,重试时不能重新生成。
  • 数据库唯一约束是并发下的最终防线,“先查再写”仍有竞争窗口。
  • 相同幂等键但参数不同必须拒绝,避免误复用结果。
  • 处理中状态要设计超时、接管和崩溃恢复,不能永久卡住。

高频追问与参考回答

追问:Redis SET NX 能完全保证幂等吗?

不能。锁过期、Redis 故障以及数据库事务之间仍有窗口。Redis 可快速拦截重复请求,核心数据仍需数据库唯一约束或条件更新兜底。

追问:幂等和防重复提交一样吗?

防重复提交偏向入口拦截;幂等要求重复执行后业务最终效果一致,必须覆盖网络重试、消息重复和服务崩溃等更广场景。

机制全景图

下面把「分布式系统如何设计幂等接口?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。

flowchart LR
    A["客户端生成业务请求号"]
    A --> B["服务端原子登记幂等键"]
    B --> C["执行业务状态迁移"]
    C --> D["保存结果快照"]
    D --> E["重复请求返回同一结果"]

完整链路:从输入到结果

沿着「客户端生成业务请求号 → 服务端原子登记幂等键 → 执行业务状态迁移 → 保存结果快照 → 重复请求返回同一结果」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。

1. 客户端生成业务请求号

幂等键必须代表同一业务意图,随机重试每次换键会完全失去去重作用。

2. 服务端原子登记幂等键

登记与业务写最好在同一数据库事务通过唯一约束完成,先查后写存在并发窗口。

3. 执行业务状态迁移

状态至少区分处理中、成功和确定失败,结果未知不能随意当失败重做。

4. 保存结果快照

成功结果或关键标识应保存,使重复调用能返回原订单号而不是空洞的“重复”。

5. 重复请求返回同一结果

幂等记录保留期要覆盖客户端、MQ 和人工重放的最大窗口,清理前确认业务已不可重试。

源码与实现定位

入口 阅读重点
数据库 UNIQUE(idempotency_key) 并发最终裁决
请求状态机 PROCESSING/SUCCESS/FAILED_UNKNOWN

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

INSERT INTO request_log(request_id,status) VALUES (?, 'PROCESSING');

在登记前后、业务提交后、返回前 kill,重放同一 key 核对结果。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「幂等冲突率」为主基线,记录值应满足「记录活动/稳态基线」;同时保存 幂等命中率、处理中超时数,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「数据库 UNIQUE(idempotency_key)」确认请求确实进入「并发最终裁决」对应的实现,再沿「请求状态机」观察「PROCESSING/SUCCESS/FAILED_UNKNOWN」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「把请求 ID 当业务 ID 导致重试绕过去重」,并把单一变量逐级放大,直到「幂等冲突率」越过「超过容量或 SLO」。随后再分别验证「处理中超时后两个执行者并发恢复」和「幂等记录先成功但业务事务回滚」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「业务意图生成稳定 key」,确认它能控制影响范围;第二轮应用「记录与业务同事务」,验证核心链路恢复;最后落实「处理中超时用租约接管」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「幂等冲突率」回到「记录活动/稳态基线」、「端到端 P99」回到「小于预算」、「状态差异」回到「0」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
幂等冲突率 记录活动/稳态基线 超过容量或 SLO 触发降级
端到端 P99 小于预算 突破预算 停止扩量
状态差异 0 任意非零 补偿并对账

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:支付回调重复导致积分发放两次

回调按 HTTP 请求 ID 去重,但渠道每次重试都会生成新请求 ID。改用支付单号+事件类型作为业务幂等键,并以积分流水唯一约束最终裁决后才正确。

失败模式 首要证据 第一处置动作
把请求 ID 当业务 ID 导致重试绕过去重 幂等命中率 业务意图生成稳定 key
处理中超时后两个执行者并发恢复 处理中超时数 记录与业务同事务
幂等记录先成功但业务事务回滚 唯一冲突 处理中超时用租约接管

发布与回滚检查点

  • 发布前:确认「数据库 UNIQUE(idempotency_key)」对应实现和上述配置在目标版本仍然有效,并保存「幂等冲突率」基线。
  • 灰度中:同时观察 幂等命中率、处理中超时数、唯一冲突;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「业务意图生成稳定 key」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「把请求 ID 当业务 ID 导致重试绕过去重」没有再次出现,才关闭变更观察窗口。

方案对比与选型

方案 更适合的场景 主要收益 代价与边界
唯一约束 最终副作用在单数据库 并发裁决强、证据持久 热点索引与冲突处理
状态表 多阶段操作和结果查询 状态可恢复、便于补偿 生命周期与清理复杂
下游幂等令牌 跨服务最终资源 端到端保护 依赖下游契约和保留窗口

选型至少带上 峰值流量、数据规模、依赖容量、一致性目标和故障预算,并用上面的量化基线验证;未知数据应明确为待测假设。

设计边界与工程取舍

幂等不是简单“拒绝第二次”,而是同一业务意图重复执行仍收敛到同一状态,并能给调用方稳定结果。

工程落地遵循:先定义 SLO 与不变量,再通过限流、隔离、异步和补偿保护核心链路。回答时直接引用「数据库 UNIQUE(idempotency_key)」、配置实验和事故数据,比复述固定模板更有说服力。