从 0 到 1 构建工业级 Redis 分布式锁
如果你在简历上写了"熟悉高并发",却被面试官问"怎么实现分布式锁"时一脸懵;如果你只听过SETNX、Lua脚本这些词,却讲不清背后的原理——这篇文章会带你从最基础的概念开始,一步步搞懂分布式锁的设计逻辑。
一、先搞懂:什么是分布式锁?
在讲"分布式锁"之前,我们先想一个生活中的例子:
厕所隔间里的锁,一旦有人进去锁上门,其他人就必须等里面的人出来才能进去。这个"锁"的核心作用是保证同一时间只有一个人使用资源——这就是"互斥性"。

在程序中,"锁"的作用也是如此:让多个线程(或进程)竞争同一个资源时,同一时间只有一个能拿到权限。
那什么是"分布式锁"?
普通的锁(比如synchronized)只能管到单个程序里的线程。但在分布式系统中,我们可能有多个服务器(比如3台Tomcat)同时跑同一个程序,这些服务器之间的线程没法用普通锁互斥——这时候就需要一个"跨服务器"的锁,让所有服务器的线程都遵守同一套规则。这个跨服务器的锁,就是分布式锁。
分布式锁最常用的实现工具是Redis——因为Redis性能高,而且支持一些特殊命令能帮我们实现互斥性。
二、最基础的分布式锁:用Redis的SETNX命令
要实现"同一时间只有一个线程拿到锁",Redis的SETNX命令是基础。
1. SETNX命令的作用
SETNX是SET if Not Exists的缩写,意思是:如果指定的key不存在,就设置它的值;如果已经存在,就不做任何操作。
比如我们要给"订单1001"加锁,执行命令:
SETNX lock:order:1001 "线程A"- 如果这个key不存在,Redis会创建它,返回
1(表示加锁成功); - 如果这个key已经存在(说明有其他线程加了锁),返回
0(表示加锁失败)。
这样一来,多个线程同时竞争时,只有一个能拿到1,实现了"互斥性"——这就是分布式锁的雏形。
三、加锁后必须考虑的问题:如何解锁?
拿到锁的线程执行完业务后,必须释放锁(删除这个key),否则其他线程永远拿不到锁了。但"解锁"没那么简单,这里藏着一个坑。
1. 解锁的坑:别释放了别人的锁
假设线程A加锁成功后,因为网络卡了没及时释放锁;这时候锁过期(后面会讲为什么要加过期时间),线程B拿到了锁。如果此时线程A恢复了,直接执行DEL lock:order:1001,就会把线程B的锁删掉——这就乱套了!
所以解锁前必须先判断:这个锁是不是自己加的。
正确的步骤应该是:
- 检查当前锁的value是不是自己线程的标识(比如线程ID);
- 如果是,就删除这个key(释放锁)。
2. 用Lua脚本保证解锁的原子性
上面的"判断+删除"是两步操作,但Redis中单个命令是原子的(要么全执行,要么全不执行),两步操作可能被其他线程打断。比如:
- 线程A刚判断完"锁是自己的",还没来得及删除;
- 此时锁突然过期,线程B加锁成功;
- 线程A再执行删除,还是会删掉线程B的锁。
解决办法是用Lua脚本把两步操作合并成一个命令。Redis会原子性地执行整个Lua脚本,中间不会被打断。
示例Lua脚本(解锁逻辑):
-- 判断锁的value是不是当前线程的标识,如果是就删除
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end调用时,KEYS[1]传锁的key(比如lock:order:1001),ARGV[1]传当前线程的标识(比如线程A的ID)。

3. 为什么SETNX不需要Lua脚本?
有同学会问:加锁用SETNX,为什么不用Lua脚本?
因为SETNX本身就是单个命令,Redis执行单个命令时天然是原子的——要么加锁成功,要么失败,不会有中间状态。而解锁是"判断+删除"两步,所以才需要Lua脚本保证原子性。
四、避免死锁:给锁加个过期时间
如果拿到锁的线程突然挂了(比如服务器断电),它没机会释放锁,这个锁就会永远存在——这就是"死锁"。
解决办法很简单:给锁加个过期时间。即使线程挂了,到了时间Redis也会自动删除key,释放锁。
在Redis中,可以用SET命令的扩展参数一次性完成"加锁+设过期时间":
-- 给lock:order:1001加锁,值为线程A的标识,过期时间30秒
SET lock:order:1001 "线程A的ID" NX PX 30000NX:等同于SETNX的作用(不存在才设置);PX 30000:表示30秒后自动过期。

五、新问题:锁过期了,业务还没执行完怎么办?
假设我们给锁设了30秒过期,但线程A的业务需要40秒才能执行完——30秒后锁自动释放,线程B就会拿到锁,导致两个线程同时操作资源。
这时候就需要一个机制:在业务没执行完时,自动给锁"续期"。这个机制就是Redis分布式锁中的"看门狗(Watchdog)"。
1. 看门狗是什么?
看门狗本质上是一个后台线程。它的工作是:
- 当线程A拿到锁后,看门狗启动,每隔10秒(比如)检查一次:线程A是否还在执行业务?
- 如果是,就给锁续期(比如把过期时间重新设为30秒);
- 直到线程A执行完业务,主动释放锁,看门狗才会停止。

