Java 面试笔记VII · 02 / 05

Lesson 67 · 分布式系统设计

分布式锁四种实现:Redis、ZooKeeper、数据库与 Redisson

高级·⭐ 必问·#分布式·#·#面试必问

第 1 站

面试官:分布式锁有哪些实现?各自的坑在哪?

"你在项目里用过分布式锁吗?用的什么方案?如果 Redis 主从切换导致锁丢失怎么办?Redisson 的 RedLock 你怎么看?" —— 面试官想考察的是你对分布式锁各种方案的深入理解,而不只是会调 API。

在单体应用中,synchronizedReentrantLock 就够了。但在分布式系统中,同一个业务可能部署在多台机器上,JVM 级别的锁无法跨进程生效:

典型场景 · 为什么需要分布式锁
// 场景:电商库存扣减
// 服务部署在 3 台机器上,同时有 100 个请求来扣减库存

没有分布式锁:
  机器A 读取库存 = 10
  机器B 读取库存 = 10   // 并发读取,读到相同值
  机器A 扣减为 9 并写回
  机器B 扣减为 9 并写回  // 超卖!实际应该剩 8

有了分布式锁:
  机器A 获取锁 → 读取 10 → 扣减为 9 → 释放锁
  机器B 等待锁 → 获取锁 → 读取 9 → 扣减为 8 → 释放锁
  // 串行化执行,不会超卖

分布式锁必须满足的核心条件:

  • 互斥性:同一时刻只有一个客户端持有锁
  • 安全性:锁不会丢失(不会因为持有者崩溃而永久锁死)
  • 可用性:锁服务本身要高可用
  • 可重入性:同一线程可重复获取同一把锁(可选)

主流实现方案有四种,我们逐一拆解。

第 2 站

数据库锁:SELECT ... FOR UPDATE

最简单的分布式锁实现:利用数据库的排他锁。

DatabaseLock.java · 基于数据库的分布式锁
// 方案一:排他锁(悲观锁)
// 建表:CREATE TABLE distributed_lock (
//   lock_name VARCHAR(64) PRIMARY KEY,
//   lock_value VARCHAR(128),
//   expire_time DATETIME
// );

public boolean tryLock(String lockName, long timeout) {
    Connection conn = dataSource.getConnection();
    conn.setAutoCommit(false);

    // SELECT ... FOR UPDATE 加排他锁
    PreparedStatement ps = conn.prepareStatement(
        "SELECT * FROM distributed_lock WHERE lock_name = ? FOR UPDATE");
    ps.setString(1, lockName);
    ResultSet rs = ps.executeQuery();

    if (rs.next()) {
        // 检查是否过期
        Date expire = rs.getTimestamp("expire_time");
        if (expire != null && expire.before(new Date())) {
            // 过期了,可以重新获取
            updateLock(conn, lockName);
            return true;
        }
        return false; // 锁被其他线程持有
    } else {
        // 不存在,插入一条(唯一索引保证互斥)
        insertLock(conn, lockName);
        return true;
    }
}
致命缺陷
  • 性能差:每次加锁都是一次数据库操作,延迟高
  • 单点故障:数据库挂了,锁服务就挂了
  • 死锁风险:如果没有超时机制,持有者崩溃后锁永远不释放
  • 不适合高并发:数据库连接池是瓶颈

数据库锁方案适合低并发场景(如定时任务的分布式调度),不适合高并发的业务锁。

方案二:唯一索引(INSERT OR IGNORE)

唯一索引方案 · 利用数据库唯一约束
// 利用 lock_name 的唯一索引保证互斥
// CREATE UNIQUE INDEX uk_lock ON distributed_lock(lock_name);

加锁:
  INSERT INTO distributed_lock (lock_name, lock_value, expire_time)
  VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 SECOND));
  // 如果已存在 → DuplicateKeyException → 加锁失败
  // 如果不存在 → 插入成功 → 加锁成功

解锁:
  DELETE FROM distributed_lock
  WHERE lock_name = ? AND lock_value = ?;
  // 只删除自己的锁(通过 lock_value 校验身份)

防死锁// 定时任务清理过期锁
  DELETE FROM distributed_lock
  WHERE expire_time < NOW();

// 优点:实现极简,不依赖 Redis/ZK 等中间件
// 缺点:数据库是瓶颈,不适合高并发

