聊一个我把代码写了很多遍、直到最近才觉得真正“想通”的项目——黑马点评。它的秒杀模块表面看只是一个优惠券抢购的 Demo,实际上把后端并发控制里最典型的一整条演进线串起来了:为什么一开始要上数据库锁,为什么后来要放弃数据库锁,Redis 分布式锁到底解决了什么,Lua 脚本又是怎么把最后一块拼图补上的。这篇总结不是代码笔记的堆砌,我会把每一步选择背后的“为什么”讲透,适合正在刷黑马点评笔记、准备面试讲秒杀项目,或者在自己系统里第一次处理超卖问题的同学。
先提醒一句:如果你只是照着教程把一个秒杀功能跑通,那这个项目对你来说还只是个 CRUD。真正值钱的地方,恰恰是那条从数据库锁慢慢走向 Redis 分布式锁的演进思路。面试官问黑马点评时,追着你问的也基本是这条线。
1. 秒杀场景下,先试试数据库锁
1.1 超卖问题的本质:读、判断、写不是一个整体
秒杀里第一个必须解决的问题就是超卖。在不做任何处理的情况下,用户点抢购按钮,请求进来后系统通常会做三步:查库存、判断库存是否大于 0、把库存减一。表面看没什么问题,但并发环境下这三个动作一旦被多个线程同时执行,就会出大事。
假设库存只剩 1 件,三个请求同时通过了“库存大于 0”的判断,然后各自把库存减 1,数据库里最终就变成 -2。订单却生成了好几张。这个问题的根源,本质上不是 SQL 写错了,而是“读—判断—写”这个整体被人为拆成了三步,步骤之间没有任何互斥保护,任何一个线程的更新都会覆盖掉其他线程的更新结果。这就是经典的丢失更新问题。
我最早接触黑马点评的时候也觉得奇怪,既然 MySQL 本身有行锁,为什么还会超卖?原因很简单:普通的 SELECT 查询默认是不加锁的,多个线程可以同时读到同一个库存值。等到 UPDATE 执行时,虽然 MySQL 会给这一行加锁,但此时判断早就在内存里做完了。所以正确的思路应该是要么让判断和更新在数据库里一步完成,要么在“读”的时候就把这行锁住。两条路分别对应乐观锁和悲观锁。
1.2 乐观锁第一版:用一条 SQL 原子扣减
先看乐观锁。它的核心思想是“我不提前锁,我只在更新的时候校验版本”。对应到秒杀库存在这里,最常见也最简洁的版本是直接利用库存本身做校验:
sql复制UPDATE tb_seckill_voucher
SET stock = stock - 1
WHERE voucher_id = #{voucherId}
AND stock > 0
在 MyBatis/MyBatis-Plus 工程里,实际操作通常是一个带 @Update 注解的 Mapper 方法:
java复制@Update("UPDATE tb_seckill_voucher SET stock = stock - 1 " +
"WHERE voucher_id = #{voucherId} AND stock > 0")
int deductStock(Long voucherId);
这里有个很多初学者会忽略的关键点:UPDATE 语句在 MySQL 里执行时本身就会对命中的行加锁。所以当多个线程同时执行这条 SQL 时,并不会出现两个线程同时把库存改成 -1 的情况。MySQL 的写锁会让更新操作排队执行,后执行的线程会发现库存已经不满足 stock > 0,影响行数为 0,从而知道自己“没抢到”。这正是乐观锁的精髓:不需要提前显式加锁,用影响行数作为冲突检测结果。
那为什么不引入一个专门的版本号字段,每次 update 时 version + 1 并且 where version = 旧值?在黑马点评这个场景里,用“库存大于 0”当版本条件其实是更聪明的做法。秒杀库存的校验条件本来就是“库存要大于 0”,你完全不需要单独维护版本号,库存余量本身就是版本。版本号的方案多用于“更新任意字段都要防止覆盖”的通用场景,在秒杀这个特定场景下反而有点多余。
1.3 乐观锁的代价:失败流量并没有被处理
乐观锁第一版能解决超卖,但它有一个明显问题:并发高的时候,大部分请求都会失败。
一个库存 20 的商品,1000 个人同时抢,可能只有十几个人成功,其余请求全部返回“抢购失败”。从业务角度看这也许是合理的,但要注意这些失败请求是真正把 SQL 打到数据库上以后才失败的。也就是说数据库已经承受了 1000 次 UPDATE 的并发冲击,只是其中大多数没改到数据而已。
有人会想到失败后重试,比如递归调用或者在循环里补一次判断。我不建议在秒杀场景里这么干。重试会把数据库压力放大好几倍,而且重试逻辑和幂等性处理很容易出 bug。如果你把乐观锁的更新包在一个事务里,重试后事务又重新开启,数据库连接会被大量占用,连接池一耗尽,整个服务就雪崩了。所以乐观锁适合“冲突率不高”的业务,但秒杀是天然的冲突率极高场景。
1.4 悲观锁:select for update 的掌控感
乐观锁不够爽快,那就试悲观锁。悲观锁的思路很直接:你要操作这行数据,那就先把它锁住,别人想读想改都必须等我。
在秒杀逻辑里,通常是在事务方法里先查询秒杀券并加上排他锁:
java复制@Transactional
public Result createVoucherOrder(Long voucherId) {
// 查询秒杀券并加行锁
SeckillVoucher voucher = seckillVoucherMapper
.selectByVoucherIdForUpdate(voucherId);
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 这里做一人一单校验,然后扣库存、创建订单
}
对应的 SQL:
sql复制SELECT * FROM tb_seckill_voucher
WHERE voucher_id = #{voucherId}
FOR UPDATE
FOR UPDATE 会命中指定的行并加排他锁,直到事务提交或回滚才释放。所以同一时刻只有一个线程能读到并操作这个秒杀券的库存,其他线程全部阻塞等待。这种方式下,超卖问题确实被“物理”解决了——因为能把库存读出来的线程只有一个,判断和扣减天然串行。
但悲观锁在秒杀场景里有几个难以忍受的问题。第一,所有并发请求都会阻塞在数据库行锁上,数据库连接被请求占住不放,连接池很快被打满。第二,如果多个请求以不同顺序锁多个资源,会出现死锁,数据库会报 Deadlock found when trying to get lock。第三,FOR UPDATE 必须命中索引,如果 WHERE 条件没有走索引,MySQL 会把整个表锁住,那就不只是秒杀券这一行的事了,整个表都瘫痪。第四,也是最本质的:数据库成了唯一的性能瓶颈,你无法靠加机器线性提升并发能力。
这里放一张我自己整理的对比表,方便看乐观锁和悲观锁的取舍:
| 维度 | 乐观锁(stock > 0) | 悲观锁(FOR UPDATE) |
|---|---|---|
| 超卖问题 | 能解决 | 能解决 |
| 并发下的表现 | 大量请求直接失败 | 大量请求阻塞在行锁 |
| 对数据库压力 | 高,每条 SQL 都执行 | 更高,连接长时间被占用 |
| 死锁风险 | 低 | 较高 |
| 适用场景 | 冲突率低的并发控制 | 冲突率高但并发量可控 |
| 秒杀场景评价 | 能防止数据错,扛不住量 | 能防止数据错,更扛不住量 |
所以如果你只是给内部管理系统做个库存防超卖,数据库锁够了。但真正的秒杀是面向高并发公开流量,数据库锁的天花板摆在那里,必须往上层想办法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 转折点:JVM 锁只是过渡,分布式锁才是方向
2.1 单体应用里先试了 synchronized
黑马点评演进到后面,其实经过了一个中间版本:用 Java 的 synchronized 锁做一人一单控制。这个版本的思想是数据库锁太重了,把互斥提前到应用层,减轻数据库压力。
常见写法是用用户 ID 做锁粒度,锁对象用字符串的 intern 拿到同一个字符串常量池对象:
java复制synchronized (userId.toString().intern()) {
// 查询订单
// 校验一人一单
// 创建订单
}
这里的锁粒度是用户维度,因为我们需要保证的是“同一个用户不能抢多张券”,而不是“所有用户必须排队抢券”。锁粒度越小,并发度越高,这个设计方向是对的。
但 JVM 锁有两个致命问题,我在实际改代码时都踩过。
第一个问题是事务失效。如果你在方法内部写 synchronized (userId.toString().intern()),然后这个锁块里直接调用同一个类的 createVoucherOrder 方法,而这个方法上的 @Transactional 注解不会被触发。因为 Spring 事务是通过 AOP 代理实现的,方法内部 this 调用不会经过代理对象,事务自然不生效。正确做法是拿到代理对象再调用,黑马点评里用的就是 AopContext.currentProxy():
java复制IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
synchronized (userId.toString().intern()) {
return proxy.createVoucherOrder(voucherId);
}
第二个问题是集群环境直接失效。一旦服务部署了两台实例,前面加了 Nginx 负载均衡,同一个用户的请求第一次打到 A 实例,第二次打到 B 实例。A 实例的 synchronized 锁管不住 B 实例的线程,因为每个 JVM 有自己独立的锁监视器。这就意味着在集群环境里,JVM 锁等于没锁。
2.2 分布式锁锁的到底是什么
既然 JVM 锁跨不了进程,那就需要一个所有实例都能访问的公共组件来承担“锁服务器”的角色。Redis 正是因为这个场景经常被选中。注意,我们要锁的不应该是“商品库存”这个大粒度资源,而是“同一个用户的重复下单动作”。黑马点评里的锁 Key 通常设计成 lock:order:{userId},含义就是:某个用户同时只能有一个下单操作在执行,其他用户之间互不阻塞。
这个粒度设计很关键。如果你把锁设计成 lock:voucher:{voucherId},等于把整个商品的抢购变成完全串行,所有用户排队,吞吐量会非常难看。按用户维度锁,单个用户串行,不同用户并行,恰好匹配“一人一单”的业务语义。
这里的核心思路转变是:我们不再锁数据行,而是锁操作本身。数据库锁锁的是“商品库存行”,分布式锁锁的是“用户的抢购动作”。动作和资源分离之后,才能把压力从数据库连接层解放出来。
2.3 Redis 分布式锁第一版:SETNX + 过期时间
Spring 的 StringRedisTemplate 提供了 setIfAbsent 方法,对应 Redis 的 SETNX 语义:只有 Key 不存在时才设置成功,并且可以一条命令同时设置过期时间。
java复制String key = "lock:order:" + userId;
String value = UUID.randomUUID().toString();
Boolean locked = stringRedisTemplate
.opsForValue()
.setIfAbsent(key, value, 30, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(locked)) {
return Result.fail("不能重复下单");
}
try {
return proxy.createVoucherOrder(voucherId);
} finally {
stringRedisTemplate.delete(key);
}
这里的细节非常值得展开说。setnx 和 expire 必须是原子操作,不能先 setnx 再单独执行 expire。如果程序在 setnx 成功之后、设置过期时间之前宕机,这个 Key 永远不会过期,锁就变成了死锁——所有后续请求都会被挡住。Redis 的 SET 命令支持 NX 和 EX 参数组合,setIfAbsent(key, value, timeout, unit) 正好是原子完成的,这也是为什么我强烈建议不要用 jedis.setnx 然后再 jedis.expire 这种老写法。
第一版的问题也很明显:锁被误删。如果线程 A 拿锁后执行时间超过 30 秒,锁自动过期。线程 B 拿到同一把锁开始干活。此时 A 终于执行完,执行 finally 里的 delete 把 B 的锁删掉了。B 后面再来一个线程 C,也能加锁成功,锁就完全失去意义。
2.4 解决锁误删:Lua 脚本释放锁
解决误删的办法,是给锁的 value 存一个每次请求唯一的标识,释放前先确认“这把锁还是我的吗”,确认是自己的才删除。但这里又出现一个新问题:GET 校验和 DEL 删除是两个操作,两个操作之间依然有缝隙。如果刚校验完是自己的一瞬间,锁过期了,另一个线程加上了新锁,DEL 又把别人的锁删了。
所以“校验 + 删除”整个动作必须原子执行。执行原子动作最优雅的方式不是事务,也不是 synchronized,而是 Lua 脚本。Redis 在单线程中执行 Lua 脚本,执行期间不会有其他命令插入,天然保证原子性:
lua复制if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
在做真实项目时,我建议你把这个脚本直接固化成一个 DefaultRedisScript 静态对象,避免每次释放锁都重新创建脚本对象。Expire 时间也不要拍脑袋定 30 秒,要结合业务平均耗时和最大耗时的压测结果来设置,保守一点可以按最长耗时的两倍来留余量。如果不想自己处理续期逻辑,生产上直接用 Redisson 的 RLock 也行,它自带看门狗自动续期,这个我在后面章节再展开。
3. 黑马点评秒杀链路真正落地的版本:Redis + Lua + 异步下单
3.1 思路演进:从“互斥”转向“原子”
到了这一步,黑马点评教学版本里其实已经不再满足于“加锁包住所有逻辑”了。因为分布式锁虽然能保证互斥,但所有业务逻辑仍然在线程里串行执行:查缓存、判库存、判重复、扣库存、写订单,每一步都是多个网络往返。即使锁能防超卖,性能也不行。
于是最终的实现思路发生了一个重要转变:把“需要保证一致性的核心操作”直接放进 Redis 的 Lua 脚本里,让 Redis 一次性完成校验、扣库存、记录用户,而不是去做大范围的互斥。
这里的本质区别是:分布式锁是“我锁住临界区,然后慢慢执行”,Lua 脚本是“我把临界区变成一个原子命令,Redis 替我一次性执行完”。后者没有锁等待、没有上下文切换,也没有锁续期问题,这在高并发秒杀里优势极大。你可以把 Lua 脚本理解成一段可以在 Redis 内部完成的“存储过程”,Redis 单线程事件模型保证了它不可能和其他命令交错执行。
3.2 一条 Lua 脚本完成库存扣减和一人一单
黑马点评里最核心的一段 Lua 脚本,我见过很多版本,核心逻辑基本一致。我这里整理一版比较容易看懂的写法:
lua复制-- KEYS[1]: seckill:stock:{voucherId} 库存 key
-- KEYS[2]: seckill:order:{voucherId} 已购用户集合 key
-- ARGV[1]: userId 当前用户 id
-- 库存不存在直接返回异常
if (redis.call('exists', KEYS[1]) == 0) then
return 3
end
-- 库存不足
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
各个返回值的含义是:0 表示秒杀成功;1 表示库存不足;2 表示重复下单;3 表示缓存中还没有这个秒杀券的库存数据,需要先做缓存预热。
Java 端执行脚本也非常简单:
java复制DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(SECKILL_SCRIPT, Long.class);
Long result = stringRedisTemplate.execute(
redisScript,
Arrays.asList("seckill:stock:" + voucherId,
"seckill:order:" + voucherId),
userId.toString()
);
这里有个很容易被忽略但很重要的细节:Redis 的 Set 天然具备去重能力,用来做“已购用户集合”非常合适。当你用 SADD 把用户 ID 写进去后,后续再来同一个用户,SISMEMBER 直接返回 1,脚本直接返回“重复下单”。这个设计比用分布式锁去保护“查询数据库订单 + 插入订单”要简单得多,也快得多。
3.3 秒杀成功后为什么要异步下单
Lua 脚本执行完,成功返回 0 之后,我们就可以立刻给用户返回“抢到了”。但是订单还没真正落库,这一步被放到了异步线程/队列里执行。
为什么这么做?核心目标是削峰。秒杀场景下,真正到达后端的高并发洪峰如果直接打到 MySQL,数据库会先扛不住。Redis 是内存操作,单机能抗十万级 QPS;MySQL 的写 TPS 通常只有几百到几千。异步化之后,用户的请求只打 Redis,真正的数据库写入被拉平到每秒可控的量级。这就是秒杀系统常说的“流量漏斗”:入口做校验,内存抗峰,数据库匀速消费。
常见实现里有两种做法:一种是用 JDK 线程池把订单保存任务丢到队列里执行;另一种是用 Redis 自带的 Stream 结构做轻量消息队列。黑马点评不同的教程版本写法略有差异,但核心都是“成功即返回,落库异步做”。我自己的建议是学习阶段先用线程池跑通,理解异步削峰的思想,等真正上生产再考虑 RocketMQ/Kafka 这类可靠消息中间件。
3.4 异步带来的数据一致性问题:唯一索引兜底
异步下单会带来一个担忧:如果异步任务执行失败,订单没写进数据库,用户明明秒杀成功了,结果查不到订单怎么办?又或者消息重复消费,同一个订单被插入两次怎么办?
第一个问题靠消息可靠性和对账解决,通常生产环境会用消息队列的 ACK 重试机制。第二个问题有一个非常经典且必须做的兜底:在数据库订单表上加上唯一索引,约束同一用户对同一秒杀券只能有一条订单:
sql复制ALTER TABLE tb_voucher_order
ADD UNIQUE KEY uk_user_voucher (user_id, voucher_id);
有了这个唯一索引,即使 Lua 脚本在极端情况下没拦住重复请求,或者消息队列里出现了重复消息,第二次 INSERT 也会因为唯一键冲突而失败,数据库层面不会产生重复订单。这是数据库做的最后一道防线,也是我在实际项目里无论 Redis 方案多完善都坚持保留的一张底牌。
还有另外一个容易忽略的点:订单 ID 不能用 MySQL 自增 ID。因为在异步下单、分库分表、分布式环境下,数据库自增 ID 很容易冲突,而且订单号本身会泄露当天订单量。黑马点评里用 Redis 生成全局唯一 ID:时间戳左移一定位数,然后拼上 Redis 自增序列。这个 ID 也是我们在异步消息里定位订单、做幂等判断的重要凭证。
3.5 完整流程串一遍
到这里,黑马点评秒杀模块的最终方案可以把完整流程理顺了:
- 请求进来,先执行 Lua 脚本,一步完成库存校验、一人一单校验、扣减库存、写入已购用户集合。
- 脚本返回 0,生成全局唯一订单 ID,把订单数据封装成消息丢进队列。
- 接口立刻返回秒杀成功和订单 ID,用户无需等待数据库落库。
- 异步线程消费队列,把订单插入 MySQL。插入失败则重试;重复插入被唯一索引挡住。
- 数据库侧的库存通过乐观 SQL 或定时任务最终对账扣减,保证 Redis 和 MySQL 在极端情况下不会出现数据永久不一致。
对比最初的数据库悲观锁方案,这个链路里最明显的区别是:请求不再长时间占用数据库连接,数据库的并发压力被大幅降低;互斥控制从“锁行”变成了“Redis 原子操作”;最终一致性由唯一索引兜底。
4. 实战中的高频坑与排查实录
4.1 锁误删问题排查记录
我在压测第一版 Redis 分布式锁时,遇到过“偶尔出现库存少卖但用户实际下单成功”的怪现象。抓日志发现根因就是锁误删:线程 A 执行时间超过了设置的过期时间,锁被 Redis 自动清理,线程 B 拿锁,然后 A 的 finally 里的 delete 把 B 的锁删了。修复方式就是把 value 换成了 UUID,并且把释放锁的逻辑改成 Lua 脚本。
这里给一个额外的小技巧:在 finally 里释放锁时,绝对不要在 delete 前面做任何可能阻塞的操作,比如网络调用、日志打印大对象、远程查询。否则释放锁本身的延迟会进一步放大误删风险。我当时还在 finally 里加了一个耗时统计的日志,压测时发现日志本身也会拖慢释放流程,后来把日志级别调整成 debug 才解决。
4.2 过期时间设置多少才合理
过期时间太短,锁容易被提前释放;太长,一旦持有锁的线程卡死,其他请求要等很久。很多同学喜欢设置 10 秒 30 秒这种“看上去合理”的值,但这是草率的做法。
正确姿势是先做压测:模拟 500 并发、1000 并发,看业务方法 P99 耗时是多少,然后设置成 P99 耗时的 2 到 3 倍。如果方法平均耗时 200 毫秒,P99 是 800 毫秒,那把过期时间设置成 2 秒到 3 秒是合理的。如果业务里有批量下单、远程调用等不确定耗时场景,不建议靠拉长过期时间硬扛,而是换用 Redisson 的看门狗机制:锁快到期时自动续期,业务执行多久就续多久,从根源上避免误删。
Redisson 的 RLock 还解决了另一个第一版锁没有解决的问题:可重入。同一个线程在持有锁的情况下再次调用同一把锁,setnx 方案会死锁自己,而 Redisson 通过计数可以实现重入。
4.3 锁释放和事务提交的顺序问题
锁误删之外的另一个隐蔽问题,是锁被释放时事务可能还没提交。常见代码是锁内调用带 @Transactional 的方法,方法返回后我们在 finally 里删除锁。如果删锁发生在事务提交之前,下一个线程立刻进入锁内,它查询到的数据很可能还是事务提交前的旧状态。
举一个具体例子:线程 A 锁内创建订单并插入数据库,但是事务还没提交;A 在 finally 中删锁;线程 B 拿到锁后立刻查询订单表,发现查不到 A 刚插入的订单,于是 B 也创建了一张订单,最终产生重复数据。
我在黑马点评学习时也踩过这个坑。解决办法是确保事务提交先于锁释放。由于 Spring 事务拦截器是在目标方法正常返回后才提交事务,而 finally 中的删锁代码在方法返回后、调用方那边才执行,所以在“锁内调用代理对象的 @Transactional 方法,之后 finally 释放锁”这种结构下,事务通常已经先提交了。但如果你把加锁、业务代码、释放锁全部写在同一个带 @Transactional 的方法里,顺序就会非常微妙,容易出问题。我的建议是锁和事务不要混在同一个方法里,把真正需要事务保护的操作拆到单独的方法,锁放在外层包裹,这样逻辑才清晰。
4.4 压测中出现重复订单和库存超扣
很多压测现场出现重复订单,第一反应是 Redis 锁没生效。实际排查下来,常见原因有三个:
第一个原因是 Lua 脚本没覆盖所有入口。比如管理后台手动补单、活动接口走的是另一套逻辑,没有经过脚本校验。每一个会写订单的入口都必须走同一个校验脚本,否则就会出现“绕开校验”的漏洞。
第二个原因是缓存预热没做好。脚本执行时如果库存 Key 不存在,会直接返回 3,前端可能把 3 当作“失败”处理,但某些异常分支仍可能继续执行下单逻辑。我在脚本里专门加了 exists 判断,并且在秒杀开始前通过定时任务或页面加载接口预热库存,避免这个问题。
第三个原因是消息重复消费。异步下单后如果消息队列重复投递,没有唯一索引兜底的表就会出现重复订单。加了唯一索引之后,重复消息只是报错,不会污染数据。
4.5 分布式锁的进一步选型思考
黑马点评教学版使用 Redis 作为锁服务器,完整够用。但在真实企业项目里,选 Redisson 还是自研,还要考虑一致性要求。我把常见的几个方案做一个横向对比,方便你在面试或做技术方案时选用:
| 方案 | 一致性模型 | 性能 | 主要风险 | 适用场景 |
|---|---|---|---|---|
| Redis SETNX 自研 | AP,最终一致 | 高 | 主从切换可能丢锁 | 容忍极端情况下少量锁丢失 |
| Redisson RLock | AP,带续期 | 高 | 依赖 Redis 可用性 | 绝大多数互联网业务 |
| ZooKeeper 临时节点锁 | CP,强一致 | 中 | 会话管理复杂,性能一般 | 对一致性要求极高的配置中心类场景 |
| 数据库悲观锁 | CP | 低 | 连接瓶颈、性能差 | 低并发内部系统 |
需要强调的是,“主从切换丢锁”是 Redis 锁方案绕不开的话题。如果你对锁一致性要求极高,可以考虑 RedLock 思路或直接上 ZooKeeper。但在秒杀场景下,Redis 锁即使极端情况丢失,下层还有数据库乐观锁和唯一索引兜底,整体数据安全是可以保证的。这也是为什么 Redis 分布式锁在实际秒杀项目里被广泛使用的原因——它不是银弹,但配合兜底策略它就足够稳。
5. 最后分享一点我自己的体会
如果只是照着视频敲代码,你很难真正理解为什么黑马点评要设计这么多层。我自己练这个项目的时候,是先把第一版数据库悲观锁跑通压测,然后改成 JVM 锁,再部署两台实例看它失效,最后才切换到 Redis + Lua。这个过程走完之后,再去看网上各种“黑马点评面试题”,很多问题根本不用背答案,你能直接说出每一步演进的理由。
我给准备面试的同学一个建议:不要只背“最终方案”,把演进过程的每一版代码都在本地跑一遍,压测数据记录下来。面试官问“为什么不用 synchronized”的时候,你说出“集群环境 JVM 锁失效,两台实例各自持锁”比背一百遍笔记都有说服力。如果简历上写了 Redis 分布式锁,一定要把锁误删、过期时间、可重入这三个问题的处理思路讲清楚,这是高频追问点。
最后分享一个压测小技巧:不要只盯着 Redis Desktop 看库存对不对。用 Jmeter 把并发从 100 调到 500 再调到 1000,同时打开 MySQL 的慢查询日志和连接池监控,你会直观看到数据库连接数暴涨、SQL 排队等待,然后才真正理解为什么把检查和扣减放到 Redis 里异步化是必要的。这个视角,比看任何源码都更能帮你建立秒杀系统的整体认知。
