人人都会AI编程

27.3 分布式锁的三种实现方案与选型

更新时间:2026-07-10

在单机环境中,synchronized 关键字或者 ReentrantLock 就能保证临界资源的互斥访问,但在分布式系统中,多个服务实例部署在不同的 JVM 甚至不同的物理机上,JVM 级别的锁便不再有效。此时就需要引入分布式锁来协调跨进程、跨主机的资源竞争。

一个合格的分布式锁通常需要满足以下条件:

  • 互斥性:任意时刻,同一把锁只能被一个客户端持有。
  • 可重入性:同一个客户端在持有锁的情况下可以再次获取该锁,不会自己把自己阻塞。
  • 锁超时:持有锁的客户端因宕机或异常未能主动释放时,锁能够被自动释放,避免死锁。
  • 高可用:锁服务本身不能成为单点故障,获取锁和释放锁的过程需要具备容错性。
  • 非阻塞式获取:应提供“尝试加锁”的能力,未能获取锁时可以立即返回或等待一段时间,而不是无限阻塞。

下面介绍目前业界主流的三种分布式锁实现方案,并分别分析其原理、优缺点和适用场景。

27.3.1 基于数据库实现的分布式锁

实现原理

利用关系型数据库的唯一约束行锁来实现互斥。最典型的做法是创建一张锁记录表:

CREATE TABLE distributed_lock (
    lock_name VARCHAR(128) PRIMARY KEY,  -- 锁名称
    holder    VARCHAR(128),              -- 持有者标识
    expire_at DATETIME,                  -- 过期时间
    version   INT                        -- 乐观锁版本号
);

加锁的时候,尝试向表中插入一条记录,利用主键冲突保证互斥:

INSERT INTO distributed_lock (lock_name, holder, expire_at)
VALUES ('order_lock', 'client-001', DATE_ADD(NOW(), INTERVAL 10 SECOND));

如果插入成功,表示获取锁成功;如果抛出主键重复异常,表示锁已被其他客户端持有。

释放锁时,通常由持有者删除对应的记录:

DELETE FROM distributed_lock WHERE lock_name = 'order_lock' AND holder = 'client-001';

为了防止客户端宕机导致锁永远无法释放,可以引入一个定时任务,定期清理已经过期的锁记录。

另一种基于数据库行锁的变体是使用 SELECT ... FOR UPDATE,它会锁定查询行,事务提交或回滚后自动释放。但这种方案必须将业务逻辑包裹在同一数据库事务中,容易造成锁范围扩大,较少用于纯粹的分布式锁场景。

优点

  • 无需额外组件:直接使用现有数据库,开发成本极低,引入依赖少。
  • 理解简单:依赖唯一键约束的原子性,逻辑直观。
  • 与业务数据天然贴近:有时候锁本身就是业务状态(如订单流转状态),使用数据库锁可以和业务数据保持强一致。

缺点

  • 性能瓶颈:数据库的读写性能远不及 Redis 等 NoSQL 存储,高并发场景下会造成大量数据库连接和锁冲突。
  • 可靠性依赖:需要自行实现锁的续期和过期清理,否则可能因客户端异常退出而导致死锁。
  • 单点故障:锁与业务数据库耦合,如果数据库主库出现问题,锁服务也会不可用,对主从架构下的延时敏感。

适用场景

  • 并发量不高、对性能要求较低的内部管理系统。
  • 极度追求架构简单、不想引入额外中间件的项目。
  • 锁与业务共享同一事务上下文,需要强一致性的少量场景。

27.3.2 基于 Redis 实现的分布式锁

实现原理

Redis 使用 单线程命令 + 原子操作 来实现分布式锁。核心是利用 SET key value NX PX milliseconds 命令:NX 保证仅在键不存在时设置,PX 设置过期时间。一次性完成加锁与过期设定,避免了两步操作的非原子性。

加锁命令示例:

SET order_lock unique_value NX PX 10000

