☰
分布式锁实战对比:数据库、Redis、ZooKeeper三大方案选型与坑
2026/10/22 18:52:28 网站建设 项目流程

说到分布式锁,我第一反应就是面试八股文里必背的三板斧:数据库锁、Redis锁、ZooKeeper锁。但真正到了线上环境,你会发现理论和实践的差距比你想象中大得多——锁超时了业务还没跑完怎么办?主节点挂了锁丢了怎么处理?可重入到底怎么做?这些问题不看踩坑实录,光背概念是填不上的。

这篇文章我把自己在实际项目里折腾这三种分布式锁的经验都翻出来,不做纯理论复述,重点放在“为什么这样设计”“上线后会遇到什么坑”以及“给出一份能直接抄作业的最小实现”上。不管你是刚接触分布式系统的新手,还是已经在生产环境里被锁坑过的老手,这六千多字的内容应该都能让你有收获。

1. 为什么要分布式锁:场景与思路拆解

1.1 什么时候真的需要分布式锁

先聊一个很实际的问题:单机上的synchronized或者ReentrantLock不好用吗?为什么非要多搞一个分布式锁出来?

原因其实很朴素:当多个进程部署在不同的机器上,同时操作同一个共享资源时,JVM 级别的锁只能锁住自己进程内的线程,锁不住其他机器上的进程。比如两个服务节点同时收到用户下单请求,都要去扣减同一件商品的库存,如果没有一个全局互斥机制,库存就可能被扣成负数。

这种场景下,分布式锁的本质就是跨进程的互斥机制:让所有节点都遵守同一个约定,同一时刻只有一个节点能访问共享资源。

不过我得先泼一盆冷水:并不是所有场景都要上分布式锁。我见过不少项目,单体应用阶段就硬塞一个 Redis 分布式锁,徒增复杂度。如果你只有一个服务实例,或者业务对极端并发不敏感,先用好本地锁、数据库事务、乐观锁版本号,往往更简单可靠。只有当“多实例并发写同一资源”成为真实瓶颈时,分布式锁才值得登场。

1.2 分布式锁的三个核心要素与两条实现路线

不管用哪种方式实现,分布式锁都必须满足三个核心要素:

  • 互斥性:任一时刻,只有一个客户端持有锁。
  • 安全性:不会产生死锁。哪怕持锁方崩溃、网络异常,锁也能在有限时间内自动释放,让其他客户端有机会拿锁。
  • 幂等性/防误删:一个客户端只能释放自己持有的锁,不能删除别人的锁。

围绕这三个要素,所有实现方案本质上都逃不开两条路线:

  • 悲观锁路线:先拿锁,再操作资源,最后释放锁。数据库的SELECT FOR UPDATE、Redis 的SETNX、ZooKeeper 的临时顺序节点都属于这条路线。
  • 乐观锁路线:不提前锁资源,而是在提交更新时通过版本号或条件更新来判断冲突。比如数据库的UPDATE ... WHERE version = ?,或者 Redis 的Watch事务。

乐观锁适合读多写少、冲突概率低的场景;一旦写冲突激烈,乐观锁会导致大量重试,反而拖垮系统。所以生产上绝大多数分布式锁方案走的还是悲观锁路线,只不过载体各有优劣。

2. 三种实现方式逐个拆解

2.1 基于数据库:唯一索引与悲观锁的经典套路

数据库方案早期很流行,现在用的少了,但它是最容易理解的一种实现。理解它,对你理解后面两种方案有帮助。

2.1.1 利用唯一索引实现互斥

做法非常直接:建一张锁表,锁名称(lock_name)设为唯一索引。要获取锁时,向表里插入一条记录;插入成功,说明拿到锁;插入失败(唯一索引冲突),说明锁被其他客户端持有。释放锁时删除这条记录。

