从 0 到 1 搭建分布式锁:Redis vs Zookeeper,谁更适合生产环境?

7 阅读13分钟

从 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分布式锁:

  1. 获取锁:执行setnx key value命令,其中key为锁的唯一标识(如“lock:stock:1001”),value可以设置为当前线程ID或随机字符串;若返回1,表示获取锁成功;若返回0,表示获取锁失败。
  2. 设置过期时间:获取锁成功后,执行expire key timeout命令,设置锁的过期时间(如30秒),避免因线程异常退出导致锁无法释放。
  3. 释放锁:业务执行完成后,执行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值进行优化,并在释放锁时增加校验逻辑:

  1. 优化value值:将value设置为“线程ID:重入次数”的格式,用于记录持有锁的线程和重入次数。
  2. 重入逻辑:获取锁时,先判断锁是否存在。若不存在,直接获取锁;若存在,判断锁的value中的线程ID是否为当前线程,若是则重入次数+1,并重置过期时间;若不是则获取锁失败。
  3. 释放锁逻辑:释放锁时,先校验当前线程是否为锁的持有者。若是,重入次数-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分布式锁的实现流程如下:

  1. 创建父节点:首先在Zookeeper中创建一个持久节点作为锁的父节点(如“/lock/stock/1001”),用于存储所有申请锁的子节点。
  2. 申请锁:每个线程在父节点下创建一个临时有序子节点(如“/lock/stock/1001/lock-”),Zookeeper会自动在节点名称后添加自增序列号,最终生成的节点可能为“/lock/stock/1001/lock-0000000001”“/lock/stock/1001/lock-0000000002”等。
  3. 判断锁获取结果:线程创建子节点后,获取父节点下的所有子节点,并按序列号排序。如果当前线程创建的子节点是序号最小的子节点,则表示获取锁成功;否则,获取前一个序号的子节点(前驱节点),并为其注册Watcher监听。
  4. 等待锁释放:当前驱节点被删除时,Zookeeper会通知当前线程。线程收到通知后,再次获取父节点下的所有子节点并排序,判断自己是否为最小子节点,若是则获取锁成功;若不是则重复步骤3-4。
  5. 释放锁:线程执行完业务后,删除自己创建的临时有序子节点,释放锁;若线程异常退出(如会话失效),临时节点会被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:核心维度对比

完成两种分布式锁的搭建后,我们从性能、可靠性、易用性、部署成本等核心维度进行对比,为生产环境的选型提供依据。