从 0 到 1 搭建分布式锁:Redis vs Zookeeper,谁更适合生产环境?
在分布式系统架构中,随着业务规模的扩大和服务节点的增多,数据一致性问题成为制约系统稳定性的核心瓶颈。分布式锁作为解决跨节点资源竞争、保障数据一致性的关键技术,被广泛应用于秒杀活动、库存扣减、分布式事务、共享资源访问等核心场景。目前主流的分布式锁实现方案中,Redis与Zookeeper凭借各自的技术特性占据了主导地位。
然而在实际生产环境中,开发者常常面临选择困境:Redis分布式锁以高性能著称,但需要手动处理诸多异常场景;Zookeeper分布式锁具备天然的可靠性优势,但性能开销和部署成本相对较高。本文将从分布式锁的基础认知出发,手把手带你完成Redis与Zookeeper分布式锁的从0到1搭建,深入剖析二者的核心原理、实现细节与优缺点,最终结合生产环境的核心诉求,给出针对性的选型建议。
一、分布式锁的核心认知:为什么需要分布式锁?
在单体应用中,我们可以通过synchronized关键字或ReentrantLock等本地锁机制,解决多线程对共享资源的并发竞争问题。但当应用架构演进为分布式集群后,多个服务节点运行在不同的JVM进程中,本地锁只能约束单个节点内的线程行为,无法对其他节点的线程形成有效限制。此时就会出现资源竞争紊乱、数据不一致等问题。
以电商平台的库存扣减场景为例:某商品库存仅剩10件,同时有20个用户发起下单请求,这些请求被负载均衡器分发到5个不同的服务节点。如果仅使用本地锁,每个节点最多只能限制自身线程的并发访问,无法感知其他节点的库存操作,最终可能导致20个请求都成功扣减库存,出现超卖现象。而分布式锁通过引入一个独立的第三方协调组件(如Redis、Zookeeper),让所有服务节点都通过该组件获取锁、释放锁,从而实现跨节点的并发控制,确保同一时间只有一个节点能操作共享资源。
一个可靠的分布式锁需要满足以下核心特性:
- 互斥性:同一时间只能有一个线程持有锁,确保共享资源的独占访问;
- 安全性:避免出现死锁、锁失效等问题,确保锁只能被持有锁的线程释放;
- 可用性:在分布式环境下,协调组件部分节点故障时,锁服务仍能正常提供功能;
- 重入性:支持线程在持有锁的情况下再次获取锁,避免因重复申请锁而导致死锁;
- 公平性(可选) :根据请求顺序分配锁,避免线程饥饿现象。
了解分布式锁的核心价值与特性后,接下来我们分别基于Redis和Zookeeper,完成分布式锁的从0到1搭建。
二、从0到1搭建Redis分布式锁
Redis分布式锁基于其高性能的键值存储特性实现,核心思路是:利用Redis的set命令设置一个锁键,当键不存在时表示获取锁成功,键存在时表示获取锁失败;通过设置键的过期时间避免死锁;释放锁时通过删除锁键实现。但原生的set命令存在诸多缺陷,需要逐步优化才能满足生产环境的要求。
2.1 初代实现:基于setnx+expire的基础版本
Redis的setnx命令(set if not exists)可以实现“键不存在则设置”的原子操作,这是实现互斥性的基础。我们可以通过以下步骤实现基础版Redis分布式锁:
- 获取锁:执行setnx key value命令,其中key为锁的唯一标识(如“lock:stock:1001”),value可以设置为当前线程ID或随机字符串;若返回1,表示获取锁成功;若返回0,表示获取锁失败。
- 设置过期时间:获取锁成功后,执行expire key timeout命令,设置锁的过期时间(如30秒),避免因线程异常退出导致锁无法释放。
- 释放锁:业务执行完成后,执行del key命令删除锁键,释放锁。
对应的Java代码实现如下:
public class RedisLockV1 {
private final Jedis jedis;
private final String lockKey;
private final int expireTime; // 过期时间,单位:秒
public RedisLockV1(Jedis jedis, String lockKey, int expireTime) {
this.jedis = jedis;
this.lockKey = lockKey;
this.expireTime = expireTime;
}
// 获取锁
public boolean lock() {
Long result = jedis.setnx(lockKey, Thread.currentThread().getId() + "");
if (result == 1) {
jedis.expire(lockKey, expireTime);
return true;
}
return false;
}
// 释放锁
public void unlock() {
jedis.del(lockKey);
}
}
但这个版本存在一个致命缺陷:setnx和expire命令是分开执行的,并非原子操作。如果线程执行完setnx命令后,在执行expire命令前突然崩溃(如机器断电、服务宕机),锁键将没有过期时间,永远不会被释放,导致死锁。为了解决这个问题,需要将两个命令优化为原子操作。
2.2 优化版本:基于set命令的原子操作
Redis 2.6.12版本及以上支持set命令的扩展参数,通过“set key value nx ex timeout”可以实现“键不存在则设置,同时设置过期时间”的原子操作,完美解决了初代版本的原子性问题。其中nx对应setnx的“不存在则设置”逻辑,ex用于指定过期时间(单位:秒)。
基于该命令优化后的获取锁逻辑如下:
// 获取锁(优化版)
public boolean lock() {
String result = jedis.set(lockKey, Thread.currentThread().getId() + "", "NX", "EX", expireTime);
return "OK".equals(result);
}
此时获取锁的操作具备原子性,避免了因命令拆分导致的死锁问题。但这个版本仍存在两个关键问题:
- 锁误删问题:如果线程A获取锁后,业务执行时间超过了锁的过期时间,锁会被自动释放。此时线程B获取到锁,而线程A执行完业务后,会执行del命令删除锁,导致线程B的锁被误删。
- 不支持重入性:线程A获取锁后,在持有锁的情况下再次申请锁时会失败,无法满足重入场景的需求(如递归调用、多方法共享锁)。
2.3 最终版本:支持重入+避免误删的完善实现
为了解决锁误删和重入性问题,我们需要对锁的value值进行优化,并在释放锁时增加校验逻辑:
- 优化value值:将value设置为“线程ID:重入次数”的格式,用于记录持有锁的线程和重入次数。
- 重入逻辑:获取锁时,先判断锁是否存在。若不存在,直接获取锁;若存在,判断锁的value中的线程ID是否为当前线程,若是则重入次数+1,并重置过期时间;若不是则获取锁失败。
- 释放锁逻辑:释放锁时,先校验当前线程是否为锁的持有者。若是,重入次数-1,当重入次数为0时删除锁键;若不是,则不执行任何操作,避免误删其他线程的锁。
由于需要执行多步命令(判断锁存在、获取value、校验线程ID、修改重入次数等),这些操作无法通过单一Redis命令完成,需要使用Lua脚本确保原子性(Redis执行Lua脚本时会阻塞其他命令,保证脚本内所有操作的原子性)。
完善后的Redis分布式锁实现代码如下:
public class RedisLockV3 {
private final Jedis jedis;
private final String lockKey;
private final int expireTime;
// 存储当前线程的重入次数
private final ThreadLocal<Integer> reentrantCount = ThreadLocal.withInitial(() -> 0);
public RedisLockV3(Jedis jedis, String lockKey, int expireTime) {
this.jedis = jedis;
this.lockKey = lockKey;
this.expireTime = expireTime;
}
// 获取锁(支持重入)
public boolean lock() {
String threadId = Thread.currentThread().getId() + "";
// Lua脚本:判断锁是否存在,不存在则设置;存在则判断线程ID,匹配则重入次数+1
String lockScript = "if redis.call('exists', KEYS[1]) == 0 then " +
"redis.call('set', KEYS[1], ARGV[1] .. ':1', 'NX', 'EX', ARGV[2]) " +
"return 1 " +
"elseif redis.call('strlen', KEYS[1]) > 0 and string.find(redis.call('get', KEYS[1]), ARGV[1] .. ':', 1, true) == 1 then " +
"local count = tonumber(string.sub(redis.call('get', KEYS[1]), string.len(ARGV[1]) + 2)) " +
"redis.call('set', KEYS[1], ARGV[1] .. ':' .. (count + 1), 'EX', ARGV[2]) " +
"return 1 " +
"else " +
"return 0 " +
"end";
List<String> keys = Collections.singletonList(lockKey);
List<String> args = Arrays.asList(threadId, String.valueOf(expireTime));
Long result = (Long) jedis.eval(lockScript, keys, args);
if (result == 1) {
reentrantCount.set(reentrantCount.get() + 1);
return true;
}
return false;
}
// 释放锁(避免误删)
public void unlock() {
String threadId = Thread.currentThread().getId() + "";
// Lua脚本:校验线程ID,匹配则重入次数-1,为0则删除锁
String unlockScript = "if redis.call('exists', KEYS[1]) == 0 then " +
"return 1 " +
"elseif not string.find(redis.call('get', KEYS[1]), ARGV[1] .. ':', 1, true) then " +
"return -1 " +
"else " +
"local count = tonumber(string.sub(redis.call('get', KEYS[1]), string.len(ARGV[1]) + 2)) " +
"if count > 1 then " +
"redis.call('set', KEYS[1], ARGV[1] .. ':' .. (count - 1), 'EX', ARGV[2]) " +
"return 1 " +
"else " +
"redis.call('del', KEYS[1]) " +
"return 1 " +
"end " +
"end";
List<String> keys = Collections.singletonList(lockKey);
List<String> args = Arrays.asList(threadId, String.valueOf(expireTime));
Long result = (Long) jedis.eval(unlockScript, keys, args);
if (result == -1) {
throw new IllegalMonitorStateException("当前线程未持有锁,无法释放");
}
reentrantCount.set(reentrantCount.get() - 1);
if (reentrantCount.get() == 0) {
reentrantCount.remove();
}
}
}
此外,在高可用场景下,单一Redis节点存在单点故障风险。为了提升锁服务的可用性,建议采用Redis集群部署(如主从复制+哨兵模式、Redis Cluster|7k0zt.HK||3r8fd.HK||6a9mc.HK||2x5hj.HK||4n1qs.HK|)。但需要注意的是,普通的主从复制存在数据同步延迟问题,可能导致主节点宕机后从节点未同步锁数据,出现“锁丢失”现象。此时可以采用Redlock算法,通过在多个独立的Redis节点上获取锁,确保至少半数以上节点获取锁成功才算最终获取锁成功,进一步提升锁的可靠性。
三、从0到1搭建Zookeeper分布式锁
Zookeeper是一个分布式协调服务,基于ZAB(Zookeeper Atomic Broadcast)协议实现数据的一致性同步。Zookeeper的节点(ZNode)具有独特的特性,使其天然适合实现分布式锁:
- ZNode分为持久节点、持久有序节点、临时节点、临时有序节点四种类型;
- 临时节点的生命周期与客户端会话绑定,客户端会话失效(如断开连接)时,临时节点会被自动删除;
- 有序节点会在节点名称后自动添加一个自增的序列号,确保节点名称的唯一性。
- Zookeeper支持Watcher机制,客户端可以监听节点的变化(如节点删除、子节点增减),并在变化发生时收到通知。
Zookeeper分布式锁的核心思路是:利用临时有序节点的特性,每个线程获取锁时,在指定的父节点下创建一个临时有序子节点;判断当前子节点是否为父节点下序号最小的子节点,若是则获取锁成功;若不是则监听前一个序号的子节点,当前一个子节点被删除(即前一个线程释放锁)时,再次判断自己是否为最小子节点,直至获取锁成功。
3.1 核心原理拆解
以“lock:stock:1001”为锁标识为例,Zookeeper分布式锁的实现流程如下:
- 创建父节点:首先在Zookeeper中创建一个持久节点作为锁的父节点(如“/lock/stock/1001”),用于存储所有申请锁的子节点。
- 申请锁:每个线程在父节点下创建一个临时有序子节点(如“/lock/stock/1001/lock-”),Zookeeper会自动在节点名称后添加自增序列号,最终生成的节点可能为“/lock/stock/1001/lock-0000000001”“/lock/stock/1001/lock-0000000002”等。
- 判断锁获取结果:线程创建子节点后,获取父节点下的所有子节点,并按序列号排序。如果当前线程创建的子节点是序号最小的子节点,则表示获取锁成功;否则,获取前一个序号的子节点(前驱节点),并为其注册Watcher监听。
- 等待锁释放:当前驱节点被删除时,Zookeeper会通知当前线程。线程收到通知后,再次获取父节点下的所有子节点并排序,判断自己是否为最小子节点,若是则获取锁成功;若不是则重复步骤3-4。
- 释放锁:线程执行完业务后,删除自己创建的临时有序子节点,释放锁;若线程异常退出(如会话失效),临时节点会被Zookeeper自动删除,避免死锁。
3.2 基于Curator客户端的实现
Zookeeper官方提供的Java客户端API较为繁琐,需要手动处理节点创建、Watcher注册、会话管理等细节。实际开发中,推荐使用Curator客户端,它是Apache基金会推出的Zookeeper分布式协调服务的开源客户端,提供了丰富的工具类和封装好的分布式锁实现(如InterProcessMutex、InterProcessSemaphore等),简化了开发流程。
首先需要引入Curator依赖(以Maven为例):
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.5.0</version>
</dependency>
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.8.4</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>
Curator提供的InterProcessMutex类已封装好可重入的Zookeeper分布式锁实现,直接使用即可完成从0到1的搭建:
public class ZkLock {
private final InterProcessMutex lock;
private final CuratorFramework client;
private final String lockPath; // 锁的父节点路径,如"/lock/stock/1001"
// 初始化Zookeeper客户端和分布式锁
public ZkLock(String zkAddress, String lockPath) {
this.lockPath = lockPath;
// 构建Curator客户端
this.client = CuratorFrameworkFactory.builder()
.connectString(zkAddress)
.sessionTimeoutMs(5000) // 会话超时时间
.connectionTimeoutMs(3000) // 连接超时时间
.retryPolicy(new ExponentialBackoffRetry(1000, 3)) // 重试策略:指数退避重试,初始间隔1秒,重试3次
.build();
// 启动客户端
client.start();
// 创建可重入分布式锁
this.lock = new InterProcessMutex(client, lockPath);
}
// 获取锁
public boolean lock(long waitTime, TimeUnit timeUnit) throws Exception {
// 支持超时等待,超过指定时间未获取到锁则返回false
return lock.acquire(waitTime, timeUnit);
}
// 释放锁
public void unlock() throws Exception {
lock.release();
}
// 关闭客户端
public void close() {
if (client != null) {
client.close();
}
}
}
使用示例:
public class ZkLockDemo {
public static void main(String[] args) throws Exception {
// 初始化Zookeeper分布式锁,连接地址为"127.0.0.1:2181",锁路径为"/lock/stock/1001"
ZkLock zkLock = new ZkLock("127.0.0.1:2181", "/lock/stock/1001");
try {
// 尝试获取锁,超时时间为5秒
if (zkLock.lock(5, TimeUnit.SECONDS)) {
// 成功获取锁,执行核心业务(如库存扣减)
System.out.println("获取锁成功,执行库存扣减业务");
Thread.sleep(3000); // 模拟业务执行
} else {
// 获取锁失败
System.out.println("获取锁失败,无法执行业务");
}
} finally {
// 释放锁
zkLock.unlock();
// 关闭客户端
zkLock.close();
}
}
}
Curator的InterProcessMutex实现已具备互斥性、安全性、可用性、重入性等核心特性:通过临时节点避免死锁,通过有序节点和Watcher机制实现公平锁,通过ZAB协议保障集群环境下的数据一致性。相比Redis分布式锁,Zookeeper分布式锁的实现更简洁,无需手动处理过多异常场景。
四、Redis vs Zookeeper:核心维度对比
完成两种分布式锁的搭建后,我们从性能、可靠性、易用性、部署成本等核心维度进行对比,为生产环境的选型提供依据。