在实际开发中,用户重复提交是常见问题,可能导致数据重复插入、订单多次创建等业务异常。若不依赖Redis分布式锁,我们可从前后端协同、数据约束、接口设计等多维度入手解决。下面介绍8种实用方案,覆盖不同业务场景。

1. 前端防抖与按钮禁用:减少无效请求的第一道防线

前端处理是防止重复提交的基础手段,核心是限制用户在短时间内多次触发提交操作。常见方式有两种:一是提交后立即禁用按钮,避免重复点击;二是通过防抖逻辑,忽略短时间内的连续请求。

这种方式实现简单,能有效减少用户误操作导致的无效请求,但存在局限性——无法拦截绕过前端的恶意请求(如直接调用API的工具),因此需配合后端校验使用。

// 前端按钮禁用示例
let isSubmitting = false; // 标记是否正在提交
function submitForm() {
if (isSubmitting) return ; // 若正在提交,直接返回
  isSubmitting = true; // 标记为提交中
// 禁用按钮(可选,增强用户体验)
document.getElementById("submitBtn").disabled = true;
// 发起请求
  fetch("/api/submit", { method: "POST" })
    .then(response => response.json())
    .finally(() => {
      // 请求完成后重置状态(无论成功失败)
      isSubmitting = false;
      document.getElementById("submitBtn").disabled = false;
    });
}

代码解析:通过isSubmitting变量标记提交状态,首次触发时进入提交流程,后续请求会被拦截;请求完成后(包括成功、失败或异常),重置状态并启用按钮,确保后续可正常提交。

扩展说明:实际开发中,可结合防抖函数(如setTimeout)处理高频点击,例如设置300ms内只执行一次提交逻辑。但需注意,前端逻辑仅为辅助手段,不能作为唯一防护,必须搭配后端校验。

2. 令牌机制:通过唯一标识校验请求合法性

令牌机制(Token)是后端防止重复提交的经典方案,核心是通过唯一标识验证请求的有效性。流程为:页面加载时,后端生成唯一Token并返回给前端,同时存储Token(如Session、数据库);前端提交时携带该Token,后端校验Token存在后处理请求,并立即删除或标记Token为已使用。

该方案适用于表单提交、接口防重,还可兼顾防CSRF攻击。

// 后端生成Token示例(Java)
@RequestMapping("/getToken")
public  String getToken(HttpSession session) {
// 生成唯一Token(如UUID)
  String token = UUID.randomUUID().toString();
// 存储Token到Session(分布式环境可用数据库/缓存)
  session.setAttribute("submitToken", token);
return  token;
}

// 后端校验Token示例
@RequestMapping("/submit")
public  Result submit(@RequestParam String token, HttpSession session) {
// 从Session获取存储的Token
  String storedToken = (String) session.getAttribute("submitToken");
// 校验Token是否存在且匹配
if (storedToken == null || !storedToken.equals(token)) {
    return  Result.fail("重复提交或Token无效");
  }
// 校验通过,删除Token(确保只能使用一次)
  session.removeAttribute("submitToken");
// 执行业务逻辑
  doBusiness();
return  Result.success();
}

代码解析getToken接口生成UUID作为Token并存储到Session;submit接口校验前端传递的Token与Session中存储的是否一致,一致则处理业务并删除Token,避免重复使用。

扩展说明:分布式系统中,Session共享存在问题,此时可将Token存储到数据库或分布式缓存(如Redis,虽不用分布式锁,但可存Token)。Token的有效期需合理设置,避免因页面停留过久导致Token失效。

3. 数据库唯一约束:从数据层阻断重复插入

利用数据库的唯一索引特性,可从根源上防止重复数据插入。为业务中具有唯一标识的字段(如订单号、用户ID+操作类型)创建唯一索引,当重复提交导致重复插入时,数据库会抛出主键冲突或唯一约束异常,我们捕获异常并处理即可。

该方案适用于需创建唯一业务数据的场景(如订单、支付记录)。

