您好,各位技术同仁和数学爱好者!我是您的博主 qmwneb946。今天,我们要深入探讨一个在构建高并发、高可用分布式系统时不可或缺且充满挑战的主题——分布式锁的实现。

在单体应用时代,我们使用 synchronized 关键字或 ReentrantLock 等工具来解决多线程并发访问共享资源的问题。但随着业务复杂度的提升,单体应用逐渐演变为分布式系统,原本行之有效的单机锁机制也随之失效。在分布式环境中,多个服务实例可能同时尝试修改同一份数据,如果没有恰当的同步机制,就可能导致数据不一致、重复操作等严重问题。分布式锁正是为了解决这些问题而生,它旨在确保在分布式系统中的任何时刻,只有一个客户端能够持有锁,从而独占对共享资源的访问。

引言:从单机到分布式,锁的演变

想象一下,你和你的朋友们正在争抢一个稀有的游戏道具。在单机游戏里,可能只有一个你,所以你可以独享道具。但如果这是个网络游戏,全球玩家都在争抢,那么就需要一套规则来确保公平性,比如“谁先点到就是谁的”,或者“谁有钥匙谁才能开宝箱”。这个“钥匙”或“规则”就是我们今天的主角——“锁”。

在单体应用中,JVM 内部的线程锁能够有效控制多个线程对共享内存的访问。例如,当多个线程尝试修改同一个变量时,我们可以使用 synchronized 关键字来保证某一时刻只有一个线程能够进行操作。

1
2
3
4
5
6
7
8
9
10
11
12
// 单机锁示例
public class Counter {
private int count = 0;

public synchronized void increment() {
count++;
}

public int getCount() {
return count;
}
}

然而,在分布式系统中,服务实例部署在不同的机器上,它们各自拥有独立的内存空间。此时,一个实例上的 synchronized 锁无法阻止另一个实例上的线程访问相同的共享资源(例如数据库中的一行记录、缓存中的一个键值对)。这就引入了分布式锁的需求。分布式锁的核心目标是:

  • 互斥性 (Mutual Exclusion): 在任何时刻,只有一个客户端能够持有锁。
  • 高可用性 (High Availability): 锁服务本身不能因为单点故障而失效。
  • 防死锁 (Deadlock Prevention): 即使客户端崩溃或网络中断,锁也最终能够被释放,避免系统长时间阻塞。
  • 可重入性 (Reentrancy, Optional): 同一个客户端在持有锁的情况下,可以再次获取该锁。
  • 公平性 (Fairness, Optional): 锁的获取应遵循某种顺序(例如,请求的先后顺序)。

接下来,我们将深入探讨几种主流的分布式锁实现方式,分析它们的原理、优缺点以及适用场景。

为什么需要分布式锁?

在分布式系统中,共享资源可能包括:

  • 数据库记录: 避免多个服务同时修改同一条订单状态导致数据错乱。
  • 缓存数据: 保证对共享缓存键的原子性更新。
  • 外部服务接口: 限制某个服务对第三方接口的调用频率,防止超限。
  • 文件系统: 避免多个服务同时写入或修改同一个文件。

以电商系统的库存扣减为例。当用户下单时,我们需要扣减商品库存。如果在高并发下,多个订单同时扣减同一个商品的库存,如果没有分布式锁的保护,就可能出现超卖现象。

例如,库存为 100 件,两个用户同时购买 60 件。

  1. 用户 A 读取库存 100。
  2. 用户 B 读取库存 100。
  3. 用户 A 计算库存 100 - 60 = 40,写入库存 40。
  4. 用户 B 计算库存 100 - 60 = 40,写入库存 40。
    最终库存变为 40,但实际上应该为 -20,造成超卖 20 件。

通过分布式锁,我们可以保证在某个时间点只有一个服务实例能够执行库存扣减操作,从而避免上述问题。

分布式锁的实现方式概览

