接口幂等性方案
阿昌 Java小菜鸡

接口幂等性方案详解

Hi,我是阿昌。今天记录一下接口幂等性。

一、什么是幂等

幂等是数学概念,比如 f(n) = 1ⁿ,不管 n 取多少,结果永远是 1。

搬到软件开发里:

不论执行多少次相同的请求,产生的效果和返回的结果,都和发出单个请求是一样的。

对应到数据库操作:

  • INSERT:不插入重复数据
  • UPDATE:多次相同更新后数据依然正确

二、为什么需要幂等

接口幂等问题通常来自这些场景:

  • 网络波动导致客户端重试
  • 用户手抖重复点击
  • 消息队列重复消费
  • 接口响应慢,前端超时自动重试
  • RPC 框架的重试机制

不保证幂等会怎样?

举个最直接的例子——支付

用户在付款时连点了两下,后端处理了两次扣款,账户被扣了两次钱。

这种 Bug 在涉及钱的业务里就是事故级别。

另外,幂等不是前端把按钮置灰就完事了,后端也必须做。

前端的限制容易被绕过了。

三、常见方案概览

后端保证幂等的方式大致有几类:

方案 原理 适用场景
悲观锁 加锁让同一时刻只有一个请求执行 写多读少
乐观锁 版本号/CAS,冲突时重试或失败 读多写少
唯一索引 数据库层面保证数据唯一 INSERT 场景
去重表 用一张表记录已处理的请求 ID 通用场景
分布式锁 跨进程加锁 分布式环境
Token 机制 先取 token,请求时校验并删除 表单提交

四、悲观锁

Java 本地锁

ReentrantLocksynchronized 这类 JDK 自带的锁只能保证同一个 JVM 进程内的线程安全。

1
2
3
4
5
6
7
8
9
10
private final ReentrantLock lock = new ReentrantLock();

public void process(String orderId) {
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock();
}
}

问题很明显:分布式环境下,多个服务实例各自有各自的锁,互不影响。 所以本地锁只在单机场景有用。

数据库排他锁

MySQL 的 SELECT ... FOR UPDATE 可以在事务中对记录加排他锁(X 锁),阻止其他事务同时修改。

1
2
3
4
5
START TRANSACTION;
SELECT * FROM t_order WHERE id = 1 FOR UPDATE;
-- 业务判断 & 更新
UPDATE t_order SET status = 'PAID' WHERE id = 1;
COMMIT;

几个注意点:

  • 必须在事务中使用,且存储引擎要支持(InnoDB)
  • 查询条件必须走索引,否则会锁全表
  • 高并发下锁竞争激烈,线程阻塞会导致大量上下文切换
  • 存在死锁风险

五、乐观锁

乐观锁通常用版本号机制或 CAS 实现。核心思路是:更新时检查版本号,一致才更新,不一致说明被别人改过了。

1
2
3
4
5
6
7
-- 初始 version = 1
UPDATE goods SET price = price + 100, version = version + 1
WHERE id = 1 AND version = 1;
-- 这条执行时 version 已经变成 2,条件不满足,更新失败

UPDATE goods SET price = price + 100, version = version + 1
WHERE id = 1 AND version = 1;

相比悲观锁:

  • 不会阻塞线程,没有死锁问题
  • 写冲突少时性能更好
  • 但只适用于 UPDATE 场景
  • 如果冲突频繁,会变成”失败→重试→失败”的循环,CPU 反而飙升

悲观锁的开销是固定的(阻塞),乐观锁的开销随冲突频率增长。写多的时候反而悲观锁更合适。

六、唯一索引 / 去重表

唯一索引

在需要保证唯一的字段上加唯一索引,重复插入直接抛异常。

1
2
3
4
5
6
7
CREATE TABLE t_order (
id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
`code` VARCHAR(200) NOT NULL COMMENT '流水号',
customer_id INT UNSIGNED COMMENT '会员id',
amount DECIMAL(10,2) UNSIGNED NOT NULL COMMENT '总金额',
UNIQUE unq_code (`code`)
) COMMENT='订单表';

重复插入时 MySQL 抛出 Duplicate entry,代码里 catch 一下就好。

但建议的做法是:不要把唯一索引当作唯一的幂等手段,而是作为兜底。 就像开篇那个阿里面试的例子:Redisson 分布式锁在前面拦截大部分重复请求,唯一索引兜底防止锁失效时产生脏数据。

去重表

