2021-03-30_Redission之接口防重复提交

20210330_Redission之接口防重复提交

1概述

本节主要是基于redis + lua+token机制,通过注解和拦截器对请求进行拦截处理,实现接口幂等性校验。

实现思路如下:

为需要保证幂等性的每一次请求创建一个唯一标识token, 先获取token, 并将此token存入redis, 接口调用接口时, 将此token放到header或者作为请求参数请求接口, 后端接口判断redis中是否存在此token:

  • 如果存在, 正常处理业务逻辑, 并从redis中删除此token
  • 那么, 如果是重复请求, 由于token已被删除,及token不存在,则不能通过校验, 返回请勿重复操作(或参数不合法),返回提示即可
  • 如果不存在,参数上面

1.1幂等概念

幂等本身是一个数学概念。即 f(n) = 1^n ,无论n为多少,f(n)的值永远为1。在编程开发中,对于幂等的定义为:无论对某一个资源操作了多少次,其影响都应是相同的。 换句话说就是:在接口重复调用的情况下,对系统产生的影响是一样的,但是返回值允许不同,如查询。
幂等性不仅仅只是一次或多次操作对资源没有产生影响,还包括第一次操作产生影响后,以后多次操作不会再产生影响。并且幂等关注的是是否对资源产生影响,而不关注结果。
同时对于幂等的使用一般都会伴随着锁的出现,用于解决并发安全问题。

1.1.1接口幂等

幂等性指任意多次执行所产生的影响均与一次执行的影响相同多次调用对系统的产生的影响是一样的,即对资源的作用是一样的,但是返回值允许不同。在我们编程中主要操作就是CURD,其中读取(Retrieve)操作和删除(Delete)操作是天然幂等的,受影响的就是创建(Create)、更新(Update)。

对于业务中需要考虑幂等性的地方一般都是接口的重复请求,重复请求是指同一个请求因为某些原因被多次提交。导致这个情况会有几种场景:

  • 前端重复提交:提交订单,用户快速重复点击多次,造成后端生成多个内容重复的订单。
  • 接口超时重试:对于给第三方调用的接口,为了防止网络抖动或其他原因造成请求丢失,这样的接口一般都会设计成超时重试多次。
  • 消息重复消费:MQ消息中间件,消息重复消费。

1.2服务幂等解决方案

1.2.1前端幂等

对于幂等的考虑,主要解决两点前后端交互与服务间交互。这两点有时都要考虑幂等性的实现。

从前端的思路解决的话,前端防重有三种:校验valid、PRG(Post,Redirect,Get)模式、Token机制(配合后台,超时、失败(解决思路:无需考虑,重新获取token))。

1.2.1.1前端校验

1.2.1.2PRG

即服务器收到 form 提交的 Post 请求后,并不是直接返回一个 2XX 的结果页面,而是返回一个 3XX 的重定向页面 (Redirect),定向到正确的结果页面 (Get)。

[图片上传失败...(image-f23ce7-1628345728679)]

1.2.1.3Token机制

1.2.2后端幂等

后端解决方案主要考虑并发锁机制。

1.2.2.1防重表(防重字段)

首先创建一张表(乐观锁),这里要注意的是,去重表和业务表应该在同一库中,这样就保证了在同一个事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据一致性
往去重表里插入数据的时候,利用数据库的唯一索引特性,保证唯一的逻辑。唯一序列号可以是一个字段,也可以是多字段的唯一性组合。
作为防重表,同时在该表中建立一个或多个字段的唯一索引PK作为防重字段,用于保证并发情况下,数据只有一条。
在向业务表中插入数据之前先向防重表插入,如果插入失败则表示是重复数据。

// 防重字段设计
// 前端的uuid作为唯一索引(页面不刷新,一致是这个)

1.2.2.2Mysql乐观锁保证幂等

MySQL乐观锁是基于数据库完成分布式锁的一种实现,实现的方式有两种:基于版本号、基于条件。但是实现思想都是基于MySQL的行锁思想来实现的。

虽然通过MySQL乐观锁可以完成并发控制,但锁的操作是直接作用于数据库上,这样就会在一定程度上对数据库性能产生影响。并且mysql的连接数量是有限的,如果出现大量锁操作占用连接时,也会造成MySQL的性能瓶颈。

悲观锁(锁整个表)

1.2.3.2.1基于版本号

