目录
(一)在测试类中添加如下方法,生成1000个用户的token
异步秒杀优化
一、回顾同步下单流程
当用户发起请求,此时会请求nginx,nginx会访问到tomcat,而tomcat中的程序,会进行串行操作,分成如下几个步骤
1、查询优惠卷
2、判断秒杀库存是否足够
3、查询订单
4、校验是否是一人一单
5、扣减库存
6、创建订单
流程图如下所示:
由这个图可以看出在这六步操作中,又有很多操作是要去操作数据库的,而且还是一个线程串行执行, 这样就会导致我们的程序执行的很慢
二、同步下单秒杀测试
(一)在测试类中添加如下方法,生成1000个用户的token
@Test
public void generateToken() throws Exception{
//数据库查询1000个用户的信息
List<User> list = userService.list(new QueryWrapper<User>().last("limit 1000"));
//创建字符输入流准备写入token到文件
BufferedWriter br = new BufferedWriter(new FileWriter("D:\\JavaProject\\IdeaProject\\hm-dianping\\hm-dianping\\src\\main\\resources\\Tokens.txt"));
for (User user : list) {
//随机生成token作为登录令牌
String token = UUID.randomUUID().toString(true);
//将User对象转为HashMap存储到Redis中
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) ->
fieldValue.toString()));
//保存用户信息到Redis中
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.SECONDS);
//写入token到文件
br.write(token);
br.newLine();
br.flush();
}
}
其中文件写入地址可以放在Resource目录下,地址更换为自己的位置
执行后的结果如下:
清空订单表,更改秒杀库存为200
(二)接下来,我们通过jemeter进行测试
1.Jmeter中线程数设为1000,执行时间为1秒,模拟1000个用户高并发访问秒杀业务
2.添加http信息头管理器,值修改如图所示
3.添加csv
4.进行如下配置,文件名换成自己的token文件的地址
5.测试秒杀id为14的优惠券接口
6.查看聚合报告
由于JMeter模拟发送请求不是同时发送,是又少到多的请求,所以响应时间最小值是198毫秒,最大值是3088毫秒,平均响应时间是957毫秒,吞吐量为323.2/sec(随着并发量增加,吞吐量减少)
7.查看数据库,正常被扣减
三、改进秒杀业务,提高并发性能
(一)优化方案
我们将耗时比较短的逻辑判断放入到redis中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功, 再在后台开一个线程,后台线程慢慢的去执行queue里边的消息,这样程序不就超级快了吗?(简单来说就是让redis在前面先提前都把东西卖好快速响应给客户,我们再在后台慢慢的将订单写入数据库)
当然这里边有两个难点:
第一个难点是我们怎么在redis中去快速校验一人一单,还有库存判断
第二个难点是由于我们校验和tomct下单是两个线程,那么我们如何知道到底哪个单他最后是否成功,或者是下单完成,为了完成这件事我们在redis操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询我们tomcat中的下单逻辑是否完成了。
流程可以优化成这样:
-
请求转发:
用户请求先经 Nginx 转发,进入系统处理链路。 -
Redis 核心校验:
- 秒杀库存判断:在 Redis 中校验秒杀库存(如通过键值对快速判断库存是否充足),若库存不足,直接结束异常流程;若充足,进入下一步。
- 一人一单校验:通过 Redis 校验用户是否已下单(如检查用户 ID 是否在特定的 Set 集合中),确保同一用户只能参与一次秒杀。
-
队列存储与异步处理:
Redis 校验通过后,将优惠券 ID、用户 ID、订单 ID 等信息存入 阻塞队列。后台异步线程读取队列数据,触发后续下单操作。 -
Tomcat 服务端处理:
- 先查询优惠券详情,再二次校验秒杀库存(双重校验保障准确性)。
- 查询用户订单记录,再次校验一人一单规则。
- 确认无误后,执行减库存操作,最后在 MySQL 集群中创建订单,完成数据持久化。
- 结果返回:
系统将订单 ID 返回给用户,用户可通过该 ID 查询下单状态,实现前端与后端的状态交互。
优化流程图如下:
Redis 优化秒杀全流程详细解析:
1. Redis 数据结构基础
- 库存校验:通过键值对存储库存,例如
stock:vid:7
的 VALUE 为 100,直接判断该值是否大于 0,快速校验库存是否充足。 - 用户下单记录:利用 Set 集合(如
order:vid:7
存储用户 ID:1、2、3 等),校验用户是否已参与秒杀,实现 “一人一单” 限制。
2. Lua 脚本原子化核心逻辑
- 开始执行:
- 库存判断:先校验
stock:vid:7
的 VALUE,若库存不足,直接返回 1,流程结束。 - 用户下单判断:若库存充足,检查用户 ID 是否在
order:vid:7
的 Set 集合中。- 若已存在(已下单),返回 2,流程结束;
- 若不存在,执行扣减库存操作,同时将用户 ID 存入 Set 集合,最后返回 0。
- 库存判断:先校验
3. 后续处理流程
- 结果判断:系统判断 Lua 脚本返回值,若为 0,说明校验通过可下单。
- 入队列与异步处理:将优惠券 ID、用户 ID、订单 ID 存入阻塞队列,后台异步线程读取队列信息,完成数据库下单操作。
- 前端交互:返回订单 ID 给前端,用户可通过该 ID 查询订单状态,确认下单是否成功。
整个方案通过 Redis 快速校验、Lua 保证原子性操作、队列异步处理,高效解决秒杀场景下的库存校验、用户限制及高并发下单问题。
(二)操作实现
1.新增秒杀优惠券的同时,将优惠券信息保存到Redis中
①在VoucherServiceImpl类中添加以下方法
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
//新增时将秒杀券保存到Redis当中,不设置过期时间,秒杀到期后需要手动删除缓存
stringRedisTemplate.opsForValue().set(RedisConstants.SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());
}
②在apifox中进行添加秒杀券(我这里添加的是14号秒杀券)
{
"shopId":2,
"title": "100元代金券",
"subTitle" : "周一至周五均可使用",
"rules" : "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
"payValue" : 8000,
"actualValue" : 10000,
"type" : 1 ,
"stock" : 100,
"beginTime" : "2022-01-25T10:09:17",
"endTime" : "2030-01-26T12:09:04"
}
2.基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
-- 参数列表
local voucherId=ARGV[1] -- 优惠券id(用于判断库存是否充足)
local userId=ARGV[2] -- 用户id(用于判断用户是否下过单)
-- 构造缓存数据Key
local stockKey ='seckill:stock:' .. voucherId --库存key
local orderKey ='seckill:order' .. voucherId --订单key
-- 脚本业务
-- 判断库存是否充足
if tonumber(redis.call('get',stockKey))<=0 then
-- 库存不足,返回1
return 1
end
-- -- 判断用户是否下过单 SISMEMBER orderKey userId,SISMEMBER:判断Set集合中是否存在某个元素,存在返回1,不存在放回0
if redis.call('sismember',orderKey,userId)==1 then
-- 存在,说明用户已经下单,返回2
return 2
end
-- 缓存中预先扣减库存incrby stockKey -1
redis.call('incrby',stockKey,-1)
-- 下单(保存用户) sadd orderKey userId
redis.call('sadd',orderKey,userId)
-- 有下单资格,允许下单,返回0
return 0;
以下操作都在VoucherOrderServiceImpl类中实现
3.在Java中执行lua脚本
①初始化lua脚本
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
// 静态代码块初始化加载lua脚本
static {
SECKILL_SCRIPT=new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
②创建阻塞队列
//创建阻塞队列
private BlockingQueue<VoucherOrder> orderTasks=new ArrayBlockingQueue<>(1024*1024);
③执行lua脚本,如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
/**
* 秒杀优惠券
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
//1.获取用户id
Long userId = UserHolder.getUser().getId();
//2.执行Lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
//3.判断结果是否为0
int r=result.intValue();
if(r!=0){
//3.1不为0,表示没有购买资格
return Result.fail(r==1?"库存不足": "不能重复下单");
}
//3.2为0,表示有购买资格,把下单信息保存到阻塞队列
long orderId = redisIdWorker.nextId("order");
//4.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
//5.保存在阻塞队列中
orderTasks.add(voucherOrder);
//6.获取代理对象
proxy=(IVoucherOrderService) AopContext.currentProxy();
return Result.ok(orderId);
}
执行部分和保存部分如图所示:
4.开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
①创建一个线程来获取阻塞队列中的任务,进行下单
//创建一个线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR= Executors.newSingleThreadExecutor();
//在类创建的之后就立马开启任务
@PostConstruct
private void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while (true){
try {
//1.获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
//2.进行下单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常",e);
}
}
}
}
②异步线程进行下单并创建订单存入数据库
IVoucherOrderService proxy;
/**
* 进行下单
* @param voucherOrder
*/
private void handleVoucherOrder(VoucherOrder voucherOrder) {
// 从订单信息里获取用户id(从线程池中取出的是一个全新线程,不是主线程,所以不能从BaseContext中获取用户信息)
Long userId = voucherOrder.getUserId();
//创建锁对象(可重入),指定锁的名称
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
//尝试获取锁对象
boolean isLock = redisLock.tryLock();
//判断是否获取锁成功
if (!isLock){
//获取锁失败,直接返回失败或者重试
log.error("不允许重复下单!");
return;
}
try{
//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效
proxy.createVoucherOrder(voucherOrder);
}finally {
//释放锁
redisLock.unlock();
}
}
/**
* 创建订单存入数据库
* @param voucherOrder
*/
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
//查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
//判断是否存在
if(count>0){
//用户已经购买过了
log.error("用户已经购买过了");
return;
}
//扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0)
.update();
if (!success){
//扣减失败
log.error("库存不足");
return;
}
save(voucherOrder);
}
-
注意:
AopContext.currentProxy()
底层也是利用ThreadLocal
获取的,所以异步线程中也无法使用。解决方案就是提升代理对象的作用域,放到成员变量位置,在主线程中初始化,或者在主线程中创建后作为方法参数一起传递给阻塞队列。
如图所示提升了代理对象的作用域:
在主线程中初始化:
完整代码展示:
VoucherOrderServiceImpl类
package com.hmdp.service.impl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisConstants;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.PostConstruct;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* <p>
* 服务实现类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private SeckillVoucherServiceImpl seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedissonClient redissonClient;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
// 静态代码块初始化加载lua脚本
static {
SECKILL_SCRIPT=new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
//创建一个线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR= Executors.newSingleThreadExecutor();
//在类创建的之后就立马开启任务
@PostConstruct
private void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while (true){
try {
//1.获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
//2.进行下单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常",e);
}
}
}
}
IVoucherOrderService proxy;
/**
* 进行下单
* @param voucherOrder
*/
private void handleVoucherOrder(VoucherOrder voucherOrder) {
// 从订单信息里获取用户id(从线程池中取出的是一个全新线程,不是主线程,所以不能从BaseContext中获取用户信息)
Long userId = voucherOrder.getUserId();
//创建锁对象(可重入),指定锁的名称
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
//尝试获取锁对象
boolean isLock = redisLock.tryLock();
//判断是否获取锁成功
if (!isLock){
//获取锁失败,直接返回失败或者重试
log.error("不允许重复下单!");
return;
}
try{
//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效
proxy.createVoucherOrder(voucherOrder);
}finally {
//释放锁
redisLock.unlock();
}
}
//创建阻塞队列
private BlockingQueue<VoucherOrder> orderTasks=new ArrayBlockingQueue<>(1024*1024);
/**
* 秒杀优惠券
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
//1.获取用户id
Long userId = UserHolder.getUser().getId();
//2.执行Lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
//3.判断结果是否为0
int r=result.intValue();
if(r!=0){
//3.1不为0,表示没有购买资格
return Result.fail(r==1?"库存不足": "不能重复下单");
}
//3.2为0,表示有购买资格,把下单信息保存到阻塞队列
long orderId = redisIdWorker.nextId("order");
//4.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
//5.保存在阻塞队列中
orderTasks.add(voucherOrder);
//6.获取代理对象
proxy=(IVoucherOrderService) AopContext.currentProxy();
return Result.ok(orderId);
}
/**
* 创建订单存入数据库
* @param voucherOrder
*/
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
//查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
//判断是否存在
if(count>0){
//用户已经购买过了
log.error("用户已经购买过了");
return;
}
//扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0)
.update();
if (!success){
//扣减失败
log.error("库存不足");
return;
}
save(voucherOrder);
}
// @Transactional
// public Result createVoucherOrder(Long voucherId) {
// Long userId = UserHolder.getUser().getId();
// synchronized(userId.toString().intern()){
// // 5.1.查询订单
// int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// // 5.2.判断是否存在
// if (count > 0) {
// // 用户已经购买过了
// return Result.fail("用户已经购买过一次!");
// }
//
// // 6.扣减库存
// boolean success = seckillVoucherService.update()
// .setSql("stock = stock - 1") // set stock = stock - 1
// .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
// .update();
// if (!success) {
// // 扣减失败
// return Result.fail("库存不足!");
// }
//
// // 7.创建订单
// VoucherOrder voucherOrder = new VoucherOrder();
// // 7.1.订单id
// long orderId = redisIdWorker.nextId("order");
// voucherOrder.setId(orderId);
// // 7.2.用户id
// voucherOrder.setUserId(userId);
// // 7.3.代金券id
// voucherOrder.setVoucherId(voucherId);
// save(voucherOrder);
//
// // 7.返回订单id
// return Result.ok(orderId);
// }
// }
//
// /**
// * 秒杀优惠券
// * @param voucherId
// * @return
// */
// @Override
// public Result seckillVoucher(Long voucherId) {
// //1. 查询优惠券信息
// SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// //2. 判断秒杀是否开始
// if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
// //2.1还没有开始
// return Result.fail("秒杀尚未开始");
// }
// //2.2已经开始
// //3.判断库存是否充足
// if (voucher.getStock() < 1){
// //3.1库存不足
// return Result.fail("库存不足");
// }
// //3.2库存充足
// //根据用户id和优惠券id查询订单
// Long userId = UserHolder.getUser().getId();
// //创建锁对象
SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate,"order:" + userId);
// RLock lock = redissonClient.getLock(RedisConstants.LOCK_KEY_PREFIX + RedisConstants.SECKILL_ORDER_KEY + userId);
// //获取锁
// boolean isLock = lock.tryLock(); //空参默认失败不等待,直接返回结果
// //加锁失败
// if(!isLock){
// return Result.fail("不允许重复下单");
// }
//
// try {
// //获取代理对象(事务)
// IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// return proxy.createVoucherOrder(voucherId);
// } finally {
// //释放锁
// lock.unlock();
// }
// }
}
IVoucherOrderService接口
package com.hmdp.service;
import com.hmdp.dto.Result;
import com.hmdp.entity.VoucherOrder;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* <p>
* 服务类
* </p>
*
* @author 虎哥
* @since 2021-12-22
*/
public interface IVoucherOrderService extends IService<VoucherOrder> {
/**
* 秒杀下单
* @param voucherId
* @return
*/
Result seckillVoucher(Long voucherId);
/**
* 代理方法
* @param voucherId
* @return
*/
void createVoucherOrder(VoucherOrder voucherId);
//Result createVoucherOrder(Long voucherId);
}
(三)优化压力测试
还是同样的步骤,这里再次进行演示
1. ①数据库秒杀券设置为200
②清空数据库订单表
③清空redis秒杀订单信息,
④ redis秒杀券设置为200
2.Jmeter中线程数设为1000,执行时间为1秒,模拟1000个用户高并发访问秒杀业务
3.添加http信息头管理器,值修改如图所示
4.添加csv
5.进行如下配置,文件名换成自己的token文件的地址(确保token没有失效)
6.测试秒杀id为14的优惠券接口
7.查看聚合报告
由于JMeter模拟发送请求不是同时发送,是又少到多的请求,所以响应时间最小值是87毫秒,最大值是1037毫秒,平均响应时间是488毫秒,吞吐量为963.4/sec(随着并发量增加,吞吐量减少),可以看出执行耗时相较上面减少,吞吐量大幅增加,提高了秒杀系统的并发性能!
8.查看数据库,正常被扣减
四、小总结
1.秒杀业务的优化思路是什么?
①先利用Redis完成库存余量、一人一单判断,完成抢单业务
② 再将下单业务放入阻塞队列,利用独立线程异步下单
2.基于阻塞队列的异步秒杀存在哪些问题?
① 内存限制问题(JDK的阻塞队列使用的是JVM的内存,高并发订单量可能导致内存溢出,队列大小是由我们自己指定的,可能会超出阻塞队列的上限)
② 数据安全问题(情况①:JVM内存是没有持久化机制的,服务重启或意外宕机时,阻塞队列中的所有任务都会丢失。情况②:当我们从阻塞队列拿到一个任务尚未处理时,如果此时发生异常,该任务也会丢失,就没有机会再次被处理了,导致数据不一致)