【黑马点评日记】Redis+Lua+异步队列:高并发秒杀系统优化方案
个人主页北极的代码欢迎来访作者简介java后端学习者❄️个人专栏苍穹外卖日记SSM框架深入JavaWeb✨命运的结局尽可永在不屈的挑战却不可须臾或缺前言我们之前学的Lua脚本把库存扣减和用户去重都放在 Redis 里原子执行。这一步确实挡住了无效请求卖完/重复也解决了大部分的秒杀问题但是还有一些值得优化的地方。摘要本文探讨秒杀系统从同步处理到异步架构的演进过程。传统同步模式Lua脚本即时写库面临三大痛点数据库瞬时压力导致宕机风险、用户响应延迟、系统耦合度高。异步模式通过RedisLua脚本快速完成资格校验后立即将请求转入消息队列实现三大优势数据库写入峰值从5000QPS降至稳定500QPS用户响应时间从秒级降至毫秒级业务逻辑解耦为独立模块。技术实现采用双线程模型Tomcat线程生产者专注快速校验2ms完成消费者线程异步处理耗时操作50-100ms。关键代码包括Redis库存预热、原子性Lua脚本校验、阻塞队列实现任务缓冲。这种架构使系统吞吐量提升20倍200线程支持10万QPS同时通过削峰填谷保护数据库体现了快速响应、异步消化的设计哲学。问题分析只靠 Redis Lua系统还面临什么问题java// 1. 在 Redis 里 Lua 脚本执行成功扣了库存记录了用户 // 2. 返回成功然后你需要执行 // - 创建订单对象 // - 写数据库订单表、库存表、活动记录表... // - 调用积分接口如果有 // - 发送通知 // ...问题来了假设 1 秒内有5000 个请求成功。那服务器就要在1 秒内同步执行 5000 次“创建订单 写库 调用其他服务”。数据库每秒能承受 5000 次写入吗大概率不能数据库会卡死甚至宕机。用户的 HTTP 连接会一直等待你做完这些事情才返回响应时间从几毫秒变成了几秒。这时我们才会发现Lua 脚本只是守住了仓门但仓库里处理单据的柜台数据库被挤爆了。异步秒杀的核心目标异步秒杀就是要解决上面这个“后续处理导致的系统崩溃和响应慢”问题。它的做法是Lua 脚本验证成功扣完 Redis 库存后立即给用户返回一个“排队中请稍候”或“抢购成功处理中”。耗时仅几毫秒HTTP 连接立刻释放用户前端开始转圈等待。系统把“谁买了什么”这条消息丢进一个消息队列。后端一个或几个消费者服务从消息队列里慢慢取消息逐一创建订单、写数据库。等订单创建好了再通过 WebSocket 或轮询通知用户“您抢购成功啦”。异步秒杀额外解决了什么问题问题同步模式Lua脚本 即时写库异步模式Lua脚本 MQ 异步写库数据库压力瞬时并发写入直接把库打崩。削峰填谷变成了稳定的低速写入保护数据库。系统响应用户等 2-5 秒才返回结果体验差。用户几十毫秒就收到“排队中”体验好得多。系统耦合下单逻辑、扣库存、积分等代码耦合在一起。秒杀结束立即返回后续逻辑解耦各自独立处理。容错性如果下单时积分服务挂了秒杀直接失败。积分服务故障只影响后续处理可以重试或补偿不影响秒杀资格判定。Lua 脚本解决这个东西能不能卖和卖给谁的问题。核心是防超卖、防重异步消息队列解决“卖成功了之后怎么慢慢处理后续复杂业务同时又不把系统干崩”的问题。核心是削峰、解耦、自保一句话如果并发量很低Lua 脚本扣完库存直接写库没问题。但到了秒杀这种量级异步处理不是为了让程序能更正确地卖。异步模式正确做法text5 万个请求/秒 │ ▼ ┌─────────────────────────────────────────┐ │ Tomcat 线程池200 个 │ │ ┌─────────────────────────────────────┐ │ │ │ 每个线程只做 │ │ │ │ 1. Redis 操作用 1ms │ │ │ │ 2. 发送 MQ 消息用 1ms │ │ │ │ 3. 返回结果 │ │ │ │ │ │ │ │ 一个线程 1 秒能处理 500 个请求 │ │ │ │ 200 个线程 1 秒能处理 10 万个请求 │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────┘ │ ▼ 所有请求都能处理100ms 内返回排队中 │ ▼ ┌─────────────────────────────────────────┐ │ 消息队列 │ │ ┌─────────────────────────────────────┐ │ │ │ 消息排队慢慢消费 │ │ │ │ 消费者以 500 QPS 的速度写数据库 │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────┘第一步Tomcat 线程处理请求只做轻量级工作text用户请求 ↓ Tomcat 线程从线程池中取一个 ↓ 执行 Lua 脚本扣 Redis 库存、校验资格 ← 耗时 1-2 毫秒 ↓ 如果成功发送一条消息到消息队列 ← 耗时 1 毫秒 ↓ 立刻返回给用户“您已排队请稍后查询结果” ↓ 【这个 Tomcat 线程就此结束回到线程池去处理下一个请求】关键点Tomcat 线程全程没碰数据库总耗时只有 3 毫秒左右。它和“后续处理”完全没有关系了。第二步独立线程消费者处理重任务与 Tomcat 线程完全无关text消息队列里的一条消息内容是用户 1001 秒杀了商品 2001 ↓ 消费者线程池独立于 Tomcat取出这条消息 ↓ 执行 - 创建订单写数据库 ← 耗时 30 毫秒 - 扣减数据库库存 ← 耗时 10 毫秒 - 调用积分接口 ← 耗时 20 毫秒 ↓ 处理完成。如果需要通知用户再发一条消息或 WebSocket 推送。关键点这个线程和之前那个 Tomcat 线程不是同一个它们同时运行互不干扰。对比串联和异步两种模式模式流程Tomcat 线程耗时能抗的并发串联同步Redis → 订单 → 库存 → 积分 → 返回100 毫秒很低分开异步Redis → 发 MQ → 返回Tomcat 结束独立线程MQ → 订单 → 库存3 毫秒很高因为这是异步架构的核心概念Tomcat 线程是珍贵的、有限的一般 200 个。不能让它们去等数据库 I/O那样 200 个线程瞬间用完后续请求只能排队或超时。必须把慢操作交给别的线程让 Tomcat 线程只做快速操作Redis、发送消息然后立刻归还线程池。“独立线程”就是指不属于 Tomcat 线程池的那些线程。总之Redis 和 Tomcat 不是“分开执行”而是 Tomcat 线程调用 Redis 快速处理后就把任务交给另一个独立线程如 MQ 消费者去完成自己立刻返回去处理下一个请求。Tomcat 线程不再参与后续的数据库等慢操作。代码实现1.新增优惠卷时把信息保存到 Redis中Override 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(SECKILL_STOCK_KEYvoucher.getId(), voucher.getStock().toString());2.判断秒杀库存一人一单用 Lua 脚本是因为它既能保证检查库存 扣库存 记录用户这三步的原子性又能做条件判断库存不足就返回失败而且性能极高不会让线程阻塞等待。Redis 事务做不了条件判断分布式锁性能差且会阻塞。Lua 脚本是秒杀场景下唯一正确的选择。-- 1. 参数列表 -- 1.1. 优惠券id local voucherId ARGV[1] -- 1.2. 用户id local userId ARGV[2] -- 2. 数据key -- 2.1. 库存key local stockKey seckill:stock: .. voucherId -- 2.2. 订单key修正原代码写成了stockKey local orderKey seckill:order: .. voucherId -- 3. 脚本业务 -- 3.1. 判断库存是否充足 local stock redis.call(get, stockKey) if (tonumber(stock) 0) then -- 库存不足返回1 return 1 end -- 3.2. 判断用户是否已经下单 local isMember redis.call(sismember, orderKey, userId) if (isMember 1) then -- 用户已下单返回2 return 2 end -- 3.3. 扣减库存 redis.call(incrby, stockKey, -1) -- 3.4. 记录用户下单 redis.call(sadd, orderKey, userId) -- 3.5. 秒杀成功返回0 return 03.抢购成功信息加入阻塞队列中抢单流程判断以及执行lua脚本private static final DefaultRedisScriptLong SECKILL_SCRIPT ; //静态代码块进行初始化 static { SECKILL_SCRIPT new DefaultRedisScript(); SECKILL_SCRIPT.setLocation(new ClassPathResource(seckill.lua)); SECKILL_SCRIPT.setResultType(Long.class); } /** * 下单秒杀优惠卷 * param voucherId 优惠券id * return */ public Result seckillVoucher(Long voucherId){ //获取用户id Long userId UserHolder.getUser().getId(); //1.执行lua脚本 Long resultstringRedisTemplate.execute( SECKILL_SCRIPT , Collections.emptyList() , voucherId.toString(),userId.toString() ); //判断结果是否为0 int rresult.intValue(); //不为0则代表没有购买资格 if (r!0){ return Result.fail(r1?库存不足:不能重复下单); }基于阻塞队列实现异步下单阻塞队列是 Java 并发包提供的线程安全的队列可以用来实现简单的异步处理不依赖外部消息队列如 RabbitMQ、RocketMQ。Component Slf4j public class SeckillConsumer { Autowired private OrderService orderService; Autowired private SeckillService seckillService; // 获取阻塞队列需要暴露 // 方式一使用 PostConstruct 启动消费者线程 PostConstruct public void startConsumer() { // 启动一个独立的消费者线程 Thread consumerThread new Thread(() - { log.info(秒杀订单消费者线程启动); while (true) { try { // 从阻塞队列中取订单如果没有订单会阻塞 SeckillService.SeckillOrder order seckillService.getOrderQueue().take(); // 处理订单创建订单、扣数据库库存 processOrder(order); } catch (InterruptedException e) { log.error(消费者线程被中断, e); Thread.currentThread().interrupt(); break; } catch (Exception e) { log.error(处理订单失败, e); // 记录失败订单待人工处理 } } }); consumerThread.setName(seckill-consumer-thread); consumerThread.setDaemon(true); // 设置为守护线程 consumerThread.start(); } /** * 处理订单在独立线程中执行 */ private void processOrder(SeckillService.SeckillOrder order) { log.info(开始处理订单userId{}, voucherId{}, order.getUserId(), order.getVoucherId()); // 1. 创建订单写数据库 orderService.createOrder(order.getUserId(), order.getVoucherId()); // 2. 扣减数据库库存 boolean success orderService.deductStock(order.getVoucherId()); if (!success) { log.error(数据库库存扣减失败voucherId{}, order.getVoucherId()); // 这里可以发送告警进行补偿 } // 3. 可选发送通知WebSocket、短信等 notifyUser(order.getUserId(), 秒杀成功订单已创建); log.info(订单处理完成userId{}, voucherId{}, order.getUserId(), order.getVoucherId()); } /** * 模拟通知用户 */ private void notifyUser(Long userId, String message) { // 实际可以用 WebSocket、MQ、短信等 log.info(通知用户{}{}, userId, message); } }角色线程做什么耗时生产者Tomcat 线程Lua 脚本校验成功 → 把订单信息放入队列 → 立即返回排队中1-2 毫秒消费者独立的后台线程从队列取出订单 → 创建订单写数据库 → 扣库存50-100 毫秒关键生产者和消费者是不同线程互不阻塞。用户请求 → Lua 脚本1ms → 放入阻塞队列1ms ← 用户只等 2ms → 返回排队中 独立线程阻塞队列.take() → 创建订单50ms → 扣库存30ms → 完成后可选通知用户生产者用offer()非阻塞放入消费者用take()阻塞获取队列满时立即回滚 Redis 并降级服务关闭前等待队列清空。时间线图text项目启动 │ ├─ Tomcat 线程池启动等待请求 ├─ 消费者线程启动执行 orderQueue.take()进入阻塞状态 │ │ 此时队列为空消费者线程在睡觉不占用 CPU │ ▼ 第 1 秒用户 A 请求来了 │ │ Tomcat 线程 │ 1. 执行 Lua 脚本成功 │ 2. orderQueue.offer(订单A) ← 放入队列 │ 3. 返回排队中 │ │ 消费者线程被唤醒 │ 1. take() 拿到订单A │ 2. 创建订单写数据库 │ ▼ 第 2 秒用户 B 请求来了 │ │ Tomcat 线程放入订单B │ 消费者线程处理订单B如果订单A已处理完 │ ▼ 一直运行...注意生产者和消费者是同时存在的只是大部分时间消费者在阻塞没有订单时订单来了就被唤醒。形象比喻角色比喻说明Tomcat 线程餐厅前台收银员客人来了收钱、开单、把单子放到待做菜挂钩上然后立刻招呼下一个客人阻塞队列待做菜挂钩挂订单的地方消费者线程后厨厨师厨师一直在后厨看着挂钩有单子就拿去做菜慢做完再拿下一单结语如果对你有帮助请点赞关注收藏你的支持就是我最大的鼓励
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2566965.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!