通过版本号控制是一种非常常见的方式,适合于大多数场景。Eg,库存扣减秒杀的场景来说,通过版本号控制就是多
人并发访问某商品(在操作业务前,需要先查询出当前的version版本。)购买时,查询时显示可以购买(预获取到LK),但最终只有一个人能成功,这也不友好,明明看到确又买不了的尴尬LK被人偷走了。

// 1.首先要对数据进行版本号初始化
// pk point version
// 1   100   1.0
// 2.更新
update t_order 
set point = point - 1, version = version + 1
where order=1 and version=1
// 3.此时数据
// pk point version
// 1   99   2.0
// 此是别人购买,在v1.0的提交方式上,是提交不了的。
1.2.3.2.2基于条件
1.2.3.4.3基于唯一主键

这个机制是利用了数据库的主键唯一约束的特性,解决了在insert场景时幂等问题。但主键的要求不是自增的主键,这样就需要业务生成全局唯一的主键,之前老顾的文章也介绍过分布式唯一主键ID的生成,可自行查阅。如果是分库分表场景下路由规则要保证相同请求下落地在同一个数据库和同一表中,要不然数据库主键约束就不起效果了,因为是不同的数据库和表主键不相关。因为对主键有一定的要求,这个方案就跟业务有点耦合了,无法用自增主键了

1.2.2.3zookeeper分布式锁(推荐)

对于分布式锁的实现,zookeeper天然携带的一些特性能够很完美的实现分布式锁。其内部主要是利用znode节点特性和watch机制完成。

服务启动时,创建针对某个类中方法Ma的永久节点PN。

线程XXX1访问Ma时,先去结点PN下创建临时结点EN,序号0001。

线程XXX2访问Ma时,先去结点PN下创建临时结点EN,序号0002。

所以后续要获取锁的线程在zookeeper中的序号也是逐次递增的。根据这个特性,当前序号最小的节点一定是首先要获取锁的线程,因
此可以规定序号最小的节点获得锁。所以,每个线程再要获取锁时,可以判断自己的节点序号是否是最小的,如果
是则获取到锁。当释放锁时,只需将自己的临时有序节点删除即可。

1.2.2.3.1优缺点

1)zookeeper是基于cp模式,能够保证数据强一致性。

2)基于watch机制实现锁释放的自动监听,锁操作性能较好。

3)频繁创建节点,对于zk服务器压力较大,吞吐量没有redis强。

1.2.2.3.2原理剖析
1.2.2.3.2.1低效锁思想(基于广播,导致羊群效应)

缺点:羊群效应。

1.2.2.3.2.2高效锁思想(基于回调监听机制)

为了避免羊群效应的出现,业界内普遍的解决方案就是,让获取锁的线程产生排队,后一个监听前一个,依次排
序。推荐使用这种方式实现分布式锁,避免羊群效应。

而Zk天生就支持临时顺序节点的,对获取到的顺序节点进行升序排序即可。

[图片上传失败...(image-b9b846-1628345728679)]

2基于各种锁的幂等具体实现

2.1基于zk的锁实现

1.4.1低效锁实现

1.4.2高效锁实现

2.2基于redis的锁实现

[图片上传失败...(image-8fa16b-1628345728679)]

这就是token+redis的幂等方案。适用于绝大部分场景。主要针对前端重复连续多次点击的情况,网上也有另一个版本的Token方案,不同的地方是:

  1. 网上方案检验token存在后,就立刻删除token,再进行业务处理。可能问题:业务处理没有成功,接口调用方也没有获取到明确的结果,然后进行重试,但token已经删除掉了,服务端判断token不存在,认为是重复请求,就直接返回了,无法进行业务处理了
  2. 而上面的方式是检验token存在后,先进行业务处理,再删除token,可能问题:用户点了多次,导致存在token,误任务时第一次操作bug。

2.1.1redis api

redis实现分布式锁也很简单,基于客户端的几个API就可以完成,主要涉及三个核心API:
setNx():向redis中存key-value,只有当key不存在时才会设置成功,否则返回0。用于体现互斥性。
expire():设置key的过期时间,用于避免死锁出现。
delete():删除key,用于释放锁。

2.2.2redis解几个参数

XX:只有key存在时才设置rc

NX:与setNX类似,只有key不存在时才设置

PX:表示过期时间的单位为毫秒

EX:表示过期时间的单位为秒