-- 创建订单表,添加唯一索引
CREATETABLE orders (
idBIGINT AUTO_INCREMENT PRIMARY KEY,
  order_no VARCHAR(64) NOT NULL , -- 订单号(唯一)
  user_id BIGINTNOT NULL ,
  amount DECIMAL(10,2) NOT NULL ,
-- 为order_no添加唯一索引
UNIQUEKEY uk_order_no (order_no)
);
// 后端插入订单示例(Java)
public  Result createOrder(Order order) {
  try {
    // 尝试插入订单
    orderMapper.insert(order);
    return  Result.success("订单创建成功");
  } catch (DuplicateKeyException e) {
    // 捕获唯一约束异常,说明订单已存在
    log.warn("订单号重复:{}", order.getOrderNo());
    return  Result.fail("订单已创建,请勿重复提交");
  }
}

代码解析:通过uk_order_no唯一索引确保order_no不重复;插入时若遇重复提交,数据库抛出DuplicateKeyException,后端捕获后返回重复提示,避免数据重复。

扩展说明:唯一索引的字段需谨慎选择,需确保业务上确实唯一(如“用户ID+日期+操作类型”可作为联合唯一索引)。索引会影响写入性能,高并发场景需评估索引对性能的影响。

4. 幂等性设计:让重复请求的结果一致

幂等性设计指同一请求多次执行,结果与一次执行一致。核心是客户端生成唯一业务ID(如请求ID),每次请求携带该ID;服务端接收后,先检查该ID是否已处理:若已处理,直接返回之前的结果;若未处理,执行业务并记录ID。

该方案适用于API接口,天然支持分布式环境,无需依赖锁机制。

// 幂等性处理示例(Java)
@RequestMapping("/pay")
public  Result pay(@RequestParam String requestId, @RequestParam Long orderId) {
// 检查requestId是否已处理(存储在数据库或缓存)
if (idProcessor.isProcessed(requestId)) {
    // 已处理,返回历史结果
    return  Result.success("支付成功", idProcessor.getResult(requestId));
  }
// 未处理,执行业务逻辑(如扣减库存、发起支付)
  PaymentResult result = paymentService.pay(orderId);
// 记录requestId及处理结果
  idProcessor.recordProcessed(requestId, result);
return  Result.success("支付成功", result);
}

// 处理记录工具类(简化)
class  IdProcessor {
// 存储已处理的requestId及结果(实际可用数据库/缓存)
private  Map<String, Object> processedMap = new   ConcurrentHashMap<>();

boolean  isProcessed(String requestId) {
    return  processedMap.containsKey(requestId);
  }

Object getResult(String requestId) {
    return  processedMap.get(requestId);
  }

void  recordProcessed(String requestId, Object result) {
    processedMap.put(requestId, result);
  }
}

代码解析:客户端生成requestId并随请求发送;服务端通过IdProcessor检查该ID是否已处理,已处理则返回历史结果,未处理则执行业务并记录,确保重复请求不重复处理。

扩展说明requestId需确保全局唯一(如UUID),且建议包含业务标识(如订单号)便于排查问题。记录requestId的存储介质需持久化(如数据库),避免服务重启后丢失记录。

5. 请求参数哈希去重:短时内拦截重复请求

对请求中的关键参数(如用户ID、操作类型、业务参数)生成唯一哈希值,服务端维护一个哈希集合,短时内若出现相同哈希值,则判定为重复请求并拒绝。

该方案适用于拦截短时间内的重复提交(如用户快速点击多次),需合理设置哈希值的过期时间。

// 请求参数哈希去重示例(Java)
@RequestMapping("/operate")
public  Result operate(@RequestParam Long userId, @RequestParam String action, @RequestParam String params) {
// 生成哈希值(组合关键参数)
  String hash = generateHash(userId, action, params);
// 检查哈希是否存在(缓存中,设置5秒过期)
if (cache.exists(hash)) {
    return  Result.fail("操作过于频繁,请稍后再试");
  }
// 存储哈希值,5秒后过期
  cache.set(hash, "1", 5);
// 执行业务逻辑
  doOperate(userId, action, params);
return  Result.success("操作成功");
}