2. 为什么看门狗要设为守护线程?
如果线程A在执行业务时突然挂了,看门狗还会一直给锁续期吗?
不会。因为我们会把看门狗设为守护线程。
守护线程的特点是:它的生命周期依赖于主线程(业务线程)。如果主线程挂了,守护线程会自动终止。就像"守护骑士"——如果要保护的主人没了,骑士也就不再工作了。
这样一来,即使业务线程意外挂了,看门狗也会跟着结束,不会再给锁续期,锁到时间后就会自动释放。
六、可重入锁:同一个线程能多次拿锁吗?
假设这样一个场景:
- 线程A拿到了锁,执行方法A;
- 方法A中又调用了方法B,而方法B也需要拿同一个锁。
如果锁是"不可重入"的,线程A在方法B中会再次请求锁——但锁已经被自己拿了,此时会请求失败,导致线程A阻塞(自己等自己释放锁),这就是"死锁"。
所以我们需要可重入锁:同一个线程可以多次获取同一个锁,不会阻塞。

在实际业务中,当同一个线程在持有锁的情况下,需要再次进入被同一把锁保护的代码块(通常是嵌套调用的方法)时,就必须使用重入锁。否则会导致线程自己阻塞自己,引发死锁。
再举一个需要用到重入锁的业务场景:电商系统的订单状态连环更新。
假设业务需求是这样的:
用户支付订单后,系统需要执行两个操作:
- 标记订单为 “已支付” 状态(
markOrderPaid方法); - 自动触发后续流程(比如通知仓库备货),需要调用
notifyWarehouse方法,而该方法内部会再次检查并更新订单的 “备货中” 状态。
这两个操作都需要针对同一个订单 ID 加锁,防止并发修改(比如用户同时支付和取消订单导致的状态混乱)。
代码简化示例:
// 订单服务类
public class OrderService {
// 假设使用重入锁(如ReentrantLock),锁的key是订单ID
private final Lock lock = new ReentrantLock(); // 重入锁实例
// 1. 主方法:处理支付并标记订单为“已支付”
public void markOrderPaid(String orderId) {
lock.lock(); // 第一次获取锁
try {
System.out.println("订单" + orderId + ":开始标记为已支付");
// 业务逻辑:更新订单状态为“已支付”
updateStatus(orderId, "PAID");
// 支付后自动触发仓库备货流程
notifyWarehouse(orderId); // 调用子方法
} finally {
lock.unlock(); // 释放第一次获取的锁
}
}
// 2. 子方法:通知仓库备货,需要再次更新订单状态
public void notifyWarehouse(String orderId) {
lock.lock(); // 第二次获取同一把锁
try {
System.out.println("订单" + orderId + ":开始通知仓库,标记为“备货中”");
// 业务逻辑:更新订单状态为“备货中”
updateStatus(orderId, "PREPARING");
} finally {
lock.unlock(); // 释放第二次获取的锁
}
}
// 实际更新订单状态的数据库操作
private void updateStatus(String orderId, String status) {
// 执行SQL:update orders set status = ? where order_id = ?
}
}为什么需要重入锁?
- 当
markOrderPaid方法被调用时,线程首先获取锁(第一次加锁),进入 “标记已支付” 的逻辑。 - 随后调用
notifyWarehouse方法,该方法需要再次获取同一把锁(第二次加锁)。
如果使用的是不可重入锁,此时线程会因为 “自己已经持有锁” 而阻塞(试图获取一个被自己占有的锁),最终导致死锁,订单状态更新失败。
而重入锁会记录线程持有锁的次数(计数器):第一次加锁时计数器为 1,第二次加锁时计数器递增为 2;释放锁时计数器递减,直到计数器为 0 时才真正释放锁。这样就允许同一个线程多次获取锁,保证嵌套逻辑正常执行。
1. 可重入锁的实现:用"计数器"记录重入次数
无论是synchronized还是ReentrantLock,可重入的核心都是"计数器":
- 线程第一次拿锁:计数器从0变成1;
- 同一线程再次拿锁:计数器加1;
- 线程释放一次锁:计数器减1;
- 当计数器减到0时,才真正释放锁。
2. 分布式锁如何实现可重入?
分布式锁的可重入,也需要一个"计数器",但因为是分布式环境,计数器要存在Redis中。常见的实现有两种:
方案1:用Redis的Hash结构(Redisson的实现)
Redis的Hash结构可以存储"键-字段-值"三层关系。我们可以这样设计:
- 锁的key:
lock:order:1001(表示给订单1001加锁); - Hash的field:
线程ID+UUID(区分不同服务器的线程,避免集群环境下线程ID重复); - Hash的value:重入次数(计数器)。
流程如下:
- 线程A第一次加锁:执行
HSETNX lock:order:1001 "线程A的ID+UUID" 1(如果field不存在,设为1); - 线程A再次加锁(比如调用方法B):执行
HINCRBY lock:order:1001 "线程A的ID+UUID" 1(计数器加1); - 线程A释放一次锁:执行
HINCRBY ... -1(计数器减1); - 当计数器减到0时,删除整个Hash(真正释放锁)。
方案2:本地维护计数器+Redis存锁标识
另一种思路是:
- Redis中只存"当前锁被哪个线程持有"(用String类型);
- 每个服务器的本地用
ConcurrentHashMap维护一个计数器:key是锁的名称,value是重入次数。
流程:
- 线程A第一次加锁:Redis中设置成功,本地map中计数器设为1;
- 再次加锁:Redis中已存在自己的标识,直接把本地计数器加1;
- 释放锁:本地计数器减1,减到0时,再调用Lua脚本删除Redis中的锁。
七、阻塞锁:没拿到锁时要一直等吗?
当线程没拿到锁时,有两种处理方式:
- 非阻塞:直接返回"加锁失败";
- 阻塞:等待一会儿,再尝试加锁,直到拿到锁或超时。
实际业务中,我们更常用"阻塞锁"(比如抢票场景,没抢到就重试)。
1. 阻塞锁的两种实现方式
方式1:自旋(简单粗暴)
线程没拿到锁时,不放弃CPU,循环重试加锁:
while (true) {
// 尝试加锁
boolean locked = tryLock();
if (locked) {
break; // 拿到锁,退出循环
} else {
Thread.sleep(100); // 等100毫秒再试
}
}优点是简单,缺点是频繁重试会消耗CPU。
方式2:Redis的发布/订阅(Pub/Sub)机制(Redisson用的方式)
这种方式更优雅:
- 线程A加锁成功,线程B加锁失败;
- 线程B订阅一个和锁相关的"频道"(比如
channel:lock:order:1001),然后进入阻塞状态; - 线程A执行完业务,释放锁时,往这个频道发送一条"锁释放"的消息;
- 线程B收到消息后,从阻塞中唤醒,再次尝试加锁;
- 如果这次没抢到(可能被其他线程抢了),线程B会再次订阅频道,继续等待。
这种方式避免了无效的循环重试,更节省资源。