private static final String XX = "xx"; // 存在性可以设置
private static final String NX = "nx"; // 不存在性可以设置
private static final String EX = "ex"; // 过期时间s
private static final String PX = "px"; // 过期时间ms

2.2.3token机制缺点

业务请求每次请求,都会有额外的请求(一次获取token请求、判断token是否存在的业务)。其实真实的生产环境中,1万请求也许只会存在10个左右的请求会发生重试,为了这10个请求,我们让9990个请求都发生了额外的请求。(当然redis性能很好,耗时不会太明显)

2.3基于Redission锁实现

1)服务端提供获取token接口,供客户端进行使用。服务端生成token后,如果当前为分布式架构,将token存放于redis中,如果是单体架构,可以保存在jvm缓存中。

2)当客户端获取到token后,会携带着token发起请求。

3)服务端接收到客户端请求后,首先会判断该token在redis中是否存在。如果存在,则完成进行业务处理,业务处理完成后,再删除token。如果不存在,代表当前请求是重复请求,直接向客户端返回对应标识。

但是现在有一个问题,当前是先执行业务再删除token。

在高并发下,很有可能出现第一次访问时token存在,完成具体业务操作。但在还没有删除token时,客户端又携带token发起请求,此时,因为token还存在,第二次请求也会验证通过,执行具体业务操作

表单页面出现token,但是用户重复点击了多次,后台就出现了并发安全问题。

[图片上传失败...(image-3cb050-1628345728679)]

2.3.1解决方案

注意:在第五步骤需加锁控制,针对token的加锁,否则并发安全问题。

对于这个问题的解决方案的思想就是并行变串行。会造成一定性能损耗与吞吐量降低。
第一种方案:对于业务代码执行和删除token整体加线程锁。当后续线程再来访问token时,则阻塞排队。
第二种方案:借助redis单线程和incr是原子性的特点。当第一次获取token时,以token作为key,对其进行自增。
然后将token进行返回,当客户端携带token访问执行业务代码时,对于判断token是否存在不用删除,而是对其继续incr。如果incr后的返回值为2。则是一个合法请求允许执行,如果是其他值,则代表是非法请求,直接返回。

这种方案无需进行额外处理,一个token只能代表一次请求。一旦业务执行出现异常,则让客户端重新获取token令牌,重新发起一次访问即可。推荐使用先删除token方案

2.3.2Redisson优缺点对比

单机standloan、哨兵redis-Sentinel(master,slave主从模式本身没有主备自动切换功能)、集群Cluster。

基于Redis哨兵模式可能的问题:

当线程一给master节点写入某个锁LK,原则上ms会异步复制master数据给slave节点。

如果此时master节点发生故障宕机,就会发生主备切换,slave节点变成了master节点。此时线程二也可以给新的master节点写入LK锁。这样就会产生在同一时刻能有多个客户端对同一个分布式锁加锁,这样就可能会导致锁脏数据的产生。

2.4状态机

对于很多业务有一个业务流转状态的,每个状态都有前置状态和后置状态,以及最后的结束状态。例如流程的待审批,审批中,驳回,重新发起,审批通过,审批拒绝。订单的待提交,待支付,已支付,取消。

以订单为例,已支付的状态的前置状态只能是待支付,而取消状态的前置状态只能是待支付,通过这种状态机的流转我们就可以控制请求的幂等。

3代码实战

3.1maven配置

