【SpringBoot应用篇】SpringBoot+Redis实现接口幂等性校验

时间:2022-11-11 11:54:11

幂等性

幂等性的定义是:一次和屡次请求某一个资源对于资源自己应该具备一样的结果(网络超时等问题除外)。也就是说,其任意屡次执行对资源自己所产生的影响均与一次执行的影响相同。

WEB系统中: 就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生不同的结果。

什么状况下须要保证幂等性
以SQL为例,有下面三种场景,只有第三种场景须要开发人员使用其余策略保证幂等性:
SELECT col1 FROM tab1 WHER col2=2,不管执行多少次都不会改变状态,是自然的幂等。
UPDATE tab1 SET col1=1 WHERE col2=2,不管执行成功多少次状态都是一致的,所以也是幂等操做。
UPDATE tab1 SET col1=col1+1 WHERE col2=2,每次执行的结果都会发生变化,这种不是幂等的。

解决方法

这里主要使用token令牌分布式锁解决

Pom

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>
<dependencies>
	<dependency>
	    <groupId>org.projectlombok</groupId>
	    <artifactId>lombok</artifactId>
	    <version>1.18.4</version>
	    <scope>provided</scope>
	</dependency>
	<dependency>
	   <groupId>org.springframework.boot</groupId>
	   <artifactId>spring-boot-starter-jdbc</artifactId>
	</dependency>
	<dependency>
	   <groupId>mysql</groupId>
	   <artifactId>mysql-connector-java</artifactId>
	</dependency>
	<dependency>
	   <groupId>org.springframework.boot</groupId>
	   <artifactId>spring-boot-starter-data-redis</artifactId>
	</dependency>
	<!-- springboot 对aop的支持 -->
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-aop</artifactId>
	</dependency>
	<!-- springboot mybatis-plus -->
	<dependency>
		<groupId>com.baomidou</groupId>
		<artifactId>mybatis-plus-boot-starter</artifactId>
		<version>3.5.2</version>
	</dependency>
</dependencies>

token令牌

这种方式分红两个阶段:
1、客户端向系统发起一次申请token的请求,服务器系统生成token令牌,将token保存到Redis缓存中,并返回前端(令牌生成方式可以使用JWT)
2、客户端拿着申请到的token发起请求(放到请求头中),后台系统会在拦截器中检查handler是否开启幂等性校验。取请求头中的token,判断Redis中是否存在该token,若是存在,表示第一次发起支付请求,删除缓存中token后开始业务逻辑处理;若是缓存中不存在,表示非法请求。

yml

spring:
  redis:
    host: 127.0.0.1
    timeout: 5000ms
    port: 6379
    database: 0
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/study_db?serverTimezone=GMT%2B8&allowMultiQueries=true
    username: root
    password: root
redisson:
  timeout: 10000

@ApiIdempotentAnn

@ApiIdempotentAnn幂等性注解。说明: 添加了该注解的接口要实现幂等性验证

