瞬间几千次的重复提交,我用 Spring Boot + Redis 扛住了(实现接口自动幂等)


使用 Spring Boot + Redis + Interceptor + 自定义 Annotation 实现接口自动幂等,解决接口重复提交问题。转载自: https://www.jianshu.com/p/c806003a8530

1 前言

在实际的开发项目中,一个对外暴露的接口往往会面临很多次请求,我们来解释一下幂等的概念:任意多次执行所产生的影响均与一次执行的影响相同。按照这个含义,最终的含义就是 对数据库的影响只能是一次性的,不能重复处理。如何保证其幂等性,通常有以下手段:

  1. 数据库建立唯一性索引,可以保证最终插入数据库的只有一条数据
  2. token机制,每次接口请求前先获取一个token,然后再下次请求的时候在请求的header体中加上这个token,后台进行验证,如果验证通过删除token,下次请求再次判断token
  3. 悲观锁或者乐观锁,悲观锁可以保证每次for update的时候其他sql无法update数据(在数据库引擎是innodb的时候,select的条件必须是唯一索引,防止锁全表)
  4. 先查询后判断,首先通过查询数据库是否存在数据,如果存在证明已经请求过了,直接拒绝该请求,如果没有存在,就证明是第一次进来,直接放行。

redis实现自动幂等的原理图:

2 搭建redis的服务Api

  • 首先是搭建redis服务器。
  • 引入springboot中到的redis的stater,或者Spring封装的jedis也可以,后面主要用到的api就是它的set方法和exists方法,这里我们使用springboot的封装好的redisTemplate
/**
 * redis工具类
 */
