分布式环境下的重复请求防护:非Redis锁替代方案全解析
目录
引言
方案一:前端防护策略
方案二:后端协同控制
方案三:流量控制与过滤
滑动窗口限流
布隆过滤器
方案四:基于框架的实践方案
多层防护策略与最佳实践
总结
引言
在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;
@Override
public 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不可用的场景下,我们仍有多种替代方案可以选择。
理想的防重复点击方案应当在安全性、可靠性和性能之间找到平衡点。在实际应用中,应根据业务特点、技术栈和性能要求等因素,选择合适的方案或组合方案。同时,也应当注意用户体验,避免过度限制影响正常操作。