高并发秒杀架构演进:从MySQL乐观锁到Redis Lua与Stream异步下单

发布时间:2026/9/10 4:12:15
高并发秒杀架构演进:从MySQL乐观锁到Redis Lua与Stream异步下单 1. 秒杀方案演进的每一个坑从MySQL纯扣减走到Redis预扣减1.1 先捋清业务链路一次秒杀请求到底经过了多少道关黑马点评的秒杀场景表面上看就是“用户点击秒杀按钮 → 返回成功/失败”但完整链路拆开其实很长。很多初学者第一次接触这个项目时会觉得代码量不多核心就一个SeckillVoucher、一个VoucherOrder真正难的是在高并发下把每一步都想清楚。一次完整秒杀请求大致要经过这么几层用户发起请求先查优惠券信息判断秒杀是否已经开始、是否已经结束。判断当前用户是否已经下过单防止重复秒杀。判断库存是否充足。扣减库存。创建订单。异步或同步返回结果。如果这套逻辑全用MySQL来实现在并发量小的时候没有问题毕竟一个学习项目本地跑个几十几百的并发MySQL还能扛。但只要把并发拉高到上千甚至上万问题就立刻暴露。我最初按照常规思路实现时是把优惠券库存字段直接放在数据库表里每次请求都走一遍“查询优惠券状态 → 判断库存 → 插入订单 → 扣减库存”的流程。测试并发200的时候就开始出现超卖而且接口响应时间从几十毫秒飙到好几秒。这里先说结论超卖的本质不是“并发冲突”四个字那么简单而是多条请求同时读到了同一个库存值然后各自以为自己扣减成功。数据库本身有行锁但前提是你要用对锁的粒度如果你先查库存再update中间天然存在竞态窗口。1.2 超卖不是并发数的问题是扣减和查询没有绑定在原子操作里网上很多人一聊超卖就说“用乐观锁加version字段”这确实是常规解法。黑马点评项目里也展示了乐观锁方案用库存作为version或者加一个独立的version字段update语句里带上条件update tb_seckill_voucher set stock stock - 1 where voucher_id #{voucherId} and stock 0这个写法是典型的“乐观锁扣减”它解决的问题是扣减动作本身带上了库存校验让“判断库存充足”和“扣减库存”变成一个原子操作。数据库的行锁会保证同一时刻只有一个事务能更新这一行其他事务的update会因为stock 0不成立而影响行数为0。但实际用下来你会发现这一版方案仍然只能解决“超卖”解决不了“性能瓶颈”。所有更新还是落在MySQL上请求量一大MySQL的连接池最先被打满接着就是行锁等待一个秒杀接口把整个数据库拖垮。我在本地测试时观察到的现象很有意思并发2000个请求其实真正能抢到库存的只有100个但剩下1900个请求全部打到了数据库上每个请求都要执行一次select一次update数据库连接瞬间满了连正常登录接口都跟着变慢。这说明了一个非常关键的问题秒杀场景里数据库不应该承接所有流量尤其不应该承接注定失败的流量。优化思路不是让数据库更快而是让大部分请求在到达数据库之前就结束。1.3 从数据库乐观锁到Redis预扣减方案是怎么一步步长出来的黑马点评的优化路线是分阶段的这也是我认为这个项目设计得比较好的地方它让你亲眼看到同一个问题在不同并发量下的不同解法。第一阶段纯MySQL 乐观锁。这个方案能保证不超卖但性能一般适合并发几百以内的小活动。第二阶段加一个Redis缓存优惠券信息把“判断秒杀时间”这一步提前到Redis里做减少对数据库的无效查询。但这只是减少了读压力写压力还在数据库上。第三阶段把库存也搬到Redis里用单线程特性做原子扣减。这一步的核心变化是库存判断和扣减都不再走MySQLRedis的INCR/DECR天然是原子的你用DECR扣减库存如果返回负数就说明库存不足需要把库存加回去。Long stock stringRedisTemplate.opsForValue().decrement(SECKILL_STOCK_KEY voucherId); if (stock 0) { // 扣减失败库存不足恢复库存 stringRedisTemplate.opsForValue().increment(SECKILL_STOCK_KEY voucherId); return Result.fail(库存不足); }这段逻辑看起来简单但它已经把数据库的压力卸掉了绝大部分。只有真正抢到库存的用户才会去创建订单其余所有失败请求在Redis这层就结束了。不过到了这一步项目又引入了新的问题订单还是要同步写入数据库如果100个用户同时抢到了库存数据库就要同时处理100个订单写入这个量级倒是不大但如果秒杀库存是10000呢那数据库瞬间还是会收到10000个写操作虽然比几百万个请求好太多但对一个普通单机数据库来说还是吃力。到这里消息队列就该上场了。2. Lua脚本与Redis事务让库存扣减和校验一次到位2.1 Redis事务为什么在秒杀场景里不够用很多人在看黑马点评的秒杀优化时会有一个疑问Redis有MULTI/EXEC事务为什么不能直接用事务来保证“判断用户是否下单 判断库存 扣减库存”的原子性答案是Redis事务和MySQL事务的模型完全不一样。MULTI/EXEC只是把多个命令按顺序打包执行中间不会被其他客户端的命令插入但它不支持回滚。你只能在EXEC之前检查错误如果命令本身没有语法错误就算运行时出了问题比如某个key不存在Redis也会继续执行后面的命令不会撤销之前的结果。而且更关键的是MULTI/EXEC里的命令不能依赖上一个命令的返回值做条件判断。比如你想先判断库存够不够够了才扣减这条逻辑在事务里没法写因为你不能在事务执行过程中用GET的结果去决定要不要继续DECR。所以黑马点评项目里引入了Lua脚本。Lua脚本在Redis里是原子执行的整个脚本执行期间不会被其他命令打断而且脚本内部可以做逻辑判断这就完美覆盖了“条件判断 操作”的统一原子性需求。2.2 把校验库存、扣减库存、写入订单信息全部塞进一个Lua脚本黑马点评最终版的秒杀Lua脚本我记得大致逻辑是这样的-- 1. 参数列表 local voucherId ARGV[1] local userId ARGV[2] local orderId ARGV[3] -- 2. 数据key local stockKey seckill:stock: .. voucherId local orderKey seckill:order: .. voucherId -- 3. 判断用户是否已经下过单 if (redis.call(sismember, orderKey, userId) 1) then return 1 -- 1 表示重复下单 end -- 4. 扣减库存 local stock redis.call(decrand, stockKey) -- 这里的命令要按实际写法 if (stock 0) then redis.call(incrby, stockKey, 1) return 2 -- 2 表示库存不足 end -- 5. 记录下单用户 redis.call(sadd, orderKey, userId) return 0 -- 0 表示成功这个脚本把三个关键校验集中在了一次Redis调用里是否重复下单用Set记录已经下单的用户ID。库存是否充足DECR后判断是否小于0。扣减库存 记录用户在同一脚本内完成天然原子。这里有两个细节很多人第一次看容易漏掉。第一个细节是为什么用Set记录用户而不是用某个string key存一个值。因为一个用户只能下一单但用户和订单是一对多的关系用Set天然支持后续查询“某个用户是否已经下单”同时也方便做用户维度的去重判断。实际项目中如果要做更复杂的数据分析这个Set还能配合其他业务使用。第二个细节是订单ID到底怎么生成。黑马点评里用的是Redis自增ID生成器因为数据库自增ID在高并发下会有锁竞争而且分布式环境下多个服务实例各自生成ID可能冲突。Redis的INCR命令可以生成全局唯一的递增值配合时间戳做拼接就得到一个既有时间信息又不重复的订单ID。2.3 脚本的边界库存余额判断和用户重复下单判断这段Lua脚本有一个边界情况值得单独拎出来说当库存扣到负数的时候脚本里做了一次INCR把库存加回去这个动作其实就是“回滚”。为什么不先判断库存再扣减呢比如先GET一下stockstock 0才DECR理论上可以但实际场景里GET和DECR是两个独立操作中间如果有其他请求插进来就会出现两个请求同时读到stock1然后都执行DECR结果库存变成-1。Lua脚本之所以安全是因为它整个执行过程是原子的DECR和INCR加回去这个动作无论如何都不会被打断所以即使库存扣成负数也能立刻恢复。我用一段简化的逻辑演示一下如果库存只有1两个请求同时进来Redis是单线程串行执行Lua脚本的第一个脚本执行DECR后stock变成0第二个脚本DECR后stock变成-1然后第二个脚本发现负数立刻INCR回0。最终库存是0只有第一个请求成功下单完美。但这里也要提一个生产环境需要注意的地方这段Lua脚本只解决了“是否超卖”和“是否重复下单”并没有解决“用户是否真的符合秒杀资格”这种业务级校验。比如有些运营活动会限制某个用户只能秒杀某些特定品类这些判断逻辑如果全塞进Lua脚本脚本会变得非常庞大维护成本极高。我个人的习惯是能放到业务层判断的尽量放业务层Lua脚本只保留最核心的并发安全逻辑也就是库存和用户去重。3. 消息队列落地实录为什么是Redis Stream而不是List或Pub/Sub3.1 List和Pub/Sub的致命短板黑马点评项目里做异步下单时需要用一个消息队列来承载“秒杀成功但还没写入数据库”的订单数据。Redis里其实有好几种可以当消息队列用的东西List、Pub/Sub、Stream。先说List很多老项目喜欢用LPUSH BRPOP这种方式做队列生产者LPUSH消息消费者BRPOP阻塞弹出。这个方案的优点是简单API大家都会但缺点也很明显不支持消息确认机制。消费者BRPOP拿到消息后如果处理失败这条消息就永久丢失了因为你已经把它从List里弹出去了。不支持消费组。多个消费者同时BRPOP同一个List时消息是被任意一个消费者拿走的不存在“广播给所有消费者”或者“每个消费者各拿一份”的概念。不支持重复消费。消息一旦弹出就没了你没法回到过去重新消费某条消息。Pub/Sub的短板更致命它是“发后即忘”模型消息发布时如果没有订阅者消息就直接丢失了。在秒杀场景里如果Redis重启、消费者服务重启期间正好有订单消息发过来消息就从世界上消失了数据库里永远不会有这条订单记录用户却以为自己抢到了。这是绝对不能接受的。所以黑马点评项目里采用了Redis 5.0引入的Stream类型它才是真正意义上的“消息队列”而不是“能用Redis凑合当队列用”。3.2 Stream的基本概念stream、group、consumer、pending entries listStream的设计很巧妙它介于List和Kafka之间。说它像Kafka是因为它也叫消费者组consumer group消息在被消费后不会立即删除而是通过ACK机制标记消费状态。说它像List是因为它底层数据结构类似一个有序的日志流可以按范围读取。当年我啃这一块时最懵的几个术语解释一下Stream就是一个消息日志。每个消息都有唯一的ID由“毫秒时间戳-序号”组成确保全局递增。Consumer Group消费者组。一个组内的多个消费者共同消费同一个Stream每条消息只会被组内的一个消费者拿到。Consumer组内的消费者实例。不同消费者消费不同的消息同一个消费者可以消费多条消息。Pending Entries ListPEL待确认消息列表。每一条被消费者读取但还没ACK的消息都会进入PEL如果消费者处理失败消息会一直留在PEL里。这套机制直接解决了List的两个痛点消息被读取后不会立刻消失而是保留在Stream里只有消费者发送XACK后才会被标记为已处理。如果消费者崩溃了未ACK的消息会留在PEL里其他消费者可以通过XREADGROUP的ID参数从PEL中读取这些未处理消息实现“故障转移”。3.3 消费者端代码流程与自我保护死信/异常订单兜底处理黑马点评项目里的消费者端代码核心流程是这样的用XREADGROUP监听秒杀订单Stream。读取到消息后解析出订单数据写入数据库。写入成功后执行XACK确认消息。如果写入失败不ACK消息会留在PEL里后续通过XREADGROUP的pending参数重新拉取。while (true) { ListMapRecordString, Object, Object messages stringRedisTemplate.opsForStream() .read(Consumer.from(consumer-group, consumer-1), StreamReadOptions.empty().count(10).block(Duration.ofSeconds(3)), StreamOffset.create(stream.orders, ReadOffset.lastConsumed())); for (MapRecordString, Object, Object message : messages) { // 1. 解析消息内容 // 2. 写入数据库 // 3. ACK确认 stringRedisTemplate.opsForStream().acknowledge(stream.orders, consumer-group, message.getId()); } }这段代码如果只是照着项目敲一遍问题不大但放到生产环境有两个坑必须处理。第一个坑是消息处理失败导致的死循环。假设消费者读取了一条消息然后解析订单数据时报错比如JSON解析异常你没有ACK这条消息一直在PEL里。下次循环用ReadOffset.lastConsumed()还是读取到最后那条已消费但未确认的消息于是又解析失败、又不ACK无限循环队列被卡死。黑马点评基础版代码里其实没有处理这个分支我当时照着写完后用XREADGROUP的ReadOffset.lastConsumed()读取一旦遇到异常数据就整个消费者卡在那里。解决办法是每次读取到消息后先处理业务成功就ACK失败时单独把消息ID记录到一个死信队列或者重试到一定次数后丢弃。伪代码大致是// 读取消息时不用lastConsumed而是从头开始读pending消息 ListMapRecordString, Object, Object pending stringRedisTemplate.opsForStream() .read(Consumer.from(consumer-group, consumer-1), StreamReadOptions.empty().count(10), StreamOffset.create(stream.orders, ReadOffset.from(0))); if (pending null || pending.isEmpty()) { // 没有pending消息才去读取新消息 messages stringRedisTemplate.opsForStream().read(...); }第二个坑是消费组创建时机。如果消费者代码启动时消息已经进入了Stream但消费组还没创建消费者会报错因为XREADGROUP要求消费者组必须提前存在。所以项目里一般会加一个初始化逻辑启动时检查消费组是否存在如果不存在用XGROUP CREATE创建。XGROUP CREATE stream.orders consumer-group 0 MKSTREAMMKSTREAM参数的意思是如果Stream不存在就自动创建。这个初始化最好在服务启动时做一次而不是等第一条消息来了再做。3.4 为什么说“先缓存刷新、再异步写库”的顺序不能乱整个秒杀优化做完后用户侧的响应流程是这样的用户发起秒杀请求。执行Lua脚本校验重复下单和库存扣减Redis库存。如果Lua脚本返回成功把订单信息推送到Stream。立刻返回“秒杀成功请稍后查看订单”。后台消费者从Stream读取订单消息异步写入数据库。这里有一个很重要的设计取舍用户看到“成功”不等于订单已经落库。如果消费者处理延迟或者数据库暂时不可用用户刷新页面时可能看不到订单但用户确实抢到了。黑马点评项目在这一步做了个折中给用户返回成功时同时把订单信息写入Redis缓存的一份订单数据这样用户刷新页面时可以先从Redis读到“订单处理中”的状态等异步写库完成后再查数据库状态变成正常。我实际测试时发现如果只写Stream不写缓存活动高峰期用户秒杀成功后刷新页面会看到“订单不存在”体验很糟。加了Redis订单缓存兜底之后至少用户能看到“订单创建中”这个状态不会误以为自己没抢到。4. 压测结果与高频追问这套优化方案的真实边界在哪里4.1 本地压测数据与资源占用对比我在自己电脑上做过一次简单的JMeter压测配置是8核16G的笔记本Redis和MySQL都跑在本机Docker里。对比三个阶段方案并发数成功订单数平均响应时间数据库连接情况纯MySQL乐观锁1000100约850ms连接池被打满Redis预扣减 同步写库1000100约45ms短暂波动Redis预扣减 Stream异步写库1000100约12ms稳定无压力数据比较直观Redis预扣减是决定性的优化把大部分请求挡在了数据库之外而Stream异步写库解决的是数据库的写压力让数据库只面对真正成功下单的100个请求。但要注意一个细节Redis预扣减方案的“平均响应时间”包含了所有失败请求的响应时间。1000个请求里900个失败请求在Redis层就快速返回了“库存不足”所以平均响应时间被拉得很低。如果你只看成功请求的响应时间其实是差不多的。4.2 缓存穿透、击穿、雪崩在这套优化里的隐藏位置黑马点评项目前半部分讲缓存时缓存穿透、缓存击穿、缓存雪崩都有处理方案但到了秒杀优化里很多人会忽略一个点Redis预扣减方案本身也会产生缓存击穿问题。秒杀活动开始的那一刻热点是优惠券的库存key。如果Redis里的库存key设置了过期时间而活动刚开始时这个key恰好过期了大量请求会同时去数据库加载库存数据数据库瞬间被打爆。黑马点评这个项目里库存key一般不会设置过期时间但如果你从零开始写一个秒杀系统很容易犯“给所有Redis key都加TTL”的习惯性错误。对秒杀库存key我的建议是活动期间不要设置过期时间活动结束后由定时任务或运营后台手动清理。缓存雪崩也有一个隐藏位置如果多个秒杀活动共用了同一批Redis实例某个活动的库存key大面积过期可能会影响其他活动。生产环境里秒杀和其他业务最好做Redis隔离至少使用不同的db或者不同的key前缀。4.3 重复消费与消息丢失Stream的确认机制到底保证了什么面试里最常被追问的一个问题是Stream会不会丢消息会不会重复消费分两种情况说清楚消息丢失如果Redis开启了AOF持久化并且appendfsync配置为everysec极端情况下Redis宕机可能丢失1秒内的消息。所以严格来说Redis Stream不保证绝对不丢消息它保证的是“消费者收到消息后如果没确认消息不会因为消费者崩溃而丢失”。要做到彻底不丢需要生产者侧做消息重发 消费者侧做幂等处理黑马点评这个学习项目里做的是“消费者启动时从pending队列重新拉取未确认消息”这已经覆盖了绝大多数场景。重复消费Stream的ACK机制并不能防止重复消费。如果消费者消费完消息后、发送ACK之前崩溃了重启后这条消息会重新被读取到这就是重复消费。所以要求消费者的处理逻辑是幂等的。在秒杀场景里幂等性体现在哪里体现在订单写入数据库时用订单ID做主键或者唯一索引重复插入时直接报错或者放弃。我在黑马点评的代码里没有看到完整的幂等处理自己实现时给tb_voucher_order表的id字段加了一个唯一索引如果消费者重复消费同一条订单消息插入时主键冲突捕获异常后直接ACK保证不会因为重复插入影响业务。4.4 我实际项目中放弃的部分和保留的优化整套学完之后我梳理了一下哪些是学习项目的特定优化、哪些是生产环境真正值得保留的设计。黑马点评原始代码里有一个基于Redisson的分布式锁实现秒杀这个方案我建议理解但不太推荐在生产环境走。原因是分布式锁的本质是让并发变成串行用一个锁把1000个请求串行处理虽然能保证安全但性能上不如Lua脚本的原子操作。黑马点评的教学顺序是先讲分布式锁方案再讲Lua脚本优化方案最终的版本是Lua这个设计是合理的。我认为真正值得保留到生产环境的设计有三个库存前置到Redis用Lua脚本做原子扣减。这个设计在不同业务里都能复用不只是秒杀任何需要高并发扣减的场景都可以参考。秒杀请求先过Redis再进消息队列最后异步写库。这个流程把数据库的压力隔离到了系统的最末端数据库只处理确定性最高的写入。用Stream的消息确认机制做可靠的异步任务处理。Stream比Redis List更稳比引入Kafka/RabbitMQ更轻适合中小规模项目。黑马点评这个项目里还有一点让我印象深刻它把同一个问题用不同的技术路线分别实现了一遍让你实际看到“分布式锁 同步写库”到“Lua 异步写库”的演进过程。这种对比学习的方式比直接给你一个最终方案有用得多。如果你也在学这个项目建议别只抄最终版的代码把中间那版“能跑但性能一般”的实现也写一遍踩一遍并发问题的坑你对Redis的理解会完全不一样。