解决按钮重复点击问题:Spring Boot 分布式防重提交实现指南

65次阅读
没有评论

用户连点按钮导致系统崩溃?教你用 Spring Boot 实现防重复提交!从自定义注解本地锁到 Redis 分布式锁,再到幂等性设计,3 步搞定从单机到分布式环境的重复提交问题,程序员必备教程。

前言:为什么你的按钮总被「疯狂连点」?

想象一下:用户对着「提交订单」按钮疯狂输出,像极了打街机游戏的少年——结果你的系统创建了 5 个一模一样的订单。客服电话被打爆,数据库多了 一堆废数据,而你凌晨两点被叫起来紧急回滚——这就是重复提交的「恐怖故事」。

重复提交问题本质是 网络延迟 + 用户急躁 的化学反应。防重提交不仅是技术需求,更是 拯救程序员睡眠质量 的人道主义行为!本文将用「本地锁→分布式锁」的递进方案,教你用 Spring Boot 搭建从青铜到王者的防重体系。

解决按钮重复点击问题:Spring Boot 分布式防重提交实现指南

一、基础玩法:本地锁注解(单机版)

1. 自定义注解实现

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {int lockTime() default 3; // 默认锁 3 秒,建议比前端按钮禁用时间长
}

2. 切面编程实现锁逻辑

@Aspect
@Component
publicclass RepeatSubmitAspect {
    // 使用 ConcurrentHashMap 作为本地锁容器(注意:分布式环境会失效!)privatestaticfinal ConcurrentHashMap<String, Object> LOCKS = new ConcurrentHashMap<>();

    @Around("@annotation(noRepeatSubmit)")
    public Object around(ProceedingJoinPoint point, NoRepeatSubmit noRepeatSubmit) throws Throwable {HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();

        // 生成唯一锁密钥:用户 ID+ 接口路径(实际业务需要更复杂规则)String lockKey = getUserId(request) + "-" + request.getServletPath();

        // 如果已经存在锁,直接抛异常(建议自定义业务异常)if (LOCKS.putIfAbsent(lockKey, Boolean.TRUE) != null) {thrownew RuntimeException("手速太快了,慢一点!"); // 用户体验友好的提示
        }

        try {return point.proceed(); // 执行实际业务方法
        } finally {
            // 延迟释放锁(注意:要用 finally 确保释放!)Thread.sleep(noRepeatSubmit.lockTime() * 1000);
            LOCKS.remove(lockKey);
        }
    }

    // 实际项目要从 token 解析用户 ID,这里简单演示
    private String getUserId(HttpServletRequest request) {return Optional.ofNullable(request.getHeader("Authorization"))
                .orElse("anonymous");
    }
}

3. 使用案例:订单创建接口

@RestController
public class OrderController {@NoRepeatSubmit(lockTime = 5) // 5 秒内禁止重复调用
    @PostMapping("/createOrder")
    public ResponseEntity createOrder(@RequestBody OrderDTO order) {
        // 真实的订单创建业务逻辑(通常需要事务控制)orderService.create(order);
        return ResponseEntity.ok("订单创建成功");
    }
}

⚠️ 吐槽时刻

这种方案在单机环境下能拦住 90% 的重复提交,但遇到集群部署就直接躺平——因为每个实例的 ConcurrentHashMap 都是 独立后宫,无法拦截其他实例的请求。接下来请看分布式环境解决方案!


二、进阶方案:Redis 分布式锁(集群版)

1. 引入 Redis 依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2. 分布式锁工具类

@Component
publicclass RedisLockHelper {
    @Autowired
    private StringRedisTemplate redisTemplate;

    /**     * 加锁(设置过期时间防止死锁)* @param key 锁密钥     * @param expireTime 过期时间(秒)     * @return 是否获取成功     */
    public boolean lock(String key, int expireTime) {
        // 使用 setIfAbsent 实现原子操作(旧版本用 setnx+expire 需要 lua 脚本保证原子性)Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(key, "locked", expireTime, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    // 释放锁(直接删除即可)public void unlock(String key) {redisTemplate.delete(key);
    }
}

3. 升级版分布式切面

