SpringBoot幂等性防重token令牌实现方案(redis+annoation+interceptor实现)

本文涉及的产品
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
云原生内存数据库 Tair,内存型 2GB
云数据库 Redis 版,经济版 1GB 1个月
简介: SpringBoot幂等性防重token令牌实现方案(redis+annoation+interceptor实现)

一、幂等性的概念


1.1、认识幂等


百度百科这么解释:在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。


简而言之就是:在某个场景下,同一个用户去请求某个接口多次也只有一次效果。


实际生产中的例子:


1、对于订单接口,在你提交的时候不可能创建多个吧。


2、支付接口,转账操作肯定也是只能操作一次。


3、支付宝的回调接口,有些时候会产生多次回调,这种情况也要避免。


4、一些普通的表单提交接口。


1.2、Restful API中的幂等性(通俗约定)


一般分为Get、POST、DELETE、PUT,一半各自定义为查、增、删、修改。


GET:一般获取信息是本身天然就满足幂等的。

POST、DELETE、PUT:其中POST一般都是新增,肯定是不满足幂等的,另外两个根据实际业务来决定。


二、幂等性的解决方案


学习:黑马程序员-玩转微服务接口幂等性与安全设计


下面介绍几种实现方案:


方案1、数据库唯一主键实现幂等性


限制:分布式ID。


流程:客户端从服务端获取到分布式ID,接着去调用请求时携带分布式ID,此时订单使用这个分布式ID作为唯一主键来进行insert,一旦出现重复提交情况,自然不会成功。


方案2、数据库悲观锁实现


场景:例如我们要查询某个订单的状态然后进行更新,此时就可以使用数据库的悲观锁实现。


对于如下正常逻辑在高并发场景下会出现问题:


select * from where id = 'xxx'
if (查询状态 == xxx) {
  //业务逻辑
}
update x


使用悲观锁,需要开启事务:


begin # 事务开始
# 添加for update关键字,即可对这行记录上锁(上行锁还是表锁取决于where的这个字段是否为索引)
select * from where id = 'xxx' for update
update xx 
commit  # 事务结束


若是线程A执行了select … for update,此时就会对这行记录上锁,直到整个线程A提交事务之前,其他线程来走这个查询操作都会进入到阻塞状态。

示例视频演示:基于for update实现悲观锁

方案3、数据库乐观锁


限制:表中需要多增加一个字段version来进行乐观锁判断


# 在更新的时候会拿上版本号来进行字段校验以及进行update更新
# 一条sql语句是原子性的,就不会产生线程安全问题
UPDATE my_table SET price=price+50,version=version+1 WHERE id=1 AND version=5


方案4:防重token令牌


限制:①唯一token。②集成redis校验


流程:①在执行某个重要业务前客户端请求服务器获取到防重token令牌(服务器端生成时会在redis中插入与该令牌相关的一条记录)。


②拿到token后来进行消费,也就是向服务器端进行重要业务请求。(该令牌token只能够被消耗一次,进入到服务器端时会将是否删除redis那条记录结果来作为约束条件)



方案5:状态机实现


限制:根据数据库表记录中的状态来作为条件(跟之前乐观锁使用版本号类似)。


# 在日常业务中对于类似订单都有不同的状态,我们可以来定义status状态字段来进行表示,在进行更新操作时就可以使用对应的状态字段来实现幂等性
update transfr_flow set status=2 where biz_seq=‘666’ and status=1;


方案6:分布式锁


限制:redis或者zookeeper。


流程:业务来临的时候在redis中插入一条记录,若是成功说明可以进行,失败就表示业务执行处理结束。(具体实际凭证条件参数根据业务来决定)


三、幂等性实战


3.1、SpringBoot集成Redis实现—(防重token令牌)


实现思路

流程:在进行请求重要业务接口前,首先获取到防重令牌,之后再去发送请求。


原理:在生成令牌的同时,会向redis中去插入一条记录,之后在进行消费时使用一条lua脚本(查询校验+删除操作),lua脚本可以保证在redis里进行原子性操作。


实操代码



依赖配置xml:
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <!--springboot data redis-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
    </dependency>
</dependencies>



yaml配置文件:


spring:
  redis:
    ssl: false
    host: 127.0.0.1
    port: 6379
    database: 0
    timeout: 1000
    password: 123456
    lettuce:
      pool:
        max-active: 100
        max-wait: -1
        min-idle: 0
        max-idle: 20
server:
  port: 8081



①TokenUtilService:token生成以及token校验(集成redis)


