接口幂等设计:token、唯一键、状态机与乐观锁
更新时间:2026-09-18。「同一个操作执行一次和执行多次,结果一样」就是幂等。分布式系统里重复不可避免:网络超时触发客户端重试、MQ 至少一次投递重复消费、用户连点提交、定时任务多实例重跑。幂等不是靠某一个万能方案,而是按写操作类型选择天然幂等、唯一约束、幂等键、版本号、状态机中的一种或组合。配套概念见 分布式事务方案怎么选、Redis 分布式锁。
本文要回答的问题
- 幂等和「防重复提交」是一回事吗?哪些 HTTP 方法天然幂等?
- 为什么「先 SELECT 查有没有,再 INSERT」挡不住并发重复?
- 唯一索引、去重表、Idempotency-Key token 分别怎么落地?
- 更新操作为什么要用绝对值/版本号,而不是 +1?
- 状态机、乐观锁、分布式锁在幂等里各管什么?MQ 消费怎么做?
一、重复从哪来,幂等的层次
重复来源:
用户连点 / 前端重放
客户端超时重试(网关、SDK 自动重试)
MQ at-least-once:重平衡、ACK 丢失 -> 重复投递
定时任务多实例 / 失败重跑
CDC/binlog 重放| HTTP | 语义 | 是否天然幂等 |
|---|---|---|
| GET/HEAD | 查询 | 幂等 |
| PUT | 用给定状态整体替换 | 幂等(按定义) |
| DELETE | 删除资源 | 幂等(删第二次通常仍是「不存在」) |
| POST | 创建/动作 | 默认不幂等,需要设计 |
| PATCH | 部分更新 | 看实现(+1 不幂等,set=5 幂等) |
幂等关注的是系统最终状态,不是返回值必须字节相同;第二次请求可以返回「已处理/首次结果」。
二、创建类:唯一约束才是最终防线
「先查后插」在并发下挡不住:两个请求都 SELECT 到不存在,然后都 INSERT,产生两条。正确做法是在数据库放唯一索引,让重复插入在落库时被约束拒绝:
-- 业务唯一键:同一用户同一活动只能一条
ALTER TABLE t_order ADD UNIQUE KEY uk_user_biz(user_id, biz_no);
-- 依赖自增 id 的表,用专门的去重表保证幂等
CREATE TABLE idem_record(
idem_key VARCHAR(64) PRIMARY KEY, -- 幂等键
result_ref BIGINT, -- 指向首单结果
created_at DATETIME
);并发插入同一唯一键:
T1 insert 成功
T2 insert -> Duplicate entry(捕获 1062/23505)
T2 改为查询并返回首单结果(而不是报 500)唯一约束是最终防线,应用层判重只是优化体验、减少撞库异常。Redis SET NX 可做前置快速拦截(见 Redis 分布式锁),但宕机/过期边界下仍要靠数据库唯一键兜底。
三、幂等键 / token 机制(无法用自然唯一键时)
下单、支付这类一次操作由服务端或客户端生成唯一 Idempotency-Key,跨重试保持不变:
1) 客户端首次请求带 header: Idempotency-Key: 7b3...(同一业务意图重试复用此值)
2) 服务端处理前:INSERT idem_record(idem_key) 或 SET key NX(占位)
- 插入成功:首次,执行业务,完成后记录结果(或状态=done)
- 唯一冲突:重复请求 ->
若首次仍在处理:返回 处理中/409
若已完成:直接返回首次结果(幂等回放)
3) key 设 TTL(如 24h)覆盖重试窗口;处理状态要落库防重启丢失「token 机制」是其变体:进入表单页先发一个一次性 token,提交时校验并删除(防连点/重复提交);但它依赖 Redis 且要保证「校验+删除」原子(Lua),更适合防重而非跨进程严格幂等。关键区别:幂等键由客户端在同一业务意图的所有重试中复用,而防重 token 用一次即废。
四、更新类:绝对值、版本号、状态机
-- ❌ 相对更新,重复执行累加
UPDATE account SET balance = balance + 100 WHERE id=1;
-- ✅ 绝对值/条件更新,重复执行结果相同
UPDATE account SET status='PAID' WHERE id=1 AND status='UNPAID';
-- ✅ 乐观锁版本号:ABA 与重复更新都被挡
UPDATE cart SET qty=2, ver=ver+1 WHERE id=1 AND ver=7;
-- 影响行数=0 说明已被改过/这是重复请求,按需重试或返回冲突状态机幂等:让状态只能单向合法流转,重复的旧事件无法把状态推回去:

支付回调尤其典型:第三方可能重复推送,服务端 UPDATE 订单 SET 已支付 WHERE 单号=? AND 状态=待支付,第一次 1 行、后续 0 行,0 行也回 ACK 成功,避免对方一直重试。
五、MQ 消费幂等与选型
MQ 通常 at-least-once,消费者必须幂等:消息带全局唯一 messageId/bizNo,消费时用去重表 INSERT IGNORE/唯一键,处理与去重记录放同一数据库事务(或先落去重再处理+补偿);或让处理逻辑本身幂等(状态机/绝对值)。CDC/binlog 重放同理,见 CDC 数据同步。
| 场景 | 首选 |
|---|---|
| 天然查询/删除/固定值替换 | 利用语义本身幂等 |
| 创建且有自然业务键 | 唯一索引兜底,冲突回查 |
| 创建无自然键、需回放结果 | Idempotency-Key + 去重表(落库) |
| 防用户连点/重复提交 | 一次性 token(Lua 原子消费)+ 前端置灰 |
| 更新计数/资料 | 乐观锁版本号或绝对值 SET |
| 订单/工单流转 | 状态机单向条件更新 |
| 跨资源临界区 | 分布式锁(提效,非正确性唯一保障) |
六、坑与对照
- 只做先查后插。 并发下必现重复,唯一索引不能省。
- 重复请求撞唯一键后返回 500。 应识别重复并回查返回首次结果,保证调用方重试得到一致应答。
- 幂等键只存内存/Redis 且无落库。 进程重启或 Redis 失效后无法判重,处理中状态要持久化。
- token 与 idempotency-key 混用。 一次性 token 防「连点」,重试应复用业务幂等键。
- 用相对增量更新。
x=x+n天然不幂等,改绝对值或带版本/条件。 - 加了锁就不做唯一约束。 锁失效/超时仍可能并发,约束才是终点;锁与幂等是两层(见 Redis 分布式锁)。
- 消费去重和业务处理不在同一事务。 中间崩溃会「记了去重却没处理」或反之,要同事务或可补偿。
服务超时与重试策略见 gRPC keepalive 与多路复用;消息选型见 Kafka 与 RabbitMQ。
总结
接口幂等设计:重复不可避免(重试、MQ at-least-once、连点、任务重跑),要按写类型选手段——创建类用数据库唯一索引/去重表做最终防线(先查后插挡不住并发,重复键要回查首次结果而非报 500),无自然键用客户端跨重试复用的 Idempotency-Key + 落库去重表记录处理中和结果、配 TTL,防连点用一次性 token 原子消费;更新类把 x=x+n 改成绝对值 SET 或加乐观锁版本号,订单流转用单向状态机条件更新(重复回调 0 行也回成功),MQ/CDC 消费把去重与业务放同一事务或让逻辑本身幂等;Redis 锁只提效,唯一约束与幂等记录才是正确性终点。