<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>myidempotentframework</artifactId>
        <groupId>com.kikop</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>myredistokenidempotentdemo</artifactId>

    <name>myredistokenidempotentdemo</name>
    <!-- FIXME change it to the project's website -->
    <url>http://www.example.com</url>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>

        <!--1.spring-boot-web-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!--2.spring-boot-test-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>




        <!-- 3.Redis-Jedis -->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <!--<version>${jedis.version}</version>-->
            <version>2.9.0</version>
        </dependency>

        <!--4.redisson-->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.6.5</version>
            <!--<version>${redisson.version}</version>-->
        </dependency>

        <!--5.mybatis-spring-boot-starter-->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.0.0</version>
        </dependency>

        <!--6.pagehelper-->
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper-spring-boot-starter</artifactId>
            <version>1.3.0</version>
        </dependency>

        <!--7.mysql connector-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <!--8.lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <!--<version>1.16.10</version>-->
            <version>${lombok.version}</version>
            <!--<optional>true</optional>-->
        </dependency>

        <!-- 9.commons-lang3 -->
        <!--StrBuilder-->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.4</version>
        </dependency>

        <!-- springboot-aop -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>



        <!-- guava -->
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>26.0-jre</version>
        </dependency>

        <!--joda time-->
        <dependency>
            <groupId>joda-time</groupId>
            <artifactId>joda-time</artifactId>
            <version>2.10</version>
        </dependency>



        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-collections4</artifactId>
            <version>4.1</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <!--<version>1.1.20</version>-->
            <version>${druid.version}</version>
        </dependency>

        <!--<dependency>-->
            <!--<groupId>org.jdom</groupId>-->
            <!--<artifactId>jdom</artifactId>-->
            <!--<version>2.0.2</version>-->
        <!--</dependency>-->

        <!-- https://mvnrepository.com/artifact/org.jdom/jdom -->
        <dependency>
            <groupId>org.jdom</groupId>
            <artifactId>jdom</artifactId>
            <version>2.0.2</version>
        </dependency>


        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
            <version>3.10.0</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/org.codehaus.groovy/groovy-all -->
        <dependency>
            <groupId>org.codehaus.groovy</groupId>
            <artifactId>groovy-all</artifactId>
            <version>3.0.0</version>
            <type>pom</type>
        </dependency>



    </dependencies>


</project>

3.2相关配置

3.2.1AppConfig

3.2.2JedisConfig

package com.kikop.config;

import jodd.util.StringUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.scripting.support.ResourceScriptSource;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;


/**
 * @author kikop
 * @version 1.0
 * @project Name: myredistokenidempotentdemo
 * @file Name: JedisConfig
 * @desc Jedis基本配置 used by TokenServiceImpl
 * jedis配合 redisson
 * @date 2021/7/31
 * @time 13:00
 * @by IDE: IntelliJ IDEA
 */
@Configuration
public class JedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Value("${spring.redis.password}")
    private String password;

    @Value("${spring.redis.jedis.pool.max-idle}")
    private int maxIdle;

    @Value("${spring.redis.jedis.pool.max-wait}")
    private long maxWait;

    @Value("${spring.redis.jedis.pool.min-idle}")
    private int minIdle;

    @Value("${spring.redis.timeout}")
    private int timeout;

    @Value("${com.kikop.lua.srcript.name}")
    private String redistokenidempotent;


    /**
     * 读取限流脚本
     * DefaultRedisScript
     *
     * @return
     */
    @Bean
    public ResourceScriptSource luaResourceScriptSource() {
        ResourceScriptSource luaResourceScriptSource = new ResourceScriptSource(new ClassPathResource(redistokenidempotent));
        return luaResourceScriptSource;
    }


    /**
     * 配置 JedisPool连接池
     *
     * @return
     */
    @Bean
    public JedisPool redisPoolFactory() {
        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxIdle(maxIdle);
        jedisPoolConfig.setMaxWaitMillis(maxWait);
        jedisPoolConfig.setMinIdle(minIdle);
        JedisPool jedisPool = null;
        if (StringUtil.isEmpty(password)) {
            jedisPool = new JedisPool(jedisPoolConfig, host, port, timeout);
        } else {
            jedisPool = new JedisPool(jedisPoolConfig, host, port, timeout, password);
        }

        return jedisPool;
    }

}

3.2.3RedissonConfig

package com.kikop.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author kikop
 * @version 1.0
 * @project Name: myredistokenidempotentdemo
 * @file Name: RedissonConfig
 * @desc Redisson分布式锁配置
 * @date 2021/7/31
 * @time 13:00
 * @by IDE: IntelliJ IDEA
 */
@Configuration
public class RedissonConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private String port;

    @Value("${spring.redis.password}")
    private String password;

    // redisson 单机模式
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
                .setAddress("redis://" + host + ":" + port);
//                .setPassword(password);
        return Redisson.create(config);
    }

}

3.3Util

3.3.1JedisUtil

package com.kikop.util;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

/**
 * @author kikop
 * @version 1.0
 * @project Name: myredistokenidempotentdemo
 * @file Name: JedisUtil
 * @desc 设置 Redis 键值对
 * @date 2021/7/31
 * @time 13:00
 * @by IDE: IntelliJ IDEA
 */
@Component
@Slf4j
public class JedisUtil {

    /**
     * jedis包,内置:commons-pool包
     */
    @Autowired(required = false)
    private JedisPool jedisPool;