@Aspect
@Component
publicclass DistributedRepeatSubmitAspect {
    @Autowired
    private RedisLockHelper redisLockHelper;

    @Around("@annotation(noRepeatSubmit)")
    public Object around(ProceedingJoinPoint point, NoRepeatSubmit noRepeatSubmit) throws Throwable {HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();

        // 生成分布式环境唯一密钥(增加客户端 IP 防不同用户冲突)String lockKey = "repeat_submit:" + getUserId(request) + ":" + 
                request.getServletPath() + ":" + 
                DigestUtils.md5DigestAsHex(request.getParameterMap().toString().getBytes());

        // 尝试获取分布式锁
        if (!redisLockHelper.lock(lockKey, noRepeatSubmit.lockTime())) {thrownew RuntimeException("您的操作太频繁了,请稍后再试");
        }

        try {return point.proceed();
        } finally {
            // 注意:不需要手动释放,依赖 Redis 过期自动删除
            // 提前释放可能导致锁有效期内的重复请求
        }
    }
}

4. 特殊场景:处理幂等性 token

// 在进入表单页时生成 token
@GetMapping("/order/page")
public ResponseEntity getOrderPage() {String token = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set("submit_token:" + token, "1", 10, TimeUnit.MINUTES);
    return ResponseEntity.ok().header("submit-token", token).build();}

// 提交时校验 token
private boolean validSubmitToken(String token) {
    String key = "submit_token:" + token;
    Long count = redisTemplate.execute(new DefaultRedisScript<>("if redis.call('get', KEYS[1]) =='1'then" +
        "return redis.call('del', KEYS[1])" +
        "else" +
        "return 0" +
        "end", 
        Long.class), Collections.singletonList(key));
    return count != null && count > 0;
}

三、避坑指南(血泪经验总结)

🚨 坑 1:锁密钥设计不合理

错误示范 只使用用户 ID→ 不同功能接口会互相阻塞

正确做法 业务类型 + 用户 ID+ 接口 + 参数指纹 组合密钥

🚨 坑 2:忘记设置锁超时

恐怖后果 :如果实例崩溃, 锁永远不释放→系统逐渐瘫痪

解决方案 :必须设置过期时间,且 业务执行时间应远小于锁超时时间

🚨 坑 3:前端防重缺失

常见误区:以为后端防重就万事大吉

最佳实践

  1. 按钮提交后立即禁用(最简单有效的前端防重)
  2. 添加加载动画降低用户重复点击欲望
  3. 单页应用使用 axios 拦截器自动取消重复请求

🚨 坑 4:网络超时导致重复请求

场景还原:第一次请求其实成功了,但网络超时让客户端重试

终极方案

// 业务层幂等检查(最后防线)@Transactional
public void createOrder(OrderDTO dto) {
    // 通过业务唯一标识检查是否已存在
    if (orderRepository.existsByOrderNo(dto.getOrderNo())) {log.warn("重复订单请求:{}", dto.getOrderNo());
        return; // 或直接返回已存在的结果
    }
    // 正常创建逻辑
}

总结与实战建议

🔑 核心要点

  1. 分层防御:前端防重 → 网络层防重 → 业务层幂等
  2. 密钥设计:保证同一用户同一操作在同一时间段内被拦截
  3. 锁时效:设置略大于业务处理时间的自动过期期限

🛠 方案选型指南

  • 单机部署:本地锁注解(简单高效)
  • 集群环境:Redis 分布式锁(必需方案)
  • 金融级要求:Redis 锁 + 数据库唯一约束 + 业务幂等校验(三重保险)

🤔 灵魂拷问

你的防重系统能扛住「用户一边疯狂 F5 一边电话投诉说系统卡顿」的经典场景吗?

最后提醒 :防重提交本质是 用空间换时间 的技术方案,会根据业务场景有不同的实现变体。建议在测试环境用 Jmeter 模拟高并发重复请求,观察系统表现——别等到线上爆雷再连夜补锅!

正文完
 0
Pa2sw0rd
版权声明:本站原创文章,由 Pa2sw0rd 于2025-09-19发表,共计4975字。
转载说明:Unless otherwise specified, all articles are published by cc-4.0 protocol. Please indicate the source of reprint.
评论(没有评论)