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

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
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
相关文章
|
4月前
|
canal 缓存 NoSQL
Redis缓存与数据库如何保证一致性?同步删除+延时双删+异步监听+多重保障方案
根据对一致性的要求程度,提出多种解决方案:同步删除、同步删除+可靠消息、延时双删、异步监听+可靠消息、多重保障方案
Redis缓存与数据库如何保证一致性?同步删除+延时双删+异步监听+多重保障方案
|
16天前
|
决策智能 数据库 开发者
使用Qwen2.5+SpringBoot+SpringAI+SpringWebFlux的基于意图识别的多智能体架构方案
本项目旨在解决智能体的“超级入口”问题,通过开发基于意图识别的多智能体框架,实现用户通过单一交互入口使用所有智能体。项目依托阿里开源的Qwen2.5大模型,利用其强大的FunctionCall能力,精准识别用户意图并调用相应智能体。 核心功能包括: - 意图识别:基于Qwen2.5的大模型方法调用能力,准确识别用户意图。 - 业务调用中心:解耦框架与业务逻辑,集中处理业务方法调用,提升系统灵活性。 - 会话管理:支持连续对话,保存用户会话历史,确保上下文连贯性。 - 流式返回:支持打字机效果的流式返回,增强用户体验。 感谢Qwen2.5系列大模型的支持,使项目得以顺利实施。
224 8
使用Qwen2.5+SpringBoot+SpringAI+SpringWebFlux的基于意图识别的多智能体架构方案
|
1月前
|
缓存 NoSQL Java
Spring Boot中的分布式缓存方案
Spring Boot提供了简便的方式来集成和使用分布式缓存。通过Redis和Memcached等缓存方案,可以显著提升应用的性能和扩展性。合理配置和优化缓存策略,可以有效避免常见的缓存问题,保证系统的稳定性和高效运行。
51 3
|
18天前
|
存储 监控 NoSQL
Redis集群方案汇总:概念性介绍
本文介绍了Redis的三种高可用和分布式解决方案:**Redis Replication(主从复制)**、**Redis Sentinel(哨兵模式)** 和 **Redis Cluster(集群模式)**。Redis Replication实现数据备份和读写分离,适合数据安全和负载均衡场景;Redis Sentinel提供自动故障转移和监控功能,适用于读写分离架构;Redis Cluster通过分布式存储和自动故障转移,解决单点性能瓶颈,适合大规模数据和高并发场景。文中还详细描述了各方案的工作原理、优缺点及适用场景。
30 0
|
7月前
|
存储 NoSQL Redis
Redis系列学习文章分享---第九篇(Redis快速入门之好友关注--关注和取关 -共同关注 -Feed流实现方案分析 -推送到粉丝收件箱 -滚动分页查询)
Redis系列学习文章分享---第九篇(Redis快速入门之好友关注--关注和取关 -共同关注 -Feed流实现方案分析 -推送到粉丝收件箱 -滚动分页查询)
79 0
|
3月前
|
easyexcel Java UED
SpringBoot中大量数据导出方案:使用EasyExcel并行导出多个excel文件并压缩zip后下载
在SpringBoot环境中,为了优化大量数据的Excel导出体验,可采用异步方式处理。具体做法是将数据拆分后利用`CompletableFuture`与`ThreadPoolTaskExecutor`并行导出,并使用EasyExcel生成多个Excel文件,最终将其压缩成ZIP文件供下载。此方案提升了导出效率,改善了用户体验。代码示例展示了如何实现这一过程,包括多线程处理、模板导出及资源清理等关键步骤。
|
3月前
|
NoSQL Java Redis
shiro学习四:使用springboot整合shiro,正常的企业级后端开发shiro认证鉴权流程。使用redis做token的过滤。md5做密码的加密。
这篇文章介绍了如何使用Spring Boot整合Apache Shiro框架进行后端开发,包括认证和授权流程,并使用Redis存储Token以及MD5加密用户密码。
51 0
shiro学习四:使用springboot整合shiro,正常的企业级后端开发shiro认证鉴权流程。使用redis做token的过滤。md5做密码的加密。
|
3月前
|
存储 NoSQL 大数据
大数据-51 Redis 高可用方案CAP-AP 主从复制 一主一从 全量和增量同步 哨兵模式 docker-compose测试
大数据-51 Redis 高可用方案CAP-AP 主从复制 一主一从 全量和增量同步 哨兵模式 docker-compose测试
50 3
|
3月前
|
存储 NoSQL Java
Spring Boot项目中使用Redis实现接口幂等性的方案
通过上述方法,可以有效地在Spring Boot项目中利用Redis实现接口幂等性,既保证了接口操作的安全性,又提高了系统的可靠性。
74 0
|
5月前
|
存储 JSON 前端开发
SpringBoot 如何实现无感刷新Token
【8月更文挑战第30天】在Web开发中,Token(尤其是JWT)作为一种常见的认证方式,被广泛应用于身份验证和信息加密。然而,Token的有效期问题常常导致用户需要重新登录,从而影响用户体验。为了实现更好的用户体验,SpringBoot可以通过无感刷新Token的机制来解决这一问题。以下将详细介绍SpringBoot如何做到无感刷新Token。
229 2