// 生成哈希值的方法
private  String generateHash(Long userId, String action, String params) {
  String content = userId + "_" + action + "_" + params;
// 使用MD5生成哈希(实际可根据需求选择算法)
return  DigestUtils.md5DigestAsHex(content.getBytes());
}

代码解析:通过generateHash方法将用户ID、操作类型、参数组合为字符串并生成MD5哈希;检查缓存中是否存在该哈希,存在则拒绝请求,否则存储哈希(5秒过期)并处理业务,避免5秒内的重复请求。

扩展说明:哈希过期时间需根据业务场景设置(如表单提交设5-10秒,高频操作设1-2秒)。缓存介质可用本地内存(单机)或分布式缓存(分布式系统),但需注意内存占用,避免存储过多过期哈希。

6. 乐观锁:控制并发更新的重复操作

乐观锁适用于防止并发更新时的重复操作,核心是通过版本号或时间戳标记数据版本,更新时仅当版本号匹配才执行操作。若多次提交导致版本号不匹配,更新会失败,从而防止重复修改。

该方案常用于库存扣减、状态更新等场景。

-- 商品表(含版本号字段)
CREATETABLE products (
idBIGINT PRIMARY KEY,
nameVARCHAR(100) NOT NULL ,
  stock INTNOT NULL , -- 库存
versionINTNOT NULL DEFAULT 0-- 版本号
);
// 乐观锁更新库存示例(Java)
public  Result deductStock(Long productId, int  quantity) {
// 查询商品及当前版本号
  Product product = productMapper.selectById(productId);
int  currentVersion = product.getVersion();

// 执行更新(仅当版本号匹配时)
int  rows = productMapper.deductStock(
    productId, quantity, currentVersion, currentVersion + 1
  );

// 检查影响行数,0则说明版本号不匹配(已被其他请求更新)
if (rows == 0) {
    return  Result.fail("操作冲突,请重试");
  }
return  Result.success("库存扣减成功");
}

// Mapper接口(MyBatis示例)
@Update("UPDATE products " +
        "SET stock = stock - #{quantity}, version = #{newVersion} " +
        "WHERE id = #{productId} AND version = #{currentVersion}")
int  deductStock(@Param("productId") Long productId, 
                @Param("quantity") int  quantity, 
                @Param("currentVersion") int  currentVersion, 
                @Param("newVersion") int  newVersion);

代码解析:商品表通过version字段标记版本;扣减库存时,SQL条件包含version = #{currentVersion},仅当版本匹配时才更新库存并递增版本号;若影响行数为0,说明其他请求已修改数据,当前请求为重复操作,返回失败。

扩展说明:乐观锁不阻塞请求,性能优于悲观锁,适合并发量不极高的场景。若并发过高导致频繁失败,可结合重试机制(如最多重试3次)提升成功率。

7. 限流与频率控制:限制单位时间内的请求次数

通过限制同一用户或IP在单位时间内的请求次数,可从源头减少重复提交的可能性。常见实现方式有滑动窗口计数(统计单位时间内的请求数)、令牌桶算法等。

该方案适用于所有接口的防重,尤其适合高频操作(如登录、提交表单)。

-- 请求记录表(用于滑动窗口计数)
CREATE TABLE request_logs (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT NOT  NULL , -- 用户ID
  request_time DATETIME NOT  NULL  -- 请求时间
);
// 滑动窗口限流示例(Java)
@RequestMapping("/submitForm")
public  Result submitForm(@RequestParam Long userId) {
// 统计5秒内的请求次数
int  count = requestLogMapper.countByUserIdAndTime(
    userId, LocalDateTime.now().minusSeconds(5)
  );

// 若5秒内请求超过3次,拒绝
if (count >= 3) {
    return  Result.fail("请求过于频繁,请5秒后再试");
  }

// 记录本次请求
  requestLogMapper.insert(new   RequestLog(userId, LocalDateTime.now()));
// 执行业务逻辑
  doSubmitForm();
return  Result.success("提交成功");
}