如果返回 OK,说明加锁成功。unique_value 是与客户端绑定的唯一标识(如 UUID),用于安全释放锁。释放锁时需要校验该值,防止误删其他客户端的锁。通常使用 Lua 脚本保证判断与删除的原子性:

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

这种设计解决了锁超时、互斥及安全释放的问题。但在实际生产环境中,单机 Redis 无法保证高可用,一旦 Redis 主节点宕机,所有锁都会失效。为此,Redis 社区提出Redlock算法,尝试基于 N 个独立 Redis 实例构建高可用的分布式锁。

Redlock 算法简述

  1. 客户端获取当前时间戳(毫秒)。
  2. 依次向 N 个 Redis 实例尝试获取锁,设置相同的 key 和随机 value,并设置较短的超时时间(远小于锁的有效期)。
  3. 统计成功获取锁的实例数,如果超过半数(N/2+1),且获取锁的总耗时小于锁的有效期,则认为锁获取成功。锁的真实有效期为原有效期减去获取锁的耗时。
  4. 如果获取失败,向所有实例发送 Lua 脚本释放锁。
  5. 释放时也必须向所有实例发送释放命令。

优点

  • 性能极高:Redis 基于内存,单机可以支撑数万 QPS 的锁操作,是三种方案中性能最高的。
  • 生态成熟:Java 客户端如 Redisson 已经实现了完整的分布式锁和 Redlock,提供了 RLock 接口,支持可重入、自动续期(看门狗)、异步加锁等能力,开箱即用。
  • 过期自动释放:内置的键过期机制天然支持死锁预防。

使用 Redisson 的示例:

RLock lock = redissonClient.getLock("order_lock");
try {
    // 尝试加锁,最多等待10秒,锁有效期30秒(看门狗会自动续期)
    if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
        // 执行业务逻辑
    }
} finally {
    lock.unlock();
}

缺点

  • 安全性争议:Redlock 算法在极端情况下(如时钟跳跃、GC 暂停)仍有安全风险,理论上仍可能出现两个客户端同时持有锁。Martin Kleppmann 和 Redis 作者有过著名的论战。
  • 依赖 Redis 集群稳定性:对于核心场景,需要额外维护一套 Redis 集群,增加运维成本。
  • 不是强一致锁:本质上是最终一致性的模型,不适合需要绝对互斥的金融场景。

适用场景

  • 高并发、对性能要求较高,且可以容忍极低概率锁失效的场景,如限流、幂等性控制、缓存重建等。
  • 已经依赖 Redis 的微服务架构,可直接复用,减少中间件种类。

27.3.3 基于 ZooKeeper / etcd 实现的分布式锁

实现原理

ZooKeeper 和 etcd(通常用于 Kubernetes 环境)都是强一致的分布式协调系统,它们通过 临时顺序节点 实现分布式锁,原理非常相似。

以 ZooKeeper 为例,加锁流程如下:

  1. 客户端在某个约定路径下(如 /locks/order_lock)创建一个临时顺序节点,比如 /locks/order_lock/0000000001
  2. 获取该目录下的所有子节点,按序号排序。
  3. 判断自己创建的节点是否是序号最小的那个:如果是,则成功获取锁;如果不是,则对前一个节点(序号比自己小且最接近)注册 Watcher 监听
  4. 当监听到前一个节点被删除(即前一个客户端释放或宕机)时,重新进行步骤 2 判断。

释放锁时直接删除自己创建的临时节点即可。如果客户端宕机或会话超时,ZooKeeper 会自动删除对应的临时节点,从而触发下一个等待者获得锁。

Apache Curator 已经将这套逻辑封装成了 InterProcessMutex,使用起来非常简单:

InterProcessMutex lock = new InterProcessMutex(curatorClient, "/locks/order_lock");
try {
    if (lock.acquire(10, TimeUnit.SECONDS)) {
        // 执行业务逻辑
    }
} finally {
    lock.release();
}

etcd 的实现机制类似,使用租约(Lease)和事务来保证原子性与顺序性,Java 客户端 jetcd 也提供了 Lock 接口。