八、主从架构的坑:锁丢失问题
如果Redis是"主从架构"(1个主节点+1个从节点),主节点负责写数据,从节点同步主节点的数据。这时候可能出现"锁丢失":
- 线程A向主节点加锁成功(主节点有锁数据);
- 主节点还没来得及把锁数据同步给从节点,突然挂了;
- 从节点升级为新的主节点,但它没有线程A加的锁数据;
- 线程B向新主节点加锁,成功——此时线程A和线程B都拿到了锁,互斥性被破坏。
如何解决锁丢失?用红锁(Redlock)
Redis的红锁机制是这么解决的:
- 部署多个独立的Redis主节点(比如5个),它们之间没有主从关系;
- 加锁时,线程需要向超过半数的主节点(比如5个中的3个)发送加锁请求;
- 只有当超过半数的节点都加锁成功,才认为整体加锁成功;
- 释放锁时,需要向所有节点发送释放请求。
为什么这样能解决问题?
因为即使有1-2个节点挂了,剩下的3个节点中还有锁数据,其他线程无法再让超过半数的节点加锁成功,保证了互斥性。
红锁的缺点(为什么很少用?)
红锁听起来完美,但实际中很少用,因为:
- 要部署多个主节点,运维成本高;
- 加锁需要访问多个节点,性能比单节点低;
- 节点之间的时钟可能不一致,极端情况下还是有问题;
- Java的GC可能暂停线程,导致看门狗无法续期,锁过期。
如果对锁丢失很敏感的场景那就用Zookeeper的分布式锁吧,但是性能肯定比Redis的锁会差一丢丢。

九、总结:分布式锁的核心要点
- 互斥性:用Redis的
SETNX(或SET NX)保证同一时间只有一个线程加锁成功; - 安全解锁:用Lua脚本保证"判断锁标识+删除锁"的原子性;
- 防死锁:给锁加过期时间,即使线程挂了也能自动释放;
- 锁续期:用看门狗(守护线程)在业务执行中自动续期,避免锁提前过期;
- 可重入:用Redis Hash或本地计数器记录重入次数,避免同一线程重复加锁导致死锁;
- 阻塞等待:用自旋或Redis发布/订阅机制,让没拿到锁的线程等待重试;
- 主从安全:红锁机制解决主从切换的锁丢失问题,但因为其它问题较多,实际中也很少用。
最后记住:面试时说"用Redisson实现分布式锁"没问题,但面试官更想听到你理解这些底层逻辑——从SETNX到看门狗,从可重入到红锁,每一步都是为了解决实际场景中的问题。理解了这些,才算真正懂了分布式锁。