大家好,我是小悟。
一、什么是接口防抖?(又名:救救那个手抖的程序员)
想象一下这个场景:用户小张在提交订单时,因为网络延迟,他以为没点中那个"提交"按钮,于是疯狂连击了10次!结果...10个一模一样的订单诞生了!
接口防抖 就像是给按钮加上了一层"冷静期"------"兄弟,你点太快了,先冷静3秒再说!"
防止重复提交 则是更严格的保安大哥------"同样的身份证(请求)只能进一次,想蒙混过关?没门!"
下面我来教你在SpringBoot中布下天罗地网,拦截这些"手抖攻击"!
二、实战方案大集合
方案1:前端防抖 + 后端令牌锁(双保险)
前端防抖代码(JavaScript版):
ini
// 给按钮加个"冷静debuff"
let isSubmitting = false;
function submitOrder() {
if (isSubmitting) {
alert("客官您点得太快了,喝口茶歇歇~");
return;
}
isSubmitting = true;
// 提交请求...
// 3秒后才能再次点击
setTimeout(() => {
isSubmitting = false;
}, 3000);
}
后端令牌锁实现:
步骤1:创建防抖注解
less
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
/**
* 防抖时间(秒),默认3秒
*/
int lockTime() default 3;
/**
* 锁的key,支持SpEL表达式
*/
String key() default "";
/**
* 提示信息
*/
String message() default "请勿重复提交";
}
步骤2:实现AOP切面
less
@Aspect
@Component
@Slf4j
public class DuplicateSubmitAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private HttpServletRequest request;
@Pointcut("@annotation(preventDuplicateSubmit)")
public void pointcut(PreventDuplicateSubmit preventDuplicateSubmit) {
}
@Around("pointcut(preventDuplicateSubmit)")
public Object around(ProceedingJoinPoint joinPoint,
PreventDuplicateSubmit preventDuplicateSubmit) throws Throwable {
// 1. 构造锁的key
String lockKey = buildLockKey(joinPoint, preventDuplicateSubmit);
// 2. 尝试加锁(setnx操作)
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "LOCKED",
preventDuplicateSubmit.lockTime(), TimeUnit.SECONDS);
if (Boolean.TRUE.equals(success)) {
// 加锁成功,执行方法
try {
return joinPoint.proceed();
} finally {
// 可以根据业务决定是否立即删除锁
// redisTemplate.delete(lockKey);
}
} else {
// 加锁失败,说明重复提交了
throw new RuntimeException(preventDuplicateSubmit.message());
}
}
private String buildLockKey(ProceedingJoinPoint joinPoint,
PreventDuplicateSubmit annotation) {
StringBuilder keyBuilder = new StringBuilder("SUBMIT:LOCK:");
// 如果有自定义key
if (StringUtils.isNotBlank(annotation.key())) {
keyBuilder.append(parseKey(joinPoint, annotation.key()));
} else {
// 默认使用:方法名 + 用户ID + 参数hash
keyBuilder.append(joinPoint.getSignature().toShortString());
// 加上用户ID(如果有登录)
String userId = getCurrentUserId();
if (userId != null) {
keyBuilder.append(":").append(userId);
}
// 加上参数摘要
Object[] args = joinPoint.getArgs();
if (args.length > 0) {
String argsHash = DigestUtils.md5DigestAsHex(
Arrays.deepToString(args).getBytes()
).substring(0, 8);
keyBuilder.append(":").append(argsHash);
}
}
return keyBuilder.toString();
}
private String getCurrentUserId() {
// 从Token或Session中获取用户ID
// 这里简化处理
return (String) request.getSession().getAttribute("userId");
}
}
步骤3:使用示例
less
@RestController
@RequestMapping("/order")
public class OrderController {
@PostMapping("/create")
@PreventDuplicateSubmit(lockTime = 5, message = "订单正在处理中,请勿重复提交")
public ApiResult createOrder(@RequestBody OrderDTO orderDTO) {
// 业务逻辑
orderService.create(orderDTO);
return ApiResult.success("下单成功");
}
@PostMapping("/pay")
@PreventDuplicateSubmit(
key = "'PAY:' + #orderNo + ':' + T(com.example.util.UserUtil).getCurrentUserId()",
lockTime = 10,
message = "支付请求已提交,请勿重复操作"
)
public ApiResult payOrder(String orderNo) {
// 支付逻辑
return ApiResult.success("支付成功");
}
}
方案2:数据库唯一约束(最硬核的方案)
有时候,最简单的最有效!
less
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 业务唯一号:时间戳 + 用户ID + 随机数
@Column(name = "order_no", unique = true, nullable = false)
private String orderNo;
// 或者使用请求ID作为防重
@Column(name = "request_id", unique = true)
private String requestId;
// ...其他字段
}
@Service
@Slf4j
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
// 生成唯一请求ID(前端传递或后端生成)
String requestId = dto.getRequestId();
if (StringUtils.isBlank(requestId)) {
requestId = UUID.randomUUID().toString();
}
// 检查是否已处理过该请求
if (orderRepository.existsByRequestId(requestId)) {
log.warn("重复请求被拦截:{}", requestId);
throw new BusinessException("订单已提交,请勿重复操作");
}
// 创建订单
Order order = new Order();
order.setRequestId(requestId);
order.setOrderNo(generateOrderNo());
// ...设置其他字段
try {
orderRepository.save(order);
} catch (DataIntegrityViolationException e) {
// 捕获唯一约束异常
throw new BusinessException("订单已存在,请勿重复提交");
}
}
}
方案3:本地Guava缓存(轻量级方案)
适合单机部署,简单快捷!
typescript
@Component
public class LocalDuplicateChecker {
// Guava缓存,3秒自动过期
private final Cache<String, Boolean> submitCache = CacheBuilder.newBuilder()
.expireAfterWrite(3, TimeUnit.SECONDS)
.maximumSize(10000)
.build();
/**
* 检查是否重复提交
* @param key 请求唯一标识
* @return true=重复提交, false=首次提交
*/
public boolean isDuplicate(String key) {
try {
// 如果key不存在,则放入缓存并返回null
// 如果key存在,则返回缓存的值
return submitCache.get(key, () -> {
// 这个lambda只在key不存在时执行
return false;
});
} catch (ExecutionException e) {
return true;
}
}
/**
* 手动放入缓存(用于防止并发时多次通过检查)
*/
public void markAsSubmitted(String key) {
submitCache.put(key, true);
}
}
// 使用方式
@RestController
public class ApiController {
@Autowired
private LocalDuplicateChecker duplicateChecker;
@PostMapping("/api/submit")
public ApiResult submitData(@RequestBody SubmitData data,
HttpServletRequest request) {
// 构造唯一key:IP + 用户ID + 数据摘要
String clientIp = request.getRemoteAddr();
String userId = getCurrentUserId();
String dataHash = DigestUtils.md5DigestAsHex(
JSON.toJSONString(data).getBytes()
).substring(0, 8);
String lockKey = String.format("SUBMIT:%s:%s:%s",
clientIp, userId, dataHash);
if (duplicateChecker.isDuplicate(lockKey)) {
return ApiResult.error("请勿重复提交");
}
// 标记为已提交
duplicateChecker.markAsSubmitted(lockKey);
// 执行业务逻辑
return processData(data);
}
}
方案4:Token令牌机制(最经典的方案)
这个方案就像发门票,一张票只能进一个人!
步骤1:生成Token
kotlin
@RestController
public class TokenController {
@GetMapping("/api/getToken")
public ApiResult getToken() {
String token = UUID.randomUUID().toString();
// 存入Redis,有效期5分钟
redisTemplate.opsForValue().set(
"SUBMIT_TOKEN:" + token,
"VALID",
5, TimeUnit.MINUTES
);
return ApiResult.success(token);
}
}
步骤2:验证Token
less
@Aspect
@Component
public class TokenCheckAspect {
@Pointcut("@annotation(needTokenCheck)")
public void pointcut(NeedTokenCheck needTokenCheck) {
}
@Around("pointcut(needTokenCheck)")
public Object checkToken(ProceedingJoinPoint joinPoint,
NeedTokenCheck needTokenCheck) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
String token = request.getHeader("X-Submit-Token");
if (StringUtils.isBlank(token)) {
throw new RuntimeException("提交令牌缺失");
}
String redisKey = "SUBMIT_TOKEN:" + token;
String value = (String) redisTemplate.opsForValue().get(redisKey);
if (!"VALID".equals(value)) {
throw new RuntimeException("无效的提交令牌");
}
// 删除令牌(一次性使用)
redisTemplate.delete(redisKey);
return joinPoint.proceed();
}
}
步骤3:前端配合
javascript
// 提交前先获取令牌
async function submitWithToken(data) {
// 1. 获取令牌
const token = await fetch('/api/getToken').then(r => r.json());
// 2. 携带令牌提交
const result = await fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Submit-Token': token
},
body: JSON.stringify(data)
});
return result;
}
三、方案对比总结
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| AOP + Redis锁 | 灵活可控,支持复杂规则 | 依赖Redis,增加系统复杂度 | 分布式系统,需要精细控制 |
| 数据库唯一约束 | 绝对可靠,永不漏网 | 对数据库有压力,需要设计唯一键 | 核心业务(如支付、订单) |
| 本地缓存 | 性能极高,零延迟 | 仅限单机,集群无效 | 单体应用,高频但非核心接口 |
| Token机制 | 安全性高,前端可控 | 需要两次请求,增加交互 | 表单提交,需要严格防重 |
四、防抖策略选择指南
-
根据业务重要性选择:
- 金融支付 → 数据库唯一约束 + Redis锁(双重保险)
- 普通表单 → Token机制或AOP锁
- 查询接口 → 本地缓存防抖
-
根据系统架构选择:
- 单机应用 → 本地缓存最香
- 分布式集群 → Redis是王道
- 微服务 → 考虑分布式锁服务
-
实用小贴士:
less// 最佳实践:组合拳! @PostMapping("/important/submit") @PreventDuplicateSubmit(lockTime = 5) @Transactional(rollbackFor = Exception.class) public ApiResult importantSubmit(@RequestBody @Valid RequestDTO dto) { // 1. 检查请求ID是否重复 checkRequestId(dto.getRequestId()); // 2. 执行业务 // 3. 数据库唯一约束兜底 return ApiResult.success(); }
五、最后
- 不要过度设计:简单的业务用简单的方案,杀鸡不要用牛刀
- 用户体验很重要:防抖提示要友好,别让用户一脸懵逼
- 监控不能少:记录被拦截的请求,分析用户行为
- 前端也要防:前后端双重防护才是王道
防抖的目的不是为难用户,而是保护系统和数据的安全。就像给你的接口穿上防弹衣,既能抵挡"手抖攻击",又能让正常请求畅通无阻!
程序员防抖口诀:
前端防抖先出手,后端加锁不能少。
令牌机制来帮忙,唯一约束最可靠。
根据场景选方案,系统稳定没烦恼。
用户手抖不可怕,我有妙招来护驾!

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。
您的一键三连,是我更新的最大动力,谢谢
山水有相逢,来日皆可期,谢谢阅读,我们再会
我手中的金箍棒,上能通天,下能探海