package com.changlu.utils;
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class TokenUtilService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    /**
     * 存入 Redis 的 Token 键的前缀
     */
    private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";
    /**
     * 创建 Token 存入 Redis,并返回该 Token
     *
     * @param value 用于辅助验证的 value 值
     * @return 生成的 Token 串
     */
    public String generateToken(String value) {
        // 实例化生成 ID 工具对象
        String token = UUID.randomUUID().toString();
        // 设置存入 Redis 的 Key
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        // 存储 Token 到 Redis,且设置过期时间为5分钟
        redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
        // 返回 Token
        return token;
    }
    /**
     * 验证 Token 正确性
     *
     * @param token token 字符串
     * @param value value 存储在Redis中的辅助验证信息
     * @return 验证结果
     */
    public boolean validToken(String token, String value) {
        // 设置 Lua 脚本,其中 KEYS[1] 是 key,KEYS[2] 是 value
        String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
        RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        // 根据 Key 前缀拼接 Key
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        // 执行 Lua 脚本
        Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
        // 根据返回结果判断是否成功成功匹配并删除 Redis 键值对,若果结果不为空和0,则验证通过
        if (result != null && result != 0L) {
            log.info("验证 token={},key={},value={} 成功", token, key, value);
            return true;
        }
        log.info("验证 token={},key={},value={} 失败", token, key, value);
        return false;
    }
}



②防重注解:ApiIdempotent.java


package com.changlu.annontions;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)  //使用于方法
@Retention(RetentionPolicy.RUNTIME) //运行时
public @interface ApiIdempotent {
}



③防重注解拦截器,会拦截所有标注自定义防重注解的controller方法:


package com.changlu.interceptors;
import com.changlu.annontions.ApiIdempotent;
import com.changlu.utils.ServletUtils;
import com.changlu.utils.TokenUtilService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
/**
 * @Description: aoi幂等性接口拦截器
 * @Author: changlu
 * @Date: 10:07 AM
 */
@Component
public class ApiIdempotentInterceptor implements HandlerInterceptor {
    @Autowired
    private TokenUtilService tokenUtilService;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //校验是否有执行方法
        if (!(handler instanceof HandlerMethod)) {
            return true;//若没有对应的方法执行器,就直接放行
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        ApiIdempotent annotation = method.getAnnotation(ApiIdempotent.class);
        //若是没有幂等性注解直接放行
        if (annotation != null) {
            //解析对应的请求头
            String token = request.getHeader("token");
            if (ObjectUtils.isEmpty(token)) {
                ServletUtils.renderString(response, "请携带token令牌");
                return false;
            }
            //若是校验失败直接进行响应
            if (!tokenUtilService.validToken(token, "changlu")) {
                ServletUtils.renderString(response, "重复提交失败!");
                return false;
            }
        }
        return true;
    }
}



④webmvc配置,来注册拦截器:WebConfiguration.java


package com.changlu.config;
import com.changlu.interceptors.ApiIdempotentInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import java.nio.charset.Charset;
import java.util.List;
/**
 * @Description:
 * @Author: changlu
 * @Date: 10:05 AM
 */
@Configuration
public class WebConfiguration extends WebMvcConfigurationSupport {
    @Autowired
    private ApiIdempotentInterceptor apiIdempotentInterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(apiIdempotentInterceptor);
        super.addInterceptors(registry);
    }
//    http请求时编码
    @Bean
    public HttpMessageConverter<String> responseBodyConverter() {
        StringHttpMessageConverter converter = new StringHttpMessageConverter(
                Charset.forName("UTF-8"));
        return converter;
    }
    /**
     * 系统配置参数编码
     * @param converters
     */
    @Override
    protected void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        super.configureMessageConverters(converters);
        converters.add(responseBodyConverter());
    }
}


⑤工具类向response进行写数据:ServletUtils.java


package com.changlu.utils;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class ServletUtils
{
    /**
     * 将字符串渲染到客户端
     *
     * @param response 渲染对象
     * @param string 待渲染的字符串
     * @return null
     */
    public static String renderString(HttpServletResponse response, String string) {
        try
        {
            response.setStatus(200);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(string);
        }
        catch (IOException e)
        {
            e.printStackTrace();
        }
        return null;
    }
}



⑥两个控制器接口:


TokenController.java:
package com.changlu.controller;
import com.changlu.utils.TokenUtilService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
public class TokenController {
    @Autowired
    private TokenUtilService tokenService;
    /**
     * 获取 Token 接口
     *
     * @return Token 串
     */
    @GetMapping("/token")
    public String getToken() {
        // 获取用户信息(这里使用模拟数据)
        // 注:这里存储该内容只是举例,其作用为辅助验证,使其验证逻辑更安全,如这里存储用户信息,其目的为:
        // - 1)、使用"token"验证 Redis 中是否存在对应的 Key
        // - 2)、使用"用户信息"验证 Redis 的 Value 是否匹配。
        String userInfo = "changlu";
        // 获取 Token 字符串,并返回
        return tokenService.generateToken(userInfo);
    }
    /**
     * 接口幂等性测试接口
     *
     * @param token 幂等 Token 串
     * @return 执行结果
     */
    @PostMapping("/test")
    public Object test(@RequestHeader(value = "token") String token) {
        // 获取用户信息(这里使用模拟数据)
        String userInfo = "changlu";
        // 根据 Token 和与用户相关的信息到 Redis 验证是否存在对应的信息
        boolean result = tokenService.validToken(token, userInfo);
        // 根据验证结果响应不同信息
        return result ? "正常调用" : "重复调用";
    }
}


