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方案,不同的地方是:
- 网上方案检验token存在后,就立刻删除token,再进行业务处理。可能问题:业务处理没有成功,接口调用方也没有获取到明确的结果,然后进行重试,但token已经删除掉了,服务端判断token不存在,认为是重复请求,就直接返回了,无法进行业务处理了
- 而上面的方式是检验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实现分布式锁
2什么是幂等?分布式锁如何实现业务幂等
3Redis三种部署方案
https://blog.csdn.net/ahfywangqiang/article/details/86537421