接口幂等性(防止接口重复提交)

文章目录

    • :tomato: 幂等性
    • :tomato: 实现幂等性方案
    • :strawberry: 实现 `JSON` 格式参数多次读取

幂等性

在计算机中编程中,一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数或幂等方法是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。


接口幂等性(防止接口重复提交)_第1张图片

实现幂等性方案


token 机制:

  1. 首先,客户端请求服务端,获取一个 token,每次请求都获取一个新的 token,将 token 设置过期时间存如 redis 中,然后返回给客户端。
  2. 客户端将返回的 token 存放在请求头中,去请求接口。
  3. 服务端收到请求后,从请求头中拿取 token,去 redis 中查找数据是否存在。
  4. 如果数据存在,则删除该 token 继续处理剩下的业务。如果不存在,则证明该 token 过期或者当前业务已经执行过了,此时就不在执行业务逻辑了。

注意:在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用 Lua 表达式来注销查询与删除操作。


去重表:

该方式主要使用 mysql 中的唯一索引机制来实现。使用该方式应该注意的是 一般并不使用数据库的自增主键,使用分布式 ID 充当主键。

  1. 客户端请求服务端,服务端将这次请求(例如:地址,参数…)存入到一个 mysql 去重表中。这个去重表,要根据这次请求的某个特殊字段,建立唯一索引或者主键索引。

  2. 如果插入成功,继续完成余下的业务,如果插入失败,表示该业务已经执行过了,余下业务不在执行。

存在的问题:mysql 的容错性会影响业务,高并发的环境下可能效率降低。


redis 中的 setnx:

  1. 客户端请求服务端,服务端将能代表本次请求的唯一性的业务字段,通过 setnx 的方式存入 redis 中,并设置超时时间。

  2. 判断 setnx 是否成功,成功的话继续处理业务,否则就是已经执行过了。

redis 一定要设置过期时间。这样能保证在这个时间范围内,如果重复调用接口,则能够进行判断识别。如果不设置过期时间,很可能导致数据无限量的存入 redis,致使 redis不能正常工作。


锁机制:

  1. 乐观锁:数据库增加版本号字段,每次更新都都根据版本号来判断,更新之前先去查询更新记录的版本号,更新的时候将版本号作为条件。
 select version from xxx where id=xxx;
update xxx set xxx=xxx where xx=xx and version=xxx。

乐观锁一般只适用于执行 更新操作 的过程。

  1. 悲观锁:假设每一次拿数据都会被修改,所以直接上排他锁就行了。
-- 开启事务,提交事务
start;
select * from xxx where xxx for update;
update xxx
commit;

接口幂等性(防止接口重复提交)_第2张图片

实现 JSON 格式参数多次读取


创建项目,添加相关依赖:

      <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-data-redisartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-aopartifactId>
            <version>2.5.13version>
        dependency>

application.yml 配置

spring.redis.host=127.0.0.1
spring.redis.port=6379

接口幂等性(防止接口重复提交)_第3张图片

自定义一个拦截器:

package org.javaboy.repeat_submit.interceptor;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.javaboy.repeat_submit.annotation.RepeatSubmit;
import org.javaboy.repeat_submit.redis.RedisCache;
import org.javaboy.repeat_submit.request.RepeatableReadRequestWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.AsyncHandlerInterceptor;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * @author: yueLQ
 * @date: 2022-09-07 19:28
 * 

* 自定义一个拦截器 *

