一种解决海量重复提交问题的方案(以Java语言为例,使用SpringBoot + Redis实现)

一、前言及原理分析

在实际的项目开发中,对于支持对外访问的接口,很多时候会出现被多次请求的情况,而这些多余的请求可能会对数据库中的数据产生多次影响,导致产异常数据,我们是不希望发生的,因此提出了幂等的概念,所谓幂等,即任意多次执行所产生的影响均与一次执行产生的影响相同。换言之,多次的请求对数据库的影响只能是一次性的,不能重复处理。关于如何保证接口幂等性,通常情况有如下几种方式:

  • 数据库建立唯一索引,可以保证最终插入的数据只有一条
  • 悲观锁或者乐观锁的方式,悲观锁可以保证每次for update时,其他sql无法update(若数据库引擎时innodb的时候,select的条件必须是唯一索引,防止锁全表)
  • 先查询,后判断,受限通过查询数据库中是否存在相应数据,如果存在,则说明已经请求过了,直接拒绝请求即可,如果不存在,直接放行
  • 通过token机制,每次请求接口前先获取一个token,然后下次请求时,在请求头中携带token,后台进行认证,如果认证通过了,放行并删除token,下次请求重复上述操作

暂以token机制的方式描述接口幂等性,如图,通过Redis实现幂等的简单原理图:
在这里插入图片描述
演示环境不再做详细说明:以Java语言为例,采用SpringBoot和Redis

二、搭建环境并封装Redis工具类

  • Maven依赖
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
  • 环境配置(application.properties)
server.port=12001
spring.redis.host=192.168.56.10
  • Redis工具类封装,可有可无
package com.ideax.idempotence.utils;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

/**
 * redis工具类
 *
 * @author zhangxs
 **/
@Component
public class RedisUtils {
    /** @ Autowired默认按照类型装配的。也就是说,要获取RedisTemplate<String, Object>的Bean,要根据名字装配。那么就使用@Resource,它默认按照名字装配 */
    @Resource
    public RedisTemplate<String, Object> redisTemplate;

