
做秒杀类功能第一次上线前总是很自信上了线才发现第一版代码在并发面前就是个纸糊的。黑马点评的秒杀模块是很多Java后端拿来练手、写进简历的项目它最值得说的就是并发控制这条演进线从数据库锁到Redis分布式锁再到Lua脚本原子操作一步步把并发问题从“能解决”做到“解决得漂亮”。这篇文章我不讲废话直接把这条演进路线掰开揉碎先讲为什么数据库锁不行再讲Redis分布式锁怎么落地最后把黑马点评里完整的秒杀方案、常见坑和面试高频题一起过一遍。适合正在做秒杀/抢购项目、准备把黑马点评写进简历、或者面试前突击分布式锁的Java后端同学。1. 秒杀系统的核心难点先搞清楚要锁的是什么1.1 黑马点评秒杀模块的业务链路黑马点评项目模仿的是大众点评、美团这类本地生活App。秒杀模块做的是优惠券抢购商家发布一批限量优惠券比如某餐饮店的5折券只放100张到点开抢。用户进入秒杀页面点击抢购后端需要依次完成校验用户是否登录、校验当前时间是否在秒杀窗口内、判断库存是否充足、判断当前用户是否已经抢过、扣减库存、生成订单。听起来就是一套常见的增删改查但“限量 高并发”让每一步都不简单。100张券、10万人同时抢后端同一瞬间收到上万请求所有请求都在读写同一个变量——库存。我们要保证最终结果精确等于100张一张不能超卖还要保证同一个人只能抢到一张。这个模块放在简历里价值就在于它把一个普通的“扣库存”业务逼成了对并发控制、分布式系统、缓存、异步消息的完整思考。这也是为什么很多面试官喜欢拿它当切入点。1.2 超卖、一人一单与高可用三个必须解决的问题做秒杀方案前先要能把问题讲清楚。我把它拆成三类第一是超卖多个线程同时读到库存还剩1同时通过校验并执行扣减数据库里库存可能变成 -1、-2券卖超了。第一次接触并发的人会觉得“这不可能吧”实际上经典的i并发问题在扣库存上是一模一样的。第二是重复购买同一个用户快速点击两次或者两台设备同时请求后端同时收到两个合法请求都通过了“是否购买过”的校验最后下了两单一人一单的限制被绕过。第三是高可用即使逻辑全部正确如果所有请求都打到数据库同一行上数据库也会被行锁、连接池、磁盘IO拖垮表现为接口超时、CPU跑满、系统雪崩。这三个问题共同决定了技术选型并发安全解决前两个高性能解决第三个。后面所有方案演进本质都是在“保证正确性”和“提升并发承载能力”之间找平衡。2. 数据库锁方案第一代实现与性能天花板2.1 悲观锁实践SELECT ... FOR UPDATE第一版实现很多人会想到悲观锁在事务里先锁住目标行其他线程只能等我提交才能继续。思路很直接代码也简单BEGIN; SELECT id, stock FROM tb_voucher_seckill WHERE voucher_id 1 FOR UPDATE; -- 业务校验判断秒杀时间、判断用户是否已抢过、判断库存0 UPDATE tb_voucher_seckill SET stock stock - 1 WHERE voucher_id 1; COMMIT;FOR UPDATE 会给查询命中的行加排他锁事务提交或回滚后释放。只要第一个事务持有这行的锁其他事务的FOR UPDATE都会被阻塞天然串行正确性没有问题。但实际用起来有三个坑。第一这条SELECT如果查不到数据FOR UPDATE什么都不会锁后面直接执行UPDATE也不会校验库存所以业务逻辑里必须把“查不到/库存为0”处理成失败。第二查询条件一定要走索引InnoDB的行锁是基于索引实现的如果where条件没走索引MySQL会升级成锁全表整个秒杀表都被堵住。第三事务里不能夹杂慢操作比如远程调用、短信验证码、长时间计算会让行锁持有时间无限拉长其他请求全部排队。我拿JMeter压过这个方案500并发下数据库连接池被占满大量线程阻塞在获取连接和等待行锁上接口响应时间飙到几秒数据库CPU接近100%QPS卡在几百。所有请求抢同一行锁而InnoDB行锁本质是排队机制并发再高也会被压成串行这就是数据库锁方案的性能天花板。2.2 乐观锁实践版本号与库存校验悲观锁是“先锁后操作”乐观锁反过来“操作前不锁更新时校验状态”。最常见的两种写法-- 写法一直接拿库存当校验条件CAS思想 UPDATE tb_voucher_seckill SET stock stock - 1 WHERE voucher_id 1 AND stock 0; -- 写法二显式版本号每次扣减版本号1 UPDATE tb_voucher_seckill SET stock stock - 1, version version 1 WHERE voucher_id 1 AND version #{version};写法一是秒杀场景用得最多的扣减语句自带“库存大于0”条件MySQL执行时如果库存已经不满足条件影响行数为0应用层就知道抢失败了。写法二更通用适合“预期冲突概率不高”的普通更新先查version再更新可以保证自己修改的数据没被别人动过。乐观锁不需要持有数据库锁所以不会阻塞整体吞吐确实上来了。但代价是冲突必然导致失败100个请求同时对同一行做CAS只有极少数成功其余全部抢失败。秒杀场景本来就是“几乎必然冲突”乐观锁的成功率会非常低。很多同学会加重试——失败后重新查版本号再来一次但压测时你会发现数据库的读压力直接翻倍因为重试意味着每个请求要多查好几次库存。所以乐观锁适合冲突率低的场景秒杀这种高冲突场景靠它硬撑也会很难受。2.3 数据库锁方案的三个致命瓶颈把悲观锁和乐观锁摆在一起看数据库锁方案的问题就非常清楚。第一是热点行锁无法避免。不管用哪种方式秒杀库存最终都在数据库某一行上所有扣减请求都落在同一行。InnoDB的行锁本质是等待队列热点行越热等待越严重数据库的连接和事务都在排队中耗尽。第二是事务过长导致锁持有时间不可控。库存扣减和订单创建往往被放在同一个大事务里业务校验越多、事务越长锁持有的时间就越长数据库的并发处理能力被进一步压低。第三是扩展性差。秒杀峰值流量来了应用层可以加机器水平扩展但数据库不行热点数据始终在同一行加从库也不能让“某一行”的锁变多。只能在应用层想办法把并发控制从数据库前移到更擅长处理高并发读写的组件上。所以这条演进路线其实是被数据库的物理限制逼出来的。下一跳就是Redis。3. 从单机锁到Redis分布式锁演进的必然性3.1 为什么JVM锁也扛不住集群部署在跳到Redis之前很多人会卡在“我直接用synchronized锁方法不行吗”这个问题上。单机运行测试加锁后扣库存确实是对的。但它有个致命前提synchronized是JVM级别的锁只能在当前进程内生效。生产环境几乎都是集群部署Nginx做负载均衡请求被分发到多台Tomcat上。你在A机器的synchronized只能拦住A机器上的线程B机器照样并发执行超卖问题原样复现。我第一次把单机版本部署到两台机器的测试环境用压测工具一打超卖立刻出现当时整个人都清醒了。从这一刻起锁就必须具备跨进程能力。也就是说需要一台所有实例都能访问的公共组件来充当“锁管理器”而且要保证这个组件自己能抗高并发。Redis刚好满足这些要求单线程模型天然串行执行命令、基于内存读写性能极高、几乎所有公司都有现成的Redis集群、实现语义也足够简单。所以Redis分布式锁成为主流方案不是偶然。3.2 Redis分布式锁的最小实现SETNX EXPIRERedis做锁的核心命令就一条SET key value NX EX seconds。NX表示“key不存在时才设置成功”所有客户端同时去SET同一个key只有第一个能成功后面的全部失败——这就是互斥。EX表示过期时间拿到锁的客户端如果崩了锁最多存在N秒后自动释放避免死锁。SET lock:voucher:1 user-A-thread-1 NX EX 30返回OK代表拿锁成功返回nil代表拿锁失败。拿到锁去执行业务业务结束释放锁拿不到锁就返回“手慢了”或者进入等待策略。这里有两个细节决定成败。第一value必须能唯一标识当前请求我用“用户ID线程ID”拼接。如果没有这个标识释放锁时无法区分锁是不是自己的极易误删别人的锁。第二过期时间要按业务最坏耗时设置宁可稍微长一点也不能短了导致业务没跑完锁先没了。踩过坑的人都知道锁提前过期引发的并发问题比死锁更难排查因为你很难复现。另外再强调一次SET NX EX 必须一条命令完成。早期很多人先SETNX再EXPIRE两步走第一步成功、第二步之前进程崩溃锁会永远存在这个坑非常经典。3.3 释放锁的原子性陷阱为什么必须用Lua分布式锁最容易翻车的地方不是加锁而是释放锁。看一个典型的时序线程A拿到锁value是user-A-thread-1锁过期了因为A的业务执行超过30秒线程B重新拿到锁value是user-B-thread-2A终于执行完执行DEL把B的锁删了线程C又拿到锁和B并发执行业务数据乱套所以正确的释放姿势是先GET锁的value确认是自己再DEL。但问题来了GET和DEL是两条独立命令非原子。假如A确认value是自己但还没执行DEL时锁过期了B抢到了锁A接着执行DEL照样把B的锁删掉。这就是为什么所有可靠的分布式锁实现释放锁都用一个Lua脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endRedis执行Lua脚本时是原子性的脚本执行过程中不会有其他命令插入从根源上避免了“判断和删除之间被插队”。分布式锁面试题里高频出现“为什么释放锁要Lua脚本”本质就是考这一点。4. 黑马点评的完整落地Lua脚本与Redis原子操作实战4.1 用Lua脚本实现校验-扣减-登记一揽子操作黑马点评这类项目不同版本实现有差异我以最主流、也最适合写进简历的版本为准。这个版本里秒杀最终并没有走“加分布式锁 → 操作 → 解锁”的循环而是把整个秒杀判定逻辑放入一个Lua脚本在Redis中原子执行。第一步提前把秒杀活动的数据预加载到RedisSET seckill:stock:1 100 SADD seckill:order:1 placeholderseckill:stock:1 保存库存seckill:order:1 保存已经抢到的用户ID集合。用户点击抢购后端执行下面这段脚本-- KEYS[1]: seckill:stock:{voucherId} -- KEYS[2]: seckill:order:{voucherId} -- ARGV[1]: userId -- 返回1库存不足返回2重复下单返回0成功 if tonumber(redis.call(get, KEYS[1])) 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 0一个脚本完成四件事查库存、查重复购买、扣库存、登记用户。Redis单线程模型保证脚本整体原子所以不需要显式加锁就天然解决了超卖和一人一单问题。这个方案相比“SETNX锁 业务操作 解锁”更简洁锁的粒度也更精细。但它的前提是所有需要互斥的判定都要能放进Redis。在秒杀场景恰好成立——库存和用户购买记录都是短小结构非常适合放Redis。4.2 锁过期、可重入、重试与锁粒度进阶设计聊完“不需要锁”的终极形态面试官经常会退一步追问如果不用Lua整体原子执行而是用传统分布式锁还有哪些细节要处理这几个点必须掌握。锁过期时间与续期。业务加了锁之后执行时间超过过期时间锁自动失效其他线程就能进来并发安全被破坏。解决方向是“看门狗”续期机制加锁成功后后台定时线程反复检查锁是否还在自己手上如果在就刷新过期时间客户端如果宕机定时线程跟着停锁自然过期。市面上Redisson已经实现了这套机制应用层直接使用即可。我自己写的话至少会用守护线程做续期绝不能把业务时长寄希望于“过期时间设得够大”。可重入问题。普通SETNX锁不可重入线程A持有锁后在锁保护的逻辑里再次调用了同一个加锁方法会把自己挡在门外形成死锁。解决方式是用Hash结构存锁信息field记录线程标识value记录重入次数。加锁时重入次数1解锁时-1减到0才真正删除锁。这也是Redisson默认采用的方式。获取锁失败的重试策略。秒杀场景建议直接返回“活动太火爆”不要原地自旋否则请求全部堆在Redis上Redis也可能被打满。非秒杀场景可以自旋但必须限制次数和间隔比如每200毫秒重试一次、最多10次避免无效请求无限重试。锁粒度问题。锁的粒度越小并发度越高。“一人一单”的锁粒度应该是userId同一个用户串行不同用户并发执行。如果把整个秒杀活动作为一把锁所有用户抢同一把锁并发能力直接归零。锁粒度选错了方案再对也扛不住流量。4.3 秒杀接口完整调用链与异步落库最终版的秒杀接口调用链如下前端点击秒杀按钮携带券ID和用户信息后端校验登录态获取userId校验秒杀活动时间窗口可以在Redis里预存活动开始/结束时间执行Lua脚本对库存和用户购买记录做原子判定Lua返回0说明资格锁定后端把“创建订单”的消息发送到Redis Stream或阻塞队列立即返回“正在排队请等待结果”给前端Lua返回1回应“秒杀已经结束”Lua返回2回应“您已经抢过不能重复购买”后台消费者线程从队列取出消息校验业务数据向数据库插入订单完成落库前端轮询订单状态查到订单即抢购成功跳转支付这里有一个容易被忽略的细节异步落库时如果多个消费者线程并发写库会不会再次超卖会。因为数据库表里的库存可能还没异步扣减。所以落库环节通常会保留一句UPDATE ... WHERE stock 0作为数据库层的最终兜底。这正好回答了“数据库锁是不是完全没用”——它从挡在流量最前线的第一道防线退位成为最后一层的保底校验。压测数据很直观。数据库乐观锁方案在500并发下QPS大概300上下接口RT飙到秒级数据库CPU拉满。切到RedisLua方案同样500并发QPS能到2000以上RT稳定在20毫秒以内数据库几乎没有压力只有异步消费者在慢慢写订单。差距的根源就是热点数据从数据库行挪到了Redis内存里。5. 常见问题与面试高频点速查5.1 实战踩坑清单把我在不同项目里踩过的坑汇总一下各位尽量绕开FOR UPDATE没走索引行锁升级成表锁。线上排查半天最后发现where字段缺索引。任何锁方案上线前EXPLAIN一定是必做的。事务里混入远程调用行锁持有时间被拉到几十秒大量请求阻塞直至连接池耗尽。乐观锁失败后无条件重试数据库读压力暴涨。SETNX之后忘了设置过期时间进程崩溃后锁变成僵尸锁。释放锁直接DEL删掉了别人的锁。必须用Lua脚本先比较value再删除。锁粒度设太大整个秒杀活动一把锁所有用户串行接口吞吐惨不忍睹。Redis的key没有规范命名和过期策略库存key、订单key混在一起排查问题时恨不得把所有key全扫一遍。5.2 面试官必问的几个问题与回答思路我把秒杀和分布式锁面试里最常出现的几个问答整理成了速查表。问题建议回答思路秒杀怎么防止超卖数据库层用UPDATE ... WHERE stock 0兜底核心判定前置到Redis用Lua脚本原子完成查库存、查重复、扣减、登记为什么不用synchronizedsynchronized是JVM级锁只在单实例内生效生产环境集群部署多实例之间无法互斥数据库锁和Redis锁怎么选数据库锁实现简单但热点行锁串行、连接池易耗尽Redis锁性能高、抗并发强适合秒杀这种高冲突场景SETNX锁怎么释放先GET比较value是否是自己再DEL必须用Lua脚本保证两步原子性避免锁过期后被他人持有却误删锁过期了业务没执行完用看门狗机制自动续期客户端宕机则停止续期锁自然释放拿不到锁怎么办秒杀场景直接返回失败非秒杀可以自旋重试但要控制次数和间隔Redis分布式锁有什么缺陷主从复制可能导致锁丢失极端一致性场景需要RedLock或者数据库锁兜底锁过期、可重入等问题也需要配套机制面试回答的时候不要只背结论。每个问题都结合一段自己的实现经历聊比如提一句“我用JMeter压测对比过数据库锁500并发QPS只有300RedisLua能到2000以上”说服力比任何抽象概念都强。这个项目我自己完整跑过一遍最大的感触是锁这东西不到真正的高并发环境根本看不出差距。本地起个单机项目200个线程模拟压测数据库乐观锁好像也能用一旦部署成两台机器、把请求分发出去JVM锁和数据库锁的真实水平立刻暴露。黑马点评这条演进路线本质上是一次“把并发控制的阵地从数据库搬到Redis”的架构升级。如果你打算把秒杀模块写进简历我强烈建议把数据库锁版、Redis分布式锁版、Lua原子操作版都亲手写一遍压测数据留下来。面试官只要问秒杀你能把这三版演进讲清楚它就是你简历上最靠谱的技术亮点。最后想提醒一句分布式锁不是银弹真正的秒杀系统后面还站着限流、削峰、异步下单、降级兜底锁只是其中一环理解到这一层才算是真正吃透了秒杀。