基础实现
如何用 Redis 实现分布式锁?核心步骤是什么?
一句话原理:Redis 分布式锁基于“占用式”思想,利用 SET NX EX 原子命令抢占锁标识,通过唯一 Value 防止误删,并结合 Lua 脚本确保“判断+删除”解锁过程的原子性。
一句话源码:在 db.c 中,SET NX 命令本质上调用 setKey 函数,在 lookupKeyWrite 查找 Key 不存在时执行 dbAdd 写入并关联 setExpire 过期时间,从而实现互斥。
一句话项目/场景:在电商秒杀扣减库存场景中,使用 SET lock:stock UUID NX EX 10 加锁,业务执行完毕后通过 Lua 脚本校验 UUID 并 DEL,严防超卖与死锁。
详细实现步骤
1. 核心实现步骤(加锁、解锁、续期)
第一步:加锁(原子性设置)
使用 Redis 的 SET 命令扩展参数,保证“设置 Key”与“设置过期时间”的原子性,避免客户端崩溃导致死锁。
- 命令:
SET lock_key unique_value NX PX 30000 - 参数解析:
NX (Not Exist):仅当 Key 不存在时才设置成功,保证互斥性。PX 30000:设置过期时间为 30 秒(毫秒级),防止客户端宕机无法解锁造成死锁。unique_value:必须是全局唯一的标识(如 UUID + 线程 ID),用于解锁时校验锁的归属,防止误删其他线程的锁。
第二步:解锁(Lua 脚本保证原子性)
解锁必须包含“判断锁归属”和“删除锁”两步,这两步必须原子执行。如果先 Get 判断再 Del,中间可能发生锁过期被其他线程抢占的情况,导致误删。
- Lua 脚本:
1
2
3
4
5
| if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
|
- 执行:将脚本发送给 Redis 执行,Redis 保证 Lua 脚本执行期间不执行其他命令。
第三步:锁续期(看门狗机制)
如果业务执行时间超过了锁的过期时间,锁会自动释放,导致并发安全问题。需要启动一个后台线程(看门狗),定时检测业务是否执行完,若未完成则延长锁的过期时间。
- 机制:每隔过期时间的 1/3 时间检查一次,如果 Key 存在且 Value 一致,则执行
PEXPIRE 重置过期时间。
进阶方案:Redisson 框架
在实际项目中不建议手动实现,推荐使用官方推荐的 Redisson 框架,它封装了上述所有逻辑:
- 可重入锁:基于 Hash 结构存储,字段为线程 ID,Value 为重入次数。
- 看门狗:默认 30 秒过期,后台自动续期。
- 高可用:支持 RedLock 算法(在 Redis Cluster 模式下向多个主节点申请锁),解决单点故障问题。
常见误区与避坑
- 误区 1:使用
SETNX + EXPIRE 分开执行。- 后果:若
SETNX 成功后程序崩溃,EXPIRE 未执行,导致死锁。 - 纠正:必须使用
SET NX EX 复合命令或 Lua 脚本合并执行。
- 误区 2:解锁时直接
DEL Key。- 后果:可能删掉其他线程已经获取的锁。
- 纠正:必须先
GET 判断 Value 是否为自己持有,再 DEL,且用 Lua 脚本保证原子性。
- 误区 3:锁的超时时间设置过短或过长。
- 后果:过短导致业务未执行完锁即失效;过长导致服务宕机后恢复时间过长。
- 纠正:使用 Redisson 的“看门狗”自动续期机制,或根据业务 P99 耗时合理估算并预留 Buffer。
SETNX 命令的用法?为什么需要结合 EXPIRE 设置过期时间?
一句话原理:SETNX 是“SET if Not eXists”的缩写,仅当 Key 不存在时才设值,用于实现互斥占用;其必须结合 EXPIRE 是为了防止客户端获取锁后宕机无法释放,导致 Key 永久存在引发死锁。
一句话源码:在 Redis 源码 db.c 中,SETNX 命令处理函数先调用 lookupKeyWrite 查找 Key,若返回 NULL(不存在),则执行 dbAdd 写入数据库并返回 1,否则直接返回 0。
一句话项目/场景:在 Redis 2.6.12 之前的版本中,需分别执行 SETNX 和 EXPIRE,存在原子性问题;现代生产环境应直接使用 SET key value EX seconds NX 复合命令,从协议层面规避死锁风险。
详细解析
SETNX 命令用法
- 基本语法:
SETNX key value - 返回值:
1:设置成功(Key 之前不存在)。0:设置失败(Key 已经存在,未执行任何操作)。
- 示例:
1
2
3
4
| redis> SETNX lock:product_101 "client_A" # 返回 1,加锁成功A
redis> SETNX lock:product_101 "client_B" # 返回 0,加锁失败,已被 A 占用
redis> DEL lock:product_101 # 释放锁
redis> SETNX lock:product_101 "client_B" # 返回 1,加锁成功
|
为什么必须结合 EXPIRE?
核心原因是为了解决死锁问题。
- 场景描述:
- 客户端 A 执行
SETNX lock_key value,成功获取锁。 - 客户端 A 在执行业务逻辑时,程序崩溃或网络中断,未能执行删除锁(
DEL)的指令。 - 此时
lock_key 永远存在于 Redis 中。 - 客户端 B 及后续所有客户端尝试
SETNX 都会返回 0,永远无法获取锁,系统陷入死锁状态。
- 解决方案:
设置过期时间
EXPIRE lock_key seconds,即使客户端 A 宕机,Redis 也会在超时后自动删除 Key,释放锁资源,允许其他客户端继续获取。
进阶:原子性问题与最佳实践
虽然 SETNX + EXPIRE 能解决死锁,但两条命令分开发送存在隐患。
- 潜在风险:
如果
SETNX 执行成功后,客户端还没来得及执行 EXPIRE 就宕机了,依然会导致死锁(Key 存在但无过期时间)。 - 最佳实践(现代标准做法):
从 Redis 2.6.12 版本开始,
SET 命令增加了扩展参数,可以原子性地完成“设值 + 不存在才设 + 设过期时间”三个动作。- 命令:
SET key value [EX seconds] [PX milliseconds] [NX|XX] - 示例:
1
2
| # 等同于 SETNX + EXPIRE,且是原子操作
SET lock:product_101 "client_A" EX 30 NX
|
- 优势:一条命令完成加锁与设过期时间,无需 Lua 脚本即可保证原子性,是当前实现分布式锁的标准做法。
如何保证加锁和设置过期时间的原子性?(SET key value NX PX 30000)
一句话原理:通过 Redis 2.6.12 版本增强的 SET 命令扩展参数,将“互斥占位(NX)”与“过期时间设置(EX/PX)”合并为一条原子指令,在服务端单次执行中完成,杜绝了指令分拆导致的“加锁成功但宕机未设过期”的死锁风险。
一句话源码:在源码 db.c 的 setGenericCommand 函数中,逻辑会先检查 nx 参数,若 Key 存在则直接返回,若不存在则调用 setKey 写入,紧接着调用 setExpire 设置过期时间,整个过程在单线程事件循环中一气呵成。
一句话项目/场景:在生产环境代码中,直接使用 stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 30, TimeUnit.SECONDS),该方法底层即封装了 SET NX EX 原子命令,彻底避免了老旧方案中先 SETNX 后 EXPIRE 的并发隐患。
如何实现可重入锁?需要额外记录什么信息?
一句话原理:可重入锁通过关联“线程标识”与“重入计数器”,允许同一线程多次获取同一把锁而不会自我死锁,其核心在于识别锁持有者身份并对计数进行递增或递减操作。
一句话源码:Redis 实现通常采用 Hash 结构存储,Key 为锁名称,Field 为线程唯一标识(UUID + ThreadID),Value 为重入次数;加锁逻辑需通过 Lua 脚本保证原子性:先判断 Key 是否存在,不存在则创建;若存在则判断 Field 是否为当前线程,若是则对 Value 执行 HINCRBY 递增。
一句话项目/场景:在秒杀业务中,如果加锁的方法 A 内部调用了同样需要该锁的方法 B,可重入锁机制会识别出当前线程已持有锁,直接将计数器加 1 并允许执行,避免了不可重入锁导致的线程自我阻塞死锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| -- KEYS[1]: 锁名称
-- ARGV[1]: 过期时间
-- ARGV[2]: 线程唯一标识
-- 第一步:判断锁是否存在
if (redis.call('exists', KEYS[1]) == 0) then
-- 锁不存在,创建 Hash,设置 Field(线程ID) 和 Value(计数1),并设置过期时间
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 第二步:锁存在,判断是否是当前线程持有
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 是自己持有的,重入次数 +1,并刷新过期时间
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 第三步:锁存在,但被别人持有,返回剩余过期时间
return redis.call('pttl', KEYS[1]);
|
高级问题
分布式锁的释放需要注意什么?为什么要用 Lua 脚本保证原子性?
一句话原理:释放锁必须遵循“先检查持有者身份,再执行删除”的逻辑,以防止误删其他线程的锁;使用 Lua 脚本是因为它能将“判断”和“删除”两步操作在服务端合并为单一原子指令,杜绝并发环境下的竞态条件。
一句话源码:在 Redis 执行 Lua 脚本时,会严格串行处理 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end 这段逻辑,确保在读取 Key 值到删除 Key 期间,不会有其他命令插入执行。
一句话项目/场景:在电商抢购场景中,若线程 A 的锁超时释放后被线程 B 获取,线程 A 恢复后如果不校验身份直接 Del,就会误删 B 的锁导致 B 业务失控;Lua 脚本确保了只有锁的真正持有者才能释放锁,严格避免了误删风险。
什么是 RedLock 算法?它解决了什么问题?有什么争议?
一句话原理:RedLock(红锁)通过在多个(通常为 5 个)完全独立的 Redis 主节点上同时加锁,并遵循“半数以上成功且耗时小于 TTL”的规则,实现了比单节点或主从架构更严格的分布式锁安全性。
一句话源码:核心逻辑在客户端实现,循环遍历 N 个节点发送 SET NX 指令,通过记录 startTime 和 endTime 计算耗时,仅当 (successCount >= N/2 + 1) && (costTime < ttl) 时才判定加锁成功,否则向所有节点发起解锁。
一句话项目/场景:适用于对数据一致性要求极高的金融或交易扣款场景,解决了传统 Redis 主从异步复制导致的主从切换期间锁丢失(双主问题)问题;但因依赖系统时钟存在安全性争议,高性能场景多被 ZooKeeper 或优化后的分布式算法替代。
如果持有锁的线程执行时间超过锁的过期时间,如何处理?(锁续期)
一句话原理:通过“看门狗”机制进行自动续期,即额外启动一个后台线程,定期检测业务是否执行完毕,若未完毕则重置锁的过期时间,防止任务未完锁先丢。
一句话源码:在 Redisson 实现中,客户端加锁成功后会启动一个 ScheduledTask,每隔过期时间的 1/3(默认 10 秒)执行 renewExpirationAsync,发送 Lua 脚本判断锁是否仍由当前线程持有,若是则执行 PEXPIRE 重置有效期。
一句话项目/场景:在执行耗时不确定的数据处理任务时,不设置过长的固定过期时间,而是利用 Redisson 的默认看门狗机制,既避免了死锁风险,又确保了长任务不会被锁超时中断导致并发异常。
Redis 分布式锁和 ZooKeeper 分布式锁的对比?各自优缺点?
1. 核心原理对比
一句话原理:Redis分布式锁基于内存存储和过期时间,通过SET NX原子命令实现互斥;ZooKeeper分布式锁基于临时顺序节点和Watch机制,通过节点最小序号判定和事件监听实现公平锁,两者在"性能"与"可靠性"的权衡上截然不同。
一句话源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // ============ Redis分布式锁(单节点实现) ============
// 获取锁(原子操作)
SET resource_name my_random_value NX PX 30000 // NX: 不存在才设置,PX: 过期时间30秒
// 释放锁(Lua脚本保证原子性)
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
// ============ ZooKeeper分布式锁(临时顺序节点) ============
// 1. 创建临时顺序节点
String lockPath = zk.create("/locks/lock-", data,
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
// 2. 获取所有子节点
List<String> children = zk.getChildren("/locks", false);
Collections.sort(children);
// 3. 判断是否是最小节点
int index = children.indexOf(lockPath.substring(lockPath.lastIndexOf('/') + 1));
if (index == 0) {
// 获得锁
} else {
// 监听前一个节点
String prevPath = "/locks/" + children.get(index - 1);
zk.exists(prevPath, true); // 注册Watcher
}
|
项目场景:在电商秒杀系统中,用Redis锁处理高频库存扣减,追求极致性能;在分布式任务调度中,用ZooKeeper锁保证主节点选举的绝对可靠,避免脑裂问题。
2. 优缺点详细对比
一句话原理:Redis锁性能高、实现简单,但存在主从切换锁丢失风险;ZooKeeper锁可靠性强、天然公平,但性能较低、部署复杂,二者在CAP理论中分别偏向AP和CP。
一句话源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
| /**
* 优缺点对比表
*/
// ============ Redis分布式锁 ============
// 优点:
// - 性能极高(基于内存,QPS可达10万+)
// - 实现简单,API友好
// - 支持自动过期,避免死锁
// - Redlock算法可提供更高可靠性
// 缺点:
// - 主从异步复制可能导致锁丢失(主宕机,从未同步)
// - 需自行处理锁续期(Watchdog机制)
// - 非公平锁,可能产生饥饿
// - 依赖时钟同步假设
// Redis锁丢失场景演示
// 1. 客户端A在主节点获取锁
// 2. 主节点宕机,锁数据未同步到从节点
// 3. 从节点升级为主节点
// 4. 客户端B也能获取同一把锁 → 锁失效!
// ============ ZooKeeper分布式锁 ============
// 优点:
// - 强一致性(ZAB协议保证)
// - 临时节点自动清理,无需设置过期时间
// - 天然公平锁(按节点序号顺序)
// - Watch机制避免惊群效应
// - 客户端断开连接自动释放锁
// 缺点:
// - 性能较低(磁盘写操作,QPS约几千)
// - 部署维护复杂(需要独立集群)
// - 可能存在"羊群效应"(需优化为监听前一个节点)
// - 会话超时时间难权衡
// ZooKeeper锁改进实现(避免惊群)
public class ZooKeeperLock {
private String currentPath;
private String prevPath;
public void lock() throws Exception {
currentPath = zk.create("/locks/lock-", null,
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
List<String> children = zk.getChildren("/locks", false);
Collections.sort(children);
if (currentPath.equals("/locks/" + children.get(0))) {
return; // 获得锁
}
// 只监听前一个节点,而非所有节点
int i = Collections.binarySearch(children,
currentPath.substring(currentPath.lastIndexOf('/') + 1));
prevPath = "/locks/" + children.get(i - 1);
zk.exists(prevPath, true); // 注册Watcher
synchronized (this) {
wait(); // 等待前一个节点释放
}
}
}
|
项目场景:在金融交易系统中,要求锁的可靠性极高,即使性能稍低也必须选择ZooKeeper;在社交APP点赞计数中,偶尔的锁失效可以接受,优先选择Redis追求高性能。
3. 高级方案:Redlock与Curator
一句话原理:针对Redis主从问题,Redlock算法通过向多个独立Redis节点同时获取锁,超过半数成功则视为成功,将可靠性从单点提升到分布式级别;ZooKeeper有Curator框架封装了锁的实现,提供可重入锁、读写锁等高级特性。
一句话源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
| // ============ Redlock算法(Redis官方推荐) ============
public class Redlock {
private static final int N = 5; // 5个独立Redis节点
private List<RedisClient> clients;
public boolean tryLock(String key, String value, int ttl) {
int successCount = 0;
long start = System.currentTimeMillis();
// 1. 向所有节点尝试获取锁
for (RedisClient client : clients) {
if (client.setnxex(key, value, ttl)) {
successCount++;
}
}
// 2. 检查是否超过半数且耗时小于TTL
long cost = System.currentTimeMillis() - start;
if (successCount >= N/2 + 1 && cost < ttl) {
return true; // 获得锁
}
// 3. 失败则释放所有节点锁
for (RedisClient client : clients) {
client.del(key);
}
return false;
}
}
// ============ Curator(ZooKeeper客户端封装) ============
public class CuratorLockDemo {
private CuratorFramework client;
private InterProcessLock lock;
public void init() {
client = CuratorFrameworkFactory.newClient(
"zk1:2181,zk2:2181,zk3:2181",
new ExponentialBackoffRetry(1000, 3));
client.start();
// 创建可重入分布式锁
lock = new InterProcessMutex(client, "/locks/my_lock");
}
public void doWithLock() {
try {
if (lock.acquire(10, TimeUnit.SECONDS)) {
try {
// 业务逻辑
} finally {
lock.release();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
|
项目场景:在跨机房部署的金融核心系统中,使用Redlock算法在5个机房的Redis节点获取锁,即使单个机房故障也不影响锁服务;在数据迁移工具中,用Curator的InterProcessMutex轻松实现可重入分布式锁,代码量减少80%。
完整对比与选型指南
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
| /**
* Redis vs ZooKeeper 分布式锁完整指南
*/
public class DistributedLockGuide {
/**
* 1. 详细对比表
*/
public class ComparisonTable {
// 维度 Redis锁 ZooKeeper锁
// 一致性 最终一致性 强一致性(ZAB)
// 性能 极高(10万+ QPS) 中等(几千 QPS)
// 可靠性 主从切换可能丢锁 无丢锁风险
// 公平性 非公平锁 公平锁(节点顺序)
// 自动释放 过期时间 临时节点自动删除
// 阻塞机制 自旋重试 Watch通知
// 实现复杂度 简单 复杂
// 部署成本 轻量级 独立集群
// 高级特性 Redlock 可重入锁、读写锁
}
/**
* 2. 选型决策树
*/
public class DecisionTree {
// 1. 对性能要求是否极高(>1万 QPS)?
// ├─ 是 → Redis(单节点或Redlock)
// └─ 否 → 转到2
// 2. 是否要求绝对可靠(金融交易)?
// ├─ 是 → ZooKeeper(强一致性)
// └─ 否 → 转到3
// 3. 是否需要公平锁?
// ├─ 是 → ZooKeeper
// └─ 否 → Redis
// 4. 现有基础设施?
// ├─ 已有Redis集群 → Redis
// └─ 已有ZooKeeper → ZooKeeper
}
/**
* 3. 适用场景示例
*/
public class RealScenarios {
// 场景1:秒杀库存扣减(选Redis)
// 原因:10万QPS,允许极小概率的锁失效
// 场景2:分布式定时任务调度(选ZooKeeper)
// 原因:需要严格保证只有一个节点执行
// 场景3:配置中心主节点选举(选ZooKeeper)
// 原因:强一致性保证,避免脑裂
// 场景4:分布式限流计数器(选Redis)
// 原因:高性能,Lua脚本原子操作
// 场景5:跨行转账防重(选ZooKeeper)
// 原因:资金操作必须绝对可靠
}
/**
* 4. 面试必备
*/
public class InterviewQA {
// Q: Redis锁如何避免锁被其他客户端释放?
// A: 使用随机value+Lua脚本,只删除自己设置的锁
// Q: ZooKeeper锁的"惊群效应"如何解决?
// A: 每个节点只监听前一个节点,而非所有节点
// Q: Redlock算法靠谱吗?
// A: 学术界有争议,但工业界广泛应用
// Q: 锁超时时间设置多少合适?
// A: 略大于最大业务执行时间 + 缓冲
}
}
/**
* 总结表
*
* 方案 性能 可靠性 公平性 适用场景
* Redis单节点 极高 中 低 缓存、计数器、非核心
* Redis Redlock 高 较高 低 跨机房、高可用要求
* ZooKeeper 中 极高 高 交易、选举、核心业务
*
* 最佳实践:
* 1. 业务核心选ZK,性能核心选Redis
* 2. Redis锁一定要用Lua脚本释放
* 3. ZooKeeper锁要用Curator框架
* 4. 始终设置合理的超时时间
*/
// 面试金句
// "Redis和ZooKeeper分布式锁就像'赛车'和'装甲车':
// Redis是'赛车'——速度极快(性能),
// 但发生事故时可能翻车(主从切换丢锁);
// ZooKeeper是'装甲车'——稳重可靠(强一致),
// 但开不快(性能较低),油耗也高(部署复杂)。
// 在秒杀系统中我开'赛车',因为要的就是快;
// 在资金结算中我开'装甲车',因为绝对安全比速度更重要。
// 理解这个区别,才能在业务和技术之间做出正确权衡。"
|