数据库乐观锁方案:

乐观锁方案 · 版本号控制
// 在业务表中加 version 字段
// ALTER TABLE inventory ADD COLUMN version INT DEFAULT 0;

第一步:读取当前值和版本号
  SELECT stock, version FROM inventory WHERE product_id = ?;
  // stock = 100, version = 5

第二步:更新时带版本号条件
  UPDATE inventory
  SET stock = stock - 1, version = version + 1
  WHERE product_id = ? AND version = 5;
  // 如果 affected_rows = 0 → 版本冲突,重试
  // 如果 affected_rows = 1 → 更新成功

// 乐观锁适合读多写少的场景(冲突概率低)
// 高并发写场景冲突频繁 → 大量重试 → 性能反而更差
第 3 站

Redis 锁:SETNX + 过期时间 + Lua 脚本

Redis 是分布式锁最常用的实现方案,核心利用 SET NX EX 命令的原子性。

RedisLock.java · Redis 分布式锁实现
public class RedisLock {
    private JedisPool jedisPool;
    private static final String LOCK_SUCCESS = "OK";

    // 加锁:SET key value NX EX timeout
    // NX = 不存在才设置(互斥)
    // EX = 设置过期时间(防死锁)
    // value = requestId(标识锁的持有者,防误删)
    public String tryLock(String key, int expireSeconds) {
        String requestId = UUID.randomUUID().toString();
        Jedis jedis = jedisPool.getResource();

        SetParams params = new SetParams().nx().ex(expireSeconds);
        String result = jedis.set(key, requestId, params);

        if ("OK".equals(result)) {
            return requestId; // 加锁成功
        }
        return null; // 加锁失败
    }

    // 解锁:Lua 脚本保证原子性
    // 先判断 value 是否是自己的,再删除
    public boolean unlock(String key, String requestId) {
        String luaScript =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "  return redis.call('del', KEYS[1]) " +
            "else " +
            "  return 0 " +
            "end";

        Jedis jedis = jedisPool.getResource();
        Object result = jedis.eval(luaScript,
            Collections.singletonList(key),
            Collections.singletonList(requestId));

        return Long.value 1L.equals(result);
    }
}
Redis 分布式锁:加锁与解锁流程 SET key val NX EX 成功? 返回 requestId 失败 重试/返回 false 解锁 Lua 脚本(原子执行) GET key == requestId ? YES → DEL key → return 1 NO → return 0(不是你的锁) 为什么要用 Lua 脚本? GET + DEL 两条命令不是原子的,中间可能被其他线程抢占 Lua 脚本在 Redis 中单线程执行,保证原子性
图 1Redis 分布式锁的核心流程:SETNX 加锁 + Lua 脚本解锁

三个常见坑点:

  • 锁过期但业务没执行完:锁过期后被其他线程获取,两个线程同时操作 → 看门狗机制解决
  • 误删别人的锁:A 锁过期,B 获取锁,A 执行完删锁删掉了 B 的锁 → 用 requestId 校验
  • 主从切换丢锁:锁还没同步到从节点,主节点挂了 → 这是 Redis 锁的固有缺陷
第 4 站

看门狗续期:解决"锁过期但业务没执行完"

"如果设置锁过期时间为 30 秒,但业务执行了 45 秒怎么办?设太大浪费资源,设太小业务可能没执行完。" —— 这就是看门狗(Watchdog)机制要解决的问题。

看门狗的核心思想:启动一个后台线程,定期检查锁是否还被持有,如果是就自动续期

WatchdogRenewal.java · 看门狗续期机制
public class RedisLockWithWatchdog {
    private static final long DEFAULT_EXPIRE = 30000; // 30 秒
    private static final long RENEW_INTERVAL = 10000; // 10 秒续一次
    private ScheduledExecutorService scheduler;

    // 加锁并启动看门狗
    public String lock(String key) {
        String requestId = UUID.randomUUID().toString();

        // 不设置过期时间,由看门狗管理
        Boolean ok = redisTemplate.opsForValue()
            .setIfAbsent(key, requestId);

        if (ok) {
            // 设置初始过期时间
            redisTemplate.expire(key, DEFAULT_EXPIRE, TimeUnit.MILLISECONDS);
            // 启动看门狗
            startWatchdog(key, requestId);
            return requestId;
        }
        return null;
    }