    private Jedis getJedis() {
        return jedisPool.getResource();
    }

    /**
     * 设值
     *
     * @param key
     * @param value
     * @return
     */
    public String set(String key, String value) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.set(key, value);
        } catch (Exception e) {
            log.error("set key: {} value: {} error", key, value, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 设值
     * 如果key值存在,使用setex将覆盖原有值
     *
     * @param key
     * @param value
     * @param expireTime 过期时间, 单位: s
     * @return
     */
    public String set(String key, String value, int expireTime) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.setex(key, expireTime, value);
        } catch (Exception e) {
            log.error("set key:{} value:{} expireTime:{} error", key, value, expireTime, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 设值
     * 只有当key不存在的情况下,将key设置为value
     *
     * @param key
     * @param value
     * @return
     */
    public Long setnx(String key, String value) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.setnx(key, value);
        } catch (Exception e) {
            log.error("set key:{} value:{} error", key, value, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 取值
     *
     * @param key
     * @return
     */
    public String get(String key) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.get(key);
        } catch (Exception e) {
            log.error("get key:{} error", key, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 删除key
     *
     * @param key
     * @return
     */
    public Long del(String key) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.del(key.getBytes());
        } catch (Exception e) {
            log.error("del key:{} error", key, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 判断key是否存在
     *
     * @param key
     * @return
     */
    public Boolean exists(String key) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.exists(key.getBytes());
        } catch (Exception e) {
            log.error("exists key:{} error", key, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 设值key过期时间
     *
     * @param key
     * @param expireTime 过期时间, 单位: s
     * @return
     */
    public Long expire(String key, int expireTime) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.expire(key.getBytes(), expireTime);
        } catch (Exception e) {
            log.error("expire key:{} error", key, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 获取剩余时间
     *
     * @param key
     * @return
     */
    public Long ttl(String key) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.ttl(key);
        } catch (Exception e) {
            log.error("ttl key:{} error", key, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    private void close(Jedis jedis) {
        if (null != jedis) {
            jedis.close();
        }
    }


    /**
     * 获取剩余时间
     *
     * @param key
     * @return
     */
    public Long checkttl(String key) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.ttl(key);
        } catch (Exception e) {
            log.error("ttl key:{} error", key, e);
            return null;
        } finally {
            close(jedis);
        }
    }


    /**
     * 阻塞式进行时间窗限流
     *
     * @param luaScript
     * @param key
     * @return
     */
    public boolean acquire(String luaScript, String key) {

        Jedis jedis = null;
        try {
            jedis = getJedis();

            // key[1]:key
            // argv[1]:limit
            // argv[2]:expiretime
            return (long) jedis.eval(luaScript, 1, key)
                    == 1L;
        } catch (Exception e) {
            // 不在限流范围内
            return false;
        } finally {
            close(jedis);
        }


    }


}

3.4注解

package com.kikop.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author kikop
 * @version 1.0
 * @project Name: myredistokenidempotentdemo
 * @file Name: AccessLimit
 * @desc 在需要保证 接口防刷限流 的 Controller的方法上使用此注解
 * @date 2021/7/31
 * @time 13:00
 * @by IDE: IntelliJ IDEA
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent {
}

3.5拦截器

package com.kikop.interceptor;

import com.kikop.annotation.ApiIdempotent;
import com.kikop.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;


/**
 * @author kikop
 * @version 1.0
 * @project Name: myredistokenidempotentdemo
 * @file Name: JedisUtil
 * @desc 接口幂等性拦截器
 * @date 2021/7/31
 * @time 13:00
 * @by IDE: IntelliJ IDEA
 */
public class ApiIdempotentInterceptor implements HandlerInterceptor {

    /**
     * 验证token有效性
     */
    @Autowired
    private TokenService tokenService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {

        if (!(handler instanceof HandlerMethod)) {
            return true;
        }

        // 获取 Handler方法及注解
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
//        System.out.println("preHandle method:"+method.getName());
        ApiIdempotent methodAnnotation = method.getAnnotation(ApiIdempotent.class);
        if (methodAnnotation != null) {
            check(request);// 幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示
        }
        return true;
    }

    private void check(HttpServletRequest request) {
//        tokenService.checkToken(request);
        tokenService.checkTokenByLuaScript(request);
    }

    @Override
    public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
    }

    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
    }
}

3.6公共变量

3.6.1Constant

3.6.2ResponseCode

package com.kikop.common;

/**
 * @author kikop
 * @version 1.0
 * @project Name: myredistokenidempotentdemo
 * @file Name: ResponseCode
 * @desc 响应状态码
 * @date 2021/7/31
 * @time 13:00
 * @by IDE: IntelliJ IDEA
 */
public enum ResponseCode {

    // 系统模块
    SUCCESS(0, "操作成功"),
    ERROR(1, "操作失败"),
    SERVER_ERROR(500, "服务器异常"),

    // 通用模块 1xxxx
    ILLEGAL_ARGUMENT(10000, "参数不合法"),
    REPETITIVE_OPERATION(10001, "请勿重复操作"),
    ACCESS_LIMIT(10002, "请求太频繁, 请稍后再试"),
    MAIL_SEND_SUCCESS(10003, "邮件发送成功"),

    // 用户模块 2xxxx
    NEED_LOGIN(20001, "登录失效"),
    USERNAME_OR_PASSWORD_EMPTY(20002, "用户名或密码不能为空"),
    USERNAME_OR_PASSWORD_WRONG(20003, "用户名或密码错误"),
    USER_NOT_EXISTS(20004, "用户不存在"),
    WRONG_PASSWORD(20005, "密码错误"),

    // 订单模块 4xxxx

    ;

    ResponseCode(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    private Integer code;

    private String msg;

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }
}

3.6.3ServerResponse

package com.kikop.common;

import com.fasterxml.jackson.annotation.JsonIgnore;

import java.io.Serializable;

/**
 * @author kikop
 * @version 1.0
 * @project Name: myredistokenidempotentdemo
 * @file Name: ServerResponse
 * @desc
 * @date 2021/7/31
 * @time 13:00
 * @by IDE: IntelliJ IDEA
 */
public class ServerResponse implements Serializable{

    private static final long serialVersionUID = 7498483649536881777L;

    // status
    private Integer status;

    // msg
    private String msg;

    // data
    private Object data;

    public ServerResponse() {
    }

    public ServerResponse(Integer status, String msg, Object data) {
        this.status = status;
        this.msg = msg;
        this.data = data;
    }

    // jackson.jar
    @JsonIgnore
    public boolean isSuccess() {
        return this.status == ResponseCode.SUCCESS.getCode();
    }

    public static ServerResponse success() {
        return new ServerResponse(ResponseCode.SUCCESS.getCode(), null, null);
    }

    public static ServerResponse success(String msg) {
        return new ServerResponse(ResponseCode.SUCCESS.getCode(), msg, null);
    }

    public static ServerResponse success(Object data) {
        return new ServerResponse(ResponseCode.SUCCESS.getCode(), null, data);
    }

    public static ServerResponse success(String msg, Object data) {
        return new ServerResponse(ResponseCode.SUCCESS.getCode(), msg, data);
    }

    public static ServerResponse error(String msg) {
        return new ServerResponse(ResponseCode.ERROR.getCode(), msg, null);
    }

    public static ServerResponse error(Object data) {
        return new ServerResponse(ResponseCode.ERROR.getCode(), null, data);
    }

    public static ServerResponse error(String msg, Object data) {
        return new ServerResponse(ResponseCode.ERROR.getCode(), msg, data);
    }

    public Integer getStatus() {
        return status;
    }

    public void setStatus(Integer status) {
        this.status = status;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }
}

3.7业务控制器

3.7.1TokenController

package com.kikop.controller;

import com.kikop.common.ServerResponse;
import com.kikop.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author kikop
 * @version 1.0
 * @project Name: myredistokenidempotentdemo
 * @file Name: TokenController
 * @desc
 * @date 2021/7/31
 * @time 13:00
 * @by IDE: IntelliJ IDEA
 */
@RestController
@RequestMapping("/token")
public class TokenController {

    @Autowired
    private TokenService tokenService;

    /**
     * 获取token
     * @return
     */
    @GetMapping
    public ServerResponse createToken() {
        return tokenService.createToken();
    }

}

3.7.2TokenService

package com.kikop.service;

import com.kikop.common.ServerResponse;

import javax.servlet.http.HttpServletRequest;

/**
 * @author kikop
 * @version 1.0
 * @project Name: myredistokenidempotentdemo
 * @file Name: TokenService
 * @desc
 * @date 2021/7/31
 * @time 13:00
 * @by IDE: IntelliJ IDEA
 */
public interface TokenService {

    ServerResponse createToken();

    void checkToken(HttpServletRequest request);

    void checkTokenByLuaScript(HttpServletRequest request);
}

3.7.2.1TokenServiceImpl(已优化)

package com.kikop.service.impl;

import com.kikop.common.Constant;
import com.kikop.common.ResponseCode;
import com.kikop.common.ServerResponse;
import com.kikop.exception.ServiceException;
import com.kikop.service.TokenService;
import com.kikop.util.JedisUtil;
import com.kikop.util.RandomUtil;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.text.StrBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

/**
 * @author kikop
 * @version 1.0
 * @project Name: myredistokenidempotentdemo
 * @file Name: TokenServiceImpl
 * @desc
 * @date 2021/7/31
 * @time 13:00
 * @by IDE: IntelliJ IDEA
 */
@Service
public class TokenServiceImpl implements TokenService {

    // token名称
    private static final String TOKEN_NAME = "token";

    @Autowired
    private JedisUtil jedisUtil;


    @Autowired
    private ResourceScriptSource luaResourceScriptSource;

    @Override
    public ServerResponse createToken() {
        // 因为是全局内存,加个随机数,控制高并发场景的粒度
        String str = RandomUtil.UUID32();
        StrBuilder token = new StrBuilder();

        // key:token:1111111111111111
        token.append(Constant.Redis.TOKEN_PREFIX).append(str);

        // key:token:56df7492282d4bcd85b5c292843c932f
        // value:token:56df7492282d4bcd85b5c292843c932f
        // expiretime:60,默认秒
        jedisUtil.set(token.toString(), token.toString(), Constant.Redis.EXPIRE_TIME_MINUTE);

        return ServerResponse.success(token.toString());
    }

    /**
     * 校验token的核心逻辑
     *
     * @param request
     */
    @Override
    public void checkToken(HttpServletRequest request) {

        // 1.校验token
        String token = request.getHeader(TOKEN_NAME); // 先看请求头
        if (StringUtils.isBlank(token)) { // header中不存在token
            token = request.getParameter(TOKEN_NAME); // 看请求参数是否有
            if (StringUtils.isBlank(token)) { // 参数不合法
                throw new ServiceException(ResponseCode.ILLEGAL_ARGUMENT.getMsg());
            }
        }

        // 这里 2和3两个步骤,如果处理好的话,用lua脚本较好
        // 2.判断是否存在token
        if (!jedisUtil.exists(token)) { // 请勿重复操作
            throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
        }
        // 3.校验完立马删除token
        Long del = jedisUtil.del(token);
        if (del <= 0) { // 防止时间片切换,别的线程删掉了,否则还会出现重复提交问题
            throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
        }
    }

    @Override
    public void checkTokenByLuaScript(HttpServletRequest request) {

        // 1.校验token
        String token = request.getHeader(TOKEN_NAME); // 先看请求头
        if (StringUtils.isBlank(token)) { // header中不存在token
            token = request.getParameter(TOKEN_NAME); // 看请求参数是否有
            if (StringUtils.isBlank(token)) { // 参数不合法
                throw new ServiceException(ResponseCode.ILLEGAL_ARGUMENT.getMsg());
            }
        }

        // 2.用lua脚本较好
        try {
            boolean acquire = jedisUtil.acquire(luaResourceScriptSource.getScriptAsString(), token);
            if (!acquire) {
                throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
            }
        } catch (IOException e) {
            e.printStackTrace();
            throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
        }
    }

}

3.8资源配置

3.8.1yml

server.port=8088
spring.resources.static-locations=classpath:/templates/,classpath:/static/

# view resolver
spring.mvc.view.suffix=.html

# redis
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=
spring.redis.jedis.pool.max-idle=32
spring.redis.jedis.pool.max-wait=-1
spring.redis.jedis.pool.min-idle=0
# 阻塞不设置超时时间
spring.redis.timeout=0

# mysql
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.url=jdbc:mysql://localhost:3306/redistokenidem?serverTimezone=GMT%2B8&autoR&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# show sql in console
logging.level.com.kikop.mapper=debug


# okhttp
ok.http.connect-timeout=30
ok.http.read-timeout=30
ok.http.write-timeout=30
# 连接池中整体的空闲连接的最大数量
ok.http.max-idle-connections=200
# 连接空闲时间最多为 300 秒
ok.http.keep-alive-duration=300

com.kikop.lua.srcript.name=redistokenidempotent.lua

3.8.2lua

--lua脚本进行原子操作

-- 1.限流KEY,两点代表拼接
local key = KEYS[1]


-- 2.逻辑判断
-- 判断key是否存在,首次肯定不存在
-- 0:不存在(Key为首次创建);1:存在
local isExistKey = tonumber(redis.call('exists', key))

if isExistKey ==0 then -- Key不存在
 return 0
else
 -- 返回被删除key的数量
 local isDeleteOk=tonumber(redis.call('del', key))
 if isDeleteOk <=0 then -- key删除异常
  return 0
 end
 return 1
end

3.9测试

package com.kikop;

import com.kikop.interceptor.AccessLimitInterceptor;
import com.kikop.interceptor.ApiIdempotentInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

@SpringBootApplication

// 扫描 mybatis mapper接口,生成动态代理类
@MapperScan("com.kikop.mapper")
// 开启 spring定时任务
//@EnableScheduling
// 开启异步任务
//@EnableAsync
public class RedisTokenIdemApplication extends WebMvcConfigurerAdapter {


    /**
     * 跨域
     *
     * @return
     */
    @Bean
    public CorsFilter corsFilter() {
        final UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource();

        final CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowCredentials(true);
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");

        urlBasedCorsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);

        return new CorsFilter(urlBasedCorsConfigurationSource);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        // 接口幂等性拦截器
        registry.addInterceptor(apiIdempotentInterceptor());

//        // 接口防刷限流拦截器
//        registry.addInterceptor(accessLimitInterceptor());

        super.addInterceptors(registry);
    }

    /**
     * 接口幂等性拦截器
     * @return
     */
    @Bean
    public ApiIdempotentInterceptor apiIdempotentInterceptor() {
        return new ApiIdempotentInterceptor();
    }

//    /**
//     * 接口防刷限流拦截器
//     * @return
//     */
//    @Bean
//    public AccessLimitInterceptor accessLimitInterceptor() {
//        return new AccessLimitInterceptor();
//    }

    public static void main(String[] args) {
        SpringApplication.run(RedisTokenIdemApplication.class, args);
    }
}

4总结

参考

1Redission实现分布式锁

https://www.jianshu.com/p/6d3edf5ce735

2什么是幂等?分布式锁如何实现业务幂等

https://www.jianshu.com/p/ea41475cd243

3Redis三种部署方案

https://blog.csdn.net/ahfywangqiang/article/details/86537421

4Java操作Redis数据类型(ByJedis)

https://blog.csdn.net/p812438109/article/details/107010170

5利用Redis和Lua的原子性实现抢红包功能

https://www.jianshu.com/p/b58ed2fe6976?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendation

6(Redis)缓存处理的几个问题xxxx

https://blog.csdn.net/Batac_Lee/article/details/105407136

7Redis 解决库存并发问题(数量控制)

https://www.cnblogs.com/benbenhan/p/13321628.html

8基于redis实现的扣减库存

https://blog.csdn.net/qq_41534566/article/details/79219023

9Post/Redirect/Get (PRG) 模式

https://www.jianshu.com/p/47d859b99044

10设计核心接口的防重幂等性

https://zhuanlan.zhihu.com/p/151438657

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容

  • 幂等接口就是多次调用不会影响到系统。 数据库唯一主键 数据库唯一主键的实现主要是利用数据库中主键唯一约束的特性,一...
    jiahzhon阅读 2,380评论 0 13
  • 一、幂等性概念 1、幂等简介 编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。就是说,...
    夕望有你阅读 778评论 0 5
  • 什么是接口幂等性? 幂等是数学和计算机学的概念,常见于抽象代数中,即f(f(x)) = f(x)。简单来讲就是接口...
    初心myp阅读 5,257评论 0 6
  • 一、概念 当微服务之间调用时服务A向服务B重复发送消息或者用户多次点击导致重复操作数据库。 例如支付订单接口,如果...
    CJ21阅读 739评论 0 12
  • 16宿命:用概率思维提高你的胜算 以前的我是风险厌恶者,不喜欢去冒险,但是人生放弃了冒险,也就放弃了无数的可能。 ...
    yichen大刀阅读 6,018评论 0 4