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里,用单线程特性做原子扣减。这一步的核心变化是:库存判断和扣减都不再走MySQL,Redis的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一下stock,stock > 0才DECR?
理论上可以,但实际场景里GET和DECR是两个独立操作,中间如果有其他请求插进来,就会出现两个请求同时读到stock=1,然后都执行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/Sub
3.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 list
Stream的设计很巧妙,它介于List和Kafka之间。说它像Kafka,是因为它也叫消费者组(consumer group),消息在被消费后不会立即删除,而是通过ACK机制标记消费状态。说它像List,是因为它底层数据结构类似一个有序的日志流,可以按范围读取。
当年我啃这一块时,最懵的几个术语解释一下:
- Stream:就是一个消息日志。每个消息都有唯一的ID,由“毫秒时间戳-序号”组成,确保全局递增。
- Consumer Group:消费者组。一个组内的多个消费者共同消费同一个Stream,每条消息只会被组内的一个消费者拿到。
- Consumer:组内的消费者实例。不同消费者消费不同的消息,同一个消费者可以消费多条消息。
- Pending Entries List(PEL):待确认消息列表。每一条被消费者读取但还没ACK的消息都会进入PEL,如果消费者处理失败,消息会一直留在PEL里。
这套机制直接解决了List的两个痛点:
- 消息被读取后不会立刻消失,而是保留在Stream里,只有消费者发送XACK后才会被标记为已处理。
- 如果消费者崩溃了,未ACK的消息会留在PEL里,其他消费者可以通过XREADGROUP的ID参数从PEL中读取这些未处理消息,实现“故障转移”。
3.3 消费者端代码流程与自我保护:死信/异常订单兜底处理
黑马点评项目里的消费者端代码,核心流程是这样的:
- 用XREADGROUP监听秒杀订单Stream。
- 读取到消息后,解析出订单数据,写入数据库。
- 写入成功后执行XACK确认消息。
- 如果写入失败,不ACK,消息会留在PEL里,后续通过XREADGROUP的pending参数重新拉取。
while (true) { List<MapRecord<String, 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 (MapRecord<String, 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消息 List<MapRecord<String, 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乐观锁 | 1000 | 100 | 约850ms | 连接池被打满 |
| Redis预扣减 + 同步写库 | 1000 | 100 | 约45ms | 短暂波动 |
| Redis预扣减 + Stream异步写库 | 1000 | 100 | 约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的理解会完全不一样。