文章目录
- [0. 前言](#0. 前言)
- [1. 常见的重复提交订单的场景](#1. 常见的重复提交订单的场景)
- [2. 防止订单重复提交的解决方案](#2. 防止订单重复提交的解决方案)
-
- [2.1 前端(禁用按钮)](#2.1 前端(禁用按钮))
- [2.2 后端](#2.2 后端)
- [3. 在SpringBoot项目中利用Redis实现防止订单重复提交](#3. 在SpringBoot项目中利用Redis实现防止订单重复提交)
-
- [3.1 引入依赖](#3.1 引入依赖)
- [3.2 编写配置文件](#3.2 编写配置文件)
- [3.3 OrderService.java](#3.3 OrderService.java)
- [3.4 OrderController.java](#3.4 OrderController.java)
- [3.5 index.html](#3.5 index.html)
- [4. 需要注意的问题](#4. 需要注意的问题)
阅读本文前可以先阅读我的另一篇博文: Windows环境下安装Redis并设置Redis开机自启
0. 前言
在涉及订单操作的业务中,防止订单重复提交是一个常见需求
用户可能会因误操作或网络延迟而多次点击提交订单按钮,导致订单重复提交,造成数据冗余,而且订单通常与库存紧密关联,重复提交订单不仅会影响用户体验,还有可能引发库存管理上的混乱,甚至导致财务数据出现偏差,带来一系列潜在的经济风险
1. 常见的重复提交订单的场景
- 网络延迟:由于网络问题,用户在提交订单后页面没有发生变化,而且没有收到通知,用户误以为订单没有提交成功,连续点击提交按钮
- 刷新页面:用户提交订单后刷新页面,再次提交相同的订单
- 用户误操作:用户无意中点击多次订单提交按钮
- 恶意攻击:大量请求绕过前端页面直接到达后端
2. 防止订单重复提交的解决方案
2.1 前端(禁用按钮)
用户点击提交订单按钮后,在成功跳转到支付页面之前,禁用提交订单按钮,防止用户多次执行提交订单
禁用提交订单按钮只能避免一部分订单重复提交的情况,如果用户点击支付按钮之后刷新页面,依然是可以重复下单的,要想完全解决订单重复提交的问题,后端也要做相应的处理
2.2 后端
我们可以借助 Redis 实现防止订单重复提交的功能
- 生成订单前的操作 :在订单生成之前,我们以
业务名+商家唯一标识+商品唯一标识+用户唯一标识
形成的字符串为 key、以任意一个字符串作为 value,将键值对保存到 Redis 中,并为键值对设置一个合理的过期时间(过期时间可以根据业务需求来设定,以确保在用户完成订单操作之前,键值对始终有效) - 订单处理完成后的操作:一旦订单成功支付或者被取消,我们就从 Redis 中删除对应的键,释放占用的内存资源,防止在键值对过期之前对订单状态产生误判
key 的形式不唯一,但要确保一个 key 对应一个订单
当客户端发起提交订单的请求时,后端会检查 Redis 中是否存在对应的键
- 如果存在,表明该订单已经被提交过,这是一个重复的提交请求,系统将拒绝此次请求,不会生成新的订单
- 如果不存在,说明这是一个新的订单提交请求,系统将继续执行订单生成的流程,并存储新的键值对到 Redis 中,以防止后续的重复提交
3. 在SpringBoot项目中利用Redis实现防止订单重复提交
本次演示的后端环境为:JDK 17.0.7 + SpringBoot 3.0.2
3.1 引入依赖
Redis
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Web
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
3.2 编写配置文件
application.yml(Redis 单机)
yaml
spring:
data:
redis:
host: localhost
port: 6379
password: 123456
timeout: 5000ms
database: 0
server:
port: 10016
application.yml(Redis 集群)
yaml
spring:
data:
redis:
cluster:
nodes: 127.0.0.1:6379
server:
port: 10016
3.3 OrderService.java
利用 Redis 提供的 setnx 指令
java
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class OrderService {
private final StringRedisTemplate stringRedisTemplate;
public OrderService(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void generateToken(String key) {
stringRedisTemplate.opsForValue().setIfAbsent(key, "uniqueTokenForOrder", 10, TimeUnit.MINUTES);
}
public boolean isOrderDuplicate(String token) {
return Boolean.TRUE.equals(stringRedisTemplate.hasKey(token));
}
}
3.4 OrderController.java
java
import cn.edu.scau.pojo.SubmitOrderDto;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/order")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping("/pay")
public ResponseEntity<String> pay(@RequestBody SubmitOrderDto submitOrderDto) {
String key = "order:" + submitOrderDto.getBusinessId() + ":" + submitOrderDto.getGoodsId() + ":" + submitOrderDto.getUserId();
if (orderService.isOrderDuplicate(key)) {
return ResponseEntity.ok("订单重复提交,请勿重复操作,您可以确认一下有没有未支付的相同订单");
}
orderService.generateToken(key);
// 处理订单逻辑
return ResponseEntity.ok("订单提交成功");
}
}
SubmitOrderDto.java
java
public class SubmitOrderDto {
private String businessId;
private String goodsId;
private String userId;
public String getBusinessId() {
return businessId;
}
public void setBusinessId(String businessId) {
this.businessId = businessId;
}
public String getGoodsId() {
return goodsId;
}
public void setGoodsId(String goodsId) {
this.goodsId = goodsId;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
@Override
public String toString() {
return "SubmitOrderDto{" +
"businessId='" + businessId + '\'' +
", goodsId='" + goodsId + '\'' +
", userId='" + userId + '\'' +
'}';
}
}
3.5 index.html
简单起见,本次演示前后端不分离,index.html 文件存放在 resources/static 目录下
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>防止订单重复提交</title>
<style>
body, html {
height: 100%;
margin: 0;
font-family: 'Arial', sans-serif;
background-color: #f4f4f9;
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 100%;
max-width: 400px; /* 设置最大宽度 */
padding: 50px 0;
display: flex;
flex-direction: column;
align-items: center;
}
.button-container, .result-container {
width: 100%;
max-width: 300px; /* 按钮和结果显示文本同宽 */
margin-bottom: 20px; /* 添加底部外边距 */
}
button {
width: 276px;
height: 67px;
padding: 20px;
font-size: 18px;
color: #ffffff;
background-color: #6a8eff;
border: none;
border-radius: 8px;
cursor: pointer;
outline: none;
transition: background-color 0.3s ease;
}
button:hover {
background-color: #527bff;
}
#result {
padding: 20px;
font-size: 18px;
color: #333333;
background-color: #ffffff;
border: 1px solid #e1e1e1;
border-radius: 8px;
text-align: center;
box-sizing: border-box;
width: 276px;
height: 67px;
}
</style>
</head>
<body>
<div class="container">
<div class="button-container">
<button onclick="submitOrder()">提交订单</button>
</div>
<div class="result-container" id="result"></div>
</div>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
const submitOrder = () => {
// 点击按钮后有0.5秒的加载效果
document.getElementById('result').innerText = '正在提交订单...'
let timer = setTimeout(() => {
axios
.post('/order/pay', {
businessId: '123456',
goodsId: '123456',
userId: '123456'
})
.then((response) => {
console.log('response =', response);
document.getElementById('result').innerText = response.data
})
.catch((error) => {
document.getElementById('result').innerText = '提交失败,请重试。'
console.error('error =', error);
})
clearTimeout(timer)
}, 500)
}
</script>
</body>
</html>
4. 需要注意的问题
- 如果在订单生成过程中出现错误,要确保有一个机制能够回滚之前的操作,比如删除已经插入 Redis 的键
- 避免因意外情况导致键未被及时清理,影响后续请求
- 如果处理的逻辑比较复杂,我们可以考虑使用通过切面(AOP)来解决,在切面中编写防止订单重复提交的代码