// Mapper接口(MyBatis示例)
@Select("SELECT COUNT(*) FROM request_logs " +
        "WHERE user_id = #{userId} AND request_time > #{startTime}")
int  countByUserIdAndTime(@Param("userId") Long userId, @Param("startTime") LocalDateTime startTime);

代码解析:通过request_logs表记录用户请求时间;提交表单时,统计用户5秒内的请求次数,若超过3次则拒绝;否则记录本次请求并处理业务,限制单位时间内的请求频率。

扩展说明:单机场景可用内存计数器(如Guava RateLimiter),分布式场景需用数据库或分布式缓存实现滑动窗口。限流阈值需根据业务承载能力设置,避免影响正常用户体验。

8. POST/REDIRECT/GET模式:避免浏览器刷新导致的重复提交

传统Web应用中,用户提交POST请求后若刷新页面,浏览器会重复发送POST请求导致重复提交。POST/REDIRECT/GET模式通过重定向解决该问题:用户提交POST请求后,服务端处理完成并返回302重定向,引导浏览器发送GET请求到结果页;此时刷新页面仅重复GET请求,不会重复提交数据。

该方案适用于传统Web应用,不适用于API接口场景。

// POST/REDIRECT/GET示例(Java Spring MVC)
@PostMapping("/placeOrder")
public  String placeOrder(OrderForm form, RedirectAttributes redirectAttrs) {
// 处理订单提交
  Long orderId = orderService.createOrder(form);
// 将结果存入重定向属性(临时存储,用于跳转后获取)
  redirectAttrs.addFlashAttribute("orderId", orderId);
// 重定向到订单结果页(GET请求)
return "redirect:/orderResult";
}

// 订单结果页(GET请求)
@GetMapping("/orderResult")
public  String orderResult(Model model) {
// 从FlashAttribute获取订单ID
  Long orderId = (Long) model.getAttribute("orderId");
// 查询订单信息
  Order order = orderService.getById(orderId);
  model.addAttribute("order", order);
return "orderResult"; // 返回结果页面
}

代码解析placeOrder接口处理POST提交后,通过redirect:/orderResult重定向到GET接口;orderResult接口查询订单结果并展示。用户刷新页面时,仅重复GET请求,不会再次执行订单创建逻辑。

扩展说明RedirectAttributesflashAttribute可临时存储数据,避免重定向时参数暴露在URL中。该模式仅适用于服务端渲染的Web应用,前后端分离架构中较少使用。

实际项目开发中的方案选择

实际开发中,单一方案往往无法覆盖所有场景,需根据业务特点组合使用:

  • 简单表单场景(如用户注册):前端防抖+按钮禁用 + 令牌机制,既能减少无效请求,又能通过后端Token校验防重。

  • 高并发订单场景:幂等性设计(请求ID) + 数据库唯一约束(订单号),确保即使重复请求也不会创建重复订单,且返回一致结果。

  • 库存更新场景:乐观锁 + 限流,既控制并发更新的正确性,又避免短时间内大量重复请求导致的系统压力。

  • 传统Web应用:POST/REDIRECT/GET模式 + 数据库唯一约束,解决浏览器刷新问题的同时,从数据层兜底防重。

选择方案时,需权衡业务复杂度、性能要求和开发成本,核心是构建“前端拦截-后端校验-数据约束”的多层防护体系,确保系统在各种场景下都能有效防止重复提交。

防止用户重复提交的核心,是从用户交互、请求校验、数据存储等多环节建立防线。无需Redis分布式锁时,上述8种方案可根据业务场景灵活组合,既能保证系统稳定性,又能降低实现复杂度。实际开发中,需结合业务特点选择最适合的方案,必要时通过压测验证方案的有效性。

Logo

更多推荐