本来打算聊聊黑马点评整个项目怎么从零写,但思来想去,最值得单独拎出来说透的,还是分布式锁这块。从热点词里的“黑马点评分布式锁”“Redis分布式锁”“黑马点评面试问题”就能看出,大家对这一块的关注度有多高。确实,优惠券秒杀、缓存击穿、订单防超卖,这些一旦并发量上来,哪一个环节没锁住,线上就得翻车。这篇就把黑马点评里分布式锁的来龙去脉、手写实现、Redisson改造、常见坑一次说清楚,适合正在学这个项目的人,也适合马上要拿这个项目去面试的朋友。
我先说一句大实话:很多人在黑马点评项目里,把分布式锁的代码抄了一遍就觉得自己会了,但面试官随便问一个“为什么用Redis做分布式锁,它到底锁住了什么”,人就愣住了。这篇不光是讲代码,重点是把原理、选型、坑点和排查思路都掰开揉碎,你看完能讲得出来,才算是真正学到位。
1. 黑马点评里的分布式锁,到底在解决什么问题
1.1 秒杀场景下的“超卖”困局
黑马点评整个项目里,最经典的业务场景就是优惠券秒杀。用户点一下“抢券”,后端需要做的动作是:先查询优惠券库存,再判断用户是否已经买过,校验通过后扣减库存,最后创建订单。这一整套操作看似简单,但问题就出在“并发”这两个字上。
想象一下,一个秒杀活动放进来一万张券,同时有两万个用户在线抢。如果后端代码不做任何并发控制,两个用户的请求同时读到剩余库存为1,然后同时通过校验,同时去扣减库存,最后都创建订单成功,数据库里的库存就变成了-1。这就是典型的超卖问题。超卖意味着什么?意味着你要么多卖出去的券自己贴钱赔,要么给用户退款并发道歉公告,不管哪种,都是事故。
很多人第一反应是:给数据库的库存字段加锁不就行了?确实可以,比如用SELECT ... FOR UPDATE悲观锁,或者用乐观锁的version字段。但在秒杀这种高并发场景下,数据库锁和重试机制的成本是很高的,数据库连接资源是稀缺的,大量线程阻塞在行锁上会导致连接池被打满,数据库整体响应变慢,最后被拖垮。这也是为什么黑马点评选择把锁这块放到Redis层面来做,因为Redis是单线程执行命令,天然不会有多线程竞争问题,而且网络IO模型决定了它的读写速度极快,能在不拖垮数据库的情况下完成互斥。
1.2 单机锁为什么扛不住集群环境
先看一段非常常见的错误代码:
java复制public synchronized Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
// 2.判断库存是否充足
// 3.扣减库存
// 4.创建订单
}
这段代码在单机部署、单实例运行的情况下没有任何问题,因为synchronized锁的是JVM进程内的Monitor对象,同一进程内的多个线程能够被正确互斥。但黑马点评一旦部署成多实例——哪怕只是简单的两台服务器加Nginx负载均衡——问题就立刻暴露了。
假设集群里有两个服务实例,用户A的请求被负载均衡转发到实例1,用户B的请求被转发到实例2,这两个请求会同时进入被synchronized修饰的方法,但它们是两把不同的JVM锁,互不感知。结果就是,库存为1的券,两个用户都能抢到,超卖问题在集群环境下依然存在。换句话说,JVM锁只能锁住自己进程内部,锁不住别人家的进程。
黑马点评让你学分布式锁,本质就是让你理解这样一个现实:一旦服务从单机走向集群,所有基于进程内的同步机制都会失效,需要一把“大家都能看见、大家都能访问、大家都能遵守”的公共锁。Redis天然就是这个公共锁的最佳载体,因为它独立于任何一个业务服务实例之外,所有服务实例都往同一个Redis上争抢写入一条特定的数据,谁写成功了,谁就拿到了锁。
1.3 黑马点评里的锁演进路线
我在学习这个项目的时候,把锁的演进路线整理成了三个阶段,整个思路非常清晰。
第一阶段是“无锁裸奔”,也就是用synchronized或ReentrantLock做本地互斥,只在单体应用且不关心并发的情况下能跑通。这时候学习的重点是理解线程安全和资源竞争。
第二阶段是“Redis手写分布式锁”,即在Redis里通过SETNX + EXPIRE或者SET key value NX EX来创建一把分布式锁,业务执行完后再DEL释放。这个阶段能解决集群环境下的互斥问题,但会面临锁误删、锁过期业务没执行完等新问题,必须配合Lua脚本、UUID标识、过期时间来完善。
第三阶段是“Redisson封装好的分布式锁”,直接使用Redisson提供的RLock,它内置了看门狗自动续期、可重入、公平锁、非阻塞尝试等机制,是生产环境中真正可落地的方案。
黑马点评项目里,这三个阶段其实是一条线:先用一个看似“能用”的第一版手写锁,再让你踩几个坑,最后用Redisson解决掉所有手写锁遗留下来的痛点。这条演进线本身就是面试时很好的讲述素材,因为能体现你对技术选型的思考过程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写一套Redis分布式锁,先理解核心原理
2.1 基础版本:SETNX加过期时间
Redis实现分布式锁最核心的命令就是SETNX,全称是“SET if Not eXists”,只有键不存在时才设置成功,返回1;如果键已经存在,则设置失败,返回0。这把锁的思路非常简单:多个服务实例同时去Redis执行SETNX lock:seckill:001 true,只有第一个写成功的实例能拿到锁,其他实例要么等待重试,要么直接返回失败。
但仅仅用SETNX是不够的,因为如果拿到锁的实例在执行业务的过程中突然宕机了,那这个lock:seckill:001这个键就永远留在Redis里了,其他实例永远都拿不到锁,整个系统就死锁了。所以必须给锁加上过期时间。这也是网上很多老教程里推荐的“两步走”写法:
java复制Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId);
if (Boolean.TRUE.equals(success)) {
stringRedisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
// 执行业务逻辑
stringRedisTemplate.delete(lockKey);
}
这种写法看着没错,但实际上有一个非常经典的原子性问题:SETNX和EXPIRE是两个独立的Redis命令,如果SETNX执行成功后,服务实例在设置过期时间之前突然宕机了,锁键依然会永久存在于Redis中,最后还是死锁。
所以正确做法是使用Redis提供的原子命令:
java复制Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);
这个setIfAbsent方法可以通过ValueOperations的重载方法一次性完成“加锁 + 设置过期时间”两个动作,底层对应的是Redis的SET key value NX EX seconds命令,原子性由Redis自身保证。这是写分布式锁的第一步,也是最重要的一步。
2.2 锁误删问题:凭什么你删掉别人的锁
掌握了SETNX + EXPIRE的原子加锁后,马上会