@Component
public class RedisService {

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 写入缓存
     * @param key
     * @param value
     * @return
     */
    public boolean set(final String key, Object value) {
        boolean result = false;
        try {
            ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
            operations.set(key, value);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }


    /**
     * 写入缓存设置时效时间
     * @param key
     * @param value
     * @return
     */
    public boolean setEx(final String key, Object value, Long expireTime) {
        boolean result = false;
        try {
            ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
            operations.set(key, value);
            redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }


    /**
     * 判断缓存中是否有对应的value
     * @param key
     * @return
     */
    public boolean exists(final String key) {
        return redisTemplate.hasKey(key);
    }

    /**
     * 读取缓存
     * @param key
     * @return
     */
    public Object get(final String key) {
        Object result = null;
        ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
        result = operations.get(key);
        return result;
    }

    /**
     * 删除对应的value
     * @param key
     */
    public boolean remove(final String key) {
        if (exists(key)) {
            Boolean delete = redisTemplate.delete(key);
            return delete;
        }
        return false;

    }

}

3 自定义注解AutoIdempotent

自定义一个注解,定义此注解的主要目的是把它添加在需要实现幂等的方法上,凡是某个方法注解了它,都会实现自动幂等。后台利用反射如果扫描到这个注解,就会处理这个方法实现自动幂等,使用元注解ElementType.METHOD表示它只能放在方法上,etentionPolicy.RUNTIME表示它在运行时

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {
  
}

4 token创建和检验

  • token服务接口

我们新建一个接口,创建token服务,里面主要是两个方法,一个用来创建token,一个用来验证token。创建token主要产生的是一个字符串,检验token的话主要是传达request对象,为什么要传request对象呢?主要作用就是获取header里面的token,然后检验,通过抛出的Exception来获取具体的报错信息返回给前端

public interface TokenService {

    /**
     * 创建token
     * @return
     */
    public  String createToken();

    /**
     * 检验token
     * @param request
     * @return
     */
    public boolean checkToken(HttpServletRequest request) throws Exception;

}
  • token的服务实现类

token引用了redis服务,创建token采用随机算法工具类生成随机uuid字符串,然后放入到redis中(为了防止数据的冗余保留,这里设置过期时间为10000秒,具体可视业务而定),如果放入成功,最后返回这个token值。checkToken方法就是从header中获取token到值(如果header中拿不到,就从paramter中获取),如若不存在,直接抛出异常。这个异常信息可以被拦截器捕捉到,然后返回给前端。

@Service
public class TokenServiceImpl implements TokenService {

    @Autowired
    private RedisService redisService;


    /**
     * 创建token
     *
     * @return
     */
    @Override
    public String createToken() {
        String str = RandomUtil.randomUUID();
        StrBuilder token = new StrBuilder();
        try {
            token.append(Constant.Redis.TOKEN_PREFIX).append(str);
            redisService.setEx(token.toString(), token.toString(),10000L);
            boolean notEmpty = StrUtil.isNotEmpty(token.toString());
            if (notEmpty) {
                return token.toString();
            }
        }catch (Exception ex){
            ex.printStackTrace();
        }
        return null;
    }


    /**
     * 检验token
     *
     * @param request
     * @return
     */
    @Override
    public boolean checkToken(HttpServletRequest request) throws Exception {

        String token = request.getHeader(Constant.TOKEN_NAME);
        if (StrUtil.isBlank(token)) {// header中不存在token
            token = request.getParameter(Constant.TOKEN_NAME);
            if (StrUtil.isBlank(token)) {// parameter中也不存在token
                throw new ServiceException(Constant.ResponseCode.ILLEGAL_ARGUMENT, 100);
            }
        }

        if (!redisService.exists(token)) {
            throw new ServiceException(Constant.ResponseCode.REPETITIVE_OPERATION, 200);
        }

        boolean remove = redisService.remove(token);
        if (!remove) {
            throw new ServiceException(Constant.ResponseCode.REPETITIVE_OPERATION, 200);
        }
        return true;
    }
}

5 拦截器的配置

  • web配置类,实现WebMvcConfigurerAdapter,主要作用就是添加autoIdempotentInterceptor到配置类中,这样我们到拦截器才能生效,注意使用@Configuration注解,这样在容器启动是时候就可以添加进入context中
@Configuration
public class WebConfiguration extends WebMvcConfigurerAdapter {

    @Resource
   private AutoIdempotentInterceptor autoIdempotentInterceptor;

    /**
     * 添加拦截器
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(autoIdempotentInterceptor);
        super.addInterceptors(registry);
    }
}
  • 拦截处理器:主要的功能是拦截扫描到AutoIdempotent到注解到方法,然后调用tokenService的checkToken()方法校验token是否正确,如果捕捉到异常就将异常信息渲染成json返回给前端
/**
 * 拦截器
 */
@Component
public class AutoIdempotentInterceptor implements HandlerInterceptor {

    @Autowired
    private TokenService tokenService;

    /**
     * 预处理
     *
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @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();
        //被ApiIdempotment标记的扫描
        AutoIdempotent methodAnnotation = method.getAnnotation(AutoIdempotent.class);
        if (methodAnnotation != null) {
            try {
                return tokenService.checkToken(request);// 幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示
            }catch (Exception ex){
                ResultVo failedResult = ResultVo.getFailedResult(101, ex.getMessage());
                writeReturnJson(response, JSONUtil.toJsonStr(failedResult));
                throw ex;
            }
        }
        //必须返回true,否则会被拦截一切请求
        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 {

    }

    /**
     * 返回的json值
     * @param response
     * @param json
     * @throws Exception
     */
    private void writeReturnJson(HttpServletResponse response, String json) throws Exception{
        PrintWriter writer = null;
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=utf-8");
        try {
            writer = response.getWriter();
            writer.print(json);

        } catch (IOException e) {
        } finally {
            if (writer != null)
                writer.close();
        }
    }

}

6 测试用例

  • 模拟业务请求类

首先我们需要通过/get/token路径通过getToken()方法去获取具体的token,然后我们调用testIdempotence方法,这个方法上面注解了@AutoIdempotent,拦截器会拦截所有的请求,当判断到处理的方法上面有该注解的时候,就会调用TokenService中的checkToken()方法,如果捕获到异常会将异常抛出调用者,下面我们来模拟请求一下:

@RestController
public class BusinessController {


    @Resource
    private TokenService tokenService;

    @Resource
    private TestService testService;


    @PostMapping("/get/token")
    public String  getToken(){
        String token = tokenService.createToken();
        if (StrUtil.isNotEmpty(token)) {
            ResultVo resultVo = new ResultVo();
            resultVo.setCode(Constant.code_success);
            resultVo.setMessage(Constant.SUCCESS);
            resultVo.setData(token);
            return JSONUtil.toJsonStr(resultVo);
        }
        return StrUtil.EMPTY;
    }


    @AutoIdempotent
    @PostMapping("/test/Idempotence")
    public String testIdempotence() {
        String businessResult = testService.testIdempotence();
        if (StrUtil.isNotEmpty(businessResult)) {
            ResultVo successResult = ResultVo.getSuccessResult(businessResult);
            return JSONUtil.toJsonStr(successResult);
        }
        return StrUtil.EMPTY;
    }
}
  • 使用postman请求

首先访问get/token路径获取到具体到token:

利用获取到到token,然后放到具体请求到header中,可以看到第一次请求成功,接着我们请求第二次:

第二次请求,返回到是重复性操作,可见重复性验证通过,再多次请求到时候我们只让其第一次成功,第二次就是失败:

7 总结

本篇博客介绍了使用springboot和拦截器、redis来优雅的实现接口幂等,对于幂等在实际的开发过程中是十分重要的,因为一个接口可能会被无数的客户端调用,如何保证其不影响后台的业务处理,如何保证其只影响数据一次是非常重要的,它可以防止产生脏数据或者乱数据,也可以减少并发量,实乃十分有益的一件事。而传统的做法是每次判断数据,这种做法不够智能化和自动化,比较麻烦。而今天的这种自动化处理也可以提升程序的伸缩性。



扫码关注微信公众号 程序员35 ,获取最新技术干货,畅聊 #程序员的35,35的程序员# 。独立站点:https://cxy35.com

<think>我们面临的任务是:在Spring Boot和Thymeleaf框架下,结合jQuery实现一次性加载所有省市县数据的方法。 根据引用[1][2][3],我们知道如何整合Thymeleaf,并且引用[5]展示了Thymeleaf中表单处理的基本结构。 但是,用户的需求是使用jQuery一次性加载所有省市县数据。这意味着我们需要: 1. 后端提供一个接口,返回所有省市县数据(通常为三级联动数据)。 2. 前端使用Thymeleaf模板,并在页面中使用jQuery发送AJAX请求(或直接在页面中嵌入数据)来获取数据,然后填充到下拉框中。 然而,用户要求“一次性加载所有省市县数据”,所以我们可以考虑两种方式: a) 在页面加载时,通过AJAX请求获取所有数据,然后使用jQuery动态生成下拉选项。 b) 在页面渲染时,将数据直接嵌入到页面中(例如作为JavaScript变量),然后使用jQuery操作DOM填充下拉框。 由于数据量可能较大(成百上千条),使用AJAX请求可能会在页面加载后再有延迟,而直接嵌入则可能增加页面体积,但减少了请求次数。用户要求一次性加载,所以我们两种方式都可以实现,但需要权衡。 考虑到引用[4]中提到了Thymeleaf的模板配置,我们可以利用Thymeleaf将数据直接嵌入到页面中,避免首次加载后再发请求,这样用户体验更好(一次加载完成)。 步骤: 1. 后端准备数据:从数据库或其他数据源获取省市县数据,并组织成树形结构(例如JSON格式)。 2. 在Controller中,将数据添加到Model中,以便在Thymeleaf模板中使用。 3. 在Thymeleaf模板中,使用JavaScript将数据嵌入到页面中(例如放在一个script标签中)。 4. 使用jQuery在页面加载完成后,根据嵌入的数据动态生成下拉框的选项。 但是,如果数据量非常大(比如几千条),直接嵌入到页面中可能会影响页面加载速度。因此,我们也可以考虑使用异步加载,但用户要求一次性加载,所以这里我们按照用户要求,一次性加载所有数据。 下面我们详细说明: 第一步:后端准备数据 - 假设我们有一个服务类(如AreaService)能够获取所有地区数据,并转换成JSON字符串。 - 在Controller中,我们将这个数据作为Model属性传递给视图。 第二步:在Thymeleaf页面中,我们使用以下方式嵌入数据: ```html <script th:inline="javascript"> var areaData = /*[[${areaJson}]]*/ 'default'; </script> ``` 这里使用了Thymeleaf的内联表达式,将后端传递的areaJson(一个JSON字符串)赋值给JavaScript变量areaData。 第三步:使用jQuery生成下拉框 - 我们假设有三个下拉框:省(province)、市(city)、县(district)。 - 页面加载完成后,用jQuery解析areaData,填充省份下拉框。 - 当省份改变时,填充对应的市;当市改变时,填充对应的县。 但是,用户要求一次性加载所有数据,所以我们不需要再发请求,所有数据都在areaData变量中。 具体实现: 1. 后端Controller示例: ```java @Controller public class AreaController { @Autowired private AreaService areaService; @GetMapping("/register") public String register(Model model) { // 获取所有地区数据,并转换成树形结构的JSON字符串 // 假设areaService.getAllAreas()返回一个List<Area>,然后我们转换成JSON List<Area> areas = areaService.getAllAreas(); // 使用Gson或Jackson将areas转换为JSON字符串 ObjectMapper mapper = new ObjectMapper(); String areaJson = mapper.writeValueAsString(areas); model.addAttribute("areaJson", areaJson); return "register"; // 返回Thymeleaf模板名称 } } ``` 2. 前端页面(register.html)中,我们引入jQuery,然后嵌入数据并编写脚本: ```html <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>注册</title> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> </head> <body> <form> <select id="province"> <option value="">请选择省</option> </select> <select id="city"> <option value="">请选择市</option> </select> <select id="district"> <option value="">请选择县/区</option> </select> </form> <script th:inline="javascript"> // 将后端传递的JSON字符串转换为JavaScript对象 var areaData = /*[[${areaJson}]]*/ {}; </script> <script> $(document).ready(function() { // 解析数据:假设areaData是一个数组,每个省份对象有children数组表示市,每个市又有children数组表示县 // 首先,填充省份 $.each(areaData, function(index, province) { $('#province').append($('<option>', { value: province.id, text: province.name })); }); // 省份改变时,填充市 $('#province').change(function() { var provinceId = $(this).val(); $('#city').empty().append('<option value="">请选择市</option>'); $('#district').empty().append('<option value="">请选择县/区</option>'); if (!provinceId) return; // 在areaData中查找当前选中的省 var selectedProvince = areaData.find(function(p) { return p.id == provinceId; }); if (selectedProvince && selectedProvince.children) { $.each(selectedProvince.children, function(i, city) { $('#city').append($('<option>', { value: city.id, text: city.name })); }); } }); // 市改变时,填充县 $('#city').change(function() { var cityId = $(this).val(); $('#district').empty().append('<option value="">请选择县/区</option>'); if (!cityId) return; // 先找到选中的省,再找到选中的市 var provinceId = $('#province').val(); var selectedProvince = areaData.find(function(p) { return p.id == provinceId; }); if (selectedProvince && selectedProvince.children) { var selectedCity = selectedProvince.children.find(function(c) { return c.id == cityId; }); if (selectedCity && selectedCity.children) { $.each(selectedCity.children, function(j, district) { $('#district').append($('<option>', { value: district.id, text: district.name })); }); } } }); }); </script> </body> </html> ``` 注意:上述代码中,我们假设后端返回的areaData是一个省份数组,每个省份对象包含id、name和一个children属性(数组),children数组中的每个元素是市,每个市对象又包含id、name和children属性(数组)表示县。 但是,如果数据量很大,这种查找方式可能效率不高(使用Array.find在大量数据时可能较慢),我们可以考虑将数据预处理成字典(以id为键)以提高查找速度。但考虑到三级数据量(省一般30多个,市几百个,县几千个),在客户端使用Array.find是可以接受的。 另外,我们也可以在后端将数据组织成前端需要的格式,例如: { "provinces": [...], "cities": { "provinceId1": [...], ... }, "districts": { "cityId1": [...], ... } } 这样前端查找起来更快。但为了简单,我们使用树形结构。 性能考虑:由于数据量不大(中国省市县数据,省30多个,市300多个,县2000多个),所以一次性加载并嵌入页面是可行的。整个JSON字符串的大小大约在几百KB,经过Gzip压缩后会更小。 替代方案:如果数据量确实很大,我们可以使用异步加载,但用户要求一次性加载,所以我们采用上述方案。 总结:在Spring Boot和Thymeleaf中,我们可以通过将省市县数据以JSON格式嵌入到页面中,然后使用jQuery动态生成下拉框选项,实现三级联动。这样避免了多次请求,用户体验较好。 引用说明:本文参考了Spring Boot整合Thymeleaf的基本方法[^1][^2][^3],并在前端使用了jQuery动态操作DOM。 §§相关问题§§ 1. 如何优化大量数据在前端的渲染性能? 2. 除了使用内联JavaScript嵌入数据,还有哪些方法可以在Thymeleaf中传递数据给前端JavaScript? 3. 如果省市县数据需要从数据库动态加载,如何设计数据库表结构?</think>在 Spring Boot 和 Thymeleaf 框架下,结合 jQuery 实现一次性加载所有省市县数据的方法,可以通过以下步骤实现: ### 实现步骤 #### 1. **后端数据准备** ```java // 实体类 public class Area { private Integer id; private String name; private Integer parentId; // 省略 getter/setter } // Controller @Controller public class AreaController { @Autowired private AreaService areaService; @GetMapping("/areas") @ResponseBody public List<Area> getAllAreas() { return areaService.getAllAreas(); // 获取所有省市县数据 } } ``` #### 2. **前端数据加载(Thymeleaf + jQuery)** ```html <!-- 在HTML头部引入jQuery --> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <!-- 省市区下拉框 --> <select id="province"> <option value="">请选择省份</option> </select> <select id="city"> <option value="">请选择城市</option> </select> <select id="district"> <option value="">请选择区县</option> </select> <script th:inline="javascript"> $(document).ready(function() { // 一次性加载所有数据 $.get("/areas", function(allAreas) { // 1. 组织数据为树形结构 const areaMap = {}; allAreas.forEach(area => { if (!areaMap[area.parentId]) { areaMap[area.parentId] = []; } areaMap[area.parentId].push(area); }); // 2. 填充省份(parentId=0为顶级) areaMap[0].forEach(province => { $('#province').append( `<option value="${province.id}">${province.name}</option>` ); }); // 3. 绑定省份变更事件 $('#province').change(function() { const provinceId = $(this).val(); $('#city').html('<option value="">请选择城市</option>'); $('#district').html('<option value="">请选择区县</option>'); if (provinceId) { // 填充城市 areaMap[provinceId]?.forEach(city => { $('#city').append( `<option value="${city.id}">${city.name}</option>` ); }); } }); // 4. 绑定城市变更事件 $('#city').change(function() { const cityId = $(this).val(); $('#district').html('<option value="">请选择区县</option>'); if (cityId) { // 填充区县 areaMap[cityId]?.forEach(district => { $('#district').append( `<option value="${district.id}">${district.name}</option>` ); }); } }); }); }); </script> ``` #### 3. **关键优化点** 1. **单次请求加载**:通过单个 AJAX 请求获取所有数据,减少网络请求次数 2. **前端数据组织**:将数据转换为树形结构(使用 parentId 建立层级关系) 3. **动态渲染**:使用 jQuery 根据选择结果实时更新下级选项 4. **空值处理**:使用可选链操作符 `?.` 防止空值异常 ### 性能优化建议 - **数据压缩**:后端返回数据时启用 GZIP 压缩 - **缓存策略**:添加 HTTP 缓存头,避免重复请求 - **虚拟滚动**:若数据量极大(>5000条),考虑分步加载 - **数据精简**:只返回必要字段(id/name/parentId) ### 数据库设计参考 ```sql CREATE TABLE areas ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, parent_id INT DEFAULT 0, -- 0表示省份 level TINYINT NOT NULL -- 1:省 2:市 3:县 ); ``` ### 实现效果 1. 页面加载时自动请求所有区域数据 2. 选择省份后自动填充该省下属城市 3. 选择城市后自动填充该市下属区县 4. 完全前端处理联动逻辑,无额外请求 > **提示**:实际部署时,建议将区域数据加入 Redis 缓存,减少数据库压力[^1]。对于国际化场景,可通过 Thymeleaf 的 `#messages` 实现多语言支持[^2]。 --- ###
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值