@Target({ElementType.TYPE,ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotentAnn {
    boolean value() default true;
}

ApiIdempotentInterceptor

这里可以使用拦截器或者使用AOP的方式实现。

幂等性拦截器的方式实现

@Component
public class ApiIdempotentInterceptor extends HandlerInterceptorAdapter {

    @Autowired
    private StringRedisTemplate redisTemplate;


    /**
     * 前置拦截器
     *在方法被调用前执行。在该方法中可以做类似校验的功能。如果返回true,则继续调用下一个拦截器。如果返回false,则中断执行,
     * 也就是说我们想调用的方法 不会被执行,但是你可以修改response为你想要的响应。
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //如果hanler不是和HandlerMethod类型,则返回true
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        //转化类型
        final HandlerMethod handlerMethod = (HandlerMethod) handler;
        //获取方法类
        final Method method = handlerMethod.getMethod();
        // 判断当前method中是否有这个注解
        boolean methodAnn = method.isAnnotationPresent(ApiIdempotentAnn.class);
        //如果有幂等性注解
        if (methodAnn && method.getAnnotation(ApiIdempotentAnn.class).value()) {
            // 需要实现接口幂等性
            //检查token
            //1.获取请求的接口方法
            boolean result = checkToken(request);
            //如果token有值,说明是第一次调用
            if (result) {
                //则放行
                return super.preHandle(request, response, handler);
            } else {//如果token没有值,则表示不是第一次调用,是重复调用
                response.setContentType("application/json; charset=utf-8");
                PrintWriter writer = response.getWriter();
                writer.print("重复调用");
                writer.close();
                response.flushBuffer();
                return false;
            }
        }
        //否则没有该自定义幂等性注解,则放行
        return super.preHandle(request, response, handler);
    }

    //检查token
    private boolean checkToken(HttpServletRequest request) {
        //从请求头对象中获取token
        String token = request.getHeader("token");
        //如果不存在,则返回false,说明是重复调用
        if(StringUtils.isBlank(token)){
            return false;
        }
        //否则就是存在,存在则把redis里删除token
        return redisTemplate.delete(token);

    }
}

MVC配置类

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private ApiIdempotentInterceptor apiIdempotentInceptor;


    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(apiIdempotentInceptor).addPathPatterns("/**");
    }
}

ApiController

@RestController
public class ApiController {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    /**
     * 前端获取token,然后把该token放入请求的header中
     * @return
     */
    @GetMapping("/getToken")
    public String getToken() {
        String token = UUID.randomUUID().toString().substring(1, 9);
        stringRedisTemplate.opsForValue().set(token, "1");
        return token;
    }

    //定义int类型的原子类的类
    AtomicInteger num = new AtomicInteger(100);

    /**
     * 主业务逻辑,num--,并且加了自定义接口
     * @return
     */
    @GetMapping("/submit")
    @ApiIdempotentAnn
    public String submit() {
        // num--
        num.decrementAndGet();
        return "success";
    }

    /**
     * 查看num的值
     * @return
     */
    @GetMapping("/getNum")
    public String getNum() {
        return String.valueOf(num.get());
    }
}

分布式锁 Redisson

Redisson是redis官网推荐实现分布式锁的一个第三方类库,通过开启另一个服务,后台进程定时检查持有锁的线程是否继续持有锁了,是将锁的生命周期重置到指定时间,即防止线程释放锁之前过期,所以将锁声明周期通过重置延长)

Redission执行流程如下:(只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下(锁续命周期就是设置的超时时间的三分之一),如果线程还持有锁,就会不断的延长锁key的生存时间。因此,Redis就是使用Redisson解决了锁过期释放,业务没执行完问题。当业务执行完,释放锁后,再关闭守护线程,

pom

<dependency>
	 <groupId>org.redisson</groupId>
	 <artifactId>redisson-spring-boot-starter</artifactId>
	 <version>3.13.6</version>
</dependency>

@RedissonLockAnnotation

分布式锁注解

@Target(ElementType.METHOD) //注解在方法
@Retention(RetentionPolicy.RUNTIME)
public @interface RedissonLockAnnotation {
    /**
     * 指定组成分布式锁的key,以逗号分隔。
     * 如:keyParts="name,age",则分布式锁的key为这两个字段value的拼接
     * key=params.getString("name")+params.getString("age")
     */
    String keyParts();
}

DistributeLocker

分布式锁接口

public interface  DistributeLocker {
    /**
     * 加锁
     * @param lockKey key
     */
    void lock(String lockKey);

    /**
     * 释放锁
     *
     * @param lockKey key
     */
    void unlock(String lockKey);

    /**
     * 加锁,设置有效期
     *
     * @param lockKey key
     * @param timeout 有效时间,默认时间单位在实现类传入
     */
    void lock(String lockKey, int timeout);

    /**
     * 加锁,设置有效期并指定时间单位
     * @param lockKey key
     * @param timeout 有效时间
     * @param unit    时间单位
     */
    void lock(String lockKey, int timeout, TimeUnit unit);

    /**
     * 尝试获取锁,获取到则持有该锁返回true,未获取到立即返回false
     * @param lockKey
     * @return true-获取锁成功 false-获取锁失败
     */
    boolean tryLock(String lockKey);

    /**
     * 尝试获取锁,获取到则持有该锁leaseTime时间.
     * 若未获取到,在waitTime时间内一直尝试获取,超过watiTime还未获取到则返回false
     * @param lockKey   key
     * @param waitTime  尝试获取时间
     * @param leaseTime 锁持有时间
     * @param unit      时间单位
     * @return true-获取锁成功 false-获取锁失败
     */
    boolean tryLock(String lockKey, long waitTime, long leaseTime, TimeUnit unit)
            throws InterruptedException;

    /**
     * 锁是否被任意一个线程锁持有
     * @param lockKey
     * @return true-被锁 false-未被锁
     */
    boolean isLocked(String lockKey);
}

RedissonDistributeLocker

redisson实现分布式锁接口

public class RedissonDistributeLocker implements DistributeLocker {

    private RedissonClient redissonClient;

    public RedissonDistributeLocker(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    @Override
    public void lock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock();
    }

    @Override
    public void unlock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.unlock();
    }

    @Override
    public void lock(String lockKey, int leaseTime) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(leaseTime, TimeUnit.MILLISECONDS);
    }

    @Override
    public void lock(String lockKey, int timeout, TimeUnit unit) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(timeout, unit);
    }

    @Override
    public boolean tryLock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        return lock.tryLock();
    }

    @Override
    public boolean tryLock(String lockKey, long waitTime, long leaseTime,
                           TimeUnit unit) throws InterruptedException {
        RLock lock = redissonClient.getLock(lockKey);
        return lock.tryLock(waitTime, leaseTime, unit);
    }

    @Override
    public boolean isLocked(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        return lock.isLocked();
    }
}