CREATE TABLE `distributed_lock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `lock_name` varchar(64) NOT NULL COMMENT '锁名称', `holder` varchar(64) NOT NULL COMMENT '持有者标识', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_lock_name` (`lock_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

获取锁:

INSERT INTO distributed_lock(lock_name, holder) VALUES ('order_20240601', 'instance-a-001');

释放锁:

DELETE FROM distributed_lock WHERE lock_name = 'order_20240601' AND holder = 'instance-a-001';

释放锁时为什么要带holder条件?因为要防误删:如果客户端 A 持锁后阻塞了很久,锁被某个兜底任务清理掉了,客户端 B 随后拿到了锁;此时 A 恢复了,直接删掉lock_name对应的记录,就会把 B 的锁也释放掉,造成互斥失效。带上holder,确保只能删除属于自己的那条记录。

这套方案最大的隐患是没有自动过期机制。客户端持锁期间崩溃了,那行记录永远删不掉,后续所有请求都会被卡死。常见补救办法是加一个expire_time字段,用定时任务扫描清理超时记录。但这也只是补救,没办法保证清理任务本身不出问题。

2.1.2 基于SELECT FOR UPDATE的悲观锁实现

另一种数据库方案是利用事务 + 行锁:

BEGIN; SELECT * FROM distributed_lock WHERE lock_name = 'order_20240601' FOR UPDATE; -- 业务操作 COMMIT;

FOR UPDATE会对命中的行加排他锁,事务提交或回滚时自动释放,天然解决“忘记释放锁”问题。但这个方案有几个明显痛点:

  • 锁释放依赖事务结束,如果业务逻辑耗时很长,数据库连接会一直被占用,连接池很容易被打满。
  • 如果lock_name不是索引字段,SELECT FOR UPDATE会锁全表,后果非常严重。
  • 存在死锁风险,尤其在多个锁交替获取的场景:A 持锁 1 等锁 2,B 持锁 2 等锁 1,InnoDB 虽然会检测并回滚一侧事务,但重试逻辑要自己写。

2.2 基于 Redis:SETNX 与过期时间的快与险

Redis 方案是当前生产环境的主流选择,因为它快、简单、社区方案成熟。但快归快,坑也最多。

2.2.1 最小可用版本:SET NX PX + Lua 释放

最经典的命令是:

SET lock_key unique_value NX PX 30000
  • NX:只有 key 不存在时才设置,保证互斥。
  • PX 30000:锁自动过期时间 30 秒,防止死锁。
  • unique_value:随机字符串(比如 UUID),用来标识锁的持有者,释放时校验。

释放锁时不能直接DEL,必须用 Lua 脚本保证“校验 + 删除”的原子性:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

如果不用 Lua,而是先GET再DEL,这两步之间存在时间窗口。假设 A 持锁后阻塞,锁过期了,B 拿到锁;此时 A 恢复执行DEL,B 的锁就被删掉了。这种事故一旦发生,并发安全立刻失效。Lua 脚本保证校验和删除在 Redis 服务端原子执行,就是为堵住这个窗口。

2.2.2 可重入锁与看门狗续期

上面只是最小版本,真正到生产环境还得处理两个问题:可重入和锁续期。

可重入的含义是:同一个线程在持锁期间再次获取同一把锁时,应该直接成功,而不是自己把自己锁死。Redis 的实现思路是把锁的值改成一个 hash 结构,key 是锁名,field 是线程标识,value 是重入计数。

# 伪代码:重入时计数加一 HLOCK lock_key thread_id count

业内比较成熟的做法是直接用 Redisson 提供的RLock接口。Redisson 内置了看门狗机制:默认锁超时 30 秒,如果业务线程还在执行,看门狗会每 10 秒自动续期一次,直到业务结束释放锁。这个设计很优雅,但有个隐蔽的问题——如果业务执行时间超过 30 秒,而客户端与 Redis 之间出现网络分区,看门狗续期失败,锁被自动释放,其他线程照样会进来。所以 Redis 锁本质上是“尽力而为”的互斥,做不到绝对安全。

2.2.3 主从架构下的锁丢失问题

还有一个重量级的坑:Redis 主从切换时,锁会丢。场景是这样的:客户端 A 在主节点写入锁,主节点还没来得及把数据同步给从节点就宕机了;哨兵把从节点提升为主节点,此时新主节点上没有这把锁;客户端 B 来拿锁,顺利拿到。两个客户端同时持锁,互斥彻底失效。

业界对此有 RedLock 算法,思路是在多个独立 Redis 节点上依次加锁,超过半数成功才视为加锁成功。但这个算法争议很大,连 Redis 官方都承认 RedLock 不是一个“银弹”。我的建议是:如果业务对一致性要求极高(比如真金白银的资金操作),优先考虑 ZooKeeper 方案,而不是靠调参让 Redis 变大;如果接受“极小概率下锁会失效”,Redis 单机 + 哨兵的高可用架构已经够用了,毕竟绝大多数业务并发场景并不需要绝对严格的互斥。

2.3 基于 ZooKeeper:临时顺序节点的稳与慢

ZooKeeper 方案用一个字总结就是“稳”,但它也不是没有代价。

2.3.1 临时顺序节点的加锁与释放原理

ZooKeeper 实现分布式锁依赖两个机制:

  • 临时节点:客户端会话结束(包括进程崩溃、网络断开导致的会话超时),临时节点会被服务端自动删除。这个特性天然解决“持锁者崩溃导致死锁”的问题。
  • 顺序节点:创建时自动追加序号,便于实现公平锁。

加锁流程是这样的:

  1. 在锁节点(比如/locks/order)下创建一个临时顺序节点,比如/locks/order/lock_0000000001。
  2. 判断自己是不是当前锁节点下序号最小的那个节点。
  3. 如果是,获得锁;如果不是,对序号最小的节点添加 Watch,等待它被删除。
  4. 持有锁的节点被删除后,ZooKeeper 会通知下一个等待者,后者再尝试获取锁。

这套机制的优点是公平 + 强一致:ZooKeeper 的 ZAB 协议保证了所有节点看到的数据是一致的,不会出现 Redis 主从切换丢锁的问题。而且通过只监听前一个节点而不是所有节点,还能避免“惊群效应”。

实际开发时一般不用手写这套流程,直接引入 Curator 框架的InterProcessMutex,它的语义已经比较完善:

CuratorFramework client = CuratorFrameworkFactory.newClient( "zk-host-01:2181,zk-host-02:2181,zk-host-03:2181", new ExponentialBackoffRetry(1000, 3)); client.start(); InterProcessMutex lock = new InterProcessMutex(client, "/locks/order_20240601"); if (lock.acquire(5, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.release(); } }
2.3.2 ZooKeeper 方案的性能和运维成本

ZooKeeper 方案最大的缺点就是慢。一次加锁通常要几次网络往返,QPS 大概在几千到几万这个量级,和 Redis 动辄十万以上的吞吐量没法比。TPS 要求高的场景,ZooKeeper 会成为瓶颈。

另外,ZooKeeper 本身是一个非常重的组件,需要独立集群部署和运维。很多中小团队没有专门的运维精力,用不好反而引入新的故障点:ZooKeeper 集群抖动、会话超时参数不合理,都可能导致锁异常。

还有一个很容易踩的坑:会话超时时间(session timeout)设置过短。如果客户端处理业务时发生一次长时间 GC,导致心跳没跟上,ZooKeeper 就会判定会话过期,把临时节点删掉,锁就提前释放了。与此同时,业务线程还在继续执行,另一个客户端进入临界区,互斥失效。这个问题不像 Redis 锁过期那样广为人知,但我实际排查过的几个“怪现象”都是它引起的。

3. 三种方式对比:性能、可靠性、落地难度的真实差异

3.1 维度对比速览

对比维度数据库唯一索引Redis SET NXZooKeeper 临时顺序节点
性能差,受数据库并发限制好,单实例 QPS 很高中等,网络往返多
可靠性中,无自动过期需额外兜底低到中,主从切换会丢锁高,ZAB 协议强一致
自动释放需定时任务兜底支持过期时间会话结束自动删除
可重入需额外设计支持(hash 计数或 Redisson)支持(Curator 封装)
客户端复杂度低中高
运维成本无额外依赖需运维 Redis需运维 ZooKeeper
公平性不保证不保证天然公平
适用场景低频、对一致性要求高且有 DB 依赖高吞吐、可容忍极小概率失效强一致、低并发、团队有运维能力

3.2 选型建议:按业务场景对号入座

把这张表放到实际业务里,我的选型原则是这样的:

  • 如果系统里已经有 Redis,业务又属于库存扣减、活动秒杀、接口幂等等高并发场景,选 Redis 锁。这类场景对“极端情况下的丢锁”容忍度较高,即使极小概率出问题,后果通常只是一次多余的扣减或一次重复请求,靠业务幂等兜底就能覆盖。
  • 如果业务涉及资金对账、任务调度、配置更新等强一致场景,宁可降低并发也要保证互斥,那就选 ZooKeeper 锁。典型例子是分布式定时任务,多节点同时执行会重复发券或重复对账,这种场景我用 ZooKeeper 锁心里更踏实。
  • 如果系统架构极简,不想引入额外中间件,且并发量不高,数据库锁可以当临时解决方案。不过我个人建议数据库锁只用在“初始化阶段”或“低频管理操作”上,长期运行的系统靠它撑并发基本是在给自己埋雷。

etcd 其实也是 ZooKeeper 的替代品,用 Raft 协议做分布式一致性,同样适合实现分布式锁,而且更轻量。如果你的团队已经在用 Kubernetes,运维 etcd 的成本比单独部署 ZooKeeper 低不少,这也是一个值得考虑的选项。

4. 操作过程中的坑与排查实录

这里整理我实际踩过、以及帮别人排查过的几个典型问题,每条背后都有真实线上教训。

4.1 Redis 锁:“锁提前过期”和“主从切换丢锁”

问题表现:业务在高峰期偶发重复执行,排查日志发现两个节点同时进入了临界区。

排查过程:

第一次遇到时,我第一反应是锁过期时间太短。查看监控后确实发现业务方法平均耗时接近锁过期时间——一旦某个节点 GC 停顿或网络抖动,业务耗时突破 30 秒,持锁方手里的锁先没了,其他节点自然就进来了。

解决方案:

把锁过期时间调长一些(比如 60 秒),再引入 Redisson 看门狗自动续期。注意:看门狗续期也解决了“持锁方还在工作却被强制解锁”的问题,但它的续期能力受制于客户端与 Redis 的心跳。如果网络分区持续较长时间,看门狗失败,锁照样会提前释放。所以更稳妥的做法是:业务逻辑里做幂等处理,不要单纯依赖锁。

第二次踩坑是在 Redis 主从切换后发生的。节点 A 在某主节点上加锁成功,主节点很快宕机,从节点晋升后没有该锁,节点 B 又加锁成功,导致两个节点同时执行。当时我用了 RedLock,但它的实现复杂度让我最终换掉了——不是 RedLock 不对,而是我们对一致性要求真的没那么高,引入五个节点互相加锁,运维复杂度和故障概率反而更大。最后做法很简单:接受极小概率失效,同时把业务关键路径改成“先查后写”的顺序 + 数据库唯一约束兜底,彻底消除了重复执行的实际危害。

4.2 数据库锁:“死锁”和“连接池打满”连环暴雷

问题表现:采用SELECT FOR UPDATE方案的服务在一次大促中连接池被打满,大量请求排队超时。

排查过程:

日志里一堆Lock wait timeout exceeded报错。点进去看,发现同事在事务里先锁了订单表,又去锁用户表;另一个接口顺序正好反了,形成了循环等待。InnoDB 虽然能检测死锁并回滚一个事务,但回滚前的等待时间已经把连接池占满了。

解决方案:

严格约定项目中所有需要加锁的代码,必须按照同一个顺序获取锁(比如先用户后订单)。同时给数据库表加上lock_name的唯一索引,把锁粒度从整表缩小到单条记录。加锁后的业务逻辑尽量精简到极致,事务内不做远程调用、不做耗时的 IO 操作。这种“约定 + 约束”的方式虽然老土,但非常有效。

4.3 ZooKeeper 锁:“会话超时”与“锁节点被误删”

问题表现:用小流量验证 ZooKeeper 锁时,发现某个节点在持锁期间,另一个节点也能拿到同一把锁。

排查过程:

查看 ZooKeeper 的会话日志,发现持锁节点的会话在某一个时刻突然 expired。进一步看客户端 GC 日志,长时间的停顿导致心跳包没有及时发出去,ZooKeeper 服务端判定会话超时,自动删除了临时节点。业务线程完全没有感知,继续执行到结束才释放锁。

解决方案:

把会话超时时间从默认的 10 秒调整到 30 秒,同时把心跳间隔调短,让 GC 停顿不易导致会话过期。还加了一层保护:在业务进入临界区后,通过本地标志位记录“当前线程是否仍然持有锁”,如果 ZooKeeper 通知会话过期,本地立刻感知并终止业务(或者至少做好降级),避免“锁没了但业务还在跑”的尴尬局面。

5. 一份可以直接参考的最小实现方案

理论知识再多,不如一份能立刻跑起来的最小实现有用。下面我给出三种方案各两份精简代码/配置,你可以直接复制到自己项目里改改。

5.1 Redis 锁:基于 Spring Data Redis 的极简封装

public class RedisLock { private final StringRedisTemplate redisTemplate; private final String lockKey; private final String lockValue; private final long expireMillis = 30000L; public RedisLock(StringRedisTemplate redisTemplate, String lockKey) { this.redisTemplate = redisTemplate; this.lockKey = lockKey; this.lockValue = UUID.randomUUID().toString(); } public boolean tryLock() { Boolean result = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofMillis(expireMillis)); return Boolean.TRUE.equals(result); } public void unlock() { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) " + "else return 0 end"; redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), List.of(lockKey), lockValue); } }

使用方式:

RedisLock lock = new RedisLock(redisTemplate, "order:pay:10001"); if (lock.tryLock()) { try { // 执行业务 } finally { lock.unlock(); } }

这段代码把核心逻辑都浓缩进去了:SET NX PX原子加锁,销毁时校验持有者再删除。生产环境建议直接用 Redisson 替代,它的可重入和看门狗机制已经经过大量验证。

5.2 数据库锁:唯一索引 + 定时清理

public class DbLock { private final JdbcTemplate jdbcTemplate; @Transactional public boolean tryLock(String lockName, String holder) { try { jdbcTemplate.update( "INSERT INTO distributed_lock(lock_name, holder) VALUES (?, ?)", lockName, holder); return true; } catch (DuplicateKeyException e) { return false; } } public void unlock(String lockName, String holder) { jdbcTemplate.update( "DELETE FROM distributed_lock WHERE lock_name = ? AND holder = ?", lockName, holder); } }

配套的清理脚本,用定时任务每 5 分钟执行一次:

DELETE FROM distributed_lock WHERE create_time < NOW() - INTERVAL 5 MINUTE;

注意:这里的清理是兜底,不是常规路径。设计锁表时一定要给create_time建索引,否则全表扫描清数据会把数据库拖垮。

5.3 ZooKeeper 锁:使用 Curator 的标准姿势

public class ZkLock { private final CuratorFramework client = CuratorFrameworkFactory .newClient("zk-host-01:2181,zk-host-02:2181", new ExponentialBackoffRetry(1000, 3)); public void executeWithLock(String lockPath, Runnable action) throws Exception { InterProcessMutex lock = new InterProcessMutex(client, lockPath); if (!lock.acquire(5, TimeUnit.SECONDS)) { throw new RuntimeException("获取分布式锁失败"); } try { action.run(); } finally { lock.release(); } } }

使用方式:

zkLock.executeWithLock("/locks/order_20240601", () -> { // 执行定时任务或核销操作 });

这套代码足够应付绝大多数 ZooKeeper 锁场景。比较耗时的业务建议把acquire超时时间调大,同时设置合理的会话超时参数。

6. 个人经验总结与最后提醒

做了一轮完整的对比和实操之后,我对分布式锁的心态已经从“追求完美方案”变成了“在约束条件下做取舍”。没有任何一种实现能同时满足高性能、强一致、零运维成本这三个诉求,你能做的只是针对自己的业务特点,选出那个“缺点最不致命”的方案。

如果让我给出最直接的建议,首推 Redis 锁搭配业务幂等兜底。它在绝大多数互联网业务场景里性价比最高,但请务必把锁的 key 设计得足够细。“锁用户”和“锁某个用户的某个订单”完全是两个级别,锁粒度越细,并发能力越强。

数据库锁可以作为无额外依赖的轻量方案,但别让它长期扛压;ZooKeeper 锁是强一致场景的保护伞,前提是你愿意承担它的运维和性能成本。

另外有个小技巧想分享给大家:无论用哪种锁,都建议在加锁成功的日志里带上锁名、持有者、业务唯一标识和耗时数据,并给锁的获取时长单独做一个监控。分布式锁相关的故障往往不是当场爆发,而是延迟到流量高峰才显现。有了充足的日志和监控曲线,你才能在两分钟内定位到问题,而不是在凌晨四点半对着Lock wait timeout发呆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询