log.info("主键冲突,是重复请求,直接返回成功,流水号:{}",bizSeq);
return rsp;
}
//正常处理请求
dealRequest(req);
return rsp;
}
#### 3.2 数据库层面,乐观锁
`乐观锁`:乐观锁在操作数据时,非常乐观,认为别人不会同时在修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下,在此期间是否有人修改了数据。
**乐观锁的实现:**
就是给表多加一列 version 版本号,每次更新数据前,先查出来确认下是不是刚刚的版本号,没有改动再去执行更新,并升级 version(version=version+1)。
比如,我们更新前,先查一下数据,查出来的版本号是 version=1。
select order_id,version from order where order_id='666';
然后使用 version=1 和 订单ID 一起作为条件,再去更新:
update order set version = version +1,status='P' where order_id='666' and version =1
最后,更新成功才可以处理业务逻辑,如果更新失败,默认为重复请求,直接返回。
**流程图如下:**

**为什么版本号建议自增呢?**
>
> 因为乐观锁存在 ABA 的问题,如果 version 版本一直是自增的就不会出现 ABA 的情况。
>
>
>
#### 3.3 数据库层面,悲观锁(select for update)【不推荐】
`悲观锁`:通俗点讲就是很悲观,每次去操作数据时,都觉得别人中途会修改,所以每次在拿数据的时候都会上锁。官方点讲就是,共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其它资源。
**悲观锁的实现:**
>
> 在订单业务场景中,假设先查询出订单,如果查到的是处理中状态,就处理完业务,然后再更新订单状态为完成。如果查到订单,并且不是处理中的状态,则直接返回。
>
>
>
可以使用数据库悲观锁(select … for update)解决这个问题:
begin; # 1.开始事务 select * from order where order_id='666' for update # 查询订单,判断状态,锁住这条记录 if(status !=处理中){ //非处理中状态,直接返回; return ; }
处理业务逻辑
update order set status='完成' where order_id='666' # 更新完成 commit; # 5.提交事务
注意:
* 这里的 order\_id 需要是主键或索引,只用行级锁锁住这条数据即可,如果不是主键或索引,会锁住整张表。
* 悲观锁在同一事务操作过程中,锁住了一行数据。这样 **别的请求过来只能等待**,如果当前事务耗时比较长,就很影响接口性能。所以一般 **不建议用悲观锁的实现方式**。
#### 3.4 数据库层面,状态机
很多业务表,都是由状态的,比如:转账流水表,就会有 0-待处理,1-处理中,2-成功,3-失败的状态。转账流水更新的时候,都会涉及流水状态更新,即涉及 **状态机(即状态变更图)**。我们可以利用状态机来实现幂等性校验。
**状态机的实现:**
比如:转账成功后,把 **处理中** 的转账流水更新为成功的状态,SQL 如下:
update transfor_flow set status = 2 where biz_seq='666' and status = 1;
**流程图如下:**

