尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

黑马点评秒杀模块实战:从超卖到Lua原子扣减的高并发设计

黑马点评秒杀模块实战:从超卖到Lua原子扣减的高并发设计 做秒杀模块的时候我最大的感受是代码量不大但每一行都在跟并发较劲。黑马点评这个项目我完整跟过两遍第一遍是照抄思路第二遍才是真正弄懂为什么这么设计。这篇把秒杀模块从需求到落地整个链路拆开讲重点放在“为什么要这样写”和“面试官到底想问什么”这两个维度上。内容适合正在学黑马点评的读者也适合准备把这个项目写进简历的人。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(); }这段代码单线程跑没问题但并发一上来就是三个大坑。第一个坑是“判断库存和扣减库存不是原子的”多个线程同时读到stock1都可以进入扣减分支。第二个坑是“版本冲突会被无条件重试吞掉”update语句更新成功与否没有校验库存是否真的大于零。第三个坑是“一人一单的校验根本没有”同一个用户并发点击两次就能生成两笔订单。所以我把这个模块的学习路径拆成了四步先解决超卖再解决一人一单然后用Lua保证原子性最后用异步削峰。每一步都是在前面基础上的优化层层递进。这也是面试官最喜欢问的递进逻辑你遇到了什么问题你怎么解决的有没有更好的方案2. 第一版秒杀从超卖问题说起为什么乐观锁能兜住并发2.1 超卖的本质check-then-act 的经典陷阱“判断库存大于0然后扣减库存”这个逻辑在并发场景下存在一个极其隐蔽的缺口——两个请求同时读到库存为1都认为可以购买于是都执行了扣减。如果没有额外约束库存就变成了-1。这种问题在计算机科学里有个专门的名字check-then-act先检查后执行。检查和执行之间存在时间窗口并发请求会在这个窗口里互相穿插。解决它的思路无非两条让检查到执行的过程互斥悲观锁或者让执行失败时能感知到冲突乐观锁。在秒杀场景里悲观锁的实现方式就是给查询语句加上for updateselect * from seckill_voucher where id ? for update这个方案能解决问题但代价是锁的粒度太大。一个用户锁住了一行记录其他所有购买这个券的用户都得排队等锁释放。虽然秒杀券一般库存不多但高峰期几十万用户抢100张券让所有人排队显然不合理。2.2 CAS思想的落地WHERE条件里带上stock黑马点评采用的方案是乐观锁但它的实现方式很有特点。经典教科书版本是“版本号”法——表里加一个version字段每次更新时version1更新条件是“当前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后面线程执行时发现stock0不满足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 QueryWrapperVoucherOrder() .eq(user_id, userId) .eq(voucher_id, voucherId)); if (count 0) { return Result.fail(不允许重复购买); }逻辑看起来没错但并发场景下一个用户同时发起两个请求两个线程都执行了count查询都发现count0然后都进入了下单流程。一人一单形同虚设。解决这个问题的标准方案是加锁。锁有两种选择在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 ListString keys Arrays.asList(seckill:stock: voucherId, seckill:order: voucherId); ListString 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数组放“用户传递的运行时参数”。比如ListString 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 简历上的黑马点评常见误区别把“课程项目”写成了“培训班作业”最后给准备把黑马点评写进简历的读者一个建议不要一上来就写“我学习了黑马点评课程”。直接写项目名称和你的职责就行。面试官问起来的时候再用自己的话梳理整个设计思路。怎么判断自己是真的理解了这个项目一个简单自测不翻笔记画一画“秒杀请求从进入到落库的流程图”如果能画出完整的链路并解释每一步为什么这么做那就可以自信地往简历上写了。如果画不出来建议再回去跟一遍课程代码。其实到了这一步黑马点评这个项目已经不能简单算作“课程作业”了。它完整覆盖了缓存设计、原子操作、异步削峰、事务控制这些后端开发日常要面对的核心问题。把秒杀模块吃透比盲目刷几十道面试题管用得多。你写代码时踩过的坑、想通的原理会在下一次真正做业务时变成你的直觉。
返回列表