在 Spring Boot 项目中实现接口幂等性是确保系统数据一致性和可靠性的关键手段,尤其在支付、订单等核心业务场景中。下面我将为你介绍几种常见的实现方案、选择建议以及一些注意事项。
方案 | 核心机制 | 实现复杂度 | 性能影响 | 外部依赖 | 典型适用场景 |
---|---|---|---|---|---|
Token 令牌 | 预生成一次性令牌,使用后即焚 | 低 | 中 | Redis | 用户下单、支付 |
数据库唯一索引 | 利用数据库唯一约束防止重复数据插入 | 低 | 低 | 数据库 | 数据插入操作,如支付记录创建 |
数据库乐观锁 | 通过版本号控制更新,避免重复更新 | 中 | 低 | 数据库 | 更新操作,如库存扣减 |
分布式锁 | 加锁确保同一业务标识的请求串行处理 | 中 | 中 | Redis/ZooKeeper | 高并发场景,如秒杀、抢券 |
请求摘要 | 计算请求参数哈希值作为幂等键 | 中 | 低 | Redis | 参数固定的重复请求 |
🔐 1. Token 令牌机制
这种方式要求客户端在发起业务请求前,先获取一个服务器颁发的唯一令牌(Token),并在后续请求中携带该令牌。
-
实现步骤:
-
服务端提供获取 Token 的接口,生成一个唯一 Token(如 UUID)并存入 Redis,设置合理的过期时间。
-
客户端调用业务接口时,在 HTTP Header(如
Idempotent-Token
)中携带此 Token。 -
服务端拦截请求,检查 Redis 中是否存在该 Token:
- 若存在,则删除 该 Token(
redis.delete(key)
)并继续执行业务逻辑。 - 若不存在,说明是重复请求,直接返回重复操作结果。
- 若存在,则删除 该 Token(
-
-
优点:对业务代码侵入性相对较小,安全性较高,能有效防止重复提交。
-
缺点:需额外一次获取 Token 的请求,依赖 Redis 等外部存储。
-
代码示意 (使用 AOP 简化):
less// 1. 自定义幂等注解 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { long timeout() default 10; // 过期时间,单位分钟 } // 2. AOP 实现 @Aspect @Component public class IdempotentAspect { @Autowired private StringRedisTemplate redisTemplate; @Around("@annotation(idempotent)") public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); String token = request.getHeader("Idempotent-Token"); // ... 校验 Token 是否为空... String key = "idempotent:token:" + token; // 原子性验证并删除 Token Boolean isDeleted = redisTemplate.delete(key); if (Boolean.FALSE.equals(isDeleted)) { throw new BusinessException("请求已处理,请勿重复提交"); } return joinPoint.proceed(); } } // 3. 在控制器方法上使用注解 @PostMapping("/order") @Idempotent(timeout = 10) public Result createOrder(@RequestBody OrderRequest request) { // 业务逻辑 }
使用 AOP 可以使业务代码更简洁。
🗄️ 2. 数据库唯一约束
利用数据库本身的唯一性索引来保证重复请求不会插入多条记录。
-
实现步骤:
- 在数据库表中为能唯一标识业务的字段(如订单号
order_no
、支付流水号transaction_id
)创建唯一索引。 - 执行业务逻辑插入数据时,如果重复插入,数据库会抛出
DataIntegrityViolationException
等异常。 - 捕获该异常,并根据业务需求处理(如查询已存在的记录并返回)。
- 在数据库表中为能唯一标识业务的字段(如订单号
-
优点:实现简单,可靠性高,利用数据库特性,强一致性。
-
缺点:依赖于数据库,高并发时可能产生较多异常,需合理处理。
-
代码示意:
java@Service public class PaymentService { @Autowired private PaymentRepository paymentRepository; @Transactional public PaymentResponse processPayment(PaymentRequest request) { try { Payment payment = new Payment(); payment.setOrderNo(request.getOrderNo()); payment.setTransactionId(request.getTransactionId()); // 此字段有唯一索引 payment.setAmount(request.getAmount()); paymentRepository.save(payment); // ...其他支付逻辑... return new PaymentResponse(true, "支付成功"); } catch (DataIntegrityViolationException e) { // 捕获唯一约束违反异常 Payment existingPayment = paymentRepository.findByTransactionId(request.getTransactionId()); return new PaymentResponse(true, "支付已处理", existingPayment.getId()); } } }
通常需要结合
@Transactional
注解保证事务性。
🔗 3. 数据库乐观锁
通过数据库的版本号字段实现,适用于更新操作。
-
实现步骤:
- 在数据库表中增加一个
version
字段。 - 更新数据时,在 SQL 语句中同时更新版本号并校验旧版本号:
update table set column = new_value, version = version + 1 where id = #{id} and version = #{oldVersion}
。 - 根据更新返回的影响行数判断是否更新成功。如果影响行数为 0,说明版本号不符或记录已被更新,可能是重复请求或数据冲突。
- 在数据库表中增加一个
-
优点:避免使用悲观锁的性能开销,适合读多写少的场景。
-
缺点:如果并发冲突多,失败次数会增加。
-
代码示意 (MyBatis-Plus 示例):
kotlin// 实体类 @Data public class InventoryItemDO { private Long id; private Integer sellableQuantity; @Version // 乐观锁版本号字段注解 private Integer version; } // Mapper 接口中使用 public interface InventoryItemMapper extends BaseMapper<InventoryItemDO> { // MyBatis-Plus 会自动在更新时带上版本条件 } // Service 中调用 updateById 方法时,MyBatis-Plus 会自动处理版本号
⚡ 4. 分布式锁
在分布式环境下,使用分布式锁(如基于 Redis)确保对同一业务键的操作是串行的。
-
实现步骤:
- 获取锁:在执行业务逻辑前,尝试获取一个分布式锁,锁的 Key 通常由业务标识生成(如
lock:order:1001
)。 - 处理业务:获取锁成功后,先检查是否已处理过(可选,二次校验),再执行业务逻辑。
- 释放锁:最后释放分布式锁。
- 获取锁:在执行业务逻辑前,尝试获取一个分布式锁,锁的 Key 通常由业务标识生成(如
-
优点:适用于分布式和高并发场景,能有效保证强一致性。
-
缺点:实现相对复杂,依赖外部分布式锁服务(如 Redis),获取释放锁会增加响应时间。
-
代码示意 (使用 Redisson):
java@Service public class SeckillService { @Autowired private RedissonClient redissonClient; @Autowired private OrderRepository orderRepository; public SeckillResponse seckill(SeckillRequest request) { String lockKey = "lock:seckill:" + request.getGoodsId(); RLock lock = redissonClient.getLock(lockKey); try { if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // 尝试获取锁,等待3秒,锁持有10秒 // 二次校验:查询是否已下单,防止重复处理 if (orderRepository.existsByUserIdAndGoodsId(request.getUserId(), request.getGoodsId())) { return SeckillResponse.alreadyOrdered(); } // 执行业务逻辑... return SeckillResponse.success(); } else { throw new BusinessException("系统繁忙,请稍后再试"); } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }
分布式锁常与唯一索引等其他幂等方案结合使用,形成双重保障。
📝 5. 请求摘要(内容指纹)
将请求参数(如请求体)通过哈希算法(如 MD5、SHA-256)生成一个唯一摘要,以此作为幂等键。
-
实现步骤:
- 接收到请求后,计算请求参数的摘要(如对 JSON 请求体计算 MD5)。
- 以该摘要为 Key,尝试在 Redis 中执行
setIfAbsent
(SETNX)操作。 - 如果设置成功,说明是首次请求,执行业务逻辑。
- 如果设置失败(Key 已存在),说明是重复请求,直接返回之前的处理结果或错误信息。
-
优点:客户端无需额外操作,对用户透明。
-
缺点:计算摘要有性能损耗,需确保计算摘要的参数能唯一标识业务操作,否则可能误判。
-
代码示意:
less@PostMapping("/transfer") public Result transfer(@RequestBody TransferRequest request) { String idempotentKey = generateIdempotentKey(request); // 根据请求参数生成摘要 String redisKey = "idempotent:digest:" + idempotentKey; // 原子性地设置键值,仅当键不存在时 Boolean isFirstRequest = redisTemplate.opsForValue().setIfAbsent(redisKey, "processing", 24, TimeUnit.HOURS); if (Boolean.FALSE.equals(isFirstRequest)) { // 重复请求,可根据业务查询之前的结果或直接返回错误 return Result.fail("请勿重复提交"); } try { // 执行业务逻辑... return Result.success(); } finally { // 可选:业务成功完成后,可以更新Redis中的状态;若失败,可删除键允许重试 // redisTemplate.delete(redisKey); } }
此方法的关键在于生成幂等键的算法要能准确识别重复请求。
🤔 如何选择幂等方案?
选择哪种方案取决于你的具体业务场景、性能要求和系统架构:
- Token 机制 :非常适合用户交互相关的操作,如防止表单重复提交、订单重复创建。需要客户端配合。
- 数据库唯一约束/索引 :最适合数据插入类的操作,简单、可靠、成本低。是许多场景的首选。
- 乐观锁 :非常适合数据更新 类的操作,特别是在并发更新同一数据的场景。
- 分布式锁 :适用于分布式环境下需要强一致性 的复杂业务逻辑,或高并发秒杀场景。要注意性能开销和死锁问题。
- 请求摘要 :适合接口回调 、第三方通知等客户端不可控,但请求内容固定的场景。
⚠️ 注意事项
- 幂等键的生成与传递 :幂等键(Token、业务ID等)需要保证全局唯一性。常见的生成方式有 UUID、雪花算法(Snowflake)等。同时,需要和调用方约定好传递方式,如放在 HTTP Header(如
Idempotency-Key
)中。 - 异常处理与重试 :设计幂等时要考虑业务失败的情况。例如,在 Token 机制中,如果业务执行失败,可能需要归还 Token 允许客户端重试。在请求摘要模式中,也需考虑失败后是否删除 Redis 中的键。
- 幂等与并发的区别 :幂等主要解决重复请求 问题(可能非并发),而并发控制解决同时操作的问题。两者常结合使用,例如用分布式锁处理并发,用唯一索引保证最终幂等。
- HTTP 方法的幂等性:了解 RESTful API 中不同 HTTP 方法的幂等性语义对你设计接口有帮助(例如,GET、PUT、DELETE 通常是幂等的,而 POST 不是)。
💎 总结
实现接口幂等性是构建健壮分布式系统的关键。你可以根据实际业务场景选择最合适的方案,很多时候这些方案会组合使用以达到最佳效果。