目录
引言
方案一:前端防护策略
方案二:后端协同控制
方案三:流量控制与过滤
滑动窗口限流
布隆过滤器
方案四:基于框架的实践方案
多层防护策略与最佳实践
总结
引言
在Web应用开发中,防止用户重复点击提交是一个常见却棘手的问题。重复提交不仅会导致数据重复、资源浪费,在交易、下单等场景中甚至可能造成严重的业务异常。通常情况下,我们会使用Redis分布式锁来解决这个问题,但当Redis不可用或由于架构限制无法使用时,我们需要其他可靠的替代方案。
本文将深入探讨几种不依赖Redis的防重复点击方案,从前端到后端,从简单到复杂,分析各自的实现原理、适用场景以及优缺点,帮助开发者根据自身业务需求选择最合适的解决方案。
方案一:前端防护策略
最直接的防重复点击方案是在前端实现按钮防抖。当用户点击按钮后,立即将按钮禁用或置灰,防止用户进行二次点击。
// 伪代码:防抖按钮实现示例
function debounceButton(btn, time = 2000) {if (btn.disabled) return;// 禁用按钮btn.disabled = true;btn.classList.add('disabled');// 发送请求sendRequest().finally(() => {// 请求完成后恢复按钮状态(也可以根据业务需要不恢复)setTimeout(() => {btn.disabled = false;btn.classList.remove('disabled');}, time);});
}
优点:
- 实现简单,无需后端配合
- 用户体验友好,提供直观的视觉反馈
- 适用于大多数普通业务场景
局限性:
- 网络延迟可能导致禁用不及时
- 技术熟练的用户可通过浏览器开发工具绕过前端限制
- 无法防止通过接口工具(如Postman)直接调用API的重复请求
开发经验分享:在实际项目中,我发现单纯依赖前端防抖虽然能解决80%的问题,但在支付等关键业务中,仍需结合后端验证机制,构建多层防护。
方案二:后端协同控制
Token机制是一种有效的服务端防重复提交方案。核心思想是为每次操作生成唯一标识,确保同一标识只被处理一次。
工作流程:
- 用户访问页面时,后端生成唯一token并返回前端
- 用户提交请求时携带该token
- 后端验证token是否已被使用,未使用则标记为已使用并处理请求
- 如token已使用,拒绝请求并返回错误提示
后端实现示例:
// 伪代码
@RestController
public class OrderController {private final Map<String, Boolean> tokenMap = new ConcurrentHashMap<>();// 获取token@GetMapping("/getToken")public Result getToken() {String token = UUID.randomUUID().toString();tokenMap.put(token, false); // false表示未使用return Result.success(token);}// 提交订单@PostMapping("/submitOrder")public Result submitOrder(@RequestParam String token, @RequestBody OrderDTO order) {// 使用数据库事务保证原子性return transactionTemplate.execute(status -> {// 查询token使用状态Boolean used = tokenMap.get(token);if (used == null) {return Result.error("无效的token");}if (used) {return Result.error("请勿重复提交");}// 标记token为已使用tokenMap.put(token, true);// 处理订单逻辑orderService.createOrder(order);return Result.success();});}
}
数据库实现方案:
在实际生产环境中,可使用数据库存储token状态,结合事务确保原子性:
-- 创建token表
CREATE TABLE submission_token (token VARCHAR(36) PRIMARY KEY,used BOOLEAN DEFAULT FALSE,create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,expire_time TIMESTAMP
);-- 验证并标记token (使用悲观锁)
BEGIN TRANSACTION;
SELECT used FROM submission_token WHERE token = ? FOR UPDATE;
-- 如果未使用,则标记为已使用
UPDATE submission_token SET used = TRUE WHERE token = ?;
COMMIT;
优点:
- 服务端验证,安全性高
- 可靠性强,能防止各种渠道的重复请求
- 结合数据库事务,保证操作原子性
局限性:
- 实现复杂度较高
- 需要额外的存储空间管理token
- 不适合所有场景下的性能要求
方案三:流量控制与过滤
滑动窗口限流
滑动窗口限流是控制请求频率的有效方法,可以限制用户在指定时间窗口内的请求次数,从而防止重复提交。
直通车:高并发系统中的限流策略:滑动窗口限流与Redis实现-CSDN博客
//伪代码
public class SlidingWindowRateLimiter {// 用户请求记录: <用户ID, 请求时间列表>private Map<String, LinkedList<Long>> requestRecords = new ConcurrentHashMap<>();// 窗口大小(毫秒)private final long windowSize;// 窗口内允许的最大请求数private final int maxRequests;public SlidingWindowRateLimiter(long windowSize, int maxRequests) {this.windowSize = windowSize;this.maxRequests = maxRequests;}/*** 判断请求是否被允许* @param userId 用户ID* @return 是否允许请求*/public synchronized boolean allowRequest(String userId) {long currentTime = System.currentTimeMillis();// 获取用户的请求记录,如不存在则创建LinkedList<Long> records = requestRecords.computeIfAbsent(userId, k -> new LinkedList<>());// 移除窗口外的过期记录while (!records.isEmpty() && currentTime - records.getFirst() > windowSize) {records.removeFirst();}// 判断窗口内请求是否超过限制if (records.size() < maxRequests) {// 记录新请求records.addLast(currentTime);return true;}return false;}
}
使用示例:
// 创建限流器: 2秒内最多允许1次请求
SlidingWindowRateLimiter limiter = new SlidingWindowRateLimiter(2000, 1);@PostMapping("/api/submit")
public Result submit(@RequestHeader String userId) {// 检查限流if (!limiter.allowRequest(userId)) {return Result.error("请求过于频繁,请稍后再试");}// 处理正常业务逻辑return businessService.process();
}
适用场景:
- 适用于高频操作的防重复控制
- 对性能要求较高的场景
- 允许在短时间内丢弃部分请求的业务
布隆过滤器
布隆过滤器是一个空间效率很高的概率型数据结构,用于判断一个元素是否存在于集合中。它可以快速进行"可能存在"或"一定不存在"的判断,适合作为防重复提交的快速过滤层。
直通车:布隆过滤器原理介绍和典型应用案例_布隆过滤器案例-CSDN博客
// 伪代码
public class BloomFilterValidator {private BitSet bitSet;private int size;private int hashFunctions;public BloomFilterValidator(int size, int hashFunctions) {this.size = size;this.hashFunctions = hashFunctions;this.bitSet = new BitSet(size);}// 添加元素public void add(String element) {for (int i = 0; i < hashFunctions; i++) {int hash = getHash(element, i);bitSet.set(hash);}}// 判断元素是否可能存在public boolean mightContain(String element) {for (int i = 0; i < hashFunctions; i++) {int hash = getHash(element, i);if (!bitSet.get(hash)) {return false; // 一定不存在}}return true; // 可能存在}// 简单哈希函数private int getHash(String element, int seed) {int hash = element.hashCode();hash = hash * seed % size;return Math.abs(hash) % size;}
}
应用架构:
- 使用布隆过滤器进行快速判断
- 如果过滤器返回"可能存在",则进一步查询数据库确认
- 如果确实是重复提交,则拒绝请求
// 伪代码
@Service
public class OrderSubmitService {private BloomFilterValidator bloomFilter = new BloomFilterValidator(10000, 3);private OrderRepository orderRepository;public Result submitOrder(OrderDTO orderDTO) {// 生成请求标识String requestId = generateRequestId(orderDTO);// 布隆过滤器快速检查if (bloomFilter.mightContain(requestId)) {// 可能是重复请求,进一步查询数据库确认if (orderRepository.existsByRequestId(requestId)) {return Result.error("订单已提交,请勿重复操作");}}// 处理订单并保存请求标识Order order = orderService.createOrder(orderDTO);bloomFilter.add(requestId); // 添加到布隆过滤器return Result.success(order);}// 生成请求唯一标识private String generateRequestId(OrderDTO orderDTO) {// 根据关键业务字段生成唯一标识return DigestUtils.md5Hex(orderDTO.getUserId() + orderDTO.getProductId() + orderDTO.getAmount() + System.currentTimeMillis());}
}
优点:
- 空间效率高,内存占用小
- 查询速度快,适合大规模数据
- 作为快速过滤层,降低数据库查询压力
局限性:
- 有一定的误判率(误报)
- 不能单独使用,需要与数据库等确切存储配合
- 不支持删除元素,需要定期重建
方案四:基于框架的实践方案
参考RuoYi框架的实现,RuoYi框架提供了一种基于表单信息的防重复提交方案,核心思想是将表单内容、提交时间等信息进行校验,限制相同内容在短时间内的重复提交。
/*** 自定义注解防止表单重复提交*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {/*** 间隔时间(ms),小于此时间视为重复提交*/int interval() default 5000;
}/*** 防重复提交拦截器*/
@Component
public class RepeatSubmitInterceptor implements HandlerInterceptor {private final FormTokenService tokenService;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {if (handler instanceof HandlerMethod) {HandlerMethod handlerMethod = (HandlerMethod) handler;Method method = handlerMethod.getMethod();RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);if (annotation != null) {// 验证表单是否重复提交return validateFormRepeat(request, annotation);}}return true;}private boolean validateFormRepeat(HttpServletRequest request, RepeatSubmit annotation) {// 获取请求参数内容String formContent = getFormContent(request);// 获取请求路径String requestPath = request.getRequestURI();// 用户标识String userToken = getUserToken(request);// 生成表单唯一标识String formKey = DigestUtils.md5Hex(requestPath + userToken + formContent);// 检查数据库中是否存在且是否在规定时间内FormSubmitRecord record = formRecordRepository.findByFormKey(formKey);if (record != null) {long interval = System.currentTimeMillis() - record.getSubmitTime();if (interval < annotation.interval()) {return false; // 判定为重复提交}}// 记录本次提交saveFormRecord(formKey);return true;}// 获取表单内容private String getFormContent(HttpServletRequest request) {// 获取POST内容或GET参数,进行排序和标准化处理// ...具体实现略}// 保存表单记录private void saveFormRecord(String formKey) {FormSubmitRecord record = new FormSubmitRecord();record.setFormKey(formKey);record.setSubmitTime(System.currentTimeMillis());formRecordRepository.save(record);}
}
使用示例
@RestController
public class UserController {@PostMapping("/user/register")@RepeatSubmit(interval = 10000) // 10秒内不允许重复提交public Result register(@RequestBody UserRegisterForm form) {// 注册逻辑return userService.register(form);}
}
优点:
- 配置简便,使用注解即可实现
- 支持配置不同接口的防重复策略
- 基于表单内容校验,更符合业务语义
局限性:
- 依赖于请求内容,不适用于所有场景
- 需要存储请求内容的哈希值
- 配置不当可能影响用户体验
多层防护策略与最佳实践
在实际项目中,往往需要综合使用多种防重复点击方案,构建多层防护机制。
前端第一道防线:
- 实现按钮防抖和禁用
- 合理设置UI反馈,提升用户体验
API网关层:
- 实现基本的流量控制和限流
- 对异常请求进行预警和拦截
应用服务层:
- 实现token验证或表单校验机制
- 使用布隆过滤器进行快速过滤
数据持久层:
- 利用数据库约束和事务保证数据一致性
- 实现业务层面的幂等性检查
总结
防止重复点击是一个需要从多角度综合考虑的问题。虽然Redis分布式锁提供了一种优雅的解决方案,但在Redis不可用的场景下,我们仍有多种替代方案可以选择。
理想的防重复点击方案应当在安全性、可靠性和性能之间找到平衡点。在实际应用中,应根据业务特点、技术栈和性能要求等因素,选择合适的方案或组合方案。同时,也应当注意用户体验,避免过度限制影响正常操作。