    /** 如果有需要,把StringRedisTemplate也可以注入 */
    private final StringRedisTemplate stringRedisTemplate;
    public RedisUtils(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 根据key取value
     * @param key k
     * @return java.lang.Object
     * @author zhangxs
     * @date 2021-11-26 16:10
     */
    public Object get(final String key) {
        return redisTemplate.opsForValue().get(key);
    }

    /**
     * 写入缓存时设置过期时间
     * @param key k
     * @param value v
     * @param timeout 超时时间
     * @return boolean
     * @author zhangxs
     * @date 2021-11-26 15:56
     */
    public boolean setExpire(final String key, Object value, Long timeout) {
        boolean result = false;
        try {
            redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    /**
     * 写入缓存
     * @param key k
     * @param value v
     * @return boolean
     * @author zhangxs
     * @date 2021-11-26 15:57
     */
    public boolean set(final String key, Object value) {
        boolean result = false;
        try {
            redisTemplate.opsForValue().set(key, value);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    /**
     * 根据key取value
     * @param key k
     * @return boolean
     * @author zhangxs
     * @date 2021-11-26 15:58
     */
    public boolean getStringValue(final String key, String value) {
        boolean result = false;
        try {
            stringRedisTemplate.opsForValue().get(key);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    /**
     * 将String类型的value写入缓存
     * @param key k
     * @param value String类型的v
     * @return boolean
     * @author zhangxs
     * @date 2021-11-26 15:58
     */
    public boolean setStringValue(final String key, String value) {
        boolean result = false;
        try {
            stringRedisTemplate.opsForValue().set(key, value);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    /**
     * 将String类型的value写入缓存,并设置过期时间
     * @param key k
     * @param value String类型的v
     * @param timeout 超时时间
     * @return boolean
     * @author zhangxs
     * @date 2021-11-26 15:58
     */
    public boolean setStringValueExpire(final String key, String value, Long timeout) {
        boolean result = false;
        try {
            stringRedisTemplate.opsForValue().set(key, value, timeout);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    /**
     * 判断是否存在key对应的value
     * @param key k
     * @return boolean
     * @author zhangxs
     * @date 2021-11-26 16:08
     */
    public boolean exists(final String key) {
        // 不能直接通过redisTemplate.hasKey(key)获取结果去判断,拆箱时有可能空指针异常
        return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key));
    }

    /**
     * 根据key删除key-value
     * @param key k
     * @return boolean
     * @author zhangxs
     * @date 2021-11-26 16:13
     */
    public boolean remove(final String key) {
        if (exists(key)) {
            // 不能直接通过redisTemplate.delete(key)获取结果去判断,拆箱时有可能空指针异常
            return Boolean.TRUE.equals(stringRedisTemplate.delete(key));
        }
        return false;
    }
}

三、自定义注解@Idempotent

自定义一个注解,该注解将会在有幂等性要求的接口上标注,为啥叫@Idempotent这个名字,是由于可读性考虑,采用了幂等俩字的英文单词,您随意。后续将通过反射扫描到这个注解,处理对应请求,实现幂等效果,注解定义如下:

package com.ideax.idempotence.annotation;

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

/**
 * 自定义幂等注解
 *
 * @author zhangxs
 **/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

}

四、创建和校验token

模拟一个token服务,包含两个接口及其实现类,一个用来创建token,一个用来验证token是否正确。创建token暂且简单用一个字符串作为token,并设置一个过期时间。验证token时,我们需要通过请求对象获取请求头信息,进而获取token信息,代码如下:

  • 接口:
package com.ideax.idempotence.service;

import javax.servlet.http.HttpServletRequest;

/**
 * Token 服务接口
 *
 * @author zhangxs
 **/
public interface TokenService {
    /**
     * 创建token
     * @return java.lang.String
     * @author zhangxs
     * @date 2021-11-26 16:19
     */
    String createToken();

    /**
     * 校验token
     * @param request 请求
     * @return boolean
     * @author zhangxs
     * @date 2021-11-26 16:19
     */
    boolean checkToken(HttpServletRequest request);
}
  • 实现类
package com.ideax.idempotence.service.impl;

import com.ideax.idempotence.service.TokenService;
import com.ideax.idempotence.utils.RedisUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import java.util.UUID;

/**
 * Token 服务接口实现类
 *
 * @author zhangxs
 **/
@Service
public class TokenServiceImpl implements TokenService {
    private final RedisUtils redisUtils;

    public TokenServiceImpl(RedisUtils redisUtils) {
        this.redisUtils = redisUtils;
    }

    @Override
    public String createToken() {
        String token = UUID.randomUUID().toString().replaceAll("-", "");
        redisUtils.setStringValueExpire("idempotent:cache:token:" + token, token, 20000L);
        return token;
    }

    @Override
    public boolean checkToken(HttpServletRequest request) {
        String token = request.getHeader("token");
        if (StringUtils.isEmpty(token)) {
            token = request.getParameter("token");
            if (StringUtils.isEmpty(token)) {
                throw new RuntimeException("不正确的token参数!");
            }
        }
        
        final String key = "idempotent:cache:token:" + token;

        if (!redisUtils.exists(key)) {
            throw new RuntimeException("重复性操作!");
        }

        boolean tag = redisUtils.remove(key);
        if (!tag) {
            throw new RuntimeException("重复性操作!");
        }
        return true;
    }
}

五、配置拦截器

  • 定义拦截器,拦截请求后,通过反射机制,扫描到@Idempotent注解标注的接口,通过token服务中的checkToken()方法校验token是否正确,若出现异常,则捕获并渲染JSON数据返回给前端,代码如下:
package com.ideax.idempotence.interceptor;

import com.ideax.idempotence.annotation.Idempotent;
import com.ideax.idempotence.service.TokenService;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
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;

/**
 * 幂等拦截器
 *
 * @author zhangxs
 **/
@Component
public class IdempotentInterceptor implements HandlerInterceptor {
    private final TokenService tokenService;

    public IdempotentInterceptor(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @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();

        // 扫描被@Idempotent标记的方法
        Idempotent annotation = method.getAnnotation(Idempotent.class);
        if (annotation != null) {
            try {
                return tokenService.checkToken(request);
            } catch (Exception e) {
                printResult(response, e.getMessage());
                return false;
            }
        }

        return true;
    }

    @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 {
    }

    private void printResult(HttpServletResponse response, String message) {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=utf-8");
        try (PrintWriter writer = response.getWriter()) {
            writer.print(message);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
  • 注册定义好的拦截器,我们需要定义一个Web配置类,并将拦截器注册,在启动时可以将其加载到context中,代码如下:
package com.ideax.idempotence.config;

import com.ideax.idempotence.interceptor.IdempotentInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;

import javax.annotation.Resource;

/**
 * web配置类
 *
 * @author zhangxs
 **/
@Configuration
public class WebConfiguration extends WebMvcConfigurationSupport {
    @Resource
    private IdempotentInterceptor idempotentInterceptor;

    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(idempotentInterceptor);
        super.addInterceptors(registry);
    }
}

六、测试

这次不用postman了,用一个更牛逼的apipost测试,先模拟一个业务场景,比如商品添加操作,我们并不希望同一个商品添加进来多个请求,因此需要对商品添加接口保证幂等性,代码如下:

package com.ideax.idempotence.controller;

import com.ideax.idempotence.annotation.Idempotent;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

/**
 * 商品 前端控制器
 *
 * @author zhangxs
 **/
@RestController
@RequestMapping("/product")
public class ProductController {

    @GetMapping("/{id}")
    public ResponseEntity<Map<String, Object>> get(@PathVariable("id") int id) {
        Map<String,Object> map = new HashMap<>(10);
        map.put("id", id);
        map.put("serial", UUID.randomUUID().toString().replaceAll("-", ""));
        map.put("name", id + "手机");
        return ResponseEntity.ok(map);
    }

    @Idempotent
    @PostMapping
    public ResponseEntity<Map<String, Object>> save(@RequestBody Map<String, Object> map) {
        return ResponseEntity.ok(map);
    }
}

上面说到了,在请求时,需要在请求头中携带token,所以在此之前,先获取一个token,代码如下:

package com.ideax.idempotence.controller;

import com.ideax.idempotence.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * token 前端控制器
 *
 * @author zhangxs
 **/
@RestController
@RequestMapping("token")
public class TokenController {
    @Autowired
    private TokenService tokenService;

    @GetMapping
    public ResponseEntity<String> getToken() {
        return ResponseEntity.ok(tokenService.createToken());
    }
}
  • 获取token
    在这里插入图片描述
  • 携带token进行第一次商品添加请求,返回请求成功的信息
    在这里插入图片描述
    在这里插入图片描述
  • 携带token进行第二次商品添加请求,请求失败,返回重复操作提示
    在这里插入图片描述

七、总结

接口幂等性的保证在实际开发中是非常重要的环节,一个接口被无数客户端访问时,保证幂等性,将保证其操作不影响后台业务处理,数据只影响一次,防止产生脏乱数据,同时在一定程度上还可以减少并发量。

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值