RedissonLockUtils

redisson锁工具类

public class RedissonLockUtils {

    private static DistributeLocker locker;

    public static void setLocker(DistributeLocker locker) {
        RedissonLockUtils.locker = locker;
    }

    public static void lock(String lockKey) {
        locker.lock(lockKey);
    }

    public static void unlock(String lockKey) {
        locker.unlock(lockKey);
    }

    public static void lock(String lockKey, int timeout) {
        locker.lock(lockKey, timeout);
    }

    public static void lock(String lockKey, int timeout, TimeUnit unit) {
        locker.lock(lockKey, timeout, unit);
    }

    public static boolean tryLock(String lockKey) {
        return locker.tryLock(lockKey);
    }

    public static boolean tryLock(String lockKey, long waitTime, long leaseTime,
                                  TimeUnit unit) throws InterruptedException {
        return locker.tryLock(lockKey, waitTime, leaseTime, unit);
    }

    public static boolean isLocked(String lockKey) {
        return locker.isLocked(lockKey);
    }
}

RedissonConfig

Redisson配置类

@Configuration
public class RedissonConfig {

    @Autowired
    private Environment env;

    /**
     * Redisson客户端注册
     * 单机模式
     */
    @Bean(destroyMethod = "shutdown")
    public RedissonClient createRedissonClient() {
        Config config = new Config();
        SingleServerConfig singleServerConfig = config.useSingleServer();
        singleServerConfig.setAddress("redis://" + env.getProperty("spring.redis.host") + ":" + env.getProperty("spring.redis.port"));
        singleServerConfig.setTimeout(Integer.valueOf(env.getProperty("redisson.timeout")));
        return Redisson.create(config);
    }

    /**
     * 分布式锁实例化并交给工具类
     * @param redissonClient
     */
    @Bean
    public RedissonDistributeLocker redissonLocker(RedissonClient redissonClient) {
        RedissonDistributeLocker locker = new RedissonDistributeLocker(redissonClient);
        RedissonLockUtils.setLocker(locker);
        return locker;
    }
}

RedissonLockAop

这里可以使用拦截器或者使用AOP的方式实现。

分布式锁AOP切面拦截方式实现

