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

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: 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
相关文章
|
3月前
|
canal 缓存 NoSQL
Redis缓存与数据库如何保证一致性?同步删除+延时双删+异步监听+多重保障方案
根据对一致性的要求程度,提出多种解决方案:同步删除、同步删除+可靠消息、延时双删、异步监听+可靠消息、多重保障方案
Redis缓存与数据库如何保证一致性?同步删除+延时双删+异步监听+多重保障方案
|
4月前
|
编解码 NoSQL Java
使用Spring Boot + Redis 队列实现视频文件上传及FFmpeg转码的技术分享
【8月更文挑战第30天】在当前的互联网应用中,视频内容的处理与分发已成为不可或缺的一部分。对于视频平台而言,高效、稳定地处理用户上传的视频文件,并对其进行转码以适应不同设备的播放需求,是提升用户体验的关键。本文将围绕使用Spring Boot结合Redis队列技术来实现视频文件上传及FFmpeg转码的过程,分享一系列技术干货。
209 3
|
16天前
|
NoSQL Java API
springboot项目Redis统计在线用户
通过本文的介绍,您可以在Spring Boot项目中使用Redis实现在线用户统计。通过合理配置Redis和实现用户登录、注销及统计逻辑,您可以高效地管理在线用户。希望本文的详细解释和代码示例能帮助您在实际项目中成功应用这一技术。
25 3
|
18天前
|
消息中间件 NoSQL Java
Spring Boot整合Redis
通过Spring Boot整合Redis,可以显著提升应用的性能和响应速度。在本文中,我们详细介绍了如何配置和使用Redis,包括基本的CRUD操作和具有过期时间的值设置方法。希望本文能帮助你在实际项目中高效地整合和使用Redis。
37 1
|
2月前
|
NoSQL Java Redis
redis的基本命令,并用netty操作redis(不使用springboot或者spring框架)就单纯的用netty搞。
这篇文章介绍了Redis的基本命令,并展示了如何使用Netty框架直接与Redis服务器进行通信,包括设置Netty客户端、编写处理程序以及初始化Channel的完整示例代码。
53 1
redis的基本命令,并用netty操作redis(不使用springboot或者spring框架)就单纯的用netty搞。
|
2月前
|
缓存 NoSQL Java
springboot的缓存和redis缓存,入门级别教程
本文介绍了Spring Boot中的缓存机制,包括使用默认的JVM缓存和集成Redis缓存,以及如何配置和使用缓存来提高应用程序性能。
114 1
springboot的缓存和redis缓存,入门级别教程
|
2月前
|
缓存 NoSQL Java
Spring Boot与Redis:整合与实战
【10月更文挑战第15天】本文介绍了如何在Spring Boot项目中整合Redis,通过一个电商商品推荐系统的案例,详细展示了从添加依赖、配置连接信息到创建配置类的具体步骤。实战部分演示了如何利用Redis缓存提高系统响应速度,减少数据库访问压力,从而提升用户体验。
88 2
|
3月前
|
JSON NoSQL Java
redis的java客户端的使用(Jedis、SpringDataRedis、SpringBoot整合redis、redisTemplate序列化及stringRedisTemplate序列化)
这篇文章介绍了在Java中使用Redis客户端的几种方法,包括Jedis、SpringDataRedis和SpringBoot整合Redis的操作。文章详细解释了Jedis的基本使用步骤,Jedis连接池的创建和使用,以及在SpringBoot项目中如何配置和使用RedisTemplate和StringRedisTemplate。此外,还探讨了RedisTemplate序列化的两种实践方案,包括默认的JDK序列化和自定义的JSON序列化,以及StringRedisTemplate的使用,它要求键和值都必须是String类型。
redis的java客户端的使用(Jedis、SpringDataRedis、SpringBoot整合redis、redisTemplate序列化及stringRedisTemplate序列化)
|
2月前
|
JSON NoSQL Java
springBoot:jwt&redis&文件操作&常见请求错误代码&参数注解 (九)
该文档涵盖JWT(JSON Web Token)的组成、依赖、工具类创建及拦截器配置,并介绍了Redis的依赖配置与文件操作相关功能,包括文件上传、下载、删除及批量删除的方法。同时,文档还列举了常见的HTTP请求错误代码及其含义,并详细解释了@RequestParam与@PathVariable等参数注解的区别与用法。
|
2月前
|
NoSQL Java Redis
shiro学习四:使用springboot整合shiro,正常的企业级后端开发shiro认证鉴权流程。使用redis做token的过滤。md5做密码的加密。
这篇文章介绍了如何使用Spring Boot整合Apache Shiro框架进行后端开发,包括认证和授权流程,并使用Redis存储Token以及MD5加密用户密码。
31 0
shiro学习四:使用springboot整合shiro,正常的企业级后端开发shiro认证鉴权流程。使用redis做token的过滤。md5做密码的加密。
下一篇
无影云桌面