OrderController.java:


package com.changlu.controller;
import com.changlu.annontions.ApiIdempotent;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
 * @Description: 订单控制器
 * @Author: changlu
 * @Date: 10:03 AM
 */
@RestController
@RequestMapping("/order")
public class OrderController {
    //幂等性注解
    @ApiIdempotent
    @PostMapping
    public Object createOrder() {
        return "创建订单成功!";
    }
}


测试接口(jemter测试)

根据控制器类提供的接口进行测试:


1、TokenController主要来测试生成token以及校验token接口



生成接口测试如下:



接着我们将这个token添加到post的测试请求header中:



测试一下:


调用成功!



此时再调用一次,就调用失败了,因为在redis中的那条记录被删除了:



2、OrderController来测试(jmeter高并发测试)


在对应的订单接口上我们添加了防重接口,之后发起请求时就会走我们的拦截器,在拦截器中会取出token并进行校验,若是通过则会执行后面的业务方法:



HTTP请求配置信息如下:



还是老样子先获取到token,然后我们将token存入到header头中,这里我们在jmeter中配置一个http管理器:



我们也设置了同步定时器,能够在同一时间有100个请求:



线程属性如下:



接着我们启动jmeter来进行测试:防重接口成功!



注:为了能够在jmeter中显示x错误的效果,我在拦截器中进行错误响应内容渲染改成了抛出异常,仅仅只是为了显示明显,如下:



对应jemter测试文件,我放在项目目录下:


相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
2月前
|
负载均衡 监控 NoSQL
Redis的集群方案有哪些?
Redis集群包括主从复制(基础,手动故障恢复)、哨兵模式(自动高可用)和Redis Cluster(官方分布式解决方案,自动分片和容错)。此外,还有如Codis、Redisson和Twemproxy等第三方工具用于代理和负载均衡。选择方案需考虑应用场景、数据规模和并发需求。
318 2
|
2月前
|
监控 NoSQL Linux
【分布式】Redis的持久化方案解析
【1月更文挑战第25天】【分布式】Redis的持久化方案解析
|
2月前
|
运维 NoSQL 算法
【Redis故障排查】「连接失败问题排查和解决」带你深入分析一下Redis阻塞原因以及问题排查方案指南
【Redis故障排查】「连接失败问题排查和解决」带你深入分析一下Redis阻塞原因以及问题排查方案指南
645 0
|
2月前
|
缓存 运维 NoSQL
【Redis故障排查】「连接失败问题排查和解决」带你总体分析和整理Redis的问题故障实战开发指南及方案
【Redis故障排查】「连接失败问题排查和解决」带你总体分析和整理Redis的问题故障实战开发指南及方案
989 0
|
1月前
|
存储 NoSQL Redis
Redis系列学习文章分享---第九篇(Redis快速入门之好友关注--关注和取关 -共同关注 -Feed流实现方案分析 -推送到粉丝收件箱 -滚动分页查询)
Redis系列学习文章分享---第九篇(Redis快速入门之好友关注--关注和取关 -共同关注 -Feed流实现方案分析 -推送到粉丝收件箱 -滚动分页查询)
22 0
|
2月前
|
负载均衡 监控 NoSQL
Redis的几种主要集群方案
【5月更文挑战第15天】Redis集群方案包括主从复制(基础,读写分离,手动故障恢复)、哨兵模式(自动高可用,自动故障转移)和Redis Cluster(官方分布式解决方案,自动分片、容错和扩展)。此外,还有Codis、Redisson和Twemproxy等工具用于代理分片和负载均衡。选择方案需考虑应用场景、数据量和并发需求,权衡可用性、性能和扩展性。
232 2
|
1月前
|
NoSQL 算法 Java
技术好文:Redis实现分布式锁的7种方案
技术好文:Redis实现分布式锁的7种方案
|
1月前
|
存储 NoSQL 算法
Redis集群,集群的概念 三种主流分片方式1.哈希求余 一致性哈希算法:方案三:哈希槽分区算法问题一Redis集群是最多有16384个分片吗问题二:为什么是16384个,集群扩容:1.新的主节点
Redis集群,集群的概念 三种主流分片方式1.哈希求余 一致性哈希算法:方案三:哈希槽分区算法问题一Redis集群是最多有16384个分片吗问题二:为什么是16384个,集群扩容:1.新的主节点
|
2月前
|
存储 缓存 NoSQL
【技术分享】求取列表需求的redis缓存方案
【技术分享】求取列表需求的redis缓存方案
54 0
|
2月前
|
存储 缓存 NoSQL
Redis实现延迟任务的几种方案
Redis实现延迟任务的几种方案