接口幂等性定义:无论一次或多次调用某个接口,对资源产生的副作用都是一致的。
简单来说:用户由于各种原因(网络超时、前端重复点击、消息重试等)对同一个接口发了多次请求,系统只能处理一次,不能因为多次请求导致数据错误(如扣款两次、生成两个订单)。
方案一:Token 机制(适用于新增、提交类操作)
这是最经典的防重提交方案,尤其适合前端表单提交、支付下单等场景。
核心思想:客户端先获取一个服务器颁发的唯一令牌(Token),提交请求时必须带上这个Token。服务器处理请求后,使该Token失效。
流程:
- 获取Token:客户端在发起业务请求前,先调用一个接口获取一个全局唯一的Token(通常存于Redis,并设置较短的有效期)。
- 提交请求:客户端带着业务参数和这个Token发起业务请求。
- 校验Token:
服务器端(通常通过AOP或Filter实现)检查Redis中是否存在该Token。
存在:执行业务逻辑,然后删除Redis中的Token。
不存在:说明该请求已被处理过,直接返回重复提交的错误信息。
代码示例(AOP实现):
// 1. 自定义一个幂等性注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {int expireTime() default 60; // Token有效期,秒
}// 2. 编写Token创建接口
@RestController
public class TokenController {@Autowiredprivate StringRedisTemplate redisTemplate;@GetMapping("/token")public String getToken() {String token = UUID.randomUUID().toString();redisTemplate.opsForValue().set(token, "1", Duration.ofSeconds(60)); // 存入Redis,60秒过期return token;}
}// 3. 编写AOP切面处理幂等性校验
@Aspect
@Component
public class IdempotentAspect {@Autowiredprivate StringRedisTemplate redisTemplate;@Around("@annotation(idempotent)")public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();String token = request.getHeader("X-Idempotent-Token"); // 从Header中获取Tokenif (StringUtils.isEmpty(token)) {throw new RuntimeException("Token不存在");}// 核心逻辑:删除Token(原子操作)。如果删除成功,返回1,说明是第一次请求。Boolean isDeleted = redisTemplate.delete(token);if (Boolean.TRUE.equals(isDeleted)) {// 是第一次请求,放行return joinPoint.proceed();} else {// 删除失败,可能是Token已过期或被使用// 尝试获取Token,如果还能获取到值,说明是重复请求(但还没过期)。如果获取不到,说明已过期。String tokenValue = redisTemplate.opsForValue().get(token);if (tokenValue != null) {throw new IdempotentException("请勿重复操作");} else {throw new IdempotentException("操作已过期,请刷新页面后重试");}}}
}// 4. 在需要幂等性的接口上使用注解
@RestController
public class OrderController {@PostMapping("/createOrder")@Idempotent // 加上注解public String createOrder(@RequestBody Order order) {// 业务逻辑...return "订单创建成功";}
}
优点:实现简单,通用性强。
缺点:需要额外一次获取Token的请求。
方案二:基于数据库唯一索引(适用于插入操作)
核心思想:利用数据库唯一索引的排他性,防止重复数据插入。
流程:
- 在数据表中为一个或多个字段建立唯一索引。这个“唯一键”可以是:
业务主键(如订单ID)
组合字段(如:用户ID + 业务类型 + 关联ID) - 插入数据时,如果唯一键重复,数据库会抛出 DuplicateKeyException。
- 代码中捕获这个异常,返回“请勿重复操作”的提示。
示例:防止用户重复创建订单。
- 在 order 表为 user_id 和 order_source_id(本次请求的唯一源ID)建立联合唯一索引。
- 每次创建订单时,如果同一个用户使用同一个源ID请求,第二次插入就会失败。
优点:实现最简单,无需额外编码,依靠数据库本身能力。
缺点:只适用于新增场景,不适用于更新、删除操作。
注意:
这个唯一id并不是数据库表的自增id,而是在发起请求前,生成一个全局唯一的业务ID,例如:order_id = 雪花算法生成的长整形ID。
将这个 order_id 随请求一起传到后端。
在数据库的 order 表上,为 order_id 字段建立一个唯一索引。
插入时,如果两个请求带着同一个 order_id 到来,第二个请求就会因为唯一索引冲突而插入失败。
方案三:悲观锁/乐观锁(适用于更新、扣减操作)
- 悲观锁(Pessimistic Lock)
思想:“先取锁,再操作”。认为并发冲突一定会发生,因此在操作数据时直接将其锁住。
实现:使用 SELECT … FOR UPDATE。
场景:适用于写操作非常频繁,冲突概率极高的场景。要谨慎使用,容易导致性能瓶颈和死锁。 - 乐观锁(Optimistic Lock) - 更推荐
思想:“先操作,再验证”。认为冲突不常发生,通过版本号(Version)或状态机来保证。
实现:
在表中增加一个 version 字段。
更新数据时,将 version 作为条件:UPDATE table SET value = new_value, version = version + 1 WHERE id = #{id} AND version = #{old_version}。 - 检查 executeUpdate() 返回的影响行数:
如果为 1:更新成功,是第一次请求。
如果为 0:说明version已被其他请求修改过,是重复请求或冲突请求,操作失败。
示例(余额扣款):
UPDATE account SET balance = balance - 100,version = version + 1
WHERE user_id = 123
AND version = 5; -- 旧的版本号
--通过在一次事务内,先执行 SELECT 语句查询出来最新的版本号再进行更新操作。
优点(乐观锁):性能高,避免数据库锁竞争。
缺点:需要修改表结构,失败后需要重试或告知客户端。
方案四:状态机约束(适用于有状态流转的业务)
核心思想: 很多业务数据都有明确的状态流转(如:订单状态:0待支付->1已支付->2已发货)。只有在特定状态下,操作才是允许的。
流程:
执行更新操作时,不仅以ID为条件,还必须加上当前状态作为条件。
如果状态不符合预期,则更新失败,说明请求无效或已处理过。
示例(支付回调接口):
-- 只有状态为“待支付”的订单才能被更新为“已支付”
UPDATE orders SET status = '已支付' WHERE order_id = '123' AND status = '待支付';
优点:业务逻辑本身自带的幂等性,无需额外组件。
缺点:仅适用于有状态变化的业务。
总结与选择
方案 | 适用场景 | 优点 | 缺点 |
---|---|---|---|
Token 机制 | 通用,尤其前端提交、创建操作 | 通用性强,可靠性高 | 需额外接口,多一次交互 |
唯一索引 | 数据插入类操作 | 实现最简单,绝对可靠 | 仅限插入操作 |
乐观锁 | 数据更新、扣减库存类操作 | 性能好,避免锁竞争 | 需修改表结构,增加version字段 |
状态机 | 有明确状态流转的业务(订单、流程) | 天然幂等,符合业务逻辑 | 局限性较强 |
悲观锁 | 极高并发写场景(少用) | 保证强一致性 | 性能差,易死锁 |
在金融项目中如何选择?
薪酬计算触发接口:可用 Token机制 或基于业务ID的唯一索引(如 calculation_id),防止重复触发计算。
奖金发放、余额扣减接口:乐观锁是最佳选择,保证金额不会重复扣减。
订单、流程状态变更:状态机约束是必须的,例如“只有未支付的订单才能支付”。
最佳实践: 通常会将多种方案组合使用。例如,先用 Token 机制 防重,在业务逻辑内再用 乐观锁 或 状态机 做最终保障,形成双保险。