引言
如何在高并发的情况下,保证各个接口的幂等性,是C端业务的必做逻辑,同时这也是面试中重要的场景题。那么下面介绍一下什么是幂等性
在高并发场景下,幂等性(Idempotency)是确保系统稳定性、防止数据因重试或并发请求而产生脏数据的核心机制。简单来说,幂等性要求对于同一个请求,执行一次和执行多次的效果必须是一致的。
作为后端开发人员(Java/Go),处理这个问题通常需要从"业务设计"和"技术手段"两个维度切入
Token 机制(前端防重)
这是应对"表单重复提交"最常用的方案。其核心思想是将"请求意图"与"执行动作"分离。
-
流程:
-
获取 Token:客户端在执行操作前,先从服务端申请一个全局唯一的 Token(通常存入 Redis,并设置过期时间)。
-
携带 Token 提交:客户端在业务请求的 Header 或 Body 中携带该 Token。
-
校验与删除 :服务端接收请求后,利用 Redis 的原子性操作(如
DEL或 Lua 脚本)判断 Token 是否存在。- 如果删除成功:说明是第一次请求,执行业务逻辑。
- 如果删除失败:说明是重复请求,直接返回。
-
这种防重复机制适用于表单提交、支付等前端可控场景,下面是示例代码
java
// 申请 token
String token = UUID.randomUUID().toString();
redis.set("idempotent:" + token, "1", 5, TimeUnit.MINUTES);
// 执行接口时校验(原子操作,防并发)
Boolean success = redis.delete("idempotent:" + token); // 只有一个并发能删成功
if (!success) {
throw new RepeatSubmitException("请求重复或token无效");
}
// 执行业务逻辑...
数据库唯一索引去重(兜底)
利用数据库层面的 Unique Key 约束,这是防止产生重复数据最简单且最可靠的手段。
- 场景:比如订单号、支付流水号。
- 实现 :在数据库中建立唯一索引。当并发请求到达时,只有第一个插入请求会成功,后续请求会触发
Duplicate Key异常。 - 技巧 :在 Java 中可以捕获
DuplicateKeyException;在 Go 中判断错误码
java
// 订单表建唯一索引
// UNIQUE KEY uk_order (biz_id, request_id)
try {
orderMapper.insert(order); // 包含 request_id
} catch (DuplicateKeyException e) {
// 幂等:查询已有结果返回
return orderMapper.selectByRequestId(requestId);
}
分布式锁
我们可以使用分布式锁来实现去重,分布式锁的实现方式有很多,比如redis、zookeeper、Etcd等,下面我们以redis来演示。
通过 Redis 的 SETNX 或 Redlock 来实现。
-
逻辑:
- 进入业务逻辑前,先尝试以业务唯一标识(如
order_id)作为 Key 加锁。 - 如果加锁成功,执行业务逻辑。
- 如果加锁失败,说明已有相同请求在处理中,直接返回。
- 进入业务逻辑前,先尝试以业务唯一标识(如
-
注意:锁的释放时间需要根据业务耗时合理评估,防止业务没跑完锁就过期了
java
String lockKey = "idempotent:" + bizId + ":" + requestId;
// SETNX + 过期时间,原子操作
Boolean locked = redis.setIfAbsent(lockKey, "PROCESSING", 10, TimeUnit.SECONDS);
if (!locked) {
// 并发请求:等待或查询已有结果
return queryResult(requestId);
}
try {
// 1. 再次查询是否已处理(double check)
Result existing = queryResult(requestId);
if (existing != null) return existing;
// 2. 执行业务
Result result = doBusiness();
// 3. 持久化结果
saveResult(requestId, result);
return result;
} finally {
redis.delete(lockKey);
}
状态机幂等(业务逻辑层面)
对于有状态流转的业务(如订单:待支付 -> 已支付 -> 已出库),可以通过状态约束实现幂等。
在下面的SQL中,我们就使用了 UPDATE ... WHERE status = 'UNPAID' 的行级锁天然保证幂等,这是最推荐的方式之一,无额外中间件依赖,利用数据库行锁天然防并发。这条 SQL 无论执行多少次,只有第一次能更新成功(影响行数为 1),后续执行由于状态已改变,影响行数均为 0
sql
UPDATE orders SET status = 'PAID' WHERE id = 123 AND status = 'UNPAID';
那么相关的业务代码可以这样来写,通过数据库的反馈来决定代码走向
java
int affected = orderMapper.casUpdateStatus(orderId, "UNPAID", "PAID");
if (affected == 0) {
// 说明已被处理,直接返回当前状态
return orderMapper.selectById(orderId);
}
乐观锁(适用于更新操作)
通过版本号(version)来控制。
- 实现 :在表里加一个
version字段。 - 逻辑:每次更新时,要求版本号必须匹配
sql
UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5;
在并发冲突时,只有一个请求能命中版本号
方案比对
我们可以根据自己的业务场景和性能容忍度来决定使用什么方案
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Token 机制 | 前端提交、防重点击 | 逻辑通用,不依赖数据库类型 | 需要多一次网络交互获取 Token |
| 唯一索引 | 插入新数据(新增用户/订单) | 实现最简单,可靠性最高 | 无法处理逻辑复杂的更新操作 |
| 分布式锁 | 极高并发下的写操作 | 性能高,能有效拦截重复请求 | 增加了对 Redis 的强依赖 |
| 状态机 | 业务流程流转 | 无额外性能损耗,逻辑优雅 | 仅适用于有状态变化的场景 |
| 乐观锁 | 账户余额、库存扣减 | 简单易行,无锁竞争 | 高竞争下重试率高,性能下降 |
下面还有一些要注意避坑的点:
-
先查后改(Check-then-Act)的陷阱 :在高并发下,
if (not exists) { insert }这种逻辑在没有锁的情况下是失效的,一定要利用数据库原子性或分布式锁。 -
Token 的删除时机 :建议先删除 Token 再执行业务(或者使用 Lua 脚本保证原子性)。如果执行完业务再删 Token,在高并发瞬间可能有两个请求都通过了"查 Token"的校验。
-
返回一致性:当识别出是重复请求时,通常应该返回"处理中"或"执行成功(返回上次的结果)",而不是直接报错,以提升用户体验。
总结
经过上面各种方案的讨论,保证接口的幂等性是我们后端必须要注意的地方,如果业务和代码没有保障这一块,那么很可能造成严重后果和资损,那么我们只能卷铺盖跑路了(Doge)