一、分布式锁的概念与应用场景
在并发编程中,当多个线程共享同一资源时,我们可以通过线程锁(如 Java 中的synchronized或ReentrantLock)来保证资源访问的互斥性,从而维护数据一致性。然而,在分布式系统架构下,业务应用通常被拆分为多个微服务并部署在不同进程中,此时传统的线程锁已无法满足跨进程的资源互斥需求。
分布式锁正是为解决这一问题而设计的机制,它能够实现多个进程对共享资源的有序访问。例如,当多个微服务实例需要修改 MySQL 数据库中的同一行记录时,分布式锁可有效避免因操作乱序导致的数据错误。
分布式锁的本质是通过一个具备严格互斥能力的外部系统,让多个分布式节点竞争同一资源时,仅允许一个节点获得 "访问许可"。其核心价值在于解决跨进程资源竞争问题,典型应用场景包括:
- 数据库行级操作:多服务实例修改 MySQL 同一行数据,避免更新覆盖;
- 分布式任务调度:确保同一定时任务(如数据同步)仅在一个节点执行;
- 共享资源控制:如分布式缓存更新、分布式计数器递增等。
实现分布式锁的关键在于外部系统需满足 "互斥性"------ 即同时只能有一个请求成功获取锁。目前主流依赖的外部系统有 MySQL、Redis 和 Zookeeper,其中 Redis 因高性能、低延迟成为大多数场景的首选,Zookeeper 则在可靠性优先场景中更具优势。
二、Redis 分布式锁的基础实现
2.1 基于 SETNX 命令的互斥机制
Redis 实现分布式锁的基础是其SETNX命令(SET if N ot eXists),该命令仅在指定 key 不存在时才会设置其值,否则不执行任何操作,天然具备互斥性。
- 客户端 1 成功获取锁:
csharp
127.0.0.1:6379> SETNX lock 1
(integer) 1 // 加锁成功
- 客户端 2 尝试获取锁(因客户端 1 已持有锁而失败):
csharp
127.0.0.1:6379> SETNX lock 1
(integer) 0 // 加锁失败
获取锁的客户端可安全操作共享资源(如修改数据库记录或调用 API),操作完成后需通过DEL命令释放锁:
csharp
127.0.0.1:6379> DEL lock // 释放锁
(integer) 1
2.2 死锁问题
上述方案存在一个致命问题:若持有锁的客户端出现异常,可能导致锁无法释放,引发死锁。具体场景包括:
- 程序处理业务逻辑时发生异常,未执行释放锁的操作
- 客户端进程意外崩溃,失去释放锁的机会
一旦发生死锁,其他客户端将永远无法获取锁,严重影响系统可用性。
为解决死锁问题,最直接的方案是为锁设置租期(过期时间)。假设操作共享资源的最大耗时为 10 秒,则可在加锁时为 key 设置 10 秒的过期时间,确保即使客户端异常,锁也能自动释放。
早期实现中需分两步操作:
csharp
127.0.0.1:6379> SETNX lock 1 // 加锁
(integer) 1
127.0.0.1:6379> EXPIRE lock 10 // 设置10秒后自动过期
(integer) 1
但这种方式存在原子性风险:若SETNX执行成功后,EXPIRE因网络故障、Redis 宕机或客户端崩溃而未执行,仍会导致死锁。
而在Redis 2.6.12 版本后,上述问题得以解决。SET命令引入扩展参数,可通过一条命令完成加锁与过期时间设置,保证操作的原子性:
ruby
127.0.0.1:6379> SET lock 1 EX 10 NX
OK
- EX 10:设置过期时间为 10 秒
- NX:仅在 key 不存在时执行
该命令从根本上解决了死锁问题,是分布式锁实现的基础。
2.3 锁过期与误释放问题
即使使用原子性加锁,仍可能出现以下场景:
- 客户端 1 获取锁后,操作共享资源的耗时超过锁的过期时间,导致锁被自动释放
- 客户端 2 获取锁并开始操作共享资源
- 客户端 1 操作完成后,释放了客户端 2 持有的锁
这一问题的核心在于:
- 锁过期:锁的租期短于实际业务处理时间
- 误释放:释放锁时未验证锁的归属
为避免释放他人的锁,需在加锁时为每个客户端设置唯一标识(如 UUID 或线程 ID):
ruby
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK
其中$uuid为客户端生成的唯一标识。
释放锁时,需先验证锁的归属,再执行删除操作。由于GET与DEL命令需保证原子性,可通过 Lua 脚本实现:
vbnet
-- 仅释放属于当前客户端的锁
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
Redis 执行 Lua 脚本时采用单线程模式,确保脚本内命令的原子性,避免中间插入其他操作。
2.4 锁过期问题
锁过期的本质是租期评估不准确。为应对这一问题,可设计自动续期机制:
- 加锁时设置初始过期时间
- 启动守护线程("看门狗"),定期检查锁的剩余租期
- 若业务未完成且锁即将过期,自动延长锁的过期时间
在 Java 技术栈中,Redis 客户端库Redisson已封装了这一机制,其内部通过定时任务实现锁的动态续期,有效避免了因业务耗时过长导致的锁过期问题。
三、Redis 分布式锁的最佳实践
综合上述优化,基于 Redis 的分布式锁实现流程如下:
- 加锁 :使用
SET $lock_key $unique_id EX $expire_time NX
命令,原子性完成加锁与过期时间设置,其中$unique_id
为客户端唯一标识
- 操作共享资源:执行需要互斥的业务逻辑
- 释放锁:通过 Lua 脚本原子性验证锁的归属并释放,确保仅删除当前客户端持有的锁
- 自动续期:使用 Redisson 等客户端库,利用 "看门狗" 机制动态延长锁的租期,避免业务未完成时锁过期
通过这一流程,可有效解决死锁、误释放、锁过期等核心问题,实现高安全性的分布式锁。
四、基于 Zookeeper 实现的分布式锁机制
基于Zookeeper实现的分布式锁是这样的:
- 客户端 1 和 2 都尝试创建「临时节点」,例如 /lock
- 假设客户端 1 先到达,则加锁成功,客户端 2 加锁失败
- 客户端 1 操作共享资源
- 客户端 1 删除 /lock 节点,释放锁
Zookeeper 不像 Redis 那样,需要考虑锁的过期时间问题,它是采用了「临时节点」,保证客户端 1 拿到锁后,只要连接不断,就可以一直持有锁。
而且,如果客户端 1 异常崩溃了,那么这个临时节点会自动删除,保证了锁一定会被释放。
客户端 1 创建临时节点后,Zookeeper 是如何保证让这个客户端一直持有锁呢?
原因就在于,客户端 1 此时会与 Zookeeper 服务器维护一个 Session,这个 Session 会依赖客户端「定时心跳」来维持连接。
如果 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。
基于此,Zookeeper 中也存在着锁的误释放问题:
- 客户端 1 创建临时节点 /lock 成功,拿到了锁
- 客户端 1 发生长时间 GC
- 客户端 1 无法给 Zookeeper 发送心跳,Zookeeper 把临时节点「删除」
- 客户端 2 创建临时节点 /lock 成功,拿到了锁
- 客户端 1 GC 结束,它仍然认为自己持有锁(冲突)
五、Redis vs Zookeeper:技术选型对比
对比维度 | Redis 分布式锁 | Zookeeper 分布式锁 |
---|---|---|
性能 | 高(内存操作,QPS 支持高) | 中(需维护节点与 Session,延迟略高) |
可靠性 | 需手动优化(续期、防误释放) | 天然可靠(临时节点 + Watch 机制) |
死锁风险 | 需设置过期时间规避 | 无(Session 超时自动释放) |
部署运维 | 简单(单机 / 集群均可) | 复杂(需部署集群,保证高可用) |
适用场景 | 高性能优先(如秒杀、缓存) | 可靠性优先(如金融交易、数据同步) |
- 若业务追求高吞吐、低延迟,且能接受少量优化工作,优先选 Redis;
- 若业务对可靠性要求极高(如资金相关操作),且可接受略高的部署成本,选 Zookeeper。