172|分布式锁选型:数据库、Redis、ZooKeeper 的一致性和可用性权衡

分布式理论、事务与一致性约 10 分钟阅读5,726 字符
知识库文档 174 / 251

阅读时间:约 45 分钟 | 难度:中高级 | 分类:分布式理论、事务与一致性


目录

  1. 什么是分布式锁(10 分钟)
  2. 分布式锁的核心需求模型(5 分钟)
  3. 基于数据库的分布式锁(8 分钟)
  4. 基于 Redis 的分布式锁(8 分钟)
  5. 基于 ZooKeeper 的分布式锁(8 分钟)
  6. 三种方案的一致性 vs 可用性对比(6 分钟)
  7. 生产环境选型决策框架(5 分钟)
  8. 面试追问与回答(10 分钟)

1. 什么是分布式锁

文章配图
文章配图

1.1 从单机锁到分布式锁

在单机 Java 应用中,我们用 synchronized 或 ReentrantLock 保护共享资源:

java
synchronized (this) {
    // 临界区:只有一个线程能执行
    deductInventory(productId, 1);
}

当应用部署到多台机器,JVM 内置锁失效了——不同 JVM 的锁对象互不可见。这就是分布式锁要解决的问题:在多个进程之间协调对共享资源的互斥访问。

1.2 典型场景

  • 库存扣减:秒杀场景下多台机器同时扣减同一商品库存
  • 定时任务互斥:多实例部署的定时任务,同一时刻只允许一个实例执行
  • 幂等性保证:防止同一请求被多次处理
  • 配置/数据发布:多个节点争抢"发布者"角色

1.3 分布式锁的本质

分布式锁在「没有一个所有进程都能直接访问的共享内存」的约束下,借助一个外部协调服务来实现互斥。这个协调服务本身需要满足:

  • 互斥性:同一时刻只有一个客户端持有锁
  • 无死锁:即使持锁客户端崩溃,锁最终也能被释放
  • 容错性:只要协调服务的大多数节点存活,锁服务就可用

2. 分布式锁的核心需求模型

不只是"加锁、解锁"这么简单,生产级分布式锁需要考虑:

需求维度 描述
互斥(Mutual Exclusion) 同一时刻只有一个客户端能持有锁
防死锁(Deadlock-Free) 锁必须有过期机制,防止客户端崩溃后锁永不释放
阻塞/非阻塞(Blocking) 获取不到锁时是自旋等待还是立即返回
可重入(Reentrant) 同一客户端在持有锁期间能否再次获取
公平性(Fairness) 多个客户端等待时,是否按请求顺序获取
高性能(Performance) 加锁/解锁的延迟和吞吐量
高可用(Availability) 协调服务自身故障时锁服务是否仍可用

CAP 视角:分布式锁的协调服务本质上是一个分布式系统,必然面临 CAP 取舍。你选择的锁方案,决定了你在一致性(C)和可用性(A)之间如何站队。


3. 基于数据库的分布式锁

文章配图
文章配图

3.1 唯一索引方案

利用数据库唯一约束实现互斥:

sql
-- 加锁:插入一条记录,利用唯一索引保证互斥
INSERT INTO distributed_lock (lock_key, holder, expire_at)
VALUES ('order:12345', 'node-1', NOW() + INTERVAL 30 SECOND);

-- 解锁:删除记录
DELETE FROM distributed_lock
WHERE lock_key = 'order:12345' AND holder = 'node-1';

加锁成功 → INSERT 返回成功;加锁失败 → 唯一键冲突,抛出 DuplicateKeyException。

3.2 乐观锁方案(CAS)

利用版本号实现乐观锁,适用于冲突不频繁的场景:

sql
-- 读出版本号
SELECT version FROM resource WHERE id = 1;  -- version = 5

-- 带版本号更新
UPDATE resource SET value = 'new', version = 6
WHERE id = 1 AND version = 5;
-- affected_rows = 0 → 被其他进程抢先修改了,重试
-- affected_rows = 1 → 成功

3.3 悲观锁方案(SELECT FOR UPDATE)