* 在接口中进行处理,拿到我们的请求路径和请求参数 * 接着我们去 redis 中判断一下这个请求是否之前发送过 */ @Component public class RepeatSubmitInterceptor implements HandlerInterceptor { public static final String REPEAT_PARAMS = "repeat_params"; public static final String REPEAT_TIME = "repeat_time"; // key 前缀 public static final String REPEAT_SUBMIT_KEY = "REPEAT_SUBMIT_KEY:"; // 请求头的认证 public static final String HEADER = "Authorization"; @Autowired private RedisCache redisCache; /** * 如果我们的请求的参数是 request.getParameter() 这个方法是可以反复获取的 * 如果我们是使用 io 流的方式获取的话 request.getReader().readLine(),例如请求参数是 json *

* 请求是一个 json,需要 io 流的方式读取参数 * * @param request * @param response * @param handler * @return * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // HandlerMethod 是我们定义的每一个接口方法,他们把接口方法定义封装成了一个对象 if (handler instanceof HandlerMethod) { // 强转 HandlerMethod handlerMethod = (HandlerMethod) handler; // 获取方法 Method method = handlerMethod.getMethod(); // 获取方法上的 RepeatSubmit 注解 RepeatSubmit repeatSubmit = method.getAnnotation(RepeatSubmit.class); // 并不是每个方法上都有该注解,所以我们需要进行判断 if (!ObjectUtils.isEmpty(repeatSubmit)) { // 需要进行重复校验 // 判断是否重复提交,如果是重复提交的,返回一段提示的内容 if (isRepeatSubmit(request, repeatSubmit)) { // 存在返回一段 json HashMap<String, Object> map = new HashMap<>(); map.put("status", 500); map.put("message", repeatSubmit.message()); response.setContentType("application/json;charset=utf-8"); response.getWriter().write(new ObjectMapper().writeValueAsString(map)); return false; } } } return true; } /** * 判断是否是重复提交,返回 true 是重复提交 * * @param request * @param repeatSubmit * @return */ private boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit repeatSubmit) { // 请求参数的字符串 String nowParams = ""; // 判断请求类型,如果请求是 RepeatableReadRequestWrapper 参数则是 json 格式 if (request instanceof RepeatableReadRequestWrapper) { try { // 按行读取 nowParams = ((RepeatableReadRequestWrapper) request).getReader().readLine(); } catch (IOException e) { e.printStackTrace(); } } // 否则的话,则说明参数是 key-value 的形式 if (StringUtils.isEmpty(nowParams)) { try { nowParams = new ObjectMapper().writeValueAsString(request.getParameterMap()); } catch (JsonProcessingException e) { e.printStackTrace(); } } // 存储数据 Map<String, Object> nowDataMap = new HashMap<>(); nowDataMap.put(REPEAT_PARAMS, nowParams); nowDataMap.put(REPEAT_TIME, System.currentTimeMillis()); // 获取当前请求的 uri String uri = request.getRequestURI(); // token 令牌,定位到某一个用户 String header = request.getHeader(HEADER); // 处理请求 key String cacheKey = REPEAT_SUBMIT_KEY + uri + header.replace("Bearer", ""); // 查询 redis 中是否存在数据,如果存在进行比较 Object cacheObject = redisCache.getCacheObject(cacheKey); if (!ObjectUtils.isEmpty(cacheObject)) { Map<String, Object> map = (Map<String, Object>) cacheObject; // 取出存储的时间和区间段进行比较是否重复提交 if (compareParams(map, nowDataMap) && compareTime(map, nowDataMap, repeatSubmit.interval())) { return true; } } // 否则我们将数据存储起来 redisCache.setCacheObject(cacheKey, nowDataMap, repeatSubmit.interval(), TimeUnit.MILLISECONDS); return false; } /** * 比较参数是否一样 * * @param map * @param nowDataMap * @return */ private boolean compareParams(Map<String, Object> map, Map<String, Object> nowDataMap) { String nowParams = (String) nowDataMap.get(REPEAT_PARAMS); String dataParams = (String) map.get(REPEAT_PARAMS); return nowParams.equals(dataParams); } /** * 时间比较 * * @param map * @param nowDataMap * @param interval * @return */ private boolean compareTime(Map<String, Object> map, Map<String, Object> nowDataMap, int interval) { Long nowTime = (Long)nowDataMap.get(REPEAT_TIME); Long dataTime = (Long)map.get(REPEAT_TIME); return nowTime-dataTime<interval ? true : false; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } }


定义可重复度的请求类:

package org.javaboy.repeat_submit.request;

import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import java.io.*;

/**
 * @author: yueLQ
 * @date: 2022-09-07 20:00
 * 

* 可重复度的请求 *

* 装饰者模式将 httpServletRequest 处理一下 */ public class RepeatableReadRequest extends HttpServletRequestWrapper { // 定义一个数组,将读出来的数据转换为 byte 数组 private final byte[] bytes; public RepeatableReadRequest(HttpServletRequest request, HttpServletResponse response) throws IOException { super(request); // 设置编码格式 request.setCharacterEncoding("UTF-8" ); // 设置编码格式 response.setCharacterEncoding("UTF-8" ); // 按行读取字节数组 bytes = request.getReader().readLine().getBytes(); } /** * 获取 io 流的方法 * @return * @throws IOException */ @Override public BufferedReader getReader() throws IOException { BufferedReader reader = new BufferedReader(new InputStreamReader(getInputStream())); return reader; } /** * 从 byte 数组中返回 inputStream * @return * @throws IOException */ @Override public ServletInputStream getInputStream() throws IOException { ByteArrayInputStream bis = new ByteArrayInputStream(bytes); return new ServletInputStream() { /** * 返回长度 * @return * @throws IOException */ @Override public int available() throws IOException { return bytes.length; } @Override public boolean isFinished() { return false; } @Override public boolean isReady() { return false; } @Override public void setReadListener(ReadListener readListener) { } /** * 读取 * @return * @throws IOException */ @Override public int read() throws IOException { return bis.read(); } }; } }


定义过滤器,将请求参数设置为可以重复读取

package org.javaboy.repeat_submit.filter;

import org.javaboy.repeat_submit.request.RepeatableReadRequest;
import org.springframework.util.StringUtils;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author: yueLQ
 * @date: 2022-09-07 20:12
 *
 */
public class RepeatableRequestFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        // 获取请求头的信息, 请求头是 json 格式的
        if (StringUtils.startsWithIgnoreCase(request.getContentType(),"application/json")){
            // 将请求转换为装饰者模式处理过的格式
            RepeatableReadRequest readRequest = new RepeatableReadRequest(request, (HttpServletResponse) servletResponse);
            filterChain.doFilter(readRequest,servletResponse);
            return;
        }
        filterChain.doFilter(servletRequest,servletResponse);
    }
}


