做秒杀模块的时候,我最大的感受是:代码量不大,但每一行都在跟并发较劲。黑马点评这个项目我完整跟过两遍,第一遍是照抄思路,第二遍才是真正弄懂为什么这么设计。这篇把秒杀模块从需求到落地整个链路拆开讲,重点放在“为什么要这样写”和“面试官到底想问什么”这两个维度上。内容适合正在学黑马点评的读者,也适合准备把这个项目写进简历的人。
1. 秒杀模块到底在考什么:先看清题再动手
黑马点评的秒杀模块,表面看是“商品限量抢购”,但藏在背后的核心考点其实是两个:并发安全和高性能扣减。这两个词听起来抽象,落到代码层面上就变成了几个具体问题:库存会不会扣成负数?同一个用户能不能抢两单?请求量一大,数据库会不会被打垮?
先看业务背景。黑马点评里的秒杀券是商家的营销手段,限量、限时、价格远低于正常价。用户在前端点击“抢购”按钮,后端要完成三个动作:校验资格、扣减库存、生成订单。难点在于这三个动作必须在高并发下依然正确、快速。
我见过不少人在刚接触这个模块时,第一反应是“不就是update库存吗”,然后就写了这样的代码:
@Transactional public Result seckillVoucher(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("库存不足"); } // 扣减库存 boolean success = seckillVoucherService.update() .setSql("stock = stock - 1") .eq("id", voucherId) .update(); if (!success) { return Result.fail("库存不足"); } // 创建订单 saveVoucherOrder(voucherId); return Result.ok(); }这段代码单线程跑没问题,但并发一上来就是三个大坑。第一个坑是“判断库存和扣减库存不是原子的”,多个线程同时读到stock=1,都可以进入扣减分支。第二个坑是“版本冲突会被无条件重试吞掉”,update语句更新成功与否没有校验库存是否真的大于零。第三个坑是“一人一单的校验根本没有”,同一个用户并发点击两次,就能生成两笔订单。
所以我把这个模块的学习路径拆成了四步:先解决超卖,再解决一人一单,然后用Lua保证原子性,最后用异步削峰。每一步都是在前面基础上的优化,层层递进。这也是面试官最喜欢问的递进逻辑:你遇到了什么问题?你怎么解决的?有没有更好的方案?
2. 第一版秒杀:从超卖问题说起,为什么乐观锁能兜住并发
2.1 超卖的本质:check-then-act 的经典陷阱
“判断库存大于0,然后扣减库存”这个逻辑,在并发场景下存在一个极其隐蔽的缺口——两个请求同时读到库存为1,都认为可以购买,于是都执行了扣减。如果没有额外约束,库存就变成了-1。
这种问题在计算机科学里有个专门的名字:check-then-act(先检查后执行)。检查和执行之间存在时间窗口,并发请求会在这个窗口里互相穿插。解决它的思路无非两条:让检查到执行的过程互斥(悲观锁),或者让执行失败时能感知到冲突(乐观锁)。
在秒杀场景里,悲观锁的实现方式就是给查询语句加上for update:
select * from seckill_voucher where id = ? for update这个方案能解决问题,但代价是锁的粒度太大。一个用户锁住了一行记录,其他所有购买这个券的用户都得排队等锁释放。虽然秒杀券一般库存不多,但高峰期几十万用户抢100张券,让所有人排队显然不合理。
2.2 CAS思想的落地:WHERE条件里带上stock
黑马点评采用的方案是乐观锁,但它的实现方式很有特点。经典教科书版本是“版本号”法——表里加一个version字段,每次更新时version+1,更新条件是“当前version等于之前读到的version”。黑马点评没有加字段,而是直接在更新语句上做文章:
boolean success = seckillVoucherService.update() .setSql("stock = stock - 1") .eq("id", voucherId) .gt("stock", 0) // 关键:库存必须大于0才允许更新 .update();.gt("stock", 0)就是CAS思想里的“比较”环节,更新操作本身是数据库行锁级别的原子操作。多个线程并发执行这条SQL时,数据库会串行化对同一行的更新,第一个线程把库存从1改成0,后面线程执行时发现stock=0,不满足stock > 0条件,更新影响行数为0,返回失败。
这里有个容易忽略的细节:setSql("stock = stock - 1")是数据库层面的原子操作,它跟“读出来减一再写回去”有本质区别。读改写有三次交互(select、计算、update),而setSql直接在数据库内部完成扣减,天然避免了计算过程中的并发干扰。
2.3 乐观锁的代价:如何正确看待“失败的用户”
乐观锁在秒杀场景下的一个客观现象是:并发越高,更新失败的比例越大。这不是bug,而是正常的限流表现。库存只有100件,系统要允许几万人同时点击,那么必然导致大量用户被CAS条件拦住,返回“库存不足”。
我之前看到有人在评论区吐槽,说“明明页面上显示有货,点一下就说抢完了”。这个问题要分两层看。第一层,页面上显示的库存本身就有延迟,不是实时数据。第二层,乐观锁返回失败不是“系统不行”,恰恰是“系统很行”的表现,用户的请求确实被正确处理了,只不过没有抢到名额。
从学习角度,理解了乐观锁的CAS思想,你就理解了为什么需要数据库行锁的配合,也理解了为什么高并发场景下“先到先得”本质上是个概率事件而非绝对时序事件。
3. 重新审视需求:一人一单不只是加个判断,事务和锁的边界才是重头戏
3.1 朴素版本的问题:一个用户开两个线程,能同时下单吗
超卖问题解决之后,下一个需求是“每个用户只能买一单”。很多人第一反应是查订单表,看这个用户有没有已存在的订单:
// 存在就拒绝 Long userId = UserHolder.getUser().getId(); Integer count = orderService.count(new QueryWrapper<VoucherOrder>() .eq("user_id", userId) .eq("voucher_id", voucherId)); if (count > 0) { return Result.fail("不允许重复购买"); }逻辑看起来没错,但并发场景下,一个用户同时发起两个请求,两个线程都执行了count查询,都发现count=0,然后都进入了下单流程。一人一单形同虚设。
解决这个问题的标准方案是加锁。锁有两种选择:在Java代码里用synchronized或ReentrantLock做单机锁,或者在数据库层面用唯一索引兜底。黑马点评的课程先讲Java锁,再引入分布式锁,这条递进路径很经典。
3.2 事务边界问题:锁释放了,事务还没提交,照样超卖
这里面藏着一个特别容易踩的坑。很多初学者会这样写:
@Transactional public synchronized Result createVoucherOrder(Long voucherId) { // 一人一单判断 + 扣库存 + 创建订单 }表面上看,方法被synchronized修饰,同一时刻只有一个线程能进入,应该没问题。但问题是:当一个线程执行完这个方法,锁立刻释放,可事务还没有提交。这时另一个线程进入方法,去查订单表,依然查不到刚才那个线程插入的订单——因为事务还没提交,数据对别的线程不可见。于是第二个线程也顺利通过了“一人一单”的校验。
这个问题的根因是事务和锁的生命周期不同步。解决方案是把这个锁的粒度放大到包含事务提交的整个链路,或者用数据库层面的唯一索引来硬性兜底。黑马点评的代码里用了一个巧妙的写法:在synchronized块内直接调用事务方法,并且通过SpringContextUtil获取代理对象来保证事务生效。
3.3 代理对象问题:为什么this调用会让@Transactional失效
这个坑我踩过之后记住了“不要用this调用本类事务方法”。Spring的事务是基于AOP代理实现的,事务注解会被代理类增强。如果你在类内部通过this.createVoucherOrder()调用,走的是原始对象而并非代理对象,事务注解完全不生效。
黑马点评课程里专门提了一个解决办法:把事务方法拆到另一个类里,或者在当前类中通过context.getBean获取代理对象再调用。我第一次看的时候觉得多此一举,直到自己写了个demo,把@Transactional加在了一个this调用的方法上,数据库里出现了一半数据——订单没有生成,但库存扣了。那瞬间就明白了。
所以在这个模块里,“一人一单”表面上是业务判断,实质上是三个问题的叠加:并发锁的选择、事务边界的控制、代理对象的正确获取。面试官要是看你简历里写了黑马点评,八成会顺着这条线往下问。
4. Lua脚本:把两步操作变成一步,让Redis在原子层面完成判断与扣减
4.1 为什么不再依赖数据库?库存预热与性能瓶颈
第一版秒杀把库存放在MySQL数据库里,虽然逻辑正确,但有两个硬伤。第一个是性能:每个请求都要走一次数据库更新,高峰时期数据库连接池会被瞬间耗尽。第二个是耦合:judge库存和扣减库存分散在多个代码路径里,出错时排查很麻烦。
黑马点评的优化方案很常见:把库存预热到Redis。开局时把秒杀券的库存写入Redis,比如seckill:stock:{voucherId}这个key,然后后续的秒杀判断、库存扣减都直接在Redis里做。Redis是单线程模型,所有命令在服务端是串行执行的,天然原子,在高并发读写下依然能保持极高的吞吐。
但预热到Redis之后,又出现了一个新问题:判断库存和扣减库存是两个独立的Redis操作。第一步get stock,第二步decr stock,中间依然有并发空隙。
4.2 原子的本质:一次性把流程发给Redis
解决Redis场景下check-then-act问题的标准方案是Lua脚本。Redis从2.6版本开始内置Lua解释器,执行EVAL命令时,Redis会原子性地运行整个脚本,脚本执行期间不会被其他命令插入。
黑马点评里的判断与扣减脚本大概长这样:
-- 判断库存key是否存在 if (redis.call('exists', KEYS[1]) == 1) then local stock = tonumber(redis.call('get', KEYS[1])); if (stock <= 0) then return -1; -- 库存不足 end -- 判断用户是否已经下过单 if (redis.call('sismember', KEYS[2], ARGV[1]) == 1) then return -2; -- 不能重复下单 end -- 扣减库存 redis.call('incrby', KEYS[1], -1); -- 记录下单用户 redis.call('sadd', KEYS[2], ARGV[1]); return 1; -- 成功 end; -- 库存key不存在,返回错误 return -1;注意,这个脚本里同时完成了“判断库存”“判断重复购买”“扣减库存”“记录用户”四件事。因为Lua脚本是原子执行的,整个过程不存在任何并发空隙。
用Java调用时,核心代码是:
// 构造keys和args List<String> keys = Arrays.asList("seckill:stock:" + voucherId, "seckill:order:" + voucherId); List<String> args = Arrays.asList(userId.toString()); // 执行脚本 Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, keys, args.toArray() );4.3 把数据库做的事交给Redis,但别忘了持久化和数据一致性
Lua方案落地后,性能确实提升了一个量级,但随之而来的是新的边界问题:Redis里的库存扣了,但订单还没生成,万一Redis数据丢失怎么办?黑马点评在课程里对这个问题做了简化处理:秒杀成功后,先把订单创建的消息发到阻塞队列,再由一个线程异步去写数据库。
这种“异步落库”设计在真实项目中非常常见,但它对Redis的持久化策略有要求。如果Redis配置为纯内存运行且没有开启持久化,Redis重启后库存数据就丢失了。生产环境至少得开启AOF持久化,并且配合主从节点保证高可用。作为项目笔记,理解这一层就够了:异步化后,Redis的正确性依赖持久化保障,数据库的正确性依赖事务和唯一索引兜底。
5. 秒杀全流程串联:从点击按钮到订单落库,一次请求经历了什么
5.1 完整链路拆解:前置校验、Lua预判、异步下单
把前面几个模块串起来,整个秒杀请求的流转路径就很清晰了。用户点击“抢购”按钮后,后端接口的工作分成了两个阶段。
第一个阶段是“前置校验+预判”。这个阶段完全基于Redis,不做任何数据库操作。先校验用户的登录状态,然后通过Lua脚本检查库存是否充足、用户是否重复购买。如果脚本返回1,表示可以下单;返回-1或-2,直接返回失败信息。这个阶段的耗时通常在几毫秒级别,因为只操作Redis。
第二个阶段是“异步下单”。如果Lua脚本通过,后端把订单信息写入一个阻塞队列(BlockingQueue),由一个独立的线程池消费队列,真正执行数据库操作:创建订单、扣减数据库库存(兜底)、记录用户。这个设计的好处是:请求不需要同步等待数据库写入完成,用户点击后能立刻收到“抢购成功”的响应,而数据库的压力也被线程池平摊了。
我之前整理过一张请求流转表,写代码之前先把这张表画出来,就不容易漏逻辑:
| 步骤 | 数据源 | 操作内容 | 并发控制方式 |
|---|---|---|---|
| 登录校验 | Redis | 查询token对应的用户信息 | 拦截器统一处理 |
| 时间校验 | 数据库/Redis | 判断当前时间是否在秒杀窗口内 | 无压力,可放在SQL里 |
| Lua预判 | Redis | 校验库存+校验重复购买+扣减库存+记录用户 | Redis单线程+Lua原子性 |
| 写入队列 | JVM内存 | 把userId和voucherId封装成订单任务 | 线程池消费 |
| 异步落库 | MySQL | 创建订单记录,更新库存 | 事务保证一致性 |
5.2 代码层面最容易漏的细节:KEYS和ARGV别搞混
Lua脚本的实现细节里有一个特别容易踩的坑:Java侧传参时,KEYS和ARGV的对应关系。在Lua脚本里,KEYS[1]、KEYS[2]对应的是Java传入的keys数组,ARGV[1]对应的是Java传入的args数组。如果你把用户的id传到了keys数组里,而把券的id传到了args里,脚本执行时会直接报错。
我一开始犯过这个错,排查了很久才发现是参数位置搞反了。建议写这段代码时,先固定一个约定:keys数组放“业务对象标识”,args数组放“用户传递的运行时参数”。比如:
List<String> keys = Arrays.asList("seckill:stock:" + voucherId, "seckill:order:" + voucherId); String userId = UserHolder.getUser().getId().toString(); Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, keys, userId );这样代码读起来也清楚:stock是券的维度,order记录的是券对应的用户集合,userId是用户维度。两者放在不同位置,语义上就不容易混。
5.3 阻塞队列的选型:JVM队列能用,但只能用来学习
黑马点评的异步下单用了JDK自带的ArrayBlockingQueue。从学习角度,这个选择非常合理——代码简单、逻辑直观、不需要额外引入中间件。但从生产实践角度,JVM队列有几个硬伤:数据只存在于本机内存,服务重启就丢失;消费者是单机的,不是真正意义上的分布式异步;队列容量受限,如果秒杀量大,消费者处理不过来,队列会积压。
真实场景一般会引入MQ(比如RabbitMQ、RocketMQ、Kafka)来替换JVM队列。这样做的好处很多:生产者和消费者解耦、消息可以持久化避免丢失、可以通过多实例消费者水平扩容消费能力。
在面试里,如果被问到“这个项目有什么优化的空间”,这就是一个很好的切入点。你不妨主动提一句“当前用JVM队列是为了简化实现,生产环境我会替换成RocketMQ”,面试官就会顺着你的思路问下去。
6. 黑马点评秒杀模块的进阶问题:为什么Redis判断不够,还要数据库兜底
6.1 Redis数据不存在了怎么办?MQ消息丢失场景回顾
在学习秒杀模块时,很多人会有个疑问:既然Lua脚本已经把库存扣了、用户也记录了,为什么还要异步去写数据库?直接用Redis当最终数据存储不就行了?
这个问题可以围绕“数据可靠性”来分析。Redis虽然快,但它本质上是缓存系统,不是为强一致性事务设计的。它的持久化机制(RDB、AOF)存在丢失窗口,在高负载下可能丢数据。秒杀结果是实实在在的业务数据,用户抢到券之后要能查到订单记录,如果订单存在Redis里,哪天Redis崩溃了,用户的券就凭空消失了。
所以数据库落库是必须的,它是数据可靠性的最终保障。同时,数据库层面的唯一索引(比如user_id + voucher_id的唯一联合索引)是防重复下单的最后一道防线,即使Redis的set集合判断被绕过,数据库的唯一索引也能拦截重复记录。两条线并用,既保证了性能,又兼顾了可靠性。
6.2 高并发秒杀中的“热key”问题:所有请求打向同一个key
秒杀场景还有一个容易忽略的性能瓶颈——Redis热key问题。秒杀开始时,所有用户的请求都集中在seckill:stock:{voucherId}这一个key上,瞬间请求量可能达到几十万。虽然Redis单线程能扛住每秒十万级的读写,但网络带宽和客户端连接数会成为新的瓶颈。
一个经典的优化思路是做本地缓存+分段扣减:把总库存拆成多个子库存,分布到不同的Redis key上,比如seckill:stock:{voucherId}:0、seckill:stock:{voucherId}:1。请求进来时,先根据用户id哈希取模,路由到某个子库存key上。这样就避免了一个key被打爆的尴尬。
这个方案在黑马点评中并没有展开,但它在真实秒杀系统中是常规操作。如果你在面试中能主动聊到这个方案,会显著提高面试官对你的评价。
6.3 秒杀接口的防刷设计:隐藏接口地址与限流
除了技术并发控制,秒杀系统还要考虑业务层面的防刷。比如,如果接口地址是固定的,用户完全可以写脚本提前调接口,绕过前端倒计时。黑马点评的课程里没有专门讲防刷,但如果你把项目写进简历,这个问题很容易被追问。
常规做法是:秒杀接口的URL是动态拼接的,服务端先把一个随机字符串(token)下发到前端页面,真正发起秒杀时校验这个token。更进一步,可以在Nginx层给秒杀接口配置限流规则,限制每个IP的访问频率,超过阈值直接返回“系统繁忙”。
这些内容虽然不是必修,但是能体现你对生产环境的理解。喜欢深究的读者可以沿着“隐藏接口+限流”方向多做点功课。
7. 写在简历上的黑马点评秒杀模块:技术点如何匹配岗位要求
7.1 一个能拿得出手的项目描述怎么写
很多人写简历的时候,只是简单写“参与秒杀模块开发,实现了商品的限时抢购”。这个描述太弱了。更好的写法是把技术难点和解决方案都体现出来。
我建议按这个格式来写:
黑马点评-秒杀模块(核心开发) 负责设计高并发秒杀功能,解决商品超卖和一人一单问题。 基于Redis Lua脚本实现库存扣减与用户限购的原子性操作,将单次请求耗时从数十毫秒降至数毫秒。 引入异步下单机制,削峰填谷,保护MySQL数据库,支持每秒千级的秒杀请求量。 通过乐观锁保证并发安全,通过数据库唯一索引兜底保证数据最终一致性。
描述里要体现三个层次:业务(做了什么)→ 技术(怎么做的)→ 数据(效果如何)。数据可以结合压测或估算值,但不要夸大。
7.2 面试中最容易被追问的五个问题
把黑马点评写进简历之后,面试官大概率会就秒杀模块问一连串问题。根据我收集到的反馈和热词里的信息,高频考题主要集中在以下几个方向:
第一,“你用什么解决超卖问题的”。回答思路:乐观锁+CAS,强调update语句中stock > 0的条件。第二,“乐观锁和悲观锁哪个更好”。回答思路:区分场景,秒杀读多写少,乐观锁更合适;悲观锁适合并发冲突严重的场景。第三,“Lua脚本为什么能保证原子性”。回答思路:Redis单线程模型,脚本执行期间不会被其他命令打断。第四,“为什么用Redis而不是MySQL直接扣库存”。回答思路:性能差异,Redis是内存操作,MySQL要落盘,秒杀场景对延迟敏感。第五,“如果Redis挂了怎么办”。回答思路:数据兜底到MySQL,配合Redis持久化和主从部署。
7.3 简历上的黑马点评常见误区:别把“课程项目”写成了“培训班作业”
最后给准备把黑马点评写进简历的读者一个建议:不要一上来就写“我学习了黑马点评课程”。直接写项目名称和你的职责就行。面试官问起来的时候,再用自己的话梳理整个设计思路。
怎么判断自己是真的理解了这个项目?一个简单自测:不翻笔记,画一画“秒杀请求从进入到落库的流程图”,如果能画出完整的链路并解释每一步为什么这么做,那就可以自信地往简历上写了。如果画不出来,建议再回去跟一遍课程代码。
其实到了这一步,黑马点评这个项目已经不能简单算作“课程作业”了。它完整覆盖了缓存设计、原子操作、异步削峰、事务控制这些后端开发日常要面对的核心问题。把秒杀模块吃透,比盲目刷几十道面试题管用得多。你写代码时踩过的坑、想通的原理,会在下一次真正做业务时变成你的直觉。