springboot接口防抖【防重复提交】
什么是防抖
所谓防抖,一是防用户手抖,二是防网络抖动。在Web系统中,表单提交是一个非常常见的功能,如果不加控制,容易因为用户的误操作或网络延迟导致同一请求被发送多次,进而生成重复的数据记录。要针对用户的误操作,前端通常会实现按钮的loading状态,阻止用户进行多次点击。而对于网络波动造成的请求重发问题,仅靠前端是不行的。为此,后端也应实施相应的防抖逻辑,确保在网络波动的情况下不会接收并处理同一请求多次。
一个理想的防抖组件或机制,我觉得应该具备以下特点:
- 逻辑正确,也就是不能误判;
- 响应迅速,不能太慢;
- 易于集成,逻辑与业务解耦;
- 良好的用户反馈机制,比如提示“您点击的太快了”
哪一类接口需要防抖?
一般需要加防抖的接口有这几类:
- 用户输入类接口:比如搜索框输入、表单输入等,用户输入往往会频繁触发接口请求,但是每次触发并不一定需要立即发送请求,可以等待用户完成输入一段时间后再发送请求。
- 按钮点击类接口:比如提交表单、保存设置等,用户可能会频繁点击按钮,但是每次点击并不一定需要立即发送请求,可以等待用户停止点击一段时间后再发送请求。
- 滚动加载类接口:比如下拉刷新、上拉加载更多等,用户可能在滚动过程中频繁触发接口请求,但是每次触发并不一定需要立即发送请求,可以等待用户停止滚动一段时间后再发送请求。
如何确定接口是重复的?
防抖也即防重复提交,那么如何确定两次接口就是重复的呢?首先,我们需要给这两次接口的调用加一个时间间隔,大于这个时间间隔的一定不是重复提交;其次,两次请求提交的参数比对,不一定要全部参数,选择标识性强的参数即可;最后,如果想做的更好一点,还可以加一个请求地址的对比。
分布式部署下如何做接口防抖?
方案一:使用共享缓存
流程图如下:
二、使用分布式锁
流程图:
具体实现
有一个保存用户的接口
@PostMapping("/add")
@RequiresPermissions(value = "add")
@Log(methodDesc = "添加用户")
public ResponseEntity<String> add(@RequestBody AddReq addReq) {return userService.add(addReq);
}
AddReq.java
package com.summo.demo.model.request;
import java.util.List;
import lombok.Data;
@Datapublic class AddReq {/** * 用户名称 */ private String userName;/** * 用户手机号 */ private String userPhone;/** * 角色ID列表 */ private List<Long> roleIdList;}
目前数据库表中没有对userPhone字段做UK索引,这就会导致每调用一次add就会创建一个用户,即使userPhone相同。
请求锁
根据上面的要求,我定了一个注解@RequestLock,使用方式很简单,把这个注解打在接口方法上即可。RequestLock.java
package com.summo.demo.model.request;import java.util.List;import lombok.Data;@Data
public class AddReq {/*** 用户名称*/private String userName;/*** 用户手机号*/private String userPhone;/*** 角色ID列表*/private List<Long> roleIdList;
}
@RequestLock注解定义了几个基础的属性,redis锁前缀、redis锁时间、redis锁时间单位、key分隔符。其中前面三个参数比较好理解,都是一个锁的基本信息。key分隔符是用来将多个参数合并在一起的,比如userName是张三,userPhone是123456,那么完整的key就是"张三&123456",最后再加上redis锁前缀,就组成了一个唯一key。
唯一key生成
这里可能就要说了,直接拿参数来生成key不就行了吗?额,不是不行,但我想问一个问题:如果这个接口是文章发布的接口,你也打算把内容当做key吗?要知道,Redis的效率跟key的大小息息相关。所以,建议是选取合适的字段作为key就行了,没必要全都加上。
要做到参数可选,那么用注解的方式最好了,注解如下RequestKeyParam.java
package com.example.requestlock.lock.annotation;import java.lang.annotation.*;/*** @description 加上这个注解可以将参数设置为key*/
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestKeyParam {}
这个注解加到参数上就行,没有多余的属性。
接下来就是lockKey的生成了,代码如下RequestKeyGenerator.java
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;public class RequestKeyGenerator {/*** 获取LockKey** @param joinPoint 切入点* @return*/public static String getLockKey(ProceedingJoinPoint joinPoint) {//获取连接点的方法签名对象MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();//Method对象Method method = methodSignature.getMethod();//获取Method对象上的注解对象RequestLock requestLock = method.getAnnotation(RequestLock.class);//获取方法参数final Object[] args = joinPoint.getArgs();//获取Method对象上所有的注解final Parameter[] parameters = method.getParameters();StringBuilder sb = new StringBuilder();for (int i = 0; i < parameters.length; i++) {final RequestKeyParam keyParam = parameters[i].getAnnotation(RequestKeyParam.class);//如果属性不是RequestKeyParam注解,则不处理if (keyParam == null) {continue;}//如果属性是RequestKeyParam注解,则拼接 连接符 "& + RequestKeyParam"sb.append(requestLock.delimiter()).append(args[i]);}//如果方法上没有加RequestKeyParam注解if (StringUtils.isEmpty(sb.toString())) {//获取方法上的多个注解(为什么是两层数组:因为第二层数组是只有一个元素的数组)final Annotation[][] parameterAnnotations = method.getParameterAnnotations();//循环注解for (int i = 0; i < parameterAnnotations.length; i++) {final Object object = args[i];//获取注解类中所有的属性字段final Field[] fields = object.getClass().getDeclaredFields();for (Field field : fields) {//判断字段上是否有RequestKeyParam注解final RequestKeyParam annotation = field.getAnnotation(RequestKeyParam.class);//如果没有,跳过if (annotation == null) {continue;}//如果有,设置Accessible为true(为true时可以使用反射访问私有变量,否则不能访问私有变量)field.setAccessible(true);//如果属性是RequestKeyParam注解,则拼接 连接符" & + RequestKeyParam"sb.append(requestLock.delimiter()).append(ReflectionUtils.getField(field, object));}}}//返回指定前缀的keyreturn requestLock.prefix() + sb;}
}
> 由于``@RequestKeyParam``可以放在方法的参数上,也可以放在对象的属性上,所以这里需要进行两次判断,一次是获取方法上的注解,一次是获取对象里面属性上的注解。
重复提交判断
Redis缓存方式
RedisRequestLockAspect.java
import java.lang.reflect.Method;
import com.summo.demo.exception.biz.BizException;
import com.summo.demo.model.response.ResponseCodeEnum;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.util.StringUtils;/*** @description 缓存实现*/
@Aspect
@Configuration
@Order(2)
public class RedisRequestLockAspect {private final StringRedisTemplate stringRedisTemplate;@Autowiredpublic RedisRequestLockAspect(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Around("execution(public * * (..)) && @annotation(com.summo.demo.config.requestlock.RequestLock)")public Object interceptor(ProceedingJoinPoint joinPoint) {MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();Method method = methodSignature.getMethod();RequestLock requestLock = method.getAnnotation(RequestLock.class);if (StringUtils.isEmpty(requestLock.prefix())) {throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "重复提交前缀不能为空");}//获取自定义keyfinal String lockKey = RequestKeyGenerator.getLockKey(joinPoint);// 使用RedisCallback接口执行set命令,设置锁键;设置额外选项:过期时间和SET_IF_ABSENT选项final Boolean success = stringRedisTemplate.execute((RedisCallback<Boolean>)connection -> connection.set(lockKey.getBytes(), new byte[0],Expiration.from(requestLock.expire(), requestLock.timeUnit()),RedisStringCommands.SetOption.SET_IF_ABSENT));if (!success) {throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试");}try {return joinPoint.proceed();} catch (Throwable throwable) {throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系统异常");}}
}
这里的核心代码是stringRedisTemplate.execute里面的内容,正如注释里面说的“使用RedisCallback接口执行set命令,设置锁键;设置额外选项:过期时间和SET_IF_ABSENT选项”,有些同学可能不太清楚SET_IF_ABSENT是个啥,这里我解释一下:SET_IF_ABSENT是 RedisStringCommands.SetOption 枚举类中的一个选项,用于在执行 SET 命令时设置键值对的时候,如果键不存在则进行设置,如果键已经存在,则不进行设置。
Redisson分布式方式
Redisson分布式需要一个额外依赖,引入方式
<dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.10.6</version>
</dependency>
由于我之前的代码有一个RedisConfig,引入Redisson之后也需要单独配置一下,不然会和RedisConfig冲突RedissonConfig.java
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;@Configuration
public class RedissonConfig {@Beanpublic RedissonClient redissonClient() {Config config = new Config();// 这里假设你使用单节点的Redis服务器config.useSingleServer()// 使用与Spring Data Redis相同的地址.setAddress("redis://127.0.0.1:6379");// 如果有密码//.setPassword("xxxx");// 其他配置参数//.setDatabase(0)//.setConnectionPoolSize(10)//.setConnectionMinimumIdleSize(2);// 创建RedissonClient实例return Redisson.create(config);}
}
配好之后,核心代码如下RedissonRequestLockAspect.java
import java.lang.reflect.Method;import com.summo.demo.exception.biz.BizException;
import com.summo.demo.model.response.ResponseCodeEnum;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.util.StringUtils;/*** @description 分布式锁实现*/
@Aspect
@Configuration
@Order(2)
public class RedissonRequestLockAspect {private RedissonClient redissonClient;@Autowiredpublic RedissonRequestLockAspect(RedissonClient redissonClient) {this.redissonClient = redissonClient;}@Around("execution(public * * (..)) && @annotation(com.summo.demo.config.requestlock.RequestLock)")public Object interceptor(ProceedingJoinPoint joinPoint) {MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();Method method = methodSignature.getMethod();RequestLock requestLock = method.getAnnotation(RequestLock.class);if (StringUtils.isEmpty(requestLock.prefix())) {throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "重复提交前缀不能为空");}//获取自定义keyfinal String lockKey = RequestKeyGenerator.getLockKey(joinPoint);// 使用Redisson分布式锁的方式判断是否重复提交RLock lock = redissonClient.getLock(lockKey);boolean isLocked = false;try {//尝试抢占锁isLocked = lock.tryLock();//没有拿到锁说明已经有了请求了if (!isLocked) {throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试");}//拿到锁后设置过期时间lock.lock(requestLock.expire(), requestLock.timeUnit());try {return joinPoint.proceed();} catch (Throwable throwable) {throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系统异常");}} catch (Exception e) {throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试");} finally {//释放锁if (isLocked && lock.isHeldByCurrentThread()) {lock.unlock();}}}
}
Redisson的核心思路就是抢锁,当一次请求抢到锁之后,对锁加一个过期时间,在这个时间段内重复的请求是无法获得这个锁,也不难理解。
测试
第一次提交,“添加用户成功”
短时间内重复提交,“BIZ-0001:您的操作太快了,请稍后重试”
过几秒后再次提交,“添加用户成功”
从测试的结果上看,防抖是做到了,但是随着缓存消失、锁失效,还是可以发起同样的请求,所以要真正做到接口幂等性,还需要业务代码的判断、设置数据库表的UK索引等操作。我在文章里面说到生成唯一key的时候没有加用户相关的信息,比如用户ID、IP属地等,真实生产环境建议加上这些,可以更好地减少误判。
相关文章:

springboot接口防抖【防重复提交】
什么是防抖 所谓防抖,一是防用户手抖,二是防网络抖动。在Web系统中,表单提交是一个非常常见的功能,如果不加控制,容易因为用户的误操作或网络延迟导致同一请求被发送多次,进而生成重复的数据记录。要针对用…...

每日一题——Python实现PAT乙级1026 程序运行时间(举一反三+思想解读+逐步优化)五千字好文
一个认为一切根源都是“自己不够强”的INTJ 个人主页:用哲学编程-CSDN博客专栏:每日一题——举一反三Python编程学习Python内置函数 Python-3.12.0文档解读 目录 我的写法 代码结构和逻辑 时间复杂度 空间复杂度 代码优化建议 总结 我要更强 …...

还在Excel中管理您的持续改进项目吗?
对于大多数公司来说,Microsoft Excel是一种可靠的资源,它确实提供了极高的价值。然而,当它被用来跟踪持续改进项目时,它的价值就减少了。浪费时间从不同内部系统的不同报告中收集数据,会占用推动重要变革的时间。让我们…...

CentOS 7 内存占用过大导致 OOM Killer 杀掉了 Java 进程
说明 Linux进程被杀掉(OOM killer),查看系统日志 oom killer 详解 测试服务器, 有一个 Java 应用, 其进程偶尔会消失掉, 已排除人为杀死的可能 该服务器内存常年处于快被占满的状态, 怀疑是内存原因, 导致服务器主动杀死了该应用的 Java 进程…...

在postgrel中使用hints
在 PostgreSQL 中,可以使用查询提示(Query Hints)来影响查询优化器的行为,但需要注意的是,PostgreSQL 并不像一些商业数据库那样有丰富的提示语法,而是提供了一些基本的方式来引导优化器。 使用查询提示的…...

OceanBase Meetup北京站|跨行业应用场景中的一体化分布式数据库:AI赋能下的探索与实践
随着业务规模的不断扩张和数据处理需求的日益复杂化,传统数据库架构逐渐暴露出业务稳定性波动、扩展性受限、处理效率降低以及运营成本高等一系列问题。众多行业及其业务场景纷纷踏上了数据库现代化升级之路。 为应对这些挑战,7月6日,OceanB…...

Spring Boot:轻松设置全局异常处理
Spring Boot:轻松设置全局异常处理 在软件开发中,异常处理是一项至关重要的任务。对于使用Spring Boot的开发者来说,设置全局异常处理不仅可以提高代码的整洁度,还可以提升用户体验。本文将详细介绍如何在Spring Boot中轻松设置全…...

Omni3D目标检测
Omni3D是一个针对现实场景中的3D目标检测而构建的大型基准和模型体系。该项目旨在推动从单一图像中识别3D场景和物体的能力,这对于计算机视觉领域而言是一个长期的研究目标,并且在机器人、增强现实(AR)、虚拟现实(VR&a…...

前端三件套开发模版——产品介绍页面
今天有空,使用前端三件套html、css、js制作了一个非常简单的产品制作页面,与大家分享,希望可以满足大家应急的需求。本页面可以对产品进行“抢购”、对产品进行介绍,同时可以安排一张产品的高清大图,我也加入了页面的背…...

Android Bitmap 和Drawable的区别
Bitmap 和 Drawable 是 Android 图形绘制的两种常用方式,它们有各自的特点和使用场景。下面将详细解释它们之间的区别,并通过示例代码说明如何使用它们。 Bitmap 解释 Bitmap 是一种用于存储图像像素数据的类,通常用于图像处理和操作。Bit…...

Linux和windows网络配置文件的修改
Linux和windows网络配置文件的修改 网络配置文件是计算机网络管理中至关重要的一部分。正确配置网络文件可以确保计算机与网络设备之间的通信顺畅,避免网络故障。本文将详细介绍网络配置文件的修改方法,包括常见命令、使用方法举例,以及一些…...

【.NET全栈】第16章 Web开发
文章目录 前言16.1 HTML概述16.1.1 HTML的基本概念16.1.2 HTML语言的基本元素16.1.3 格式设置16.1.4 超级链接16.1.5 图像16.1.6 表格16.1.7 框架16.1.8 表单 16.2 ASP.NET Web Forms的组织16.2.1 认识ASP.NET16.2.2 Web Forms的组织 16.3 Web服务器控件16.3.1 使用Label和Text…...

检测水管缺水的好帮手-管道光电液位传感器
管道光电液位传感器是现代清水管道管理中的重要技术创新,不仅提高了检测液位的精确度,还解决了传统机械式和电容式传感器存在的诸多问题,成为检测管道缺水的可靠利器。 该传感器采用先进的光学感应原理,利用红外光学组件通过精密…...

渗透测试流程基本八个步骤
渗透测试遵循软件测试的基本流程,但由于其测试过程与目标的特殊性,在具体实现步骤上渗透测试与常见软件测试并不相同。渗透测试流程主要包括8个步骤,如下图所示: 下面结合上图介绍每一个步骤所要完成的任务。 (1 )明确目标 当测…...

2024年移动手游趋势:休闲类手游收入逆势增长,欧美玩家成为主力
移动手游广告情报平台Sensor Tower近期发布的报告显示,从宏观数据来看,尽管2023年对于移动游戏市场来说是艰难的一年,无论是总下载量亦或是总收入都较去年有所下降,尤其是Google Play。但在总体下降的大趋势下,休闲游戏…...

npm 淘宝镜像证书过期,错误信息 Could not retrieve https://npm.taobao.org/mirrors/node/latest
更换 npm 证书 问题描述报错原因更换步骤1 找到 nvm 安装目录2 发现证书过期3 更换新地址4 保存后,重新安装成功 问题描述 在使用 nvm 安装新版本时,未成功,出现报错: Could not retrieve https://npm.taobao.org/mirrors/node/l…...

axios发送请求,后端无法获取cookie
1.前端 axios默认不携带cookie 添加如下代码 import axios from "axios" const requrst axios.create({baseURL: import.meta.env.VITE_APP_URL,timeout: 5000,}) //让axios携带cookie requrst.defaults.withCredentials true 2.后端nestjs main.ts app.enabl…...

【Spring Boot 源码学习】初识 ConfigurableEnvironment
《Spring Boot 源码学习系列》 初识 ConfigurableEnvironment 一、引言二、主要内容2.1 Environment2.1.1 配置文件(profiles)2.1.2 属性(properties) 2.2 ConfigurablePropertyResolver2.2.1 属性类型转换配置2.2.2 占位符配置2.…...

开关电源中强制连续FCCM模式与轻载高效PSM,PFM模式优缺点对比笔记
文章目录 前言一、连续FCCM模式优点:缺点: 二,轻载高效PSM,PFM优点:缺点: 总结 前言 今天我们来学习下开关电源中,强制连续FCCM模式与轻载高效PSM,PFM模式优缺点对比 一、连续FCCM模式 优点: …...

5分钟教你用AI把老照片动起来,别再去花49块9的冤枉钱了
文章目录 需要的工具 最近,AI视频在各大平台上,又火了。 只是火的形式,变成了将老照片动起来,打情感牌,或者做很多经典电视剧的再整活。 直接把可灵的生成时间,从以前的4分钟,生生的干成了20分钟…...

Ruby 环境变量
Ruby 环境变量 概述 环境变量在编程中扮演着重要的角色,尤其是在Ruby这样的动态编程语言中。它们是操作系统用来存储有关其操作环境的信息的变量,可以在程序运行时影响其行为。Ruby程序可以通过环境变量来获取配置信息、系统细节或用户特定的设置。本文将深入探讨Ruby中环境…...

BPF:BCC工具 funccount 统计内核函数调用(内核函数、跟踪点USDT探针)认知
写在前面 博文内容涉及BCC工具 funccount 认知funccount 可以帮助用户追踪和分析Linux系统上特定函数、系统探针或USDT探针的运行次数。这对于性能分析、故障排查和系统优化等场景非常有用。理解不足小伙伴帮忙指正 😃,生活加油 不必太纠结于当下,也不必…...

DPO算法推导
DPO 核心思想:直接使用偏好数据进行策略优化,省去 reward 模型策略优化。 技术背景知识: 首先给定prompt x,生成两个答案 ( y 1 , y 2 ) Π S F T ( y ∣ x ) (y_1,y_2)~\Pi^{SFT}(y|x) (y1,y2) ΠSFT(y∣x) ,并通…...

Qt源码分析:窗体绘制与响应
作为一套开源跨平台的UI代码库,窗体绘制与响应自然是最为基本的功能。在前面的博文中,已就Qt中的元对象系统(反射机制)、事件循环等基础内容进行了分析,并捎带阐述了窗体响应相关的内容。因此,本文着重分析Qt中窗体绘制相关的内容…...

docker 安装 禅道
docker pull hub.zentao.net/app/zentao:20.1.1 sudo docker network create --subnet172.172.172.0/24 zentaonet 使用 8087端口号访问 使用禅道mysql 映射到3307 sudo docker run \ --name zentao2 \ -p 8087:80 \ -p 3307:3306 \ --networkzentaonet \ --ip 172.172.172.…...

【简要说说】make 增量编译的原理
make 增量编译的原理 make是一个工具,它可以根据依赖关系和时间戳来自动执行编译命令。 当您修改了源代码文件后,make会检查它们的修改时间是否比目标文件(如可执行文件或目标文件)新,如果是,就会重新编译…...

DETRs Beat YOLOs on Real-time Object Detection论文翻译
cvpr 2024 论文名称 DETRs在实时目标检测上击败YOLO 地址 https://arxiv.longhoe.net/abs/2304.08069 代码 https://github.com/lyuwenyu/RT-DETR 目录 摘要 1介绍 2.相关工作 2.1实时目标探测器 2.2.端到端物体探测器 3.检测器的端到端速度 3.1.NMS分析 3.2.端到端速度…...

SpringBoot 多数据源配置
目录 一. 引入maven依赖包 二. 配置yml 三、创建 xml 分组文件 四、切换数据源 一. 引入maven依赖包 <dependency><groupId>com.baomidou</groupId><artifactId>dynamic-datasource-spring-boot-starter</artifactId><version>3.6.1&…...

RK3568驱动指南|第十六篇 SPI-第192章 mcp2515驱动编写:完善write和read函数
瑞芯微RK3568芯片是一款定位中高端的通用型SOC,采用22nm制程工艺,搭载一颗四核Cortex-A55处理器和Mali G52 2EE 图形处理器。RK3568 支持4K 解码和 1080P 编码,支持SATA/PCIE/USB3.0 外围接口。RK3568内置独立NPU,可用于轻量级人工…...

#BI建模与数仓建模有什么区别?指标体系由谁来搭建?
问题1: 指标体系是我们数仓来搭建还是分析师来做,如何去推动? 问题2:BI建模与数仓建模有什么区别? 指标体系要想做好,其实是分两块内容的,一块是顶层设计阶段,业务指标体系的搭建&am…...