创建 web 配置类:

package org.javaboy.repeat_submit.config;

import org.javaboy.repeat_submit.filter.RepeatableRequestFilter;
import org.javaboy.repeat_submit.interceptor.RepeatSubmitInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @author: yueLQ
 * @date: 2022-09-07 19:46
 *
 * 将我们定义好的拦截器注册进来,
 *
 * 过滤器先执行,拦截器后执行,拦截器是在 DispatchServlet
 * 确保过滤器先执行,将请求参数替换过来,然后我们在拦截器后面处理就可以了。
 * 如果两个都是在过滤器中处理,要设置优先级的问题
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    RepeatSubmitInterceptor submitInterceptor;

    /**
     *  将自定义拦截器注册进来
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 添加拦截器,并且添加拦截规则,/** 拦截所有请求
        registry.addInterceptor(submitInterceptor).addPathPatterns("/**");
    }

    /**
     * 配置过滤器的 bean
     * @return
     */
    @Bean
    FilterRegistrationBean<RepeatableRequestFilter> repeatableRequestFilterFilterRegistrationBean(){
        FilterRegistrationBean<RepeatableRequestFilter> bean = new FilterRegistrationBean<>();
        bean.setFilter(new RepeatableRequestFilter());
        // 拦截所有请求
        bean.addUrlPatterns("/*");
        return bean;
    }
}


定义注解 RepeatSubmit

package org.javaboy.repeat_submit.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author: yueLQ
 * @date: 2022-09-14 19:23
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {

    /**
     * 两个请求之间的间隔时间
     *
     * @return
     */
    int interval() default 5000;

    /**
     * 重复提交的提示文本
     *
     * @return
     */
    String message() default "不允许重复提交,请稍后再试!";
}


封装 redis 工具类:

package org.javaboy.repeat_submit.redis;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
 * @author: yueLQ
 * @date: 2022-09-14 19:15
 * 

* 封装 redis */ @Component public class RedisCache { @Autowired RedisTemplate redisTemplate; /** * 存储数据 * * @param key * @param val * @param timeOut 超时时间 * @param timeUnit 时间单位 * @param */ public <T> void setCacheObject(final String key, final T val, Integer timeOut, final TimeUnit timeUnit) { redisTemplate.opsForValue().set(key, val, timeOut, timeUnit); } /** * 获取数据 * * @param key * @param * @return */ public <T> T getCacheObject(final String key) { ValueOperations<String, T> ops = redisTemplate.opsForValue(); return ops.get(key); } }


测试:

package org.javaboy.repeat_submit.controller;

import org.javaboy.repeat_submit.annotation.RepeatSubmit;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author: yueLQ
 * @date: 2022-09-07 19:42
 */
@RestController
public class HelloController {

    @PostMapping("/hello")
    @RepeatSubmit(interval = 10000)
    public String hello(@RequestBody String hello){
        return hello;
    }
}

接口幂等性(防止接口重复提交)_第4张图片

你可能感兴趣的:(java,redis,spring,boot,redis,数据库,缓存)