一、业务场景介绍
优惠券、门票等限时抢购常常出现在各类应用中,这样的业务一般为了引流宣传而降低利润,所以一旦出现问题将造成较大损失,那么在业务中就要求我们对这类型商品严格限时、限量、每位用户限一次、准确无误的创建订单,这样的要求看似简单,但在分布式系统中,要求我们充分考虑高并发下的线程安全问题,今天我们来看一下两种解决思路。
二、基于Redisson分布式锁的秒杀方案
这里我们就不进行自定义redis锁了,Redisson 基于 Redis 实现了 Java 驻内存数据网格(In-Memory Data Grid),它不仅提供了对 Redis 原生命令的封装,还提供了一系列高级的分布式数据结构和服务,促进使用者对 Redis 的关注分离,让开发者能够更专注于业务逻辑,所以我们直接使用Redisson,但底层源码还是需要我们去自己学习掌握的。
1.流程概览
其实单看流程图我们就能发现这一连串的串行逻辑就会非常影响效率,我们先留着这个问题后面优化 。
2.具体实现
@Override
public Result generate(Long voucherId) {
//查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//活动是否开始/结束
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("活动未开始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("活动已结束!");
}
//库存表是否充足
if (voucher.getStock()<1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();//只锁同一个id
//创建锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁,防止同一用户的并发请求
boolean isLock = lock.tryLock();//默认不等待,30秒过期
if (!isLock) {
//获取锁失败
return Result.fail("网络繁忙!");
}
//拿到spring事务代理,这里为了简单解决事务自调用直接去拿代理可能造成问题,建议将事务方法重构至另一服务类并注入
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
@Transactional//要锁住事物,防止事物在锁释放后才提交导致其他线程进入
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//一人一单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count >0) {
return Result.fail("您最多只可购买一单!");
}
//扣减库存
boolean flag = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId).gt("stock",0)
.update();
if (!flag){
//高并发下已经被其他用户线程扣减
return Result.fail("库存不足2!");
}
//创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//唯一ID
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//用户id
voucherOrder.setUserId(userId);
//代金券Id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//返回订单ID
return Result.ok(orderId);
}
3.测试分析
接下来我们登录数据库中所有的用户并记录Authorization
@SpringBootTest
@Component
public class SecKill {
@Autowired
private IUserService userService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
void userLogin() throws IOException {
// 定义保存 token 的文件路径
String filePath = "D:\\tokens.txt";
// 使用 BufferedWriter 写入文件
try (BufferedWriter writer = new BufferedWriter(new FileWriter(filePath, true))) { // 追加模式
for (User user : userService.list()) {
String phone = user.getPhone();
HttpSession session = null;
userService.sendCode(phone, session);
String code = stringRedisTemplate.opsForValue().get("login:code:" + phone);
LoginFormDTO loginFormDTO = new LoginFormDTO();
loginFormDTO.setCode(code);
loginFormDTO.setPhone(phone);
String token = userService.logIn(loginFormDTO, session);
// 将 token 写入文件
writer.write(token);
writer.newLine(); // 换行
writer.flush(); // 刷新缓冲区,确保数据写入文件
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
然后我们设置优惠券数量为200,通过jmeter(一款测试工具,大家自行学习如何使用)模拟数据库中1000多个用户总计每秒1000的高并发请求
从聚合报告中可以看到虽然80%的异常率确实满足了我们对优惠券的限量要求,通过查看数据库订单和库存也不存在问题,但是我们可以看到我们的平均响应时间在高并发下达到了344ms,吞吐量只有1200左右,如果面临更高的并发难免因性能局限出现问题。
三、基于消息队列的异步秒杀
1.问题分析
正如我们一开始发现的,每个请求来到服务器都需要执行一串的数据库读写操作,而写操作耗时是比较久的,可是当我们确定用户抢单成功后只要能确保订单最终写入即可,无需让其阻塞请求,所以我们其实可以将读写操作分离开。
我们可以利用读操作完成下单资格的各种校验,校验成功即可对请求做出响应,那么后续写订单操作怎么完成呢?我们需要根据校验成功的记录完成写操作,那谁来完成校验成功的记录呢,这样记录是不是又和原来的读写串行一样了呢?
2.工具对比
首先我们的目的是加快请求响应效率,减轻数据库压力,其实我们需要的就是一个中间工具做到能够快速存储校验成功的记录并有限制的可控的逐渐将存储起来的记录转发给数据库让其创建订单,能做到上述要求的工具有很多,这里简单对比以下三种供大家参考。
特性/技术 | 阻塞队列 | Redis | MQ消息中间件(如RabbitMQ、Kafka) |
---|---|---|---|
系统解耦 | 低,主要用于单机环境 | 中,支持集群部署 | 高,天然用于系统解耦 |
异步通信 | 支持,但需要手动实现 | 通过发布/订阅模式实现 | 专为异步通信设计 |
削峰填谷 | 临时存储请求,能力有限 | 缓存请求,需合理设计策略 | 缓存大量请求,后端按速率消费 |
可靠性和持久性 | 依赖具体实现,需额外持久化 | 支持持久化,可靠性较高 | 高可靠性和持久性,支持消息确认 |
性能和吞吐量 | 受限于单机处理能力 | 性能较高,支持集群 | 最高,适用于大规模分布式系统 |
功能丰富性 | 单一,主要用于线程间通信 | 支持多种数据结构和操作 | 支持多种消息协议、路由机制等 |
开发和维护成本 | 低,但需手动实现异步逻辑 | 中等,易于实现和使用 | 高,需学习和理解相关协议和机制 |
适用场景 | 小规模、单机环境 | 中小规模、集群部署 | 大规模分布式系统、复杂路由 |
3.流程概览
由于阻塞队列局限较大,MQ中间件比较简单,这里我们以Redis中的stream为例(除此之外,list和PubSub也能实现,但是局限较大)实现异步秒杀。
对于红框部分,为了确保原子性,我们借助lua脚本完成,这样一来我们就将MySQL的读写操作分离开来,请求响应中只需要读取验证,用redis更高效的io操作完成简单记录,随后异步逐渐处理MySQl的订单写入。
4.具体实现
- lua脚本
--- --- Generated by EmmyLua(https://github.com/EmmyLua) --- Created by cds. --- DateTime: 2025/3/23 13:03 --- --1.参数列表 --1.1优惠券id local voucherId=ARGV[1] --1.2用户id local userId=ARGV[2] --1.3订单id local orderId=ARGV[3] --2.数据key --2.1库存key local stockKey='seckill:stock:' .. voucherId --2.2订单key local orderKey='seckill:order:' .. voucherId --脚本业务 --判断库存是否充足 if (tonumber(redis.call('get',stockKey))<=0) then --库存不足返回1 return 1 end --判断用户是否下单 if (redis.call('sismember',orderKey,userId)==1) then --下过单返回2 return 2 end --扣库存 redis.call('incrby',stockKey,-1) --下单 redis.call('sadd',orderKey,userId) --发送消息到消息队列 xadd stream.orders * k1 v1 k2 v2 .. redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,"id",orderId) return 0
- 具体业务
@Autowired private IVoucherOrderService proxy; //初始化lua脚本信息 private static final DefaultRedisScript<Long> SECKILL_SCRIPT; static { SECKILL_SCRIPT =new DefaultRedisScript<>(); SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); SECKILL_SCRIPT.setResultType(Long.class); } //异步单例线程 private static final ExecutorService SECKILL_ORDER_EXECTUOR= Executors.newSingleThreadExecutor(); //在spring的Bean初始化并注入后开始 @PostConstruct private void init(){ SECKILL_ORDER_EXECTUOR.submit(new VoucherOrderHandler()); } //线程任务 private class VoucherOrderHandler implements Runnable { String queueName = "stream.orders"; @Override public void run() { while (true) { try { //获取消息队列中的订单信息 XREAD GROUP group1 c1 count 1 block 2000 streams stream.orders > List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read( Consumer.from("group1", "c1"), StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), StreamOffset.create(queueName, ReadOffset.lastConsumed()) ); //判断消息是否获取成 if (list == null || list.isEmpty()) { //获取失败 没有消息,继续循环 continue; } //获取成功,可以下单 //解析消息中的订单信息 MapRecord<String, Object, Object> record = list.get(0); Map<Object, Object> value = record.getValue(); VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true); handleVoucherOrder(voucherOrder); //ACK确认 SACK stream.orders group1 id stringRedisTemplate.opsForStream().acknowledge(queueName, "group1", record.getId()); } catch (Exception e) { log.error("创建订单异常{}", e.getMessage()); //有异常去pendingList拿 handlePendingList(); } } } private void handlePendingList() { while (true) { try { //获取pending-list队列中的订单信息 XREAD GROUP group1 c1 count 1 block 2000 streams stream.orders 0 List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read( Consumer.from("group1", "c1"), StreamReadOptions.empty().count(1), StreamOffset.create(queueName, ReadOffset.from("0")) ); //判断消息是否获取成 if (list == null || list.isEmpty()) { //获取失败 pending-list没有消息,结束循环 break; } //获取成功,可以下单 //解析消息中的订单信息 MapRecord<String, Object, Object> record = list.get(0); Map<Object, Object> value = record.getValue(); VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true); handleVoucherOrder(voucherOrder); //ACK确认 SACK stream.orders group1 id stringRedisTemplate.opsForStream().acknowledge(queueName, "group1", record.getId()); } catch (Exception e) { log.error("创建订单异常{}", e.getMessage()); try { Thread.sleep(20); } catch (InterruptedException ex) { throw new RuntimeException(ex); } } } } } private void handleVoucherOrder(VoucherOrder voucherOrder) { Long userId = voucherOrder.getUserId(); //创建锁对象 RLock lock = redissonClient.getLock("lock:order:" + userId); //获取锁 boolean isLock = lock.tryLock();//默认不等待,30秒过期 if (!isLock) { //获取锁失败 log.info("请勿重复购买!"); return; } //拿到spring事务代理 try { proxy.createVoucherOrder(voucherOrder); } finally { //释放锁 lock.unlock(); } } //这部分的检验是以防stream消息队列里出现问题导致重复save操作 @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 flag = seckillVoucherService.update() .setSql("stock=stock-1") .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0) .update(); if (!flag) { log.info("库存不足!"); return; } //创建订单 save(voucherOrder); } } @Override public Result secKill(Long voucherId) { //查询优惠券 SeckillVoucher voucher = seckillVoucherService.getById(voucherId); //活动是否开始/结束 if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("活动未开始!"); } if (voucher.getEndTime().isBefore(LocalDateTime.now())) { return Result.fail("活动已结束!"); } //库存表是否充足 if (voucher.getStock() < 1) { return Result.fail("库存不足!"); } //获取用户 Long userId = UserHolder.getUser().getId(); //1执行lua脚本 //唯一ID long orderId = redisIdWorker.nextId("order"); Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString(), String.valueOf(orderId)); int r = result.intValue(); //2判断lua脚本返回值0 if (r != 0) { //2.1不为零无资格 return Result.fail(r == 1 ? "库存不足!" : "不能重复下单!"); } return Result.ok(orderId); }
5.测试分析
我们再次使用jmeter进行同样的测试,但这次我们需要提前将库存信息同步到redis
可以看到经过优化的秒杀业务吞吐量大大增加,平均响应时间降低到30ms左右,得到了十倍左右的提升,大大增加了响应处理效率
redis订单记录
redis消息队列记录
如果去控制台观察日志可以发现,删改请求少量穿插在中间,大部分聚集在查询校验结束的末尾,读操作基本都聚集在最前面,DB操作得到有效控制,这就是异步写入处理的体现
好了,本次分享到这里结束,谢谢阅读!