sql
-- 开启事务
BEGIN;
-- 对目标行加排他锁
SELECT * FROM resource WHERE id = 1 FOR UPDATE;
-- 执行业务操作
UPDATE resource SET value = 'new' WHERE id = 1;
-- 提交事务释放锁
COMMIT;

3.4 数据库方案的关键问题

防死锁:需要一个定时任务扫描并清理过期的锁记录:

sql
DELETE FROM distributed_lock WHERE expire_at < NOW();

性能瓶颈:数据库的 QPS 有限(通常几千到几万),不适合高并发加锁场景。

单点故障:主库挂了锁服务就不可用,虽然可以通过主从切换缓解,但切换期间锁服务中断。

可用性问题:数据库通常是 CP 系统(主从复制有延迟),当发生网络分区时,可能牺牲可用性。


4. 基于 Redis 的分布式锁

文章配图
文章配图

4.1 基础实现:SET NX PX

bash
加锁:SET key value NX PX 30000
NX = 不存在时才设置;PX = 毫秒级过期
SET lock:order:12345 node-1 NX PX 30000

解锁:Lua 脚本保证原子性
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

4.2 Redisson 的工业级实现

Redisson 解决了很多工程问题:

Watch Dog 自动续期:如果业务还没执行完,看门狗自动延长锁的过期时间(默认每 10 秒续期到 30 秒)。

java
// 默认 30 秒过期,watchdog 每 10 秒续期
RLock lock = redisson.getLock("order:12345");
lock.lock();  // 阻塞等待
try {
    // 业务逻辑
} finally {
    lock.unlock();
}

可重入:通过 Redis Hash 结构,key 为锁名,field 为客户端 ID,value 为重入次数。

红锁(Redlock):在多个独立的 Redis 节点上依次加锁,过半成功才算获取锁。

4.3 Redis 方案的关键问题

锁丢失风险:Redis 主从异步复制,主节点宕机后锁信息可能丢失。

code
时间线:
  T1: Client A 在主节点加锁成功
  T2: 主节点宕机,锁信息还未同步到从节点
  T3: 从节点提升为主,锁信息丢失
  T4: Client B 在新主节点上对同一 key 加锁成功
  → 两个客户端同时持锁!

这就是 Redis 分布式锁的核心争议:在 CP 场景下不够安全。

时钟跳跃:Redlock 依赖过期时间,如果系统时钟发生跳跃(NTP 调整),可能导致锁提前过期。


5. 基于 ZooKeeper 的分布式锁

文章配图
文章配图

5.1 临时顺序节点方案

ZooKeeper 通过临时顺序节点 + Watch 机制实现分布式锁:

code
加锁流程:
1. 在 /lock 下创建临时顺序节点,如 /lock/0000000001
2. 获取 /lock 下所有子节点,排序
3. 如果自己是最小的节点 → 获得锁
4. 如果不是最小 → Watch 前一个节点
5. 前一个节点删除(锁释放)→ 收到通知,回到步骤 2

5.2 Curator 实现

java
InterProcessMutex lock = new InterProcessMutex(client, "/lock/order:12345");
if (lock.acquire(10, TimeUnit.SECONDS)) {
    try {
        // 业务逻辑
    } finally {
        lock.release();
    }
}

5.3 ZooKeeper 方案的优势

强一致性:ZooKeeper 基于 ZAB 协议,保证所有节点数据一致。客户端只会看到线性一致的锁状态。

自动释放:临时节点与客户端 Session 绑定,客户端崩溃 → Session 超时 → 临时节点自动删除 → 锁自动释放。天然防死锁。

公平锁:按节点序号排队,谁先来谁先得,天然的公平锁。

Watcher 通知:等待锁的客户端不需要轮询,前一个节点释放时 ZK 主动通知。

5.4 ZooKeeper 方案的关键问题

性能较低:每次加锁都需要创建临时节点并同步到半数以上节点,延迟较高(通常 2-10ms)。

Session 超时风险:GC 长时间停顿、网络抖动可能导致 Session 超时,锁被意外释放。

羊群效应:如果所有等待者都 Watch 同一个节点,锁释放时所有等待者同时被唤醒,造成惊群。Curator 通过 Watch 前一个节点来避免。

