本文目录导读:

- 核心原则:先做“防重校验”,后做“业务逻辑”
- 方案一:基于数据库唯一索引(最安全、最推荐)
- 方案二:基于分布式锁 + 状态机(适用于修改操作)
- 方案三:基于 Token(预发凭证)
- 方案四:全局唯一序列号(去重表)
- 安全实现的关键避坑指南(常见失败原因)
- 总结:推荐的“黄金组合”
这是一个非常核心且常被问到的设计问题。“接口幂等”是指任意多次执行操作所产生的影响,都与一次执行的影响相同,不安全实现会导致重复下单、重复扣款、重复转账等严重资金或数据错误。
要实现安全的幂等,关键在于 “唯一标识 + 锁 + 状态机” 的组合策略,并且要防止并发下的竞态条件。
以下是安全实现幂等的核心方案与最佳实践:
核心原则:先做“防重校验”,后做“业务逻辑”
不能先执行业务逻辑,发现重复了再回滚(回滚很难),而要在业务逻辑执行前就拦截住。
基于数据库唯一索引(最安全、最推荐)
这是成本最低、最不容易出错的方案,利用数据库的约束来保证幂等。
适用场景: 新增数据(如创建订单、注册用户)。
实现步骤:
- 提取幂等Key: 在业务表中增加一个
idempotent_key字段(或利用已有业务唯一字段,如订单号、支付流水号)。 - 设置唯一索引: 为
idempotent_key建立唯一索引(UNIQUE KEY)。 - 业务逻辑:
- 先尝试
INSERT INTO order (id, idempotent_key, status, amount) VALUES (?, ?, ?, ?)。 - 插入成功:说明是第一次请求,继续执行后续业务。
- 插入失败(DuplicateKeyException):说明是重复请求,直接返回成功结果(或已存在的旧结果)。
- 先尝试
为什么安全?
- 数据库的唯一索引是 强一致性 的,即使在极高并发下,数据库也会保证只有一条记录能插入成功,不会出现“插入一条,查询发现没有”的幻读情况。
注意: 建议使用业务意义的幂等Key(如:支付业务ID + 流水号),而非单纯依靠时间戳或UUID(虽可用,但需存储)。
基于分布式锁 + 状态机(适用于修改操作)
适用于已有数据的修改或状态流转(如支付、退款、取消订单)。
核心: 锁住资源 + 检查状态值。
实现步骤:
- 获取锁: 使用 Redis(推荐 Redisson)或 Zookeeper,以
业务ID + 方法名为Key加锁(如order:pay:123456)。 - 检查状态:
- 从数据库查询当前订单状态(如
status)。 - 如果当前状态 等于 或 大于 目标状态(订单已支付,你又收到支付请求),直接返回成功,不再处理。
- 从数据库查询当前订单状态(如
- 执行业务:
- 执行更新操作:
UPDATE order SET status = 'paid', version = version + 1 WHERE id = 123 AND status = 'unpaid'。
- 执行更新操作:
- 释放锁。
为什么安全?
- 乐观锁(状态判断):
WHERE status = 'unpaid'保证了只有在正确的初始状态下才能更新成功,如果两次请求同时到达,一个成功了,另一个会因为status已变为paid而更新 0 行数据。 - 锁的防并发: 锁防止了两个请求同时去读状态,但最终安全性靠数据库的
UPDATE原子性(或CAS比较更新)兜底。
基于 Token(预发凭证)
适用于防止表单重复提交。
实现步骤:
- 申请Token: 客户端在打开操作页面时,先调用服务端获取一个唯一Token(存于Redis,带过期时间)。
- 携带Token: 提交请求时,必须带上这个Token。
- 校验与删除:
- 服务端收到请求后,先尝试 删除 Redis 中的 Token(
DEL key或 Lua 脚本GET + DEL)。 DEL成功(返回1),说明是合法的第一次请求,执行后续处理。DEL失败(返回0或Token不存在),说明是重复请求,直接拒绝。
- 服务端收到请求后,先尝试 删除 Redis 中的 Token(
为什么安全?
- Redis 单线程机制 & Lua 原子性:
DEL操作是原子性的,一个Token只能被一个请求成功删除,这就确保了同一Token的请求最多被执行一次。
全局唯一序列号(去重表)
类似于方案一,但独立出一张“去重记录表”。
适用场景: 多个不同业务模块都需要幂等,不想修改各自的核心表。
实现步骤:
- 创建一张独立的表
idempotent_record,唯一字段是(type, biz_id, value)。 - 业务逻辑开始前,先向该表插入一条记录。
- 若插入成功,则继续执行业务;若失败,则判断为重复。
安全实现的关键避坑指南(常见失败原因)
-
先查后插(无锁):
- 错误做法: 先
SELECT检查是否存在,不存在则INSERT。 - 风险: 高并发时,两个请求同时
SELECT都发现不存在,然后都INSERT,导致重复数据。 - 正确做法: 直接
INSERT并捕获唯一索引冲突,或使用SELECT ... FOR UPDATE(行锁)。
- 错误做法: 先
-
分布式锁释放过早:
- 错误做法: 获取锁后,
if(status == 未处理) { doBusiness(); releaseLock(); }。 - 风险: 业务执行完、锁释放后,另一个请求进来发现状态已变化(但异常回滚了?),或锁超时导致并发。
- 正确做法: 锁保护整个业务周期(直到状态写死到DB),使用 Redisson 的 WatchDog 自动续期。
- 错误做法: 获取锁后,
-
状态机丢失(更新语句不精确):
- 错误做法:
UPDATE order SET status = 'paid' WHERE id = 123。 - 风险: 重复请求会导致状态被反复覆盖,甚至回退(若业务逻辑有误)。
- 正确做法:
UPDATE order SET status = 'paid' WHERE id = 123 AND status = 'unpaid'。
- 错误做法:
-
结果返回不一致:
- 问题: 第一次请求成功了,返回了“成功”,第二次请求也成功了,但因为在去重阶段直接返回了“重复”,导致客户端以为失败了。
- 最佳实践: 被幂等拦截的重复请求,应该返回与第一次完全相同的业务结果(如:订单详情、成功码),而不是返回“请求重复”的错误,这要求去重记录里缓存第一次请求的响应内容。
推荐的“黄金组合”
| 场景 | 推荐方案 | 核心保底机制 |
|---|---|---|
| 创建资源(下单、注册) | 数据库唯一索引 | DB 行锁 + UNIQUE KEY |
| 状态变更(支付、退款) | 分布式锁 + 乐观锁 (状态机) | DB UPDATE WHERE 条件 + 版本号 |
| 表单提交 | Token 预发 | Redis 原子 DEL |
| 高并发、弱一致性可接受 | Redis SETNX + 过期 | SET NX EX 指令 (需权衡数据一致性) |
安全性排序(由高到低):
- 数据库唯一索引(强一致,无并发问题)
- 分布式锁 + 状态机(强一致,但依赖锁和DB)
- Token 预发(弱一致,Token可能丢失)
- SETNX/缓存(最终一致性,可能因宕机丢失)
最终建议:能用数据库唯一索引解决的,不用其他方案。 如果必须用锁,请用 Redisson 等成熟框架,并配合 乐观锁(WHERE 条件) 最终兜底。