接口幂等性方案详解
Hi,我是阿昌。今天记录一下接口幂等性。
一、什么是幂等
幂等是数学概念,比如 f(n) = 1ⁿ,不管 n 取多少,结果永远是 1。
搬到软件开发里:
不论执行多少次相同的请求,产生的效果和返回的结果,都和发出单个请求是一样的。
对应到数据库操作:
INSERT:不插入重复数据UPDATE:多次相同更新后数据依然正确
二、为什么需要幂等
接口幂等问题通常来自这些场景:
- 网络波动导致客户端重试
- 用户手抖重复点击
- 消息队列重复消费
- 接口响应慢,前端超时自动重试
- RPC 框架的重试机制
不保证幂等会怎样?
举个最直接的例子——支付。
用户在付款时连点了两下,后端处理了两次扣款,账户被扣了两次钱。
这种 Bug 在涉及钱的业务里就是事故级别。
另外,幂等不是前端把按钮置灰就完事了,后端也必须做。
前端的限制容易被绕过了。
三、常见方案概览
后端保证幂等的方式大致有几类:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 悲观锁 | 加锁让同一时刻只有一个请求执行 | 写多读少 |
| 乐观锁 | 版本号/CAS,冲突时重试或失败 | 读多写少 |
| 唯一索引 | 数据库层面保证数据唯一 | INSERT 场景 |
| 去重表 | 用一张表记录已处理的请求 ID | 通用场景 |
| 分布式锁 | 跨进程加锁 | 分布式环境 |
| Token 机制 | 先取 token,请求时校验并删除 | 表单提交 |
四、悲观锁
Java 本地锁
ReentrantLock、synchronized 这类 JDK 自带的锁只能保证同一个 JVM 进程内的线程安全。
1 | private final ReentrantLock lock = new ReentrantLock(); |
问题很明显:分布式环境下,多个服务实例各自有各自的锁,互不影响。 所以本地锁只在单机场景有用。
数据库排他锁
MySQL 的 SELECT ... FOR UPDATE 可以在事务中对记录加排他锁(X 锁),阻止其他事务同时修改。
1 | START TRANSACTION; |
几个注意点:
- 必须在事务中使用,且存储引擎要支持(InnoDB)
- 查询条件必须走索引,否则会锁全表
- 高并发下锁竞争激烈,线程阻塞会导致大量上下文切换
- 存在死锁风险
五、乐观锁
乐观锁通常用版本号机制或 CAS 实现。核心思路是:更新时检查版本号,一致才更新,不一致说明被别人改过了。
1 | -- 初始 version = 1 |
相比悲观锁:
- 不会阻塞线程,没有死锁问题
- 写冲突少时性能更好
- 但只适用于 UPDATE 场景
- 如果冲突频繁,会变成”失败→重试→失败”的循环,CPU 反而飙升
悲观锁的开销是固定的(阻塞),乐观锁的开销随冲突频率增长。写多的时候反而悲观锁更合适。
六、唯一索引 / 去重表
唯一索引
在需要保证唯一的字段上加唯一索引,重复插入直接抛异常。
1 | CREATE TABLE t_order ( |
重复插入时 MySQL 抛出 Duplicate entry,代码里 catch 一下就好。
但建议的做法是:不要把唯一索引当作唯一的幂等手段,而是作为兜底。 就像开篇那个阿里面试的例子:Redisson 分布式锁在前面拦截大部分重复请求,唯一索引兜底防止锁失效时产生脏数据。
去重表
去重表是唯一索引的变体——单独建一张表记录”已处理”的请求 ID。
1 | CREATE TABLE deduplication ( |
流程:
1 | 请求进来 → INSERT INTO deduplication (processed_code) VALUES ('XXX') |
本质和唯一索引一样,只是把幂等判断单独抽了一张表,不跟业务表耦合。
七、分布式锁
分布式系统下,不同服务实例跑在不同的 JVM 里,本地锁管不到。这时候需要分布式锁。
通常基于 Redis 或 ZooKeeper 实现,Redis 用得更多。比如 Redisson 的 RLock:
1 | RLock lock = redissonClient.getLock("order:lock:" + orderId); |
基于 MySQL 也能做分布式锁,但一般不推荐——性能差,没有锁失效机制,容易死锁。
分布式锁的核心作用和悲观锁一样:同一时刻只让一个请求执行业务逻辑。 区别是它能跨进程。
配合幂等判断的逻辑通常是:
1 | 加锁 → 查订单状态 → 已处理?直接返回 : 继续处理 → 释放锁 |
八、Token 机制
Token 机制需要两次请求:
1 | 第一步:客户端 GET /token → 服务端生成 token 存入 Redis,返回给客户端 |

关键点:
- Token 必须由服务端生成,可以签名防篡改
- Token 设置短有效期
- 验证方式一般是 删除 token,删除成功才算有效
得物技术有一张流程图把这个过程画得很清晰:
1 | 客户端 服务端 |
先删 token 还是先执行业务?
两者都有风险:
- 先执行业务:token 还在,客户端可能重试导致第二次也通过
- 先删 token:业务执行超时,重试时 token 已经没了,请求失败
一般建议先删 token。如果业务执行失败,客户端重新获取 token 再请求。只有极少数请求会碰到这个问题,属于可接受范围。
九、方案对比
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 悲观锁 | 实现简单,保证强一致 | 性能差,可能死锁 | 写多,单机 |
| 乐观锁 | 无阻塞,无死锁 | 冲突多时 CPU 高,仅 UPDATE | 读多写少,更新 |
| 唯一索引 | 数据库兜底,绝对可靠 | 仅 INSERT | 插入,兜底 |
| 去重表 | 不耦合业务表 | 多一张表,多一次插入 | 通用 |
| 分布式锁 | 跨进程,灵活 | 引入中间件,有网络开销 | 分布式环境 |
| Token | 不需要锁 | 两次请求,实现复杂 | 表单提交,重复点击 |
十、实际怎么选
没有万能方案,通常都是组合使用。开篇那个面试例子就是个很好的实践:
1 | Redisson 分布式锁(主力拦截) |
一个典型的订单幂等处理流程:
1 | 请求进来 |
核心原则就一条:永远不要让唯一索引单兵作战,也永远不要只靠分布式锁。两者组合,各司其职。
总结
接口幂等不是什么高大上的概念,但它属于那种”不出事没人管,出了事就是大问题”的基础能力。
几个要点再强调一下:
- 前后端都要做,不能只靠前端置灰按钮
- 涉及钱的业务必须做,没有商量余地
- 没有银弹方案,根据场景组合使用
- 唯一索引作为兜底是好习惯,别省
- Token 方案先删 token 再执行业务,重试时重新获取即可