* 第1次请求来时,bizSeq 流水号是 666,该流水的状态是处理中,值是 1,要更新为 2-成功的状态,所以该 update 语句可以正常更新数据,sql 执行结果的影响行数是 1,流水状态最后变成了 2。
* 第2次请求也过来了,如果它的流水号还是 666,因为该流水状态已经变为 2-成功的状态,所以更新结果是0,不会再处理业务逻辑,接口直接返回。
**伪代码实现如下:**
Rsp idempotentTransfer(Request req){ String bizSeq = req.getBizSeq(); int rows= "update transfr_flow set status=2 where biz_seq=#{bizSeq} and status=1;" if(rows==1){ log.info(“更新成功,可以处理该请求”); //其他业务逻辑处理 return rsp; } else if(rows == 0) { log.info(“更新不成功,不处理该请求”); //不处理,直接返回 return rsp; }
log.warn("数据异常")
return rsp:
}
#### 3.5 应用层面,token令牌【不推荐】
token 唯一令牌方案一般包括两个请求阶段:
1. 客户端请求申请获取请求接口用的token,服务端生成token返回;
2. 客户端带着token请求,服务端校验token。
**流程图如下:**

1. 客户端发送请求,申请获取 token。
2. 服务端生成全局唯一的 token,保存到 redis 中(一般会设置一个过期时间),然后返回给客户端。
3. 客户端带着 token,发起请求。
4. 服务端去 redis 确认 token 是否存在,一般用 `redis.del(token)` 的方式,如果存在会删除成功,即处理业务逻辑,如果删除失败,则直接返回结果。
>
> **补充:** 这种方式个人不推荐,说两方面原因:
>
>
> 1. 需要前后端联调才能实现,存在沟通成本,最终效果可能与设想不一致。
> 2. 如果前端多次获取多个 token,还是可以重复请求的,如果再在获取 token 处加分布式锁控制,就不如直接用分布式锁来控制幂等性了,即下面这种解决方式。
>
>
>
#### 3.6 应用层面,分布式锁【推荐】
`分布式锁` 实现幂等性的逻辑就是,请求过来时,先去尝试获取分布式锁,如果获取成功,就执行业务逻辑,反之获取失败的话,就舍弃请求直接返回成功。
**流程图如下:**

* 分布式锁可以使用 Redis,也可以使用 Zookeeper,不过 Redis 相对好点,比较轻量级。
* Redis 分布式锁,可以使用 `setIfAbsent()` 来实现,注意分布式锁的 key 必须为业务的唯一标识。
* Redis 执行设置 key 的动作时,要设置过期时间,防止释放锁失败。这个过期时间不能太短,太短拦截不了重复请求,也不能设置太长,请求量多的话会占用存储空间。
---
### 四、Java 代码实现
#### 4.1 @NotRepeat 注解
@NotRepeat 注解用于修饰需要进行幂等性校验的类。
**NotRepeat.java**
import java.lang.annotation.*;
/** * 幂等性校验注解 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface NotRepeat {
}
#### 4.2 AOP 切面
AOP切面监控被 @Idempotent 注解修饰的方法调用,实现幂等性校验逻辑。
**IdempotentAOP.java**
import com.demo.util.RedisUtils; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.After; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Pointcut; import org.springframework.stereotype.Component;
import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import java.util.concurrent.TimeUnit;
/** * 重复点击校验 */ @Slf4j @Aspect @Component public class IdempotentAOP {
/\*\* Redis前缀 \*/
private String API\_IDEMPOTENT\_CHECK = "API\_IDEMPOTENT\_CHECK:";
@Resource
private HttpServletRequest request;
@Resource
private RedisUtils redisUtils;
/\*\*
* 定义切面 */ @Pointcut("@annotation(com.demo.annotation.NotRepeat)") public void notRepeat() { }
/\*\*
* 在接口原有的方法执行前,将会首先执行此处的代码 */ @Before("notRepeat()") public void doBefore(JoinPoint joinPoint) { String uri = request.getRequestURI();
// 登录后才做校验
UserInfo loginUser = AuthUtil.getLoginUser();
if (loginUser != null) {
assert uri != null;
String key = loginUser.getAccount() + "\_" + uri;
log.info(">>>>>>>>>> 【IDEMPOTENT】开始幂等性校验,加锁,account: {},uri: {}", loginUser.getAccount(), uri);
// 加分布式锁
boolean lockSuccess = redisUtils.setIfAbsent(API\_IDEMPOTENT\_CHECK + key, "1", 30, TimeUnit.MINUTES);
log.info(">>>>>>>>>> 【IDEMPOTENT】分布式锁是否加锁成功:{}", lockSuccess);
if (!lockSuccess) {
if (uri.contains("contract/saveDraftContract")) {
log.error(">>>>>>>>>> 【IDEMPOTENT】文件保存中,请稍后");
throw new IllegalArgumentException("文件保存中,请稍后");
} else if (uri.contains("contract/saveContract")) {
log.error(">>>>>>>>>> 【IDEMPOTENT】文件发起中,请稍后");
throw new IllegalArgumentException("文件发起中,请稍后");
}
}
}
}
/\*\*
* 在接口原有的方法执行后,都会执行此处的代码(final) */ @After("notRepeat()") public void doAfter(JoinPoint joinPoint) { // 释放锁 String uri = request.getRequestURI(); assert uri != null; UserInfo loginUser = SysUserUtil.getloginUser(); if (loginUser != null) { String key = loginUser.getAccount() + "_" + uri; log.info(">>>>>>>>>> 【IDEMPOTENT】幂等性校验结束,释放锁,account: {},uri: {}", loginUser.getAccount(), uri); redisUtils.del(API_IDEMPOTENT_CHECK + key); } } }
#### 4.3 RedisUtils 工具类
**RedisUtils.java**
import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component;
import java.util.Arrays; import java.util.concurrent.TimeUnit;
/** * redis工具类 */ @Slf4j @Component public class RedisUtils {
/\*\*
* 默认RedisObjectSerializer序列化 */ @Autowired private RedisTemplate<String, Object> redisTemplate;
/\*\*
* 加分布式锁 */ public boolean setIfAbsent(String key, String value, long timeout, TimeUnit unit) { return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit); }
/\*\*
* 释放锁 */ public void del(String... keys) { if (keys != null && keys.length > 0) { //将参数key转为集合 redisTemplate.delete(Arrays.asList(keys)); } } }
#### 4.4 测试类
**OrderController.java**
import com.demo.annotation.NotRepeat; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays; import java.util.List;
/** * 幂等性校验测试类 */ @RequestMapping("/order") @RestController public class OrderController {
@NotRepeat
@GetMapping("/orderList")
public List<String> orderList() {
// 查询列表
return Arrays.asList("Order\_A", "Order\_B", "Order\_C");
// throw new RuntimeException("参数错误");
}
}
#### 4.5 测试结果
请求地址:http://localhost:8080/order/orderList
日志信息如下:

经测试,加锁后,正常处理业务、抛出异常都可以正常释放锁。
整理完毕,完结撒花~ 🌻