分布式锁的实现方式多种多样,但目前主流的方案主要基于以下三类技术:

  1. 基于数据库: 利用数据库的特性(唯一索引、悲观锁、乐观锁)来实现锁。
  2. 基于缓存(如 Redis): 利用缓存系统的高性能和原子操作来实现锁。
  3. 基于协调服务(如 ZooKeeper、Etcd): 利用协调服务提供的高一致性和事件通知机制来实现锁。

每种方案都有其独特的优势和局限性。选择哪种方案,需要根据具体的业务场景、对性能、可靠性以及一致性的要求来权衡。

基于数据库的分布式锁

数据库是许多分布式系统的核心,利用其事务和 ACID 特性来实现分布式锁是自然而然的想法。

实现原理

1. 唯一索引法
这是最常见且相对简单的方法。我们可以创建一张专门的锁表,并为表示锁名称的字段添加唯一索引。当一个客户端需要获取锁时,它尝试向这张表中插入一条记录,记录包含锁的唯一标识。

  • 获取锁: INSERT INTO locks (lock_name, owner_id) VALUES ('my_resource_lock', 'client_A');
    • 如果插入成功,则表示获取到锁。
    • 如果插入失败(因为 lock_name 冲突,唯一索引生效),则表示锁已被其他客户端持有,当前客户端需要等待或重试。
  • 释放锁: DELETE FROM locks WHERE lock_name = 'my_resource_lock' AND owner_id = 'client_A';
    • 通过 owner_id 字段可以防止误删其他客户端的锁。

表结构示例:

1
2
3
4
5
6
7
8
9
CREATE TABLE `distributed_lock` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`lock_name` VARCHAR(128) NOT NULL COMMENT '锁名称',
`owner_id` VARCHAR(128) NOT NULL COMMENT '锁持有者标识',
`expire_time` DATETIME NOT NULL COMMENT '锁过期时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_lock_name` (`lock_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分布式锁表';

为了防止客户端崩溃导致死锁,我们通常会设置一个 expire_time 字段,并启动一个定时任务来清理过期的锁记录。

2. 悲观锁 (SELECT ... FOR UPDATE)
利用数据库的行级锁或表级锁。当一个事务执行 SELECT ... FOR UPDATE 时,它会锁定查询到的行,直到事务提交或回滚。其他事务无法修改这些被锁定的行。

  • 获取锁: BEGIN; SELECT * FROM resource_table WHERE id = 1 FOR UPDATE;
  • 释放锁: COMMIT;ROLLBACK;

这种方式的优点是利用了数据库事务的原子性,但它要求业务逻辑和锁操作在同一个事务中,且性能受限于数据库的并发处理能力。

3. 乐观锁 (版本号/CAS)
乐观锁不真正加锁,而是通过版本号或时间戳等机制来判断数据在更新过程中是否被其他操作修改过。

  • 获取锁: 读取数据和其版本号。
  • 更新数据: UPDATE resource_table SET value = newValue, version = version + 1 WHERE id = 1 AND version = currentVersion;
    • 如果 UPDATE 影响的行数为 1,则表示获取锁并更新成功。
    • 如果影响行数为 0,则表示在读取到 currentVersion 后,数据已被其他操作修改,更新失败,需要重试。

乐观锁适用于读多写少的场景,因为它没有真正的锁竞争,但在高并发写入时可能导致大量重试。

优点

  • 实现简单: 依赖数据库的成熟功能,易于理解和实现。
  • 强一致性: 数据库本身提供事务和持久化保证,数据一致性高。
  • 防死锁(通过超时机制): 结合 expire_time 或事务超时可以避免长时间死锁。

缺点

  • 性能瓶颈: 数据库是集中式服务,所有的锁操作都需要访问数据库,并发量高时容易成为瓶颈。
  • 单点故障: 如果数据库宕机,则锁服务也会失效(可以通过数据库高可用方案解决)。
  • 不可重入性复杂: 唯一索引法和悲观锁法天然不支持重入,需要额外的逻辑(例如在锁记录中增加持有者和计数器)来实现。
  • 可靠性问题: 如果客户端在持有锁后崩溃,可能需要依赖过期时间来释放锁,如果过期时间设置不合理,可能导致短暂的死锁。