运维成本:ZK 集群需要专门的运维,不像 Redis 那样普及。


6. 三种方案的一致性 vs 可用性对比

文章配图
文章配图

code
┌──────────────────────────────────────────────────────────────────┐
│                    分布式锁方案 CAP 定位                           │
├──────────────┬───────────────┬──────────────┬────────────────────┤
│    方案      │   一致性(C)   │  可用性(A)   │   分区容错(P)       │
├──────────────┼───────────────┼──────────────┼────────────────────┤
│  数据库      │  强(主库)   │  中(主从切换 │   必须满足          │
│              │               │   有中断)    │                    │
│   Redis      │  弱(异步复制 │  高(哨兵/    │   必须满足          │
│              │   可能丢锁)  │  集群自动切换)│                    │
│  ZooKeeper   │  强(ZAB协议)│  中(选主期间 │   必须满足          │
│              │               │   不可用)    │                    │
└──────────────┴───────────────┴──────────────┴────────────────────┘
维度 数据库 Redis ZooKeeper
互斥性 ✅ 唯一索引保证 ⚠️ 主从异步复制可能丢锁 ✅ ZAB 协议保证
防死锁 ⚠️ 需要定时清理 ✅ 过期时间 ✅ 临时节点自动释放
阻塞等待 ❌ 需要自旋轮询 ✅ 发布订阅/自旋 ✅ Watcher 通知
可重入 ❌ 需自行实现 ✅ Redisson 内置 ✅ Curator 内置
性能(QPS) 低(~1K) 高(~10W) 中(~1W)
一致性 强(单主) 弱(最终一致) 强(线性一致)
可用性 中 高 中
运维成本 低(已有DB) 中(已有Redis) 高(需专门运维)

7. 生产环境选型决策框架

文章配图
文章配图

7.1 决策树

code
你的场景是什么?
│
├── 对一致性的要求极高,锁丢失不可接受
│   └── 选 ZooKeeper
│       例如:金融交易、资金扣减、关键资源分配
│
├── 对性能要求极高,可容忍极低概率的锁丢失
│   └── 选 Redis(Redisson)
│       例如:秒杀库存扣减(有最终对账兜底)、定时任务互斥
│
├── 不想引入额外组件,已有数据库
│   └── 选数据库方案
│       例如:并发量低的内部管理系统、定时任务
│
└── 需要最高可用性,一致性可妥协
    └── 选 Redis + 业务层补偿
        例如:缓存更新锁、非关键资源协调

7.2 混合方案:Redis + 业务兜底

业界常见做法:用 Redis 分布式锁做快速互斥,用数据库唯一约束做最终兜底。

java
public void deductInventory(String productId, int quantity) {
    String lockKey = "lock:inventory:" + productId;
    RLock lock = redisson.getLock(lockKey);
    try {
        if (lock.tryLock(100, 30000, TimeUnit.MILLISECONDS)) {
            // Redis 锁保护,大概率成功
            int stock = inventoryMapper.selectForUpdate(productId).getStock();
            if (stock >= quantity) {
                inventoryMapper.deduct(productId, quantity);
            }
        }
    } finally {
        lock.unlock();
    }
}
// 数据库行锁 + 唯一约束是最后的防线

7.3 Fencing Token:防止锁过期后的误操作

文章配图
文章配图

当锁过期但业务还在执行时,需要一个递增的 token 来防止"前任"的错误写入:

java
// 加锁时返回 fencing token
long token = acquireLockWithToken("resource:123");

// 写操作时带上 token
// 存储系统只接受 token 大于当前已处理 token 的写请求
storage.writeWithToken("resource:123", data, token);

ZooKeeper 的 zxid 和 Redlock 协议中都有类似的 fencing 机制。


8. 面试追问与回答

文章配图
文章配图

以下问题从面试官视角设计,考察你对分布式锁的深度理解,不限于本文内容。

Q1:如果 Redis 主节点宕机,Redlock 算法能否保证锁的安全性?有什么边界条件?

Q2:ZooKeeper 分布式锁中,客户端 GC 停顿 30 秒导致 Session 超时,锁被释放,另一个客户端拿到了锁。GC 恢复后原客户端以为自己还持有锁,继续操作——如何防止这种"脑裂"?

