随着业务的发展,单体应用逐渐演变成分布式系统。在分布式环境中,多个服务或进程可能同时访问同一个共享资源(例如数据库中的库存数量、缓存中的配置信息、文件等),这就带来了并发访问的问题。如果不加以控制,可能会出现数据不一致、资源抢占等严重后果,这就是所谓的“竞态条件”(Race Condition)。
在单体应用中,我们可以使用多线程锁(如 Java 中的 synchronized
或 Lock
)来解决并发问题。但这些锁只能在同一个进程内部工作。在分布式系统中,不同的进程运行在不同的机器上,单体锁无法跨越进程和机器的边界。这时,我们就需要一种能在分布式环境下工作的锁机制——分布式锁。
什么是分布式锁?
分布式锁是用于控制分布式系统不同进程之间同步访问共享资源的机制。它确保在任何时刻,只有一个进程能够获得锁并访问共享资源,从而避免竞态条件。
简单来说,分布式锁就像一个全局唯一的“门票”。任何想要访问共享资源的进程,必须先拿到这张门票。拿到门票的进程可以安全地访问资源,用完后归还门票,其他进程才能继续竞争。
为什么需要分布式锁?
考虑一个电商场景:用户购买商品,需要扣减库存。
在单体应用中,多个用户同时购买同一商品时,可以通过方法级别的锁来保证同一时刻只有一个线程能执行库存扣减逻辑。
// 单体应用中的伪代码
public synchronized void deductStock(int productId, int quantity) {
// 检查库存
// 扣减库存
// 生成订单
}
但在分布式系统中,处理用户请求的可能是部署在不同服务器上的多个服务实例。
用户 A 的请求到达服务器 A,用户 B 的请求到达服务器 B。两个请求几乎同时读取了数据库中商品的库存(假设库存为 1)。
- 服务器 A 读到库存 1,判断可以购买。
- 服务器 B 读到库存 1,判断可以购买。
- 服务器 A 执行扣减,库存变为 0。
- 服务器 B 执行扣减,库存变为 -1。
最终,库存出现了负数,一个商品被卖出了两次!这就是典型的竞态条件。
为了解决这个问题,我们需要一个分布式锁。当用户 A 的请求到达时,它先尝试获取针对该商品的分布式锁。如果获取成功,则执行库存扣减逻辑,完成后释放锁。用户 B 的请求到达时,发现锁已经被用户 A 持有,就会等待或获取失败。只有当用户 A 释放锁后,用户 B 才能尝试获取锁。
一个好的分布式锁应该具备哪些特性?
实现一个可靠的分布式锁并非易事,它需要满足以下几个关键特性:
- 互斥性(Mutual Exclusion): 在任何时刻,只有一个客户端能持有锁。
- 活性(Liveness):
- 避免死锁: 即使持有锁的客户端崩溃或网络中断,也能保证后续客户端能最终获得锁。这通常需要锁有超时机制。
- 容错性: 只要提供锁服务的集群大部分节点正常运行,客户端就能获取和释放锁。
- 一致性(Consistency): 锁的状态在分布式环境中是相对一致的,不会因为节点故障导致锁状态混乱。
实现分布式锁的常见方案
实现分布式锁有多种方案,各有优缺点,适用于不同的场景。以下是几种常见的实现方式:
1. 基于数据库
可以使用数据库的唯一约束或行锁来实现分布式锁。
方案一:基于唯一约束
创建一个锁表,包含一个唯一约束的字段(例如 resource_name
)。当一个客户端尝试获取锁时,就向表中插入一条记录,resource_name
为要锁定的资源标识。
CREATE TABLE distributed_locks (
resource_name VARCHAR(255) UNIQUE,
client_id VARCHAR(255), -- 记录持有锁的客户端信息
expire_time DATETIME, -- 锁过期时间
PRIMARY KEY (resource_name)
);
- 获取锁:
INSERT INTO distributed_locks (resource_name, client_id, expire_time) VALUES ('product:123', 'client_a', NOW() + INTERVAL 60 SECOND);
如果插入成功,表示获取锁成功;如果因为唯一约束冲突而失败,表示锁已被占用。 - 释放锁:
DELETE FROM distributed_locks WHERE resource_name = 'product:123' AND client_id = 'client_a';
释放锁时需要校验client_id
,避免误删。 - 处理死锁: 需要一个后台任务定期清理过期(
expire_time
< 当前时间)的锁记录。
方案二:基于行锁
在锁表中为每个资源预先创建一条记录。客户端获取锁时,使用 SELECT ... FOR UPDATE
语句。
-- 假设表中已有 resource_name = 'product:123' 的记录
SELECT * FROM distributed_locks WHERE resource_name = 'product:123' FOR UPDATE;
这条语句会尝试获取该行的排他锁。如果获取成功,表示获得分布式锁;如果该行已被其他事务锁定,则当前事务会阻塞直到锁释放。
- 获取锁: 开启事务,执行
SELECT ... FOR UPDATE
。 - 释放锁: 提交或回滚事务。
- 处理死锁: 依赖数据库的事务超时机制。
数据库方案的优缺点:
- 优点: 实现简单,依赖数据库本身的特性,易于理解。
- 缺点:
- 性能瓶颈:数据库的并发能力有限,高并发场景下可能成为瓶颈。
- 单点故障:数据库宕机则锁服务不可用(可以通过主备、集群解决,但增加了复杂度)。
- 非阻塞:
SELECT ... FOR UPDATE
是阻塞的,可能导致客户端长时间等待。 - 死锁处理:依赖数据库事务或手动清理,不够灵活。
2. 基于缓存(Redis)
Redis 因其高性能和原子操作特性,成为实现分布式锁的热门选择。
利用 Redis 的 SET
命令的 NX
(Not Exists) 和 EX
(Expire) 参数可以非常简洁地实现一个带过期时间的分布式锁。
SET resource_name unique_value NX EX timeout_seconds
resource_name
: 要锁定的资源标识 key。unique_value
: 一个客户端生成的唯一值,用于在释放锁时验证是否是自己持有的锁,防止误删。NX
: 只在 key 不存在时设置成功,用于实现互斥性。EX timeout_seconds
: 设置 key 的过期时间(秒),用于避免死锁。
获取锁:
执行 SET resource_name unique_value NX EX 60
。
如果命令返回 OK
,表示成功获取锁;如果返回 nil
,表示锁已被占用。
释放锁:
释放锁时,需要先判断当前持有的锁的 unique_value
是否与自己设置的一致,然后再删除 key。这两步操作必须是原子性的,以防止在判断后、删除前,锁因过期而被其他客户端获取,导致误删了别人的锁。可以使用 Lua 脚本来实现原子性操作。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
KEYS[1]
是锁的 key (resource_name
)。ARGV[1]
是客户端设置的unique_value
。
Redis 方案的优缺点:
- 优点: 性能高,实现相对简单。
- 缺点:
- 单点故障: 如果只使用单个 Redis 实例,该实例宕机则锁服务不可用。
- 主从复制延迟: 如果使用 Redis 主从复制,主节点获取锁成功后,在数据同步到从节点之前主节点宕机,从节点升级为主节点后,其他客户端可能也能获取到锁,导致多个客户端同时持有锁。
- 锁续期问题: 如果业务处理时间超过了锁的过期时间,锁会自动释放,其他客户端可能获取到锁,导致并发问题。需要实现锁续期机制(例如,使用一个守护线程定期检查锁是否快过期,如果业务还在执行则延长过期时间)。
- Redlock 算法: 为了解决单点和主从复制延迟问题,Redis 作者提出了 Redlock 算法,尝试在多个独立的 Redis Master 节点上获取锁。但 Redlock 算法的正确性和可靠性存在争议,实现也更复杂。
3. 基于协调服务(ZooKeeper, etcd)
ZooKeeper 和 etcd 是专门为分布式协调而设计的服务,它们提供了强一致性的数据存储和事件通知机制,非常适合实现分布式锁。
以 ZooKeeper 为例:
利用 ZooKeeper 的**临时顺序节点(Ephemeral Sequential Node)**特性。
- 定义锁路径: 在 ZooKeeper 中创建一个持久节点作为锁的根目录,例如
/locks/resource_name
。 - 获取锁: 客户端连接 ZooKeeper,在指定的锁目录下创建一个临时顺序节点,例如
/locks/resource_name/lock-
。ZooKeeper 会自动在后面加上一个递增的序列号,形成/locks/resource_name/lock-0000000001
、/locks/resource_name/lock-0000000002
等。 - 判断是否获得锁: 客户端获取
/locks/resource_name
下的所有子节点列表,并判断自己创建的节点是否是其中序号最小的节点。- 如果是序号最小的节点,表示成功获取锁。
- 如果不是序号最小的节点,则表示锁已被其他客户端持有。客户端需要找到比自己序号小的前一个节点,并对其注册一个监听器(Watcher)。
- 等待锁: 客户端进入等待状态,当监听的前一个节点被删除时(表示前一个客户端释放了锁或崩溃了),客户端会收到通知,然后重新执行步骤 3,判断自己是否成为序号最小的节点。
- 释放锁: 客户端执行完业务逻辑后,删除自己创建的临时顺序节点。由于是临时节点,如果客户端崩溃,连接断开,ZooKeeper 也会自动删除该节点,避免死锁。
ZooKeeper/etcd 方案的优缺点:
- 优点:
- 强一致性:基于 ZAB 协议(ZooKeeper)或 Raft 协议(etcd),保证了分布式环境下的数据一致性。
- 可靠性高:天然支持集群,具有较好的容错性。
- 避免死锁:临时节点特性保证客户端崩溃时锁能被释放。
- 公平性:顺序节点保证了客户端获取锁的顺序性(FIFO)。
- 缺点:
- 实现复杂度高:相对于 Redis 或数据库,需要理解 ZooKeeper/etcd 的概念和 API。
- 性能相对较低:获取锁需要多次与 ZooKeeper/etcd 集群交互(创建节点、获取子节点列表、设置 Watcher),吞吐量可能不如 Redis。
- 需要独立的 ZooKeeper/etcd 集群,增加了运维成本。
如何选择合适的分布式锁方案?
选择哪种分布式锁方案取决于你的具体需求和场景:
- 对一致性要求不高,追求高性能: 可以考虑基于 Redis 的方案,但需要处理好单点、主从延迟和锁续期问题。
- 对一致性要求高,愿意接受一定复杂度和性能开销: ZooKeeper 或 etcd 是更可靠的选择,它们提供了更强的分布式协调能力。
- 系统已经广泛使用关系型数据库,且并发量不是特别高: 基于 数据库 的方案实现简单,可以作为一种入门或低成本的选择。
在实际应用中,还需要考虑:
- 锁的粒度: 是锁定整个资源类型,还是锁定某个资源的具体实例?
- 是否需要可重入性: 同一个客户端是否可以多次获取同一个锁?
- 锁的超时时间如何设置: 需要根据业务处理时间合理估算。
- 如何进行锁的监控和报警。
总结
分布式锁是构建可靠分布式系统不可或缺的一部分,它帮助我们解决了共享资源的并发访问问题。不同的实现方案各有优劣,从简单易用的数据库和高性能的 Redis,到强一致性的 ZooKeeper/etcd,选择哪种方案需要权衡系统的需求、性能、可靠性以及运维成本。
理解各种方案的原理和潜在问题,并结合业务场景选择最合适的实现方式,是设计和开发分布式系统的关键技能之一。
希望这篇文章能帮助你对分布式锁有一个更清晰的认识!