SpringBoot幂等性防重token令牌实现方案(redis+annoation+interceptor实现)_防重 token 令牌-程序员宅基地

技术标签: spring boot  幂等性  java  # SpringBoot  redis  

前言

本章主要介绍幂等性以及幂等性实战案例。

所有博客文件目录索引:博客目录索引(持续更新)

本章案例版本:``SpringBoot 2.3.4.RELEASE`

配套代码地址:Gitee仓库Github仓库

一、幂等性的概念

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那条记录结果来作为约束条件)

image-20220820142450207

方案5:状态机实现

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

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

方案6:分布式锁

限制:redis或者zookeeper。

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

三、幂等性实战

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

实现思路

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

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

实操代码

image-20220820143909445

依赖配置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接口

image-20220820145231920

生成接口测试如下:

image-20220820145319139

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

image-20220820145406067

测试一下

调用成功!

image-20220820145423590

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

image-20220820145451115

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

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

image-20220820145557905

HTTP请求配置信息如下:

image-20220820145939296

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

image-20220820145749454

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

image-20220820145824306

线程属性如下:

image-20220820145836553

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

image-20220820150126053

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

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

image-20220820150349729


参考资料

[1]. 面试官:你能聊聊高并发下的接口幂等性如何实现吗?

[2]. 如何通过springboot + redis + 注解 + 拦截器,实现接口幂等性校验?

[3]. 如何使用 SpringBoot + Redis 优雅的解决接口幂等性问题

[4]. SpringBoot 接口幂等性实现的4种方案!这个我真的服气了!

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/cl939974883/article/details/126440193

智能推荐

Linux用户管理详解_linux登录qq是什么意思-程序员宅基地

文章浏览阅读448次。Linux用户管理用户基本概念什么是用户用户指的是能够正常登录Linux或Windows系统,比如:登录QQ的用户、登入王者荣耀的用户、等等[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nz1edsjq-1626145230283)(C:\Users\李开开\AppData\Roaming\Typora\typora-user-images\image-20210712171546940.png)]为什么需要用户系统上的每一个进程(运行的程序),都_linux登录qq是什么意思

Unity中协程里Animator获取状态一些笔记_getanimatortransitioninfo-程序员宅基地

文章浏览阅读4.5k次,点赞4次,收藏7次。最近用Animator获取状态各种获取错误,所以记一下笔记Animator中可以获取三种不同的状态:GetCurrentAnimatorStateInfo 获取正确的状态机状态GetNextAnimatorStateInfo 获取下一个状态机的状态GetAnimatorTransitionInfo 获取状态机的过渡状态动画同步是在帧最前,而协程是在帧的最后调用。所以切换状态后在协程获取状..._getanimatortransitioninfo

LATEX 中参考文献顺序_spphys.bst-程序员宅基地

文章浏览阅读924次。\bibliography{report} % bibliography data in report.bib\bibliographystyle{unsrt} % makes bibtex use spphys.bstunsrt 表示按照引用的先后顺序进行排序_spphys.bst

Linux下部署maven-web项目,包括JDK安装、TOMCAT安装、MYSQL安装详细解释-程序员宅基地

文章浏览阅读335次。为什么80%的码农都做不了架构师?>>> ..._linux系统搭建maven+tomcat+mysql

effective stl 第18条: 避免使用vector<bool>-程序员宅基地

文章浏览阅读354次。vector不是容器,并且它不存储bool,因为他是按照位来存储的,即一个bool只占一个二进制位。假设有vector v;则&v[0]会引起编译错误。如果不使用&v[0]可以使用vector,否则,可以用deque 和bitset来替代_避免使用vector

Bug的严重程度(缺陷程度)有哪几种。。。。_bug严重程度-程序员宅基地

文章浏览阅读6.8k次,点赞2次,收藏20次。Bug程度分为四种,分别为:致命S0:致命缺陷是指会造成安全问题的各类缺陷。在测试中很少出现,一旦出现立即中止版本测试。系统崩溃,数据丢失,数据毁坏,无法运行等Bug。严重S1:是指可以引起易于纠正的异常情况,可能引起易于修复的故障或对产品外观造成难以接受的缺陷。不影响其他功能的情况下可以继续版本测试。功能和性能不能实现。 次要功能全部丧失。 功能遗漏等等。 一般S2:一般缺陷是指不影响产品的..._bug严重程度

随便推点

《iOS 9 开发指南》——第1章,第1.3节工欲善其事,必先利其器——搭建开发环境...-程序员宅基地

文章浏览阅读202次。本节书摘来自异步社区《iOS 9 开发指南》一书中的第1章,第1.3节工欲善其事,必先利其器——搭建开发环境,作者 管蕾,更多章节内容可以访问云栖社区“异步社区”公众号查看1.3 工欲善其事,必先利其器——搭建开发环境iOS 9 开发指南图片 2 知识点讲解:光盘:视频知识点第1章搭建开发环境.mp4学习iOS 9开发也离不开好的开发工具的帮助,如果使..._(1)下载完成后单击打开下载的“.dmg”格式文件,然后双击xcode文件开始安装。

iView 3.3.2 发布,基于 Vue.js 的企业级 UI 组件库-程序员宅基地

文章浏览阅读115次。开发四年只会写业务代码,分布式高并发都不会还做程序员? iView 3.3.2 发布了,iView 是一套基于 Vue..._iview 3.2.2

详解not in与not exists的区别与用法(not in的性能并不差!)-程序员宅基地

文章浏览阅读93次。2019独角兽企业重金招聘Python工程师标准>>> ..._predicate not in查询

SpringBoot整合Spring Data JPA、MySQL、Druid并使用Mockito实现单元测试_spring jpa mock-程序员宅基地

文章浏览阅读4.7k次,点赞3次,收藏7次。SpringBoot整合Spring Data JPA、MySQL、Druid并使用Mockito实现单元测试_spring jpa mock

java 解析excel金额_java解析Excel(xls、xlsx两种格式)-程序员宅基地

文章浏览阅读441次。package poi;import java.io.FileInputStream;import java.io.FileNotFoundException;import java.io.IOException;import java.io.InputStream;import java.util.ArrayList;import java.util.LinkedHashMap;import j..._java getcellformatvalue

50个漂亮免费的 WordPress 主题(下)_wordpressbo'ke 免费-程序员宅基地

文章浏览阅读170次。50个漂亮免费的 WordPress 主题(上)Minimatica( Demo | Download )Placeholder( Demo | Download )Navly( Demo | Download )Cobera( Demo | Download )The Blog( Demo | Download )Gabi..._wordpressbo'ke 免费

推荐文章

热门文章

相关标签