1.概念
幂等性原本是数学中的概念,在开发中意为:
对于同一个系统,在同样条件下,一次请求和重复多次请求对资源的影响是一致的,就称该操作为幂等的。
2.业务场景
什么意思呢?
用户在实际操作中,有可能因为手抖或者网络波动,短时间内将一个相同的请求重复提交多次。系统也会多次执行这些请求,就有可能会出现一些问题。
例如创建新用户功能:
我们一般会先验证用户名是否已存在,再去insert用户信息。
但由于用户手抖,极短的时间点了两次提交按钮。前端没防住的情况下两个相同参数的请求几乎同时到达了后端。两个请求由于间隔时间太短,验证用户名是否存在的时候都成功了,于是数据库insert了两条除id外,其余字段都相同的数据。这可能会对以后的业务造成意料之外的影响。
对于这种情况,我们有很多解决方式。例如对这个接口采用分布式锁、使用lombok的@Synchronized注解等。
但事实上,大部分的POST、PUT、DEL请求几乎都需要预防幂等问题。每个接口都去逐一实现分布式锁工作量太大了,所以在项目中我通过springframework自带的HandlerInterceptor实现了全局的分布式锁。
3.代码实现
(1)引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.12.5</version>
</dependency>
(2)我们先来了解一下HandlerInterceptor是什么
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return true;
}
default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
}
default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
}
}
这其实是一个springboot的拦截器,有三个接口,可以简单的理解为:
preHandle——在处理请求之前执行
postHandle——在处理请求后、生成视图前执行
afterCompletion——在完全处理完请求、整个请求的周期结束时执行
我们需要用到preHandle来给请求上锁、afterCompletion来给请求释放锁。
(3)写一个自己的拦截器,实现HandlerIntecepter,重写preHandle和afterCompletion
public class MyInterceptor implements HandlerInterceptor {
private final Redisson redisson; // 通过构造器的方式注入redisson
private ThreadLocal<RLock> lockThreadLocal = new ThreadLocal<>(); // 线程隔离
public MyInterceptor(Redisson redisson) {
this.redisson = redisson;
}
/**
* 在请求处理请求之前执行该方法
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("x-token"); //从request中得到token,用你的token解析器解析出用户id(或其他用户唯一标识)
String userId = MyTokenUtils.parseToken(token);
String method = request.getMethod(); // 请求方式
String path = request.getServletPath(); // 请求路径
if (!"GET".equals(method)) {
try {
RLock rLock = null;
String key = "ApiIdempotent_" + userId + path;
rLock = redisson.getLock(key);
boolean b = rLock.tryLock(5, TimeUnit.SECONDS); // 尝试获取锁的时间 , 锁的持有时间 , 时间单位
if (!b) {
throw new MyException().setCode(10018).setMsg("操作太快啦,请稍后重试!");
}
lockThreadLocal.set(rLock);
} catch (InterruptedException e) {
throw new MyException().setCode(10018).setMsg("获取分布式锁被中断");
}
}
return true;
}
/**
* 在完全处理完请求后执行该方法
* @param request
* @param response
* @param handler
* @param ex
* @throws Exception
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 释放api幂等的分布式锁
String method = request.getMethod(); // 请求方式
if (!"GET".equals(method)) {
RLock rLock = null;
rLock = lockThreadLocal.get();
if (rLock != null && rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
}
}
}
(4)解释代码
1.大致流程是在处理业务前给非Get请求上锁,用请求路径+用户id作为锁的唯一标识。在处理完成业务后释放掉锁。这样的操作确保了两件事:(1)防止单个用户上个请求还没执行完成就开始执行下个请求。(2)多个用户间不影响彼此的调用。
2.rLock.tryLock()方法可以放入三个参数。尝试获取锁的时间、锁的持有时间、时间单位。因为锁一定会在afterCompletion里销毁掉,所以我没设置第二个参数。为了安全期间,大家可以自行设置。
3.代码中我使用了ThreadLocal<RLock>,这是一个线程隔离的安全容器。我使用它来在preHandle里保存锁,在afterCompletion里取出锁并释放掉。如果不用ThreadLocal的话。有可能A线程的锁会被B线程取出并释放掉,不能保证线程安全。
4.这里我拦截了所有的非GET请求。因为GET不会对我们数据库资源用影响,而其余请求会写入数据库所以可以无脑全部拦截。但如果你的项目不是严格的Rest风格,或者不想无脑拦截,可以在这自己写一个黑名单类,只拦截指定的路径。
5.如果写了想测试可以使用JMeter测试工具,自行搜索使用方法。可以模拟多个线程几乎同时发起请求。查看拦截情况。
4.致谢
感谢看完,如果对你有帮助,不要吝惜你的点赞评论收藏关注哟~
有问题欢迎讨论。