优点

  • 强一致性:基于 ZAB 或 Raft 协议,能保证任意时刻只有一个客户端持有锁,安全性优于 Redis。
  • 自动释放:临时节点与会话绑定,客户端宕机后锁自动释放,无需设置过期时间,也不必担心业务执行超时。
  • 支持阻塞等待:通过有序监听前驱节点,可以公平地等待锁,避免羊群效应(所有客户端同时竞争)。
  • 可重入支持:客户端可以记录自身持有的锁,在框架层面实现可重入。

缺点

  • 性能较低:ZooKeeper 的写操作需要经过 Leader 的选举和半数以上的确认,吞吐量远低于 Redis,不适合超高并发的锁竞争。
  • 运维成本高:需要独立部署和维护 ZooKeeper 集群,且对网络延迟敏感,客户端数量和 Session 数不能过多。
  • 重量级:与 Redis 相比,ZooKeeper 更像一个协调服务,单独为了锁引入一套 ZooKeeper 集群显得成本偏高。

适用场景

  • 对锁的准确性要求极高、不能容忍任何并发冲突的核心业务,如资金账户扣减、分布式任务调度。
  • 已经使用 ZooKeeper 做服务发现、配置管理的系统,可以复用集群实现分布式锁。
  • etcd 方案尤其适合跑在 Kubernetes 中的云原生应用,可作为轻量级的强一致锁服务。

27.3.4 三种方案对比与选型建议

| 维度 | 数据库锁 | Redis 锁 | ZooKeeper / etcd 锁 |
|------------|------------------------------|------------------------------|------------------------------|
| 一致性 | 强一致(依赖 DB 事务) | 最终一致(有低概率失效) | 强一致(ZAB / Raft) |
| 性能 | 低(千级 QPS) | 极高(万级 ~ 十万级 QPS) | 中(千级 QPS) |
| 自动释放 | 需自行实现清理或定时任务 | 过期时间自动释放 | 会话断开自动删除临时节点 |
| 假死风险 | 高(客户端宕机无法清理) | 可设置锁超时 | 低(心跳保活,连接断开即释放)|
| 运维成本 | 低(复用业务数据库) | 中(需独立 Redis 集群) | 高(独立 ZK/etcd 集群) |
| 客户端成熟度 | 需自行封装 | 高(Redisson、Jedis) | 高(Curator、jetcd) |
| 推荐场景 | 低并发、无额外中间件的小型系统 | 高并发、高吞吐的互联网应用 | 金融级精确性要求、已有协调服务的系统 |

选型决策树(你可以按这样的路径思考):

  1. 先看系统已有基础设施:如果已经部署了 Redis 集群,优先选用 Redis 分布式锁(Redisson),成本最低。如果已经有了 ZooKeeper 或 etcd,且对一致性要求高,直接复用它们即可。
  2. 评估性能与安全性的权衡:面对“秒杀”、“限流”等超高并发场景,Redis 是唯一能扛住吞吐量的选项;面对账户余额、库存扣减等涉及资金或核心资源的场景,宁可牺牲一些性能也需要选择 ZooKeeper / etcd 的强一致锁,或者配合数据库的乐观锁作为二次校验。
  3. 中小型系统“最小化依赖”:如果并发不高、机器数量少,且不想为分布式锁引入单独的中间件,数据库锁完全可以胜任。要做的就是确保清理过期锁的定时任务稳定运行,或者将锁与业务记录设计为状态机,尽量避免纯锁场景。
  4. 混合使用也并不罕见:某些系统会使用 Redis 作为快速准入锁,减少热点竞争,然后在真正执行关键操作时通过数据库乐观锁做最终保障,达到性能与可靠性的平衡。

最后需要提醒的是,无论选用哪种方案,都要为锁的持有者做好监控和告警。例如记录每次锁持有时间的分布,一旦出现长时间未能释放的异常锁,自动发出警报。一个好的分布式锁设计不仅在于“锁住”,更在于“可观测”。