Q3:你设计一个秒杀系统,库存扣减用 Redis 分布式锁还是 ZooKeeper 分布式锁?为什么?如果 Redis 锁丢了怎么办?

Q4:数据库方案中,定时清理过期锁的任务如果也挂了怎么办?如何保证锁不被永久占用?

Q5:分布式锁和数据库行锁(SELECT FOR UPDATE)有什么区别?在什么场景下应该用分布式锁而不是数据库行锁?

Q6:有没有不需要分布式锁就能保证互斥的架构方案?请举例说明。


下一篇:173|幂等设计:幂等键、去重、状态机、Token 与唯一约束


📝 参考答案

A1:Redlock 的安全性边界条件

Redlock 算法(Redis 作者 antirez 提出)的核心思路:在 N 个独立 Redis 节点上依次加锁,过半(N/2+1)成功才算获取锁。但它有严格的安全边界:

能保证安全的前提:

  • 所有 Redis 节点没有持久化(重启后数据清空),且锁的过期时间远小于节点重启时间
  • 客户端在有效时间内(锁过期时间 - 时钟漂移 - 网络延迟)完成加锁
  • 各节点时钟偏差在可控范围内

无法保证安全的场景:

  1. 节点重启:如果 Redis 节点开启了 AOF/RDB 持久化,重启后可能恢复旧的锁信息,导致两个客户端同时持锁。Martin Kleppmann 在《How to do distributed locking》中详细论证了这一点。
  2. 时钟跳跃:NTP 大幅调整系统时钟时,锁可能提前过期。Redlock 依赖"过期时间"作为安全边界,而时钟是不可靠的。
  3. GC 停顿:客户端 GC 停顿期间锁过期,其他客户端拿到锁,原客户端恢复后继续操作。

结论:Redlock 在不重启、时钟稳定、网络可靠的理想条件下是安全的,但生产环境很难同时满足。它更适合作为"效率锁"(提高效率,降低冲突概率)而非"正确性锁"(保证数据绝对正确)。

A2:ZooKeeper GC 停顿导致脑裂的防护方案

核心问题是:客户端不知道自己已经"过期"了。解决方案:

  1. Fencing Token(栅栏令牌):每次加锁时,ZK 的 zxid 或自定义递增 ID 作为 token 返回。写共享资源时,必须携带 token,资源存储层只接受 token 大于当前已处理 token 的写请求。Curator 的 InterProcessSemaphoreMutex 配合 Lease 可实现此机制。
  2. 版本号校验:在共享资源上维护版本号,加锁时获取当前版本号,写入时做 CAS 校验。如果版本号已被其他客户端更新,则写入失败。
  3. 客户端侧 Token 自检:在每次写操作前,检查本地 Session 状态是否仍然有效:
java
if (client.getState() == CuratorFrameworkState.STARTED
    && client.getZookeeperClient().isConnected()) {
    // 执行写操作
} else {
    throw new LockExpiredException();
}
  1. 共享存储层防护:不管用什么锁,最终的防线在数据层——数据库的乐观锁版本号、唯一约束、状态机——这些是"不相信锁"的兜底。

A3:秒杀系统的分布式锁选型

选 Redis 分布式锁(Redisson),理由:

  • 性能:秒杀场景 QPS 极高,ZK 的延迟(2-10ms)是 Redis 的 10 倍以上,会成为瓶颈
  • 可用性:Redis 哨兵/集群模式在主节点宕机时能快速切换,ZK 选主期间(通常 200ms-2s)锁服务不可用
  • Redis 锁丢失的兜底:数据库行锁 + 唯一约束 + 库存扣减原子操作是最后防线。即使 Redis 锁丢了,两个请求同时走到数据库,SELECT FOR UPDATE + 行锁也能保证不会超卖

核心设计:

java
// 第一层:Redis 分布式锁(快速互斥,减少 DB 压力)
RLock lock = redisson.getLock("seckill:" + productId);
if (lock.tryLock(10, 5000, TimeUnit.MILLISECONDS)) {
    try {
        // 第二层:数据库兜底(行锁 + 唯一约束)
        int affected = inventoryMapper.deductIfStockEnough(productId, 1);
        if (affected == 0) throw new SoldOutException();
    } finally {
        lock.unlock();
    }
}

