注册

SpringBoot接口防抖大作战,拒绝“手抖”重复提交!

大家好,我是小悟。


一、什么是接口防抖?(又名:救救那个手抖的程序员)


想象一下这个场景:用户小张在提交订单时,因为网络延迟,他以为没点中那个“提交”按钮,于是疯狂连击了10次!结果...10个一模一样的订单诞生了!


接口防抖 就像是给按钮加上了一层“冷静期”——“兄弟,你点太快了,先冷静3秒再说!”


防止重复提交 则是更严格的保安大哥——“同样的身-份-证(请求)只能进一次,想蒙混过关?没门!”


下面我来教你在SpringBoot中布下天罗地网,拦截这些“手抖攻击”!




二、实战方案大集合


方案1:前端防抖 + 后端令牌锁(双保险)


前端防抖代码(JavaScript版):


// 给按钮加个“冷静debuff”
let isSubmitting = false;

function submitOrder() {
if (isSubmitting) {
alert("客官您点得太快了,喝口茶歇歇~");
return;
}

isSubmitting = true;
// 提交请求...

// 3秒后才能再次点击
setTimeout(() => {
isSubmitting = false;
}, 3000);
}

后端令牌锁实现:


步骤1:创建防抖注解


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
/**
* 防抖时间(秒),默认3秒
*/

int lockTime() default 3;

/**
* 锁的key,支持SpEL表达式
*/

String key() default "";

/**
* 提示信息
*/

String message() default "请勿重复提交";
}

步骤2:实现AOP切面


@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:使用示例


@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:数据库唯一约束(最硬核的方案)


有时候,最简单的最有效!


@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缓存(轻量级方案)


适合单机部署,简单快捷!


@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


@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


@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:前端配合


// 提交前先获取令牌
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机制安全性高,前端可控需要两次请求,增加交互表单提交,需要严格防重

四、防抖策略选择指南



  1. 根据业务重要性选择

    • 金融支付 → 数据库唯一约束 + Redis锁(双重保险)
    • 普通表单 → Token机制或AOP锁
    • 查询接口 → 本地缓存防抖


  2. 根据系统架构选择

    • 单机应用 → 本地缓存最香
    • 分布式集群 → Redis是王道
    • 微服务 → 考虑分布式锁服务


  3. 实用小贴士
    // 最佳实践:组合拳!
    @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();
    }



五、最后



  1. 不要过度设计:简单的业务用简单的方案,杀鸡不要用牛刀
  2. 用户体验很重要:防抖提示要友好,别让用户一脸懵逼
  3. 监控不能少:记录被拦截的请求,分析用户行为
  4. 前端也要防:前后端双重防护才是王道

防抖的目的不是为难用户,而是保护系统和数据的安全。就像给你的接口穿上防弹衣,既能抵挡"手抖攻击",又能让正常请求畅通无阻!




程序员防抖口诀



前端防抖先出手,后端加锁不能少。


令牌机制来帮忙,唯一约束最可靠。


根据场景选方案,系统稳定没烦恼。


用户手抖不可怕,我有妙招来护驾!



SpringBoot接口防抖大作战,拒绝“手抖”重复提交!.png


谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。


您的一键三连,是我更新的最大动力,谢谢


山水有相逢,来日皆可期,谢谢阅读,我们再会


我手中的金箍棒,上能通天,下能探海


作者:悟空码字
来源:juejin.cn/post/7586208617603661858

0 个评论

要回复文章请先登录注册