@Aspect
@Component
@Slf4j
public class RedissonLockAop {
    /**
     * 切点,拦截被 @RedissonLockAnnotation 修饰的方法
     */
    @Pointcut("@annotation(cn.zysheep.biz.redis.RedissonLockAnnotation)")
    public void redissonLockPoint() {
    }

    @Around("redissonLockPoint()")
    @ResponseBody
    public ResultVO checkLock(ProceedingJoinPoint pjp) throws Throwable {
        //当前线程名
        String threadName = Thread.currentThread().getName();
        log.info("线程{}------进入分布式锁aop------", threadName);
        //获取参数列表
        Object[] objs = pjp.getArgs();
        //因为只有一个JSON参数,直接取第一个
        JSONObject param = (JSONObject) objs[0];
        //获取该注解的实例对象
        RedissonLockAnnotation annotation = ((MethodSignature) pjp.getSignature()).
                getMethod().getAnnotation(RedissonLockAnnotation.class);
        //生成分布式锁key的键名,以逗号分隔
        String keyParts = annotation.keyParts();
        StringBuffer keyBuffer = new StringBuffer();
        if (StringUtils.isEmpty(keyParts)) {
            log.info("线程{} keyParts设置为空,不加锁", threadName);
            return (ResultVO) pjp.proceed();
        } else {
            //生成分布式锁key
            String[] keyPartArray = keyParts.split(",");
            for (String keyPart : keyPartArray) {
                keyBuffer.append(param.getString(keyPart));
            }
            String key = keyBuffer.toString();
            log.info("线程{} 要加锁的key={}", threadName, key);
            //获取锁
            if (RedissonLockUtils.tryLock(key, 3000, 5000, TimeUnit.MILLISECONDS)) {
                try {
                    log.info("线程{} 获取锁成功", threadName);

                    // Thread.sleep(5000);

                    return (ResultVO) pjp.proceed();
                } finally {
                    RedissonLockUtils.unlock(key);
                    log.info("线程{} 释放锁", threadName);
                }
            } else {
                log.info("线程{} 获取锁失败", threadName);
                return ResultVO.fail();
            }
        }
    }
}

ResultVO

统一响应实体

@Data
public class ResultVO<T> {
    private static final ResultCode SUCCESS = ResultCode.SUCCESS;
    private static final ResultCode FAIL = ResultCode.FAILED;

    private Integer code;

    private String message;

    private T  data;

    public static <T> ResultVO<T> ok() {

        return result(SUCCESS,null);
    }

    public static <T> ResultVO<T> ok(T data) {
        return result(SUCCESS,data);
    }

    public static <T> ResultVO<T> ok(ResultCode resultCode) {
        return result(resultCode,null);
    }

    public static <T> ResultVO<T> ok(ResultCode resultCode, T data) {
        return result(resultCode,data);
    }

    public static <T> ResultVO<T> fail() {
        return result(FAIL,null);
    }

    public static <T> ResultVO<T> fail(ResultCode resultCode) {
        return result(FAIL,null);
    }


    public static <T> ResultVO<T> fail(T data) {
        return result(FAIL,data);
    }

    public static <T> ResultVO<T> fail(ResultCode resultCode, T data) {
        return result(resultCode,data);
    }

    private static <T>  ResultVO<T> result(ResultCode resultCode, T data) {
        ResultVO<T> resultVO = new ResultVO<>();
        resultVO.setCode(resultCode.getCode());
        resultVO.setMessage(resultCode.getMessage());
        resultVO.setData(data);
        return resultVO;
    }
}

BusiController

@RestController
public class ApiController {
	@PostMapping(value = "testLock")
	@RedissonLockAnnotation(keyParts = "name,age")
	public ResultVO testLock(@RequestBody JSONObject params) {
	    /**
	     * 分布式锁key=params.getString("name")+params.getString("age");
	     * 此时name和age均相同的请求不会出现并发问题
	     */
	    //TODO 业务处理dwad
	    return ResultVO.ok();
	}
}