代码示例 (基于唯一索引的伪代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// Java 伪代码
public class DatabaseLock {
// 假设 dbClient 是一个数据库操作客户端
private final DbClient dbClient;
private final String lockName;
private final String ownerId; // 客户端唯一标识

public DatabaseLock(DbClient dbClient, String lockName, String ownerId) {
this.dbClient = dbClient;
this.lockName = lockName;
this.ownerId = ownerId;
}

public boolean tryLock(long expireMillis) {
String sql = "INSERT INTO distributed_lock (lock_name, owner_id, expire_time) VALUES (?, ?, ?)";
long expireTime = System.currentTimeMillis() + expireMillis;
try {
int rowsAffected = dbClient.executeInsert(sql, lockName, ownerId, new Date(expireTime));
return rowsAffected > 0;
} catch (DuplicateKeyException e) {
// 唯一索引冲突,表示锁已被占用
return false;
} catch (Exception e) {
// 其他异常
e.printStackTrace();
return false;
}
}

public void releaseLock() {
String sql = "DELETE FROM distributed_lock WHERE lock_name = ? AND owner_id = ?";
try {
dbClient.executeDelete(sql, lockName, ownerId);
} catch (Exception e) {
e.printStackTrace();
}
}

// 定时清理过期锁的逻辑 (不在主流程中,通常是后台任务)
public void cleanExpiredLocks() {
String sql = "DELETE FROM distributed_lock WHERE expire_time < ?";
try {
dbClient.executeDelete(sql, new Date(System.currentTimeMillis()));
} catch (Exception e) {
e.printStackTrace();
}
}
}

基于缓存(Redis)的分布式锁

Redis 因其高性能和原子命令而成为分布式锁的流行选择。

实现原理

Redis 分布式锁的核心思想是利用 Redis 的 SET 命令及其参数来实现锁的原子性获取。

1. 基本 SET NX EX/PX 命令

Redis 的 SET 命令提供了 NX (Not eXists) 和 PX/EX (eXpire) 参数,可以原子地实现“如果键不存在则设置,并设置过期时间”的操作。

  • 获取锁: SET lock_key unique_value NX PX expire_milliseconds

    • lock_key: 锁的唯一标识。
    • unique_value: 客户端的唯一标识(例如 UUID),用于防止误删。
    • NX: 只在 lock_key 不存在时才设置,保证互斥性。
    • PX expire_milliseconds: 设置锁的过期时间,单位毫秒,防止死锁。
    • 如果命令返回 OK,则表示成功获取锁。
    • 如果返回 NIL,则表示获取锁失败。
  • 释放锁:

    • 直接使用 DEL lock_key 命令存在一个严重的问题:如果客户端 A 获取锁后,由于网络延迟或 GC 等原因,导致业务处理时间超过了锁的过期时间,锁自动释放。此时,客户端 B 获得了锁。然后客户端 A 执行 DEL lock_key,就会误删客户端 B 的锁。这被称为 “误删锁问题”
    • 为了解决这个问题,释放锁时必须判断 unique_value 是否与当前锁的值一致。这个判断和删除操作必须是原子性的。Redis 提供了 Lua 脚本来保证原子性。

Lua 脚本释放锁示例:

1
2
3
4
5
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
  • KEYS[1]lock_key
  • ARGV[1]unique_value
    这段脚本的含义是:获取 lock_key 的值,如果与传入的 unique_value 相同,则删除 lock_key 并返回 1;否则返回 0。

锁续期(Watchdog/Redisson)

业务处理时间可能不确定,导致锁在业务完成前过期。为了解决这个问题,可以引入锁续期机制,也被称为“看门狗(Watchdog)”。

其基本思想是:

  1. 客户端成功获取锁后,启动一个后台线程(看门狗)。
  2. 看门狗线程会定时检查锁是否仍然由当前客户端持有。
  3. 如果锁仍然由当前客户端持有,并且锁即将过期,看门狗会刷新锁的过期时间,延长锁的有效期。
  4. 当业务逻辑执行完毕,客户端主动释放锁时,看门狗线程也随之停止。

著名的 Java 分布式锁框架 Redisson 就实现了这一机制。Redisson 默认每隔 10 秒检查一次锁的持有情况,如果锁还存在,则将过期时间重置为 30 秒(默认值),直到业务执行完毕或客户端宕机。

Redis 集群下的问题:Redlock 算法

在单点 Redis 部署模式下,如果 Redis 实例发生故障(例如宕机,或主从切换),可能会导致锁的可靠性问题。

  • 单点故障: Redis 实例宕机,所有锁都失效。
  • 主从切换: 如果使用主从复制,客户端在主节点获取锁后,在数据还未同步到从节点时,主节点宕机并发生主从切换。新的主节点没有同步到该锁的信息,此时另一个客户端就可以再次获取到同一把锁,导致多客户端同时持有锁,破坏互斥性。

为了解决这个问题,Redis 的作者 Antirez 提出了 Redlock 算法。Redlock 的核心思想是,在多个独立的 Redis 实例(通常是 5 个)上获取锁,并遵循“大多数原则”(Quorum),即只有在大多数 Redis 实例上都成功获取锁,才认为获取到了分布式锁。

Redlock 算法步骤 (假设有 N 个 Redis 实例):

  1. 获取当前时间戳 T1T_1 (毫秒)。
  2. 尝试在所有 N 个 Redis 实例上获取锁: 客户端依次向每个实例发送 SET lock_key unique_value NX PX expire_milliseconds 命令。这个 expire_milliseconds 应该相对较小,例如几十毫秒,以减少客户端因等待超时而阻塞的时间。
  3. 计算获取锁的耗时: 获取所有实例响应后,计算当前时间戳 T2T_2,耗时为 Tcost=T2T1T_{cost} = T_2 - T_1
  4. 判断是否成功获取锁:
    • 成功获取锁的实例数量 N2+1\geq \frac{N}{2} + 1 (大多数原则)。
    • 总耗时 Tcost<expire_millisecondsT_{cost} < expire\_milliseconds。这意味着锁在大多数实例上还未过期。
  5. 如果成功获取锁: 认为成功获得了分布式锁,锁的有效时间为 expire_milliseconds - T_cost
  6. 如果获取锁失败: 客户端需要立即向所有 Redis 实例发送 DEL lock_key 命令,释放所有已获取的锁,无论是否成功获取到多数锁。

Redlock 的争议:
Redlock 算法在提出后引起了广泛的讨论和争议,其中最著名的质疑来自分布式系统专家 Martin Kleppmann。他认为 Redlock 算法存在以下问题:

  • 对时钟同步的依赖: Redlock 假设各个 Redis 实例的系统时钟是同步的,但实际中时钟漂移是普遍存在的,这可能导致锁的有效期计算不准确。
  • GC 暂停问题: 如果某个持有锁的客户端发生长时间的 GC 暂停,可能导致锁过期被释放,而此时客户端仍然认为自己持有锁,继续操作资源,从而破坏互斥性。
  • 持久化问题: 即使 Redis 开启了 AOF 或 RDB 持久化,如果 Redis 实例在崩溃后重启,它可能会丢失未同步的键,从而导致锁失效。

尽管有这些争议,Redlock 在一些场景下仍然被使用,并且像 Redisson 这样的库也实现了 Redlock。实践中,如果对一致性要求极高,通常会考虑 ZooKeeper 等强一致性系统。

优点

  • 高性能: Redis 基于内存,读写速度快,能够支持高并发。
  • 实现相对简单: 利用 SET NX PX 命令即可实现基本的分布式锁。
  • 自带过期时间: 避免死锁问题。

缺点

  • 可靠性问题(单点 Redis): 在主从切换时,可能出现锁失效,多客户端同时持有锁的情况(Redlock 试图解决但有争议)。
  • 误删锁问题: 必须使用 Lua 脚本保证原子性判断和删除。
  • 锁续期机制复杂: 需要额外的看门狗线程来维持锁的有效期。
  • Redlock 的争议: 算法的健壮性在极端情况下受到质疑。

代码示例 (基于 Redis SET NX PX 和 Lua 的伪代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Java 伪代码 (Jedis/Lettuce 客户端)
public class RedisLock {
private final Jedis jedis; // 或 RedissonClient
private final String lockKey;
private final String uniqueValue; // UUID
private final long expireMillis;

public RedisLock(Jedis jedis, String lockKey, String uniqueValue, long expireMillis) {
this.jedis = jedis;
this.lockKey = lockKey;
this.uniqueValue = uniqueValue;
this.expireMillis = expireMillis;
}

public boolean tryLock() {
// SET key value NX PX milliseconds
String result = jedis.set(lockKey, uniqueValue, "NX", "PX", expireMillis);
return "OK".equals(result);
}

public boolean releaseLock() {
// Lua 脚本,保证原子性
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(uniqueValue));
return Long.valueOf(1).equals(result);
}

// 假设这是一个简化的续期逻辑,实际生产中 Redisson 更复杂
// public void startWatchdog() {
// new Thread(() -> {
// while (true) {
// try {
// Thread.sleep(expireMillis / 3); // 每1/3过期时间检查一次
// if (jedis.exists(lockKey) && uniqueValue.equals(jedis.get(lockKey))) {
// jedis.pexpire(lockKey, expireMillis); // 重新设置过期时间
// } else {
// break; // 锁已丢失或释放
// }
// } catch (InterruptedException e) {
// Thread.currentThread().interrupt();
// break;
// }
// }
// }).start();
// }
}

基于 ZooKeeper 的分布式锁

ZooKeeper 是一个分布式协调服务,它提供了一个高性能、高可用、严格有序且最终一致的文件系统(Znode)以及事件通知机制。这些特性使其成为实现分布式锁的理想选择。

ZooKeeper 特性回顾

  • 数据模型: 树形结构,每个节点(Znode)可以存储数据,并有子节点。
  • 临时节点 (Ephemeral Nodes): 客户端断开连接后,Znode 会被自动删除,天然防死锁。
  • 有序节点 (Sequential Nodes): 创建的 Znode 会带上一个单调递增的序号,保证了顺序性。
  • Watcher 机制: 客户端可以监听 Znode 的变化(创建、删除、数据改变),当变化发生时会收到通知。
  • ZAB 协议: ZooKeeper 的原子广播协议,保证了集群内数据的一致性和顺序性。

实现原理

ZooKeeper 实现分布式锁通常利用临时有序节点Watcher 机制

  1. 定义锁路径: 首先在 ZooKeeper 中创建一个持久节点作为所有锁的根目录,例如 /locks
  2. 获取锁:
    • 当一个客户端 A 想要获取锁时,它在 /locks 目录下创建一个临时有序节点,例如 /locks/lock_node_000000001
    • 客户端 A 获取 /locks 下所有的子节点列表。
    • 客户端 A 检查自己创建的节点(lock_node_000000001)是否是所有子节点中序号最小的。
    • 如果是最小的,则表示客户端 A 成功获取到锁。
  3. 等待锁:
    • 如果客户端 A 创建的节点不是最小的(例如,在它之前还有 lock_node_000000000),那么客户端 A 不会立即获取锁。
    • 它会找到比自己序号小的前一个节点(例如 lock_node_000000000),并对其注册一个Watcher,监听这个前一个节点的删除事件。
    • 客户端 A 进入等待状态。
  4. 释放锁:
    • 当持有锁的客户端(例如创建了 lock_node_000000000 的客户端)完成业务逻辑后,它会主动删除自己创建的临时节点。
    • lock_node_000000000 被删除时,监听它的客户端 A 会收到 Watcher 通知。
    • 客户端 A 收到通知后,会再次执行步骤 2,检查自己是否成为当前最小节点。如果是,则获取锁成功。

为什么监听前一个节点而不是根节点?
如果所有等待的客户端都监听根节点 /locks 的子节点变化,当一个锁被释放时(一个子节点被删除),所有等待的客户端都会收到通知,然后它们又会同时去 ZooKeeper 读取子节点列表并判断自己是否是最小的。这会造成羊群效应 (Herd Effect),导致 ZooKeeper 服务器压力剧增。
通过监听前一个节点,可以有效避免羊群效应,因为每次只有一个客户端会被唤醒。

优点

  • 强一致性: ZooKeeper 通过 ZAB 协议保证了数据强一致性,解决了 Redis 单点模式下的可靠性问题。
  • 高可用性: ZooKeeper 集群本身具有高可用性,只要多数节点存活,服务就可用。
  • 天然防死锁: 临时节点特性保证了即使客户端崩溃,锁也会被自动释放。
  • 天然支持公平锁: 有序节点保证了锁的获取顺序与请求顺序一致。
  • 可重入性: 可以在客户端本地维护一个计数器,或者在 Znode 中存储持有者信息和重入次数,来支持可重入。

缺点

  • 性能相对较低: ZooKeeper 是 CP 系统(一致性高,可用性次之,分区容错性),其写操作性能不如 Redis 等 AP 系统。在高并发场景下,ZooKeeper 可能会成为瓶颈。
  • 实现相对复杂: 相较于 Redis 的简单命令,ZooKeeper 的客户端 API 和 Watcher 机制需要更复杂的逻辑来实现。
  • 网络开销: 频繁的节点创建、删除和监听操作会产生一定的网络开销。

代码示例 (基于 ZooKeeper Curator 框架的伪代码)

在实际开发中,我们通常会使用 Apache Curator 框架,它提供了 ZooKeeper 的高级封装,包括 InterProcessMutex (分布式可重入锁)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// Java 伪代码 (Apache Curator)
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;

import java.util.concurrent.TimeUnit;

public class ZookeeperLock {
private CuratorFramework client;
private InterProcessMutex lock;
private final String lockPath; // /locks/my_resource_lock

public ZookeeperLock(String zkConnectString, String lockPath) {
this.lockPath = lockPath;
client = CuratorFrameworkFactory.builder()
.connectString(zkConnectString)
.sessionTimeoutMs(60000)
.connectionTimeoutMs(15000)
.retryPolicy(new ExponentialBackoffRetry(1000, 3))
.build();
client.start();
lock = new InterProcessMutex(client, lockPath);
}

public boolean tryLock(long timeout, TimeUnit unit) {
try {
// 尝试获取锁,带超时
return lock.acquire(timeout, unit);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

public void lock() throws Exception {
// 阻塞式获取锁
lock.acquire();
}

public void releaseLock() {
try {
// 释放锁
lock.release();
} catch (Exception e) {
e.printStackTrace();
}
}

public void closeClient() {
if (client != null) {
client.close();
}
}
}

Curator 的 InterProcessMutex 内部已经封装了创建临时有序节点、判断最小节点、监听前一个节点以及重入性等复杂逻辑,大大简化了 ZooKeeper 分布式锁的开发。

分布式锁的挑战与进阶话题

分布式锁的实现并非简单,在实际应用中会遇到各种挑战。

锁的重入性(Reentrancy)

定义: 允许同一个客户端在持有锁的情况下,再次获取同一把锁,而不会造成死锁。

  • Redis 实现: 通常通过 Redis 的 Hash 数据结构来存储锁。键是 lock_key,值是一个 Hash,其中包含 field (客户端标识) 和 value (重入次数)。
    • 获取锁时,先判断 Hash 中是否存在当前客户端的标识。如果存在,则递增重入次数。如果不存在,则尝试 SET NX 获取锁,成功后设置重入次数为 1。
    • 释放锁时,递减重入次数。当重入次数为 0 时,才真正删除锁。
  • ZooKeeper 实现: 在创建临时有序节点时,可以同时在节点数据中写入客户端标识。当客户端再次请求锁时,检查当前持有锁的最小节点数据是否为自己。如果是,则增加本地的重入计数。

锁的公平性(Fairness)

定义: 按照请求锁的顺序来分配锁,先请求的客户端先获得锁。

  • ZooKeeper 天然支持: ZooKeeper 的临时有序节点机制保证了节点的创建顺序就是请求顺序,因此可以天然实现公平锁。
  • Redis 实现: Redis 本身没有内置的公平性机制。如果需要实现公平锁,可能需要结合 Redis 的阻塞队列(List)来实现一个 FIFO 的等待队列,但这种实现会引入额外的复杂性,且效率通常不如 ZooKeeper。

锁的容错性(Fault Tolerance)

客户端在持有锁期间崩溃是分布式系统中的常见问题。

  • 数据库: 可以通过设置锁的过期时间,并由定时任务清理过期锁。
  • Redis: 通过 PX 参数设置过期时间,结合锁续期机制。客户端崩溃后,锁会在过期时间到达后自动释放。
  • ZooKeeper: 临时节点特性完美解决了这个问题。客户端与 ZooKeeper 的会话断开后,临时节点会自动删除,锁随之释放。

误删锁问题(Lost Lock Problem)

前文已述,Redis 中常见的由于锁过期后被其他客户端获取,原客户端却误删新客户端锁的问题。解决方案是使用 Lua 脚本,原子性地判断 value 和删除 key

死锁问题(Deadlock Problem)

  • 客户端崩溃: 最常见的死锁原因。所有分布式锁实现都通过设置过期时间来解决。
  • 程序逻辑错误: 例如,程序没有在 finally 块中释放锁,导致锁无法释放。

良好的编程实践是:

1
2
3
4
5
6
7
8
9
10
11
12
try {
if (lock.tryLock(timeout, unit)) {
// 业务逻辑
} else {
// 未获取到锁
}
} finally {
// 确保锁在任何情况下都能被释放
if (lock != null && lock.isHeldByCurrentThread()) { // 检查是否由当前线程持有,特别是对于重入锁
lock.releaseLock();
}
}

时钟漂移问题(Clock Drift)

主要影响基于过期时间的锁(如 Redis)。如果集群中不同机器的系统时钟存在较大偏差,可能导致:

  • 锁的实际有效时间与预期不符。
  • Redlock 算法的安全性受到影响。

解决办法:

  • 使用 NTP 等服务同步系统时间。
  • 在设计时,对锁的过期时间保持保守,预留充足的容错时间。

CAP 定理与分布式锁

CAP 定理指出,在一个分布式系统中,无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三者。我们只能在其中选择两项。

  • ZooKeeper: 属于 CP 系统。它追求强一致性,在网络分区发生时,为了保证一致性,可能会牺牲部分可用性(例如,少数派节点无法提供服务)。
  • Redis: 通常被视为 AP 系统。它追求高可用性,在网络分区发生时,即使无法保证强一致性,也能继续提供服务。Redlock 试图在 AP 的基础上提供 C,但其健壮性受到质疑。
  • 数据库: 通常也追求强一致性,倾向于 CP。

在选择分布式锁方案时,需要根据业务对一致性和可用性的权衡来决定。

  • 如果业务对数据一致性要求极高,宁愿牺牲一点性能和可用性,ZooKeeper 是更好的选择。
  • 如果业务对性能和可用性要求极高,允许短暂的弱一致性,Redis 是更好的选择。

实践中的选择与最佳实践

没有一劳永逸的分布式锁方案,选择最合适的取决于你的具体需求。

如何选择?

  • 对性能要求极高,对一致性要求相对宽松(允许短暂不一致,通过业务层面补偿)的场景:
    • 推荐: 基于 Redis 的分布式锁。
    • 理由: Redis 性能卓越,可以应对高并发。误删锁问题通过 Lua 脚本解决,高可用性通过 Sentinel/Cluster + Redisson Watchdog 机制实现。Redlock 在某些场景下也可以考虑,但需充分理解其局限性。
  • 对数据一致性要求非常高,宁愿牺牲一定性能的场景:
    • 推荐: 基于 ZooKeeper 的分布式锁。
    • 理由: ZooKeeper 提供了强一致性保证,通过临时有序节点和 Watcher 机制,可以实现高可靠、防死锁、公平性、可重入的分布式锁。Curator 框架简化了开发。
  • 业务并发量不大,且已经使用了关系型数据库的简单场景:
    • 推荐: 基于数据库的分布式锁(例如唯一索引法)。
    • 理由: 实现简单,无需引入额外组件,利用现有数据库能力。但要时刻注意性能瓶颈。

最佳实践

  1. 设置合理的锁超时时间: 预估业务处理时间,并留出足够的缓冲,防止因业务处理时间过长而导致锁提前释放。
  2. 引入锁续期机制(Watchdog): 如果业务处理时间不确定或可能超过预设的锁超时时间,务必引入锁续期机制,保证锁在业务执行期间不被释放。
  3. 使用原子操作: 对于 Redis 而言,释放锁时务必使用 Lua 脚本保证“判断-删除”的原子性,避免误删。
  4. 异常处理和资源释放: 务必在 finally 块中释放锁,确保在程序出现异常时也能正确释放锁,防止死锁。
  5. 监控和告警: 监控锁的获取失败率、锁等待时间、死锁情况等指标,及时发现并处理问题。
  6. 幂等性设计: 即使分布式锁失效,或者在获取锁时发生网络抖动导致重复提交,也要确保业务操作的最终结果是一致的。例如,库存扣减操作应确保不会重复扣减。这是对分布式锁失效的终极防护。
  7. 避免热点锁: 尽量细化锁粒度,避免对大范围资源加锁。例如,不要对整个商品表加锁,而是对具体商品的库存加锁。
  8. 考虑业务锁与技术锁分离: 有时候,业务层面的并发控制可以通过乐观锁、版本号等方式实现,不一定都需要分布式锁。技术锁(如本文所讨论的)是解决更底层的互斥访问问题。

结论

分布式锁是构建健壮分布式系统的重要基石。我们今天深入探讨了基于数据库、Redis 和 ZooKeeper 的三种主流实现方式。每种方式都有其独特的优点和缺点,适用于不同的场景:

  • 数据库锁 简单易用,但性能受限于数据库,不适合高并发。
  • Redis 锁 性能卓越,但需要解决主从同步、误删锁、锁续期等复杂问题,Redlock 算法的健壮性也有争议。
  • ZooKeeper 锁 提供了强一致性和高可靠性,天然支持公平锁和防死锁,但在性能上略逊一筹,实现相对复杂(但有 Curator 等框架简化)。

在实际项目中,没有“银弹”式的解决方案,关键在于根据业务对一致性、可用性、性能的需求进行权衡。例如,对于需要强一致性且不追求极致并发的场景,ZooKeeper 是可靠的选择;对于高并发、允许短暂弱一致性的场景,Redis 则是性能优选。

分布式系统的复杂性决定了分布式锁的挑战性。理解其原理、优缺点以及常见的陷阱,并结合最佳实践进行设计和实现,才能构建出稳定可靠的分布式应用。

希望这篇博文能帮助您更深入地理解分布式锁的奥秘。如果您有任何疑问或见解,欢迎在评论区与我交流!

—— qmwneb946 敬上