如果没有数据库兜底:那就不能只用 Redis 锁——ZooKeeper 锁更安全,或者用 Redlock + fencing token 方案。

A4:数据库定时清理任务故障的防护

定时清理任务挂了,锁记录会一直留在表里,导致这些锁永久不可用。防护措施:

  1. 锁记录自带过期字段:加锁时写入 expire_at,业务代码在加锁前先清理过期锁,不依赖定时任务:
sql
-- 加锁前先清理过期锁
DELETE FROM distributed_lock WHERE expire_at < NOW();
-- 然后尝试加锁
INSERT INTO distributed_lock (lock_key, holder, expire_at)
VALUES ('order:12345', 'node-1', NOW() + INTERVAL 30 SECOND);
  1. 定时任务多实例 + 分布式锁保护:清理任务本身用分布式锁保证只有一个实例执行,但多个实例都在竞争执行权,挂了一个另一个顶上。
  2. 数据库 Event Scheduler:MySQL 内置的定时任务,不需要外部进程:
sql
CREATE EVENT clean_expired_locks
ON SCHEDULE EVERY 10 SECOND
DO DELETE FROM distributed_lock WHERE expire_at < NOW();
  1. 监控告警:对 distributed_lock 表的记录数设置告警阈值,异常增长时及时通知。

A5:分布式锁 vs 数据库行锁的区别与选型

维度 分布式锁 数据库行锁(SELECT FOR UPDATE)
作用范围 跨进程、跨 JVM 数据库连接/事务内
保护对象 任意共享资源 数据库中的行数据
锁粒度 自定义(key 粒度) 行级
阻塞行为 可阻塞、可超时 事务提交/回滚时释放
性能 Redis 可达 10W QPS 受限于数据库连接数和事务吞吐

该用分布式锁而非数据库行锁的场景:

  1. 保护非数据库资源:如文件系统、第三方 API 调用频率控制
  2. 跨数据库的协调:多个数据库之间的数据一致性需要分布式锁
  3. 锁持有时间较长:持有数据库行锁意味着持有数据库连接和事务,长事务会阻塞其他请求、影响连接池
  4. 减少数据库压力:高并发场景下,先用分布式锁过滤掉大部分请求,减少打到数据库的流量

该用数据库行锁的场景:

  1. 锁和数据在同一事务中,需要原子性保证
  2. 并发量不高,不想引入额外组件
  3. 作为分布式锁的最终兜底

A6:不需要分布式锁的互斥方案

有几种架构设计可以从根本上避免分布式锁:

  1. 消息队列 + 单线程消费:将互斥操作序列化,通过消息队列的 Partition 机制,同一 key 的消息路由到同一 Partition,由单线程消费者处理。RocketMQ 的 MessageQueue 和 Kafka Partition 天然支持。
  2. 数据库唯一约束 + 乐观锁:对于幂等性要求,直接在数据库层面用唯一约束保证:
sql
INSERT INTO order (order_id, ...) VALUES (?, ...);  -- 重复插入直接报错

对于状态更新,用乐观锁版本号:

sql
UPDATE resource SET status = 'processing', version = version + 1
WHERE id = ? AND version = ?;
  1. Actor 模型:每个资源对应一个 Actor,所有对该资源的操作都发给这个 Actor 串行处理。Akka、Orleans 等框架天然支持。
  2. Redis 原子操作:如果操作本身可以在 Redis 内完成(如 INCR、DECR、lua 脚本),就不需要分布式锁:
lua
-- 原子扣减库存
local stock = redis.call('GET', KEYS[1])
if stock and tonumber(stock) > 0 then
    return redis.call('DECR', KEYS[1])
else
    return -1
end
  1. 分片 + 路由:将资源按 key 哈希分片,同一资源的所有请求路由到同一台机器,单机内用 JVM 锁即可。典型实现:一致性哈希路由 + 单机 ReentrantLock。

核心思想:问自己——"能不能不做分布式锁?"如果可以通过架构设计消除竞争,那比任何分布式锁方案都更优雅。