    // 看门狗:定期续期
    private void startWatchdog(String key, String requestId) {
        scheduler.scheduleAtFixedRate(() -> {
            // Lua 脚本:只有 value 匹配才续期
            String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then "
                + "return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end";
            redisTemplate.execute(new DefaultRedisScript<>(lua, Long.class),
                Collections.singletonList(key),
                requestId, String.valueOf(DEFAULT_EXPIRE));
        }, RENEW_INTERVAL, RENEW_INTERVAL, TimeUnit.MILLISECONDS);
    }
}
看门狗续期机制:防止锁过期导致并发问题 时间轴 → 业务执行(45 秒) 锁 TTL = 30s ← 过期! 有看门狗: 锁 TTL = 30s 🐕 续期 → TTL = 30s 🐕 续期 → 释放锁 没看门狗:锁在 30s 过期,业务还在执行 → 并发问题!
图 2看门狗定期续期,保证业务执行期间锁不过期
Redisson 的做法
Redisson 的看门狗默认锁过期时间为 30 秒,每 10 秒续期一次。如果持有锁的线程崩溃,看门狗线程也会停止,锁在 30 秒后自动释放——不会死锁。
第 5 站

ZooKeeper 锁:临时顺序节点

ZooKeeper 分布式锁利用两个核心特性:临时节点(客户端断开自动删除)和顺序节点(自动递增编号)。

ZkLock.java · ZooKeeper 分布式锁实现
public class ZkLock {
    private CuratorFramework client;
    private static final String LOCK_ROOT = "/locks";

    public String tryLock(String lockName) throws Exception {
        String path = LOCK_ROOT + "/" + lockName;

        // 1. 创建临时顺序节点
        String myNode = client.create()
            .withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
            .forPath(path + "/lock-");

        // 2. 获取所有子节点并排序
        List<String> children = client.getChildren().forPath(path);
        Collections.sort(children);

        String myNodeName = myNode.substring(myNode.lastIndexOf('/') + 1);

        // 3. 判断是否是最小的节点
        if (myNodeName.equals(children.get(0))) {
            return myNode; // 最小节点 = 获取到锁
        }

        // 4. 不是最小节点,监听前一个节点的删除事件
        int myIndex = children.indexOf(myNodeName);
        String prevNode = children.get(myIndex - 1);

        CountDownLatch latch = new CountDownLatch(1);
        client.checkExists()
            .usingWatcher(new CuratorWatcher() {
                public void processWatchedEvent(WatchedEvent event) {
                    latch.countDown(); // 前一个节点删除,唤醒等待
                }
            })
            .forPath(path + "/" + prevNode);

        latch.await(); // 阻塞等待
        return myNode; // 前一个节点释放锁后,我获取到锁
    }

    // 解锁:删除自己的节点
    public void unlock(String nodePath) throws Exception {
        client.delete().forPath(nodePath);
    }
}
ZooKeeper 分布式锁:临时顺序节点排队 /locks/order-lock/(父节点,PERSISTENT) 子节点为 EPHEMERAL_SEQUENTIAL,自动编号递增 lock-0000000001 ✓ 持有锁 lock-0000000002 监听 001 lock-0000000003 监听 002 lock-0000000004 监听 003 每个节点只监听前一个节点的删除事件 优势:避免羊群效应(不是所有节点都监听同一个)
图 3ZooKeeper 临时顺序节点实现公平锁,避免羊群效应
ZK 锁 vs Redis 锁
ZK 锁优势:临时节点天然防死锁(客户端断开自动删除);公平锁(顺序排队);强一致性(ZAB 协议)。
ZK 锁劣势:性能不如 Redis(每次操作都要写 ZooKeeper 集群);Leader 选举期间不可用;不适合高并发场景。
第 6 站

Redisson:生产级分布式锁框架

手写 Redis 分布式锁有很多坑,Redisson 是一个成熟的 Java 框架,封装了完整的分布式锁实现:

RedissonLockExample.java · Redisson 使用示例
// 1. 创建 Redisson 客户端
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);

// 2. 获取锁对象
RLock lock = redisson.getLock("order:lock:1001");

