本来打算聊聊黑马点评整个项目怎么从零写,但思来想去,最值得单独拎出来说透的,还是分布式锁这块。从热点词里的“黑马点评分布式锁”“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的原子加锁后,马上会遇到第二个问题:锁误删。
看一下这个场景。线程A拿到锁,设置了10秒过期时间,然后开始执行业务。结果这次业务执行得特别慢,超过了10秒,Redis把锁键自动释放了。这时候线程B立刻抢到了锁,也开始执行业务。又过了几秒,线程A终于执行完了,代码里最后一步执行了delete(lockKey),直接把线程B持有的锁给删了。线程B刚执行到一半,发现锁没了,线程C又抢了过来。于是B和C同时操作共享资源,互斥失效了。
这个问题的根源是:A删锁的时候,根本不知道这把锁已经不是自己的了。要解决它,必须在加锁时给锁绑定一个“唯一标识”,通常用UUID + 线程ID或直接用一个全局唯一的clientId作为value存入Redis。释放锁时,先判断当前锁的value是否等于自己的clientId,相等才删,不相等就说明锁已经被别人持有了,不能动。
java复制String clientId = UUID.randomUUID().toString();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(success)) {
try {
// 执行业务
} finally {
// 先比较,再删除
String currentValue = stringRedisTemplate.opsForValue().get(lockKey);
if (clientId.equals(currentValue)) {
stringRedisTemplate.delete(lockKey);
}
}
}
但这又引出了一个新问题:先比较、再删除,是两个独立操作,中间存在时间窗口。假设线程A判断完clientId相等,刚准备执行delete,锁过期了,线程B抢到锁并写入了自己的clientId,线程A紧接着执行了delete,又把B的锁删了。本质上,只要“判断”和“删除”不是原子的,就依然存在误删的可能。
解决这个问题的标准方案是用Lua脚本,把“比较value是否相等”和“删除key”写在一个脚本里,由Redis保证脚本的原子执行。这也是为什么黑马点评在第二版手写分布式锁里,专门封装了一个SimpleRedisLock类,用Lua脚本来做释放锁的操作,相当于把分布式锁的“打开方式”从“近似正确”升级到了“确定性正确”。
2.3 过期时间的死循环:锁自动释放但业务还没跑完
锁误删问题解决之后,还有第三个问题,也是手写分布式锁最棘手的问题:业务执行时间超过锁的过期时间,锁被Redis自动释放了,但业务还在继续跑,与此同时另一个线程已经抢到了锁,两个线程同时处在临界区里。
网上最常见的处理办法是把过期时间设置得很长,比如30秒、60秒。但这治标不治本,因为业务执行时间是没法预判的,网络抖动、数据库慢查询、第三方接口超时,都可能导致某个请求的执行时间突然飙到几十秒。过期时间短了,锁提前释放;过期时间长了,一旦持有锁的实例宕机,其他实例要等很久才能拿到锁,系统吞吐量大打折扣。
生产环境中成熟的解决方案是自动续期机制,也就是看门狗(WatchDog)的思路:拿到锁之后,启动一个定时任务,每隔一段时间检查一下锁是否还在,如果还在就自动给锁续期,把过期时间往后推。这样锁的过期时间就不是固定的了,而是跟着业务执行时间动态走,既不会因为业务超时而释放锁,又能在实例宕机后自动释放。
Redisson的做法是,默认给锁设置30秒的leaseTime,同时启动一个后台线程,每10秒检查一次锁是否还被当前线程持有,如果持有就把过期时间重置为30秒。这个机制非常好地解决了“业务没跑完锁就没了”的问题,也是手写分布式锁和Redisson之间最大的差距所在。
3. 实操黑马点评:从引入依赖到Redisson改造
3.1 环境和依赖准备
黑马点评项目的基础环境需要一个可用的Redis服务。本机用Docker起一个是最方便的,生产环境里用的云数据库或自建集群原理都一样。关键是要把spring-boot-starter-data-redis依赖引入进来,并且在application.yml里配置连接参数:
yaml复制spring:
data:
redis:
host: 127.0.0.1
port: 6379
password:
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
如果你是用Docker快速起Redis,可以直接跑:
bash复制docker run -d --name redis -p 6379:6379 redis:7.0
另外要留意,黑马点评项目里用到了很多Redis相关功能,包括缓存、分布式锁和消息队列,建议先跑通整体项目环境,再单独针对锁部分做实验。这样你能直观看到秒杀模块在没有锁、有本地锁、有Redis锁、有Redisson锁时的并发表现差异。
3.2 自己封装一个SimpleRedisLock
黑马点评里手写分布式锁,通常的做法是自定义一个ILock接口,然后实现一个基于StringRedisTemplate的SimpleRedisLock。接口设计很简单,就两个方法:加锁tryLock和释放锁unlock。
java复制public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁自动释放时间
* @return true表示获取成功
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
然后是实现类,核心逻辑就是把前面讲到的SET NX EX原子命令、UUID标识、Lua脚本删除三个要点全部整合进来:
java复制public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString() + "-";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
public void unlock() {
String threadId = ID_PREFIX + Thread.currentThread().getId();
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(KEY_PREFIX + name), threadId);
}
}
这里有几个细节值得展开说一下。第一,ID_PREFIX使用了类加载时生成的UUID,再加上当前线程ID,组成一个唯一的value标识,这样即使不同实例、不同线程之间也不会冲突。第二,释放锁时使用了Lua脚本,脚本先通过get获取锁当前持有的value,与传入的线程标识做比较,一致才执行del,整个判断和删除是原子操作,避免了误删别人的锁。第三,tryLock返回Boolean类型时要记得判空或使用Boolean.TRUE.equals(),因为Redis操作发生异常时返回的可能不是简单布尔值。
3.3 在秒杀业务里集成自定义锁
锁封装好之后,要在秒杀业务代码中去使用。黑马点评的VoucherOrderServiceImpl中,秒杀下单的核心逻辑大致是这样:
java复制@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束!");
}
Integer stock = voucher.getStock();
if (stock < 1) {
return Result.fail("库存不足!");
}
// 2.使用分布式锁,锁的是用户id,防止同一用户重复下单
Long userId = UserHolder.getUser().getId();
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
boolean isLock = lock.tryLock(10);
if (!isLock) {
return Result.fail("不允许重复下单!");
}
try {
// 3.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
int count = voucherOrderService.save(voucherOrder);
if (count > 0) {
// 4.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.update();
if (!success) {
return Result.fail("扣减库存失败!");
}
}
return Result.ok(voucherOrder.getId());
} finally {
lock.unlock();
}
}
这里我特意标注了“锁的是用户id”,这是黑马点评里容易被忽略的一个细节。秒杀场景下,锁的粒度不应该是“整个优惠券”,而应该是“用户维度”。因为同一个用户不能重复抢同一张券,但不同用户之间是可以并发抢的,如果锁整个优惠券ID,相当于把所有请求串行化了,性能会直线下降。锁的粒度越小,并发度越高。这个思考在面试时非常加分。
3.4 用Redisson替换手写锁,能省掉多少事
手写锁虽然可以解决误删和原子性问题,但业务超时的自动续期问题依然存在。黑马点评在进阶阶段引入了Redisson,就是为了解决这个痛点。引入Redisson非常简单:
xml复制<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.20.0</version>
</dependency>
然后配置Redisson客户端:
java复制@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
}
业务代码改造后的形态是这样的:
java复制@Resource
private RedissonClient redissonClient;
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
if (!isLock) {
return Result.fail("不允许重复下单!");
}
try {
// 秒杀下单核心逻辑
} finally {
lock.unlock();
}
}
Redisson相比手写锁,最大的优势就是不用管续期、不用管误删、不用写Lua脚本。lock.tryLock()默认会根据看门狗机制自动续期,每10秒检查一次并重置过期时间,业务执行多久,锁就能续多久。同时锁是可重入的,同一个线程可以多次获取同一把锁,内部通过计数来实现,类比synchronized的重入特性。
如果你希望锁有过期时间上限,也可以显式传入leaseTime:
java复制boolean isLock = lock.tryLock(10, TimeUnit.SECONDS);
这种情况下Redisson会严格按10秒过期,不再启动看门狗续期。这就给了你两种策略选择,一种是“绝对不能提前过期”,另一种是“绝不允许死锁”,具体选哪个,要结合业务场景来决定。
4. 黑马点评分布式锁的经典问题与排错实录
4.1 并发测试能过,为什么压测还是超卖
这是我见到过最多人踩的坑:本地起一个单实例服务,用Postman模拟并发发了几百次请求,发现一切正常,没有超卖,就觉得分布式锁写对了。但一上压测环境,两台实例同时跑,数据就出问题了。
原因其实很简单:你本地测试的时候,所有请求都打进同一个JVM进程,synchronized就能帮你挡掉大部分并发问题。但如果压测环境是多实例部署,而代码里用的还是本地锁,那锁就直接失效了。所以验证分布式锁,前提条件一定是多实例环境,至少也要通过多开几个Spring Boot实例、用Nginx分流来模拟集群环境。
我实测过的压测方式是这样的:把秒杀服务启动两个实例,端口分别设为8081和8082,Nginx配置轮询转发,然后用JMeter开200个线程分别模拟不同用户抢同一个优惠券,优惠券库存设成50。检查数据库里订单记录加上库存扣减记录,最终应该恰好50单,库存归0。只要出现51单,说明锁没生效。
这里还要注意一个细节,即使分布式锁写对了,订单表和库存表的写入也不是原子的,如果数据库层面的扣减不是通过条件更新来保证,依然可能出现库存对不上。黑马点评里的做法是用UPDATE语句带stock = stock - 1的条件更新,同时配合where stock > 0的查询条件,在数据库层面再加一道保险。
4.2 同一个用户疯狂点击,订单重复创建了怎么办
秒杀防超卖解决之后,很容易忽略重复下单问题。用户手一抖连点了两下,或者前端没做按钮置灰,又或者用户在两个页面重复提交,都可能导致同一个用户在同一毫秒创建了两条订单。
用分布式锁锁用户ID可以有效解决这个问题,因为第二个请求在尝试获取锁时会失败,直接返回“不允许重复下单”。但这里有一个小陷阱:如果你把锁的过期时间设置得太短,比如1秒,而秒杀下单逻辑加上数据库写入耗时超过了1秒,锁就会提前释放,第二个请求趁虚而入。所以秒杀场景下锁的过期时间至少要大于平均业务耗时,我一般会设为10秒,配合Redisson看门狗机制,既不会死锁,也不会提前失效。
顺便说一句,如果面试官追问“分布式锁一定能阻止重复下单吗”,你可以回答:分布式锁是拦截了并发请求,但更稳妥的方案是在数据库表结构上给user_id和voucher_id建一个唯一索引,让重复数据在数据库层面直接被拒绝。这种“前端防重 + 分布式锁防并发 + 唯一索引兜底”的多层防护,才是生产级的答案。
4.3 手写锁遇到业务异常,finally里删锁要注意什么
手写Redis锁的时候,很多人会忽略一个很小的点:unlock()要在finally代码块里调用,而且不能直接放在try代码块的最后一行。如果业务代码在中途抛了异常,try后面的unlock()永远不会执行,锁就会一直存在直到过期时间到达,这期间其他线程全都进不来。
正确的写法永远是这样的:
java复制boolean isLock = lock.tryLock(10);
if (!isLock) {
return Result.fail("获取锁失败");
}
try {
// 业务逻辑
} finally {
lock.unlock();
}
我还见过有人把unlock()写在了tryLock()的if语句块内、但业务逻辑通过线程池异步执行的场景,这就更危险了。锁的持有者是“当前线程”,如果你把业务丢给另一个线程去执行,当前线程已经无关业务状态了,锁的释放时机无法对齐业务的真实结束点。这种情况下,要么不用线程池,要么把锁的获取和释放都放在同一个异步任务内,确保锁的生命周期与业务执行生命周期完全一致。
4.4 缓存穿透和缓存击穿,能不能也用分布式锁解决
黑马点评项目里,热点商铺的缓存更新也涉及分布式锁的应用。比如查询一家商铺信息时,代码会先查Redis缓存,缓存没命中再查数据库,然后把结果写回缓存。这个逻辑在高并发下有一个问题:如果缓存刚刚失效,大量请求同时穿透到数据库,数据库会被瞬间打爆。
常见的解决手段就是分布式锁:当缓存未命中时,多个线程同时去争抢一个锁,只有拿到锁的线程去查数据库并重建缓存,其他线程等待一段时间后重试,读取刚刚重建好的缓存。这就是缓存击穿的解决思路。在Redis里还有一种对应的写法是SET命令配合NX和过期时间,再加上随机的过期时间偏移,避免大量key同时失效导致缓存雪崩。
不过要提醒的是,缓存击穿的“分布式锁”和秒杀的“分布式锁”虽然底层都是Redis加锁,但在业务含义上是两回事。秒杀锁保护的是“单用户不能重复操作”,缓存重建锁保护的是“同一热点key只能有一个线程去查库”。面试时如果能把这两种场景分开讲,会显得你对项目的理解深入了一个层次。
4.5 锁的粒度选择:锁整个商品还是锁单个用户
我在学习过程中复盘黑马点评的锁设计时,最感慨的一点就是它把锁的粒度控制得非常好。如果上来就锁order:voucher:{voucherId},那么同一张优惠券的所有用户抢购请求全部串行,比如800个并发请求,就变成了800个请求排队依次执行,虽然不会超卖,但QPS直接被打到只有几十,用户体验差到难以接受。
黑马点评选择锁order:user:{userId},把并发粒度降到了“单用户级别”。不同用户之间互不阻塞,只有同一个用户的重复请求才需要排队。这种粒度设计既保证了数据正确性,又把并发能力提升到了极致。这个点我建议每一个学习这个项目的人都认真想一想,面试官非常爱问“你为什么不锁整个秒杀操作”。
理论上,锁的粒度越细越好,但与业务耦合也会越深。最理想的粒度,应该是“数据竞争发生的那个维度”。在秒杀场景中,数据竞争发生在“同一个人操作同一张券”,而不是“所有人抢同一张券”,所以锁用户维度就是最合适的。过度细粒度会导致锁失去意义,过度粗粒度会导致性能无法接受,找到那个平衡点,才是分布式锁设计的真正功力。
5. 面试被深挖的几个点,提前准备不心虚
5.1 分布式锁为什么不用数据库或ZooKeeper
Redis分布式锁是黑马点评的标准答案,但面试官经常会问一句:“为什么不用数据库悲观锁或者ZooKeeper分布式锁?”如果你能说出对比差异,这部分回答基本满分。
数据库悲观锁的实现思路是用SELECT ... FOR UPDATE锁住一行库存记录,事务提交后自动释放。优点是实现简单,纯靠数据库能力,不会存在锁过期问题。缺点是数据库性能瓶颈很明显,所有抢锁请求都落在数据库行锁上,秒杀这种瞬时高并发场景下,数据库连接池很容易被打满,而且可能引发大量死锁重试。
ZooKeeper分布式锁的原理是利用ZNode临时顺序节点加Watch监听机制,多个客户端同时创建顺序节点,只有序号最小的客户端能拿到锁,其他客户端监听前一个节点。优点是可靠性高,没有过期时间导致锁提前释放的问题,但劣势是性能不如Redis,因为ZooKeeper的写入路径是Leader节点单点处理,锁的获取和释放需要多次网络调用,同时在大量请求竞争时,ZNode的创建和删除本身也会成为瓶颈。
Redis分布式锁最大的优势就是性能高、简单、基础设施通常已经具备。它的问题是存在锁过期、主从切换导致锁丢失等极端风险。但黑马点评这个项目是单体架构加Redis的场景,用Redis实现分布式锁是性能和可靠性的最佳平衡点。面试时把这三个方案的优缺点讲清楚,说明你理解了为什么黑马点评选Redis,就足够了。
5.2 RedLock到底靠不靠谱,要不要掌握
如果你在面试中被问到“Redisson的分布式锁原理”,面试官很可能会顺带问一句“你了解RedLock吗”。RedLock是Redis官方提出的一种跨多节点部署的分布式锁算法,目的是解决单个Redis节点故障导致的锁失效问题。思路是向多个独立的Redis节点依次尝试加锁,只要超过一半节点加锁成功并且耗时小于锁的有效时间,就认为锁获取成功。
这个算法在实际生产中的争议比较大,因为它依赖一个假设:所有Redis节点都是独立故障的,没有共享的底层存储。如果节点之间本身存在同步延迟或者网络分区,RedLock的可靠性会大打折扣。分布式系统专家Martin Kleppmann专门发表文章批评过RedLock,Redis作者也有过回应,属于分布式领域的经典论战。
我个人的建议是,面试时你只需要知道RedLock的存在和基本思想,同时能分析它的优缺点即可。实际项目中,绝大多数场景用单节点Redis加哨兵模式或者主从模式已经足够,真到了需要跨多个机房部署锁服务的级别,通常有更偏向于强一致性的替代方案。黑马点评里用的是Redisson的普通RLock,已经覆盖了项目需求,不必在RedLock上过度纠结。
5.3 看门狗续期的底层实现,源码要怎么看
Redisson看门狗是面试中的一个高频提问点,很多人在项目里用了、也说“它能自动续期”,但被问到底层怎么实现就卡住了。这里给一个快速理解路径:RLock.tryLock()最终会调用到RedissonLock的tryAcquireAsync方法,该方法内部会先尝试向Redis执行一段Lua脚本,加锁成功后会启动一个Timer任务,每10秒执行一次续期动作,对应的实现类是RedissonBaseLock里的renewExpirationAsync方法,续期的Lua脚本会把锁的过期时间重新设置为30秒,只要锁还存在,就不断续期。
看门狗机制的关键在于:它只有在没有显式传入leaseTime时才生效。如果你调用tryLock(10, TimeUnit.SECONDS),Redisson会把leaseTime原样传给Redis,不做续期。这一点在源码里体现得特别明显,也是面试官很爱设坑的地方。如果你答出来了,证明你真的看过源码,而不是只会背诵“有看门狗”。
还有一点值得补充,看门狗续期的前提是“锁还属于当前线程”,Redisson会通过Lua脚本检查锁的value是否等于当前线程的唯一标识,不相等说明锁已经被别人持有或者已经释放,这时候续期任务会主动停止,不会出现一直给别人的锁续期的bug。整个看门狗自动续期的链路,本质上是“作用域限定在当前线程内的定时续期任务”。
5.4 学了黑马点评的锁,写进简历时该怎么说
热点词里有“黑马点评怎么写进简历”,这块我也多说一句。分布式锁的写法如果只是写“熟练使用Redis分布式锁”,那和没写一样。要在项目描述里体现出你的技术思考,建议按“技术难点 -> 方案演进 -> 最终收益”的格式来写。
比如可以这样写:
负责优惠券秒杀模块的开发,解决高并发场景下的超卖和重复下单问题。使用Redis的SETNX命令和Lua脚本手写分布式锁,完成锁粒度的精细化设计,以用户ID为维度控制并发,支撑秒杀接口的高并发访问。进一步引入Redisson框架,利用看门狗自动续期机制解决业务执行时间超过锁过期时间导致的锁提前释放问题。数据库层面通过唯一索引兜底,多层防护保障订单数据一致性。
这段话虽然不长,但把问题、方案、演进、细节都覆盖到了。面试官看完起码知道你是真的做过,不是把教程文档抄了一遍。
黑马点评里有一个容易被忽略的经典问题,热点词里也提到过:“为什么登录后跳转首页”。这里简单带一句,黑马点评的登录是通过Redis存Session来做的,用ThreadLocal保存用户信息。用户登录后跳转回首页,是因为原本访问的页面可能是首页或者需要登录后才能访问的页面,后端会在登录成功后跳转到之前意图访问的地址。这个问题经常被面试官用来考察你对登录流程和拦截器机制的理解,建议在学习时留意一下登录流程中的跳转逻辑代码。
5.5 一个实战小实验,建议动手试一下
最后分享一个适合实际动手的实验方法。你可以自己写一个简易的秒杀接口,库存设为1,然后用JMeter模拟50个并发请求。分别测试以下三种情况下的数据结果:不加锁、加本地synchronized、加Redis分布式锁。
本地单实例时,synchronized和Redis分布式锁的结果可能都是正确的,看不出太大区别。但如果你把服务拆成两个实例,再配一下Nginx负载均衡,同样跑一次并发测试,就会发现只有Redis分布式锁能保证数据正确。这个实验我建议亲自动手做一遍,因为做完之后,你对“为什么需要分布式锁”的理解绝不是看几篇博客能比的。
Redisson的看门狗续期机制,也可以通过一个简单的日志实验来验证。你可以在持锁的线程里手动Thread.sleep(20)秒,然后在代码里打印锁的剩余过期时间,观察它是否在持续恢复成30秒。这个实验能直观地验证看门狗在工作,面试时讲起来会更有底气。
学习黑马点评的分布式锁,本质上学的不是某一套代码,而是一种思路:当你的系统从单机走向集群、从低并发走向高并发,所有原本默认“可靠”的东西都会变得脆弱,唯一能做的就是提前想好极端场景,用合理的机制去兜底。分布式锁只是这条路上的一个节点,把它学透之后,你会发现自己对Redis的理解、对并发的理解、对系统架构的理解,都会上一个台阶。