去重表是唯一索引的变体——单独建一张表记录”已处理”的请求 ID。

1
2
3
4
5
CREATE TABLE deduplication (
id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
processed_code VARCHAR(200) NOT NULL COMMENT '已处理的流水号',
UNIQUE unq_processed_code (processed_code)
) COMMENT='去重表';

流程:

1
2
3
请求进来 → INSERT INTO deduplication (processed_code) VALUES ('XXX')
├── 插入成功 → 第一次请求 → 执行业务逻辑
└── 插入失败(唯一键冲突)→ 重复请求 → 直接返回

本质和唯一索引一样,只是把幂等判断单独抽了一张表,不跟业务表耦合。

七、分布式锁

分布式系统下,不同服务实例跑在不同的 JVM 里,本地锁管不到。这时候需要分布式锁。

通常基于 RedisZooKeeper 实现,Redis 用得更多。比如 Redisson 的 RLock

1
2
3
4
5
6
7
8
RLock lock = redissonClient.getLock("order:lock:" + orderId);
try {
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
// 获取锁成功,执行业务
}
} finally {
lock.unlock();
}

基于 MySQL 也能做分布式锁,但一般不推荐——性能差,没有锁失效机制,容易死锁。

分布式锁的核心作用和悲观锁一样:同一时刻只让一个请求执行业务逻辑。 区别是它能跨进程。

配合幂等判断的逻辑通常是:

1
加锁 → 查订单状态 → 已处理?直接返回 : 继续处理 → 释放锁

八、Token 机制

Token 机制需要两次请求:

1
2
3
第一步:客户端 GET /token → 服务端生成 token 存入 Redis,返回给客户端

第二步:客户端 POST /submit(携带 token) → 服务端校验并删除 token

image

关键点:

  • Token 必须由服务端生成,可以签名防篡改
  • Token 设置短有效期
  • 验证方式一般是 删除 token,删除成功才算有效

得物技术有一张流程图把这个过程画得很清晰:

1
2
3
4
5
6
7
8
9
10
客户端                    服务端
| |
|── GET /token ──────────>|
|<── token ────────────── |
| |
|── POST /submit + token >|
| |── 删除 token(redis del)
| | ├── 删除成功 → 执行业务
| | └── 删除失败 → 拒绝请求
|<── result ──────────── |

先删 token 还是先执行业务?

两者都有风险:

  • 先执行业务:token 还在,客户端可能重试导致第二次也通过
  • 先删 token:业务执行超时,重试时 token 已经没了,请求失败

一般建议先删 token。如果业务执行失败,客户端重新获取 token 再请求。只有极少数请求会碰到这个问题,属于可接受范围。

九、方案对比

方案 优点 缺点 适用
悲观锁 实现简单,保证强一致 性能差,可能死锁 写多,单机
乐观锁 无阻塞,无死锁 冲突多时 CPU 高,仅 UPDATE 读多写少,更新
唯一索引 数据库兜底,绝对可靠 仅 INSERT 插入,兜底
去重表 不耦合业务表 多一张表,多一次插入 通用
分布式锁 跨进程,灵活 引入中间件,有网络开销 分布式环境
Token 不需要锁 两次请求,实现复杂 表单提交,重复点击

十、实际怎么选

没有万能方案,通常都是组合使用。开篇那个面试例子就是个很好的实践:

1
2
3
Redisson 分布式锁(主力拦截)
+
MySQL 唯一索引(兜底防脏数据)

一个典型的订单幂等处理流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
请求进来

├── 1. 参数校验

├── 2. 加分布式锁(key = 订单号)
│ └── 获取失败 → 返回"处理中"

├── 3. 查订单状态
│ └── 已处理 → 返回结果,释放锁

├── 4. 执行业务逻辑

├── 5. INSERT 订单记录(唯一索引兜底)
│ └── Duplicate entry → 回滚

└── 6. 释放锁,返回结果

核心原则就一条:永远不要让唯一索引单兵作战,也永远不要只靠分布式锁。两者组合,各司其职。

总结

接口幂等不是什么高大上的概念,但它属于那种”不出事没人管,出了事就是大问题”的基础能力。

几个要点再强调一下:

  • 前后端都要做,不能只靠前端置灰按钮
  • 涉及钱的业务必须做,没有商量余地
  • 没有银弹方案,根据场景组合使用
  • 唯一索引作为兜底是好习惯,别省
  • Token 方案先删 token 再执行业务,重试时重新获取即可
 请作者喝咖啡