本文目录导读:

- 核心思路
- 方案一:数据库层面(利用唯一索引 / 唯一约束)
- 方案二:使用 Redis 分布式锁 + Token 机制
- 方案三:乐观锁(版本号机制)
- 方案四:状态机前置校验
- 生产环境中的完整实现建议(最佳实践)
- 总结:如何选择?
这是一个非常经典且重要的后端问题。接口幂等是指:无论调用者调用同一个接口多少次(在相同条件下),最终产生的效果都是一样的,且不会因为重复调用而产生副作用(如重复扣款、重复下单)。
下面按照从简单到复杂,再到实际生产环境落地的逻辑,梳理 Java 中实现接口幂等的常见方案。
核心思路
幂等的核心是 “防重”:系统需要能识别出这是“同一个请求”还是“不同的请求”,识别手段一般是全局唯一标识 + 状态锁。
数据库层面(利用唯一索引 / 唯一约束)
这是最常用、最可靠的方案之一,尤其适合关键业务(如订单创建、资金交易)。
- 场景:创建支付单、创建订单。
- 实现:
- 客户端在发起请求时,生成一个全局唯一的 幂等键(如:
UUID、订单号、业务流水号)。 - 在数据库表中为这个幂等键字段建立 唯一索引。
- 业务处理时,直接
INSERT数据。- 如果插入成功 -> 新请求,继续处理。
- 如果插入报错“Duplicate entry”(重复键) -> 重复请求,直接返回上一次成功的结果。
- 客户端在发起请求时,生成一个全局唯一的 幂等键(如:
Java 示例(伪代码):
@Service
public class PaymentService {
@Autowired
private PaymentMapper paymentMapper;
public Result createPayment(String idempotentId, PaymentRequest request) {
try {
// 尝试插入,数据库中 idempotent_id 字段有 UNIQUE KEY
paymentMapper.insert(new Payment(idempotentId, request));
} catch (DuplicateKeyException e) {
// 重复请求,查询已有结果并返回
Payment existing = paymentMapper.selectByIdempotentId(idempotentId);
return Result.success("请求已处理", existing);
}
// 正常处理后续业务逻辑
return Result.success("处理成功");
}
}
使用 Redis 分布式锁 + Token 机制
适合请求到达时,需要先进行“防重校验”再执行业务的场景。
- 流程:
- 客户端先向服务端请求一个 Token(存到 Redis,设置 TTL)。
- 客户端带着这个 Token 去执行业务请求。
- 服务端收到请求后,尝试从 Redis 中 删除 这个 Token(必须使用 Lua 脚本保证原子性)。
- 如果删除成功 -> 第一次请求,放行。
- 如果删除失败(Token 不存在或已被删除) -> 重复请求,拒绝。
Java 示例(使用 RedisTemplate + Lua):
@Service
public class OrderService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LUA_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
// 1. 生成 Token 接口(客户端调用)
public String generateToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("token:" + token, "1", 30, TimeUnit.MINUTES);
return token;
}
// 2. 执行业务接口(验证 Token)
public Result createOrder(String token, OrderRequest request) {
// 使用 Lua 脚本原子性删除
Long result = redisTemplate.execute(
new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
Collections.singletonList("token:" + token),
"1"
);
if (result == null || result == 0) {
return Result.error("重复请求或 Token 无效");
}
// 这里是真正的业务逻辑
return doCreateOrder(request);
}
}
乐观锁(版本号机制)
适用于 更新 操作,特别是存在并发修改的场景(如库存更新、状态流转)。
- 核心:给数据表增加一个
version字段。 - 原理:每次更新时,
where条件带上当前的version,并设置set version = version + 1。 - 如果影响行数 = 0 -> 说明数据已被别人修改,本次请求视为重复或冲突,返回失败。
Java 示例:
@Service
public class InventoryService {
@Autowired
private InventoryMapper inventoryMapper;
public Result deductStock(Long productId, int quantity, int currentVersion) {
// UPDATE inventory SET stock = stock - ?, version = version + 1
// WHERE product_id = ? AND version = ?
int affectedRows = inventoryMapper.deductStock(productId, quantity, currentVersion);
if (affectedRows == 0) {
// version 不匹配,说明是重复请求或数据已变更
return Result.error("更新失败,请重试");
}
return Result.success("扣减成功");
}
}
状态机前置校验
最适合 状态流转 的业务(如:订单状态 待支付 -> 已支付 -> 已发货 -> 已完成)。
- 原理:每个请求都包含期望的“当前状态”,数据库更新时,
where条件检查当前状态是否一致。 - 示例:
UPDATE orders SET status = '已支付' WHERE order_id = ? AND status = '待支付';
如果影响行数为 0,说明订单状态不是“待支付”,即为重复请求/非法请求。
生产环境中的完整实现建议(最佳实践)
在实际微服务项目中,建议将幂等逻辑“横切”出去,通过 注解 + AOP 或 过滤器 实现,避免侵入每个业务的代码。
自定义注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
// 幂等键的来源:从请求参数哪个字段取值
String key();
// 过期时间
int expireSeconds() default 60;
}
AOP 切面实现
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 1. 提取幂等键(从参数或 Header 中)
String idempotentKey = extractKey(joinPoint, idempotent.key());
String redisKey = "idempotent:" + idempotentKey;
// 2. 检查 Redis 中是否存在该 key
// 使用 SET NX EX 命令,第一次成功,后续失败
// 或者使用 Lua 脚本保证原子性
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(redisKey, "1", idempotent.expireSeconds(), TimeUnit.SECONDS);
if (Boolean.FALSE.equals(success)) {
// 重复请求,直接返回“请求正在处理”或上次结果
return Result.error("请勿重复提交");
}
try {
// 3. 执行业务逻辑
return joinPoint.proceed();
} finally {
// 注意:这里不建议业务完成后立刻删除 redis key,
// 因为幂等需要在 TTL 时间内一直生效,防止请求重试。
// 如果业务有失败回滚需求,可以在这里处理。
}
}
}
使用方式
@RestController
public class OrderController {
@PostMapping("/order")
@Idempotent(key = "#request.orderId", expireSeconds = 30)
public Result createOrder(@RequestBody OrderRequest request) {
// 业务逻辑,不用担心重复提交
return orderService.create(request);
}
}
如何选择?
| 业务场景 | 推荐方案 | 理由 |
|---|---|---|
| 创建资源(下单、注册) | 数据库唯一索引 + 业务幂等键 | 强一致,天然幂等 |
| 防止重复提交(表单、按钮) | Redis Token 机制 + 注解 AOP | 无侵入,性能好,防前端连点 |
| 更新资源(库存、余额) | 乐观锁(version) | 适合高并发,避免行锁竞争 |
| 状态流转(订单状态、审核流) | 状态机前置校验(where 条件) | 简单高效,符合业务逻辑 |
还有一个容易被忽略的点: 如果业务处理时间很长,或者需要调用远程服务,建议在业务处理完成之前不要删除 Redis 中的幂等标记,等 TTL 到期自动失效即可,这样即使客户端重试,也能保证在 TTL 时间内只执行一次。