分布式锁实现方案
什么是分布式锁
分布式锁是在分布式系统环境下,用于控制多个节点上的进程或服务对共享资源进行互斥访问的一种同步机制。
为什么需要它
在单机单进程时代,我们可以用编程语言自带的锁(如 Java 的 synchronized 或 ReentrantLock)来保证线程安全。但在分布式系统中,应用被部署在多台机器上,这些本地锁只对当前 JVM 进程有效,无法跨网络影响到其他机器上的服务。
典型场景:
- 避免重复处理:比如一个定时任务被部署了多个实例,在某一时刻只能有一个实例执行。
- 防止超卖:秒杀场景中,多个用户的请求被分发到不同的服务器节点,需要保证库存扣减的原子性。
- 保证数据一致性:对同一个共享数据进行读写操作时,需要防止并发导致的数据错乱。
分布式锁的核心特性
一个合格的分布式锁必须具备以下特性:
- 互斥性:这是最基本的要求。在任意时刻,只有一个客户端能持有锁。
- 安全性:锁只能由持有它的客户端释放,不能被其他客户端释放(包括意外释放)。
- 避免死锁:即使获取锁的客户端崩溃或者发生网络分区,锁最终也一定能被释放,从而保证其他客户端后续可以获取锁。这通常通过给锁设置一个过期时间(租约)来实现。
- 容错性:只要分布式锁服务的大部分节点还存活,客户端就能正常获取和释放锁。即锁服务需要具备高可用性。
常见的实现方案
分布式锁的实现方式主要分为三大类:基于数据库、基于缓存(如 Redis)和基于协调服务(如 ZooKeeper/Etcd)。
1. 基于数据库
a) 悲观锁:利用 SELECT ... FOR UPDATE
通过数据库的事务和行锁来实现。获取锁时,查询一条特定记录并加上排他锁(X Lock)。其他事务在执行相同操作时会被阻塞。
- 优点:实现简单,直接利用数据库现有功能。
- 缺点:
- 性能差,频繁加锁会大大增加数据库负担。
- 容易产生死锁。
- 对数据库的可用性要求高,数据库挂掉则整个系统崩溃。
- 非重入。
b) 乐观锁:利用版本号(Version)字段
在数据库表中增加一个 version 字段。更新数据时,同时检查版本号是否和之前读取的一致。
1 | UPDATE table SET stock = stock - 1, version = version + 1 WHERE product_id = 100 AND version = 5; |
- 优点:性能比悲观锁好,避免了大量阻塞。
- 缺点:
- 需要自己处理更新失败(版本号不一致)的情况,通常要重试。
- 不保证互斥性,它只是一种冲突检测机制,严格来说不算分布式锁。适用于冲突发生概率较低的场景。
总结:不推荐将数据库作为分布式锁的首选方案,因为性能瓶颈和可靠性问题非常突出。
2. 基于缓存(以 Redis 为代表)
这是目前最常用、性能最高的方案。通常使用 Redis 的 SET 命令及其参数来实现。
基础命令:
1 | SET lock_key unique_value NX PX 30000 |
NX:表示当lock_key不存在时才设置(Not eXists),即获取锁。PX 30000:设置键的过期时间为 30000 毫秒,这是为了避免死锁。unique_value:一个唯一标识(如 UUID),每个客户端生成自己的值。这个值必须唯一,用于保证只能释放自己加的锁,避免误删。
释放锁:通过 Lua 脚本保证原子性
释放锁时不能简单地 DEL key,需要先检查 unique_value 是否匹配,再删除。这个过程必须是原子的,所以用 Lua 脚本实现。
1 | if redis.call("get", KEYS[1]) == ARGV[1] then |
RedLock 算法
在单 Redis 主节点模式下,如果主节点宕机且数据未同步到从节点,可能导致锁丢失,出现多个客户端同时持有锁的情况。
为了解决这个问题,Redis 作者提出了 RedLock 算法,用于在多个独立的 Redis 主节点(不是主从集群,而是多个主节点)上实现分布式锁。其核心思想是少数服从多数。
- 客户端获取当前毫秒级时间戳。
- 依次向 N 个独立的 Redis 实例发送加锁命令(
SET NX PX)。 - 只有当客户端从超过半数(至少
N/2 + 1)的节点上成功获取锁,且总耗时小于锁的有效时间,才算加锁成功。 - 如果加锁失败,客户端会向所有 Redis 实例发送释放锁的脚本。
- 优点:安全性更高,解决了单点故障问题。
- 缺点:实现复杂,性能有所下降,需要部署多个独立 Redis 实例。对于一般业务场景,单 Redis 实例或主从哨兵模式配合合适的过期时间已经足够。只有在极度要求可靠性的金融场景才会考虑 RedLock。
3. 基于协调服务(以 ZooKeeper 为代表)
ZooKeeper 的临时顺序节点特性非常适合实现分布式锁。
实现原理:
- 获取锁:所有客户端在同一个父节点(如
/locks/my_lock)下创建临时顺序节点。 - 判断顺序:客户端获取父节点下的所有子节点,并判断自己创建的节点是否是序号最小的那个。
- 如果是,则获取锁成功。
- 如果不是,则对自己序号的前一个节点注册监听(Watcher)。
- 等待锁:当前一个节点被删除(即前一个客户端释放了锁)时,ZooKeeper 会通过 Watcher 通知当前客户端,它再次尝试判断自己是否是最小节点。
- 释放锁:客户端只需删除自己创建的那个临时节点即可。由于是临时节点,如果客户端宕机,会话失效后节点会自动被删除,从而自动释放锁,避免了死锁。
- 优点:
- 高可靠性:ZooKeeper 本身保证了强一致性和高可用性,锁模型非常健壮。
- 自动释放:临时节点的特性完美避免了死锁。
- 可排队(公平锁):顺序节点的特性天然实现了先来后到的公平锁。
- 缺点:
- 性能较低:由于需要动态创建、删除节点以及维持心跳,性能通常低于基于 Redis 的实现。
- 实现复杂度较高。
常用方案总结与选型建议
| 特性 | Redis | ZooKeeper/Etcd | 数据库 |
|---|---|---|---|
| 性能 | 最高 | 较低 | 低 |
| 可靠性 | 依赖配置(单点/RedLock) | 非常高(强一致性) | 一般(依赖DB) |
| 实现复杂度 | 简单(单点模式) | 较复杂 | 简单 |
| 锁特性 | 非公平锁(可通过队列实现公平) | 天然公平锁 | 非公平锁 |
| 额外功能 | 无 | 可监听状态变化 | 无 |
| 适用场景 | 高并发、对可靠性要求不是极致的场景 | 并发量不是极高,但对可靠性要求极高的场景(如金融) | 不推荐用于生产环境 |
如何选择
- 首选 Redis:如果你的系统并发量很大,并且可以接受极端情况下(如主从切换时)极短时间的锁失效(可能导致数据重复处理,但可通过其他幂等手段补救),那么基于 Redis 的实现是最佳选择,因为它性能卓越,实现简单。99% 的业务场景都适用。
- 选择 ZooKeeper/Etcd:如果你的系统并发量不是关键瓶颈,但要求绝对可靠,不允许一丁点锁失效的风险(例如与资金安全相关的核心系统),那么应该选择基于 ZooKeeper 或 Etcd 的实现。
- 避免使用数据库:除非是非常简单的场景,或者公司技术栈限制,否则不建议使用数据库实现分布式锁。
结论:
目前业界最常用、最主流的方案是 基于 Redis 的分布式锁(SET NX PX + Lua 脚本释放)。它在性能、实现复杂度和可靠性之间取得了最佳的平衡。ZooKeeper 则在需要强一致性的特定领域发挥着不可替代的作用。


