订单重复提交

订单重复提交指的是同一个订单多次提交的情况,常见原因如下:

  • 重复点击:用户可能会因为网络延迟、手抖、页面加载缓慢等原因,多次点击下单提交按钮
  • 页面刷新:用户在订单提交后,如果刷新页面,浏览器可能会重新发送上一次的表单提交请求

防止订单重复提交的本质就是保证下单方法不要被重复调用,这首先可以从前端处理

比如在用户点击提交按钮后,立即将按钮禁用或者改为提交中状态,防止用户再次点击

但是前端只能提高用户的体验,不能保证安全,因为用户可以通过一些脚本提交请求,因此还需要后端处理

后端的处理方式有很多,经常用到的就是Token机制分布式锁

Token机制

  • 生成Token:用户每次进入下单页面,前端会向服务器请求一个唯一的Token
  • 存储Token:后端将生成Token存放在Redis中,并设置一定的过期时间,然后给前端返回
  • 验证Token:用户提交订单时需要携带Token,服务器验证Token,一旦Token被使用过就失效
whiteboard_exported_image (1)

分布式锁

分布式锁是在分布式系统下的锁机制,旨在保证代码在加锁期间只能被同一个线程访问

实现分布式锁的方案有很多,比如下面这些:

  • MySQL:可以利用MySQL主键、唯一列不能保存重复数据这一特性做分布式锁
  • Redis:可以利用setnx命令无法向Redis重复保存同一key的特性做分布式锁
whiteboard_exported_image (2)

Redisson

Redisson是一个高级的Redis客户端,借助它可以轻松的实现使用Redis做分布式锁、延迟消息队列等功能

相比于RedisTemplate之类的客户端,它更侧重于对Redis功能的模块化封装

API

RedissonClient是Redisson的客户端对象,核心方法如下:

  • 定义锁:getLock(锁)
  • 获取锁:tryLock(获取锁的超时时间, 持有锁的最大时间, 时间单位)
  • 释放锁:unlock()

注意:释放锁的代码一定要写在finally中,保证锁一定可以得到释放

将资料中的demo-redisson.zip解压后导入到Idea中,打开测试类,学习它的核心API

  1. 创建客户端连接对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class RedissonConfiguration {
@Bean
public RedissonClient redissonClient() {
// 创建配置类,设置连接地址和密码
Config config = new Config();
config.useSingleServer()
.setAddress("redis://192.168.101.68:6379")
.setPassword("redis");

// 创建客户端
return Redisson.create(config);
}
}
  1. 在测试类中测试分布式锁的使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@SpringBootTest
public class RedissonTest {

@Autowired
private RedissonClient redissonClient;

@Test
public void test() {
//模拟启动三个线程去获取锁
for (int i = 0; i < 3; i++) {
new Thread(() -> {
//定义锁对象,参数就是锁的标识
RLock lock = redissonClient.getLock("LOCK");
try {
//尝试获取锁对象
//参数1: 获取锁的超时时间
//参数2: 持有锁的最大时间
//参数3: 时间单位
//返回结果代表获取锁是否成功
boolean flag = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!flag) {
throw new RuntimeException("请求太频繁");
}
//模拟业务逻辑代码
System.out.println(Thread.currentThread().getId() + "执行逻辑");
//休眠
ThreadUtil.sleep(1, TimeUnit.MINUTES);
} catch (Exception e) {
System.out.println(Thread.currentThread().getId() + "抢锁失败");
} finally {
lock.unlock();//释放锁
}
}).start();
}

//不让主线程退出
ThreadUtil.sleep(30000000);
}
}

实现防重复下单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Autowired
private RedissonClient redissonClient;

@GetMapping("/placeOrder")
public void placeOrder(){
//定义锁对象
RLock lock = redissonClient.getLock("LOCK");
try {
//尝试获取锁对象
//参数1: 获取锁的超时时间
//参数2: 持有锁的最大时间
//参数3: 时间单位
boolean flag = lock.tryLock(1, 30, TimeUnit.SECONDS);
if (!flag) {
throw new RuntimeException("请求太频繁");
}

//执行下单业务逻辑,耗时3秒
System.out.println(Thread.currentThread().getId() + "下单成功");
ThreadUtil.sleep(3000);

} catch (Exception e) {
System.out.println(Thread.currentThread().getId() + "下单失败");
} finally {
//不去手动释放锁,代表是让锁等到最大持有时间(30秒)后,自动释放
//lock.unlock();//释放锁
}
}

AOP优化代码

上一步的代码虽然可以实现防止重复下单的功能,但是代码繁琐,可以考虑使用AOP的方式来进行优化

当前提供的工程中已经提供好了一个切面类LockAspect,在类中实现的加锁和解锁的逻辑

直接在方法中使用它来简化下单的方法,具体实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
public class OrderController {

// formatter: 锁标识
// waitTime: 获取锁的超时时间,默认单位是秒
// time: 持有锁的最大时间,默认单位是秒
// unlock: 方法执行完毕是否需要自动释放锁
@Lock(formatter = "Lock2", waitTime = 1, time = 30, unlock = false)
@GetMapping("/placeOrder")
public void placeOrder() {
//执行下单业务逻辑,耗时3秒
System.out.println(Thread.currentThread().getId() + "下单成功");
ThreadUtil.sleep(3000);
}
}

AOP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
package com.heima.redisson.aspect;

import com.heima.redisson.annotation.Lock;
import com.heima.redisson.util.AspectUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

/**
* @author Mr.M
* @version 1.0
* @description 分布式锁工具类
* @date 2023/7/23 22:56
*/
@Aspect
@Component
public class LockAspect {

@Autowired
private RedissonClient redissonClient;

@Around("@annotation(lock)")
public Object handleLock(ProceedingJoinPoint pjp, Lock lock) throws Throwable {
//锁key的格式化字符,此字符串中有spEL表达式
String formatter = lock.formatter();
Method method = AspectUtils.getMethod(pjp);
Object[] args = pjp.getArgs();
//得到锁的key
String redisKey = AspectUtils.parse(formatter, method, args);
//获取锁阻塞等待的时间,如果是0表示去尝试获取锁,如果获取不到则结束
long waitTime = 0;
//阻塞等待获取锁
if (lock.block()) {
//根据时间单位转换成ms
waitTime = lock.waitTime();
}
//加锁时长
long time = lock.time();
//启动看门狗自动续期
if (lock.startDog()) {
time = -1;
//如果设置自动续期必须在方法执行后释放锁
if (!lock.unlock()) {
throw new RuntimeException("请求参数不合法");
}
}
//得到锁对象
RLock rLock = redissonClient.getLock(redisKey);
//尝试加锁
boolean success = rLock.tryLock(waitTime, time, lock.unit());
if (!success && !lock.block()) {
//未阻塞要求的情况下未得到锁
throw new RuntimeException("操作频繁,请稍后重试");
}
if (!success) {
//阻塞情况下未得到锁,请求超时
throw new RuntimeException("请求超时");
}

try {
return pjp.proceed();
} finally {
if (lock.unlock()) {
rLock.unlock();
}
}
}

}