// 3. 加锁(自动续期 — 看门狗机制)
try {
    // 不指定时间 = 使用看门狗自动续期(默认 30s,每 10s 续一次)
    lock.lock();

    // 或者指定等待时间和持锁时间
    // lock.tryLock(5, 30, TimeUnit.SECONDS);
    // 等 5 秒,持锁 30 秒(不指定持锁时间则启用看门狗)

    // 4. 执行业务逻辑
    deductStock(orderId);
} finally {
    // 5. 释放锁(只有持有者才能释放)
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

Redisson 的底层实现(核心 Lua 脚本):

加锁 Lua 脚本 · Redisson 内部实现
-- Redisson 加锁的核心 Lua 脚本(简化版)
-- KEYS[1] = 锁的 key
-- ARGV[1] = 过期时间(毫秒)
-- ARGV[2] = 客户端标识(threadId)

if (redis.call('exists', KEYS[1]) == 0) then
    -- 锁不存在,创建并设置过期时间
    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]);

Redisson 的核心特性:

  • 可重入:使用 Hash 结构,key=clientId, value=重入次数
  • 看门狗自动续期:Netty 的 HashedWheelTimer 定时续期
  • Pub/Sub 等待:加锁失败时订阅解锁消息,避免轮询
  • 支持多种锁:可重入锁、公平锁、读写锁、信号量
第 7 站

RedLock 争议与方案对比

RedLock 算法是 Redis 作者 Antirez 提出的,试图解决 Redis 主从架构下锁丢失的问题:

RedLock 算法 · 核心思想
// 部署 N 个独立的 Redis 实例(推荐 5 个,奇数)
// 客户端依次向每个实例尝试加锁

加锁流程1. 获取当前时间戳 T1
2. 依次向 N 个 Redis 实例加锁(SET NX EX)
      每个实例设置短超时(如 5-50ms),避免某个实例挂了阻塞太久
3. 计算成功加锁的数量
4. 获取当前时间戳 T2
5. 如果成功数 >= N/2 + 1(多数派),且 T2 - T1 < 锁的过期时间
      → 加锁成功,有效时间 = 过期时间 - (T2 - T1)
6. 否则 → 加锁失败,向所有实例发送解锁请求(包括之前成功的)

Martin Kleppmann 的质疑(2016):

Martin Kleppmann(《DERTA》作者)发表文章《How to do distributed locking》,指出 RedLock 存在根本性问题:

  • 时钟跳跃:RedLock 依赖各节点的时钟基本同步,但 NTP 调整、闰秒等可能导致时钟跳跃
  • GC 停顿:客户端在获取锁后、执行业务前如果发生长时间 GC,锁可能已经过期
  • 自动过期 ≠ 安全:无法区分"锁被持有者主动释放"和"锁超时自动过期"
  • 没有 fencing token:正确做法是用单调递增的 fencing token,拒绝过期请求

Antirez 的反驳:

  • RedLock 适用于"效率锁"(efficiency lock)场景,不需要绝对安全
  • 自动续期(看门狗)可以缓解 GC 停顿问题
  • 现代服务器时钟同步精度足够
"那到底该不该用 RedLock?" —— 结论:对于需要绝对安全的分布式锁(如资金操作),不要用 RedLock,用 ZooKeeper + fencing token。对于"尽力而为"的互斥场景,Redis 单节点锁 + Redisson 看门狗就够了。

四种方案的完整对比:

维度数据库RedisZooKeeperRedisson
互斥性排他锁保证SET NX 保证临时节点保证同 Redis
性能低(数据库 IO)高(内存操作)中(磁盘写入)
防死锁需手动超时过期时间临时节点自动删除看门狗续期
可重入需手动实现需手动实现需手动实现原生支持
高可用数据库主从主从可能丢锁ZK 集群保证同 Redis
公平锁不支持不支持支持(顺序节点)支持
适用场景低并发定时任务高并发业务锁强一致性场景生产首选
本课文脑图回顾
  • 分布式锁四种方案:数据库(简单但慢)→ Redis(快但有坑)→ ZooKeeper(安全但慢)→ Redisson(生产首选)
  • Redis 锁三要素:SETNX + 过期时间 + Lua 脚本原子解锁
  • 看门狗解决"锁过期但业务没完":定期续期,持有者崩溃则停止续期
  • ZooKeeper 锁:临时顺序节点,天然防死锁,支持公平锁
  • RedLock 有争议:Martin Kleppmann 指出时钟依赖问题,建议关键场景用 ZK + fencing token