1. 为什么需要分布式锁:从单机锁到分布式场景的痛
在单体应用时代,我们解决并发问题最常用的手段就是 JVM 内置锁,比如synchronized或者ReentrantLock。同一台机器上的多个线程可以竞争同一个锁对象,JVM 会帮我们做好线程调度和阻塞唤醒。这套机制在单机环境下非常可靠,性能也不错。但一旦应用拆成多个实例,部署在多台机器上,原先的锁就失效了:线程 A 在节点 1 上拿到了锁,线程 B 在节点 2 上根本感知不到这把锁的存在,两边同时进入临界区,数据就被写坏了。
这时候就需要一个“全局可见”的协调者,让所有节点都去同一个地方抢锁。这个全局协调者可以是数据库、缓存、或者分布式协调服务,对应的方案就是分布式锁。简单说,分布式锁就是让不同进程、不同机器上的代码,在访问共享资源时能够互斥执行的一种机制。
开发同学经常会遇到这类问题:库存扣减、订单状态流转、定时任务防止重复执行、缓存并发重建。这些问题如果不加锁,轻则数据错乱,重则线上事故。所以掌握分布式锁的几种实现方式,是后端进阶绕不开的基本功。下面我会从原理、代码、坑三个维度,把数据库锁、Redis 锁、ZooKeeper/etcd 锁这三种主流方案讲透,并且给出我自己的踩坑记录和选型建议。
2. 实现方式一:基于数据库的分布式锁
2.1 利用唯一约束实现互斥
数据库实现分布式锁最朴素的做法,就是建一张锁表,利用数据库唯一索引的特性来保证同一时刻只有一个节点能插入成功。
假设我们有一张distributed_lock表,结构如下:
CREATE TABLE `distributed_lock` ( `id` bigint NOT NULL AUTO_INCREMENT, `lock_key` varchar(64) NOT NULL COMMENT '锁的唯一标识', `owner` varchar(128) NOT NULL COMMENT '持有锁的节点标识', `expire_time` datetime DEFAULT NULL COMMENT '过期时间,可选', PRIMARY KEY (`id`), UNIQUE KEY `uk_lock_key` (`lock_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;加锁操作就是执行一条插入语句,锁的 key 就是业务资源的唯一标识,比如“order:123:pay”。插入成功,说明抢到了锁;插入失败,说明锁已被别人持有。
INSERT INTO distributed_lock (lock_key, owner, expire_time) VALUES ('order:123:pay', 'instance-node-1', DATE_ADD(NOW(), INTERVAL 30 SECOND));解锁操作就是删除对应记录:
DELETE FROM distributed_lock WHERE lock_key = 'order:123:pay' AND owner = 'instance-node-1';删除条件必须带上owner,防止自己把别人持有的锁给释放了。这个问题的典型场景:节点 A 拿到锁后卡顿很久,锁超时自动释放,节点 B 拿到锁开始干活,此时 A 恢复过来执行删除操作,如果不带owner条件,就会把 B 的锁删掉,造成两个节点同时进入临界区。带上 owner 之后,A 删除时发现 owner 不匹配,删除失败,就不会误删。
这种实现非常简单直观,很多小团队初期就是这么干的。但缺点也很明显:依赖数据库的可用性,数据库挂了锁就废了;没有线程阻塞等待机制,拿不到锁的业务只能自己写循环重试;锁没有自动续期能力,超时时间设置得不好就容易误删或死锁。
2.2 乐观锁版本号方案
除了插入唯一记录,还有一种常见做法是使用乐观锁。在业务表上加一个版本号字段,更新时检查版本号是否匹配。
比如库存表:
UPDATE stock SET count = count - 1, version = version + 1 WHERE id = 100 AND version = 5;如果UPDATE影响的行数为 1,说明版本匹配,更新成功,相当于获取了锁;如果为 0,说明版本已被别人改过,需要重试。
这种方案的优点是无需额外建表,业务更新和锁合二为一,性能比建表插入要高一点。缺点也很明显:它不是真正的“锁”,更像是一种乐观并发控制,适合冲突较少的场景,冲突多时重试成本很高。而且如果业务更新涉及多张表,乐观锁就很难覆盖。
2.3 数据库锁的优缺点与适用场景
数据库分布式锁最大的优势是“唾手可得”,不需要引入任何中间件,适合团队规模小、基础设施简单、并发量不高的场景。缺点是性能天花板低,每次加锁解锁都是一次数据库 IO,高并发下会成为瓶颈;另外数据库的锁表会一直占用连接,如果锁记录不清理,表会膨胀。
我见过一个实际案例,某公司定时任务用数据库锁防止重复执行,锁表只有几千条记录,平时没毛病。后来业务增长,任务调度频率从每秒 1 次涨到每秒 200 次,数据库连接池被反复插入和删除操作占满,导致核心业务查询变慢。后来他们切换到 Redis 锁,问题立刻消失。
所以我的建议是:数据库锁可以作为过渡方案,但不要作为长期依赖。如果你的系统已经接入了 Redis,或者未来一定会引入缓存,那直接上 Redis 锁会更省心。
3. 实现方式二:基于 Redis 的分布式锁
3.1 SET NX EX 的正确姿势
Redis 实现分布式锁的核心命令是SET key value NX EX seconds。NX表示只有 key 不存在时才设置成功,EX表示设置过期时间。这两个参数必须放在同一个命令里,不能分开执行。
SET order:123:pay 1 NX EX 30返回 OK 表示加锁成功,返回 nil 表示锁已被占用。解锁时为了安全,需要用 Lua 脚本原子地比较 value 再删除:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end这里的ARGV[1]是加锁时设置的唯一标识,可以是 UUID 或者“节点ID+线程ID”。为什么要用 Lua 脚本?因为get和del是两个操作,如果分两步走,中间可能被别的线程插一脚。举个例子:线程 A 的锁超时过期了,线程 B 加锁成功,此时 A 执行del直接把 B 的锁删了。用 Lua 脚本先比较后删除,可以保证这两个操作是原子的,避免误删。
3.2 锁续期与 Redisson 实现
很多人用 Redis 锁时只设置一个过期时间,比如 30 秒,然后就不管了。如果你的业务执行时间超过 30 秒,锁自动释放,其他线程就能进来,此时第一个线程还在写数据,两个线程同时操作共享资源,问题就出来了。
解决方案是“自动续期”,业内一般叫“看门狗”机制。以 Java 生态里最常用的 Redisson 为例,它的RLock默认会启动一个后台定时任务,每隔锁过期时间的 1/3 就会检查一次,如果锁还在持有中,就自动把过期时间重置。这样只要业务不结束,锁就不会因为超时而释放。
RLock lock = redissonClient.getLock("order:123:pay"); try { // 尝试加锁,最多等待 5 秒,锁自动过期时间 30 秒 boolean locked = lock.tryLock(5, 30, TimeUnit.SECONDS); if (locked) { // 业务逻辑 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这种“持有期间自动续期”的能力,是数据库锁不具备的。如果你不用 Redisson,自己手写续期逻辑也不是不行,但很容易出现边界问题。比如续期线程和业务线程的生命周期如何绑定?业务抛异常时续期线程何时停止?这些问题自己实现起来很繁琐,所以能用现成库就用现成库。
3.3 Redis 锁的坑:主从切换与 Redlock
Redis 单机部署的情况下,只要主节点不宕机,上面这套方案是够用的。但生产环境一般会做主从架构,主节点负责写,从节点负责读。问题是 Redis 的主从复制是异步的,存在一个时间窗口:主节点刚成功执行SET,还没来得及把数据同步到从节点,主节点就宕机了;此时从节点被提升为新主节点,但它没有这把锁的数据,其他线程就可以再次加锁成功,原来的锁就失效了。
针对这个问题,Redis 作者提出了 Redlock 算法:向多个独立部署的 Redis 节点同时加锁,只有超过半数节点加锁成功,才算真正拿到锁。这个算法在理论上有争议,但实践中还是有很多团队在用。Redisson 里也提供了RedissonRedLock的实现,用法和普通锁类似。
不过我要提醒你,Redlock 并不能保证绝对安全,尤其是在发生时钟跳跃或者 GC 停顿的时候。Martin Kleppmann 和 Salvatore Sanfilippo 之间的著名争论已经把这层窗户纸捅破了。所以对于要求极高一致性的场景,比如金融转账、库存扣减,我更推荐下面的 ZooKeeper/etcd 方案。如果场景允许丢失极少量锁的正确性,比如防重复点击、缓存重建,Redis 锁完全够用。
4. 实现方式三:基于 ZooKeeper/etcd 的分布式锁
4.1 临时顺序节点的原理
ZooKeeper 实现分布式锁的核心机制是临时顺序节点(Ephemeral Sequential Node)。临时节点的特点是:客户端会话断开后,节点会自动删除;顺序节点的特点是:同一路径下创建的节点,ZooKeeper 会自动追加一个单调递增的序号。
加锁过程是这样的:所有客户端在同一个锁路径下创建临时顺序节点,比如/locks/order-123-,每个客户端拿到一个带序号的节点,比如order-123-000000001。然后客户端获取该路径下的所有子节点,如果自己创建的节点序号最小,说明拿到了锁;如果不是最小,就监听序号比自己小的前一个节点,等它被删除后再尝试获取锁。
这种机制天然避免了“惊群效应”:每个客户端只监听前一个节点,而不是监听所有节点,释放锁时只有下一个节点会被唤醒,效率高很多。
4.2 Curator 实现与代码示例
Java 开发最常用的是 Curator 框架,它封装了 ZooKeeper 的分布式锁功能。核心代码非常简洁:
InterProcessMutex lock = new InterProcessMutex(client, "/locks/order-123-pay"); try { if (lock.acquire(5, TimeUnit.SECONDS)) { // 业务逻辑 } } finally { lock.release(); }这个InterProcessMutex是公平锁,多个请求会按照顺序依次获取锁。Curator 还提供了InterProcessReadWriteLock读写锁,适合读多写少的场景。
与 Redis 锁相比,ZooKeeper 锁最大的优势是无需设置过期时间,只要客户端会话不结束,锁就不会丢失。如果客户端进程挂了,ZooKeeper 检测到会话超时后会自动删除临时节点,锁自动释放。这里的“会话超时时间”需要在创建连接时配置,一般几秒到几十秒不等,取决于你的网络状况和业务要求。
4.3 etcd 的租约与 Watch 机制
如果用 etcd 实现分布式锁,思路与 ZooKeeper 类似,但实现方式更简洁。etcd 提供以下几个核心能力:
lease:租约,相当于一个带过期时间的会话。客户端创建租约后,只要不断续约,租约就不会失效。Watch:监听某个 key 的变化,当 key 被删除时会收到通知。- 事务:etcd 的事务可以完成“key 不存在时才创建”这种原子判断,等价于 Redis 的
SET NX。
加锁过程是:创建一个租约,然后通过事务创建一个 key/locks/order-123,key 绑定这个租约。如果事务执行成功,说明拿到了锁。客户端在持有锁期间要定期续约,相当于 Redisson 的看门狗。其他客户端通过Watch监听这个 key,如果 key 被删除了(锁被释放),就尝试重新加锁。
这里有一个关键点:etcd 的租约机制比 ZooKeeper 的会话更灵活,租约时长可以由业务自己控制,续约频率也可以调节。如果你的公司已经引入了 etcd(比如配合 K8s 使用),用它实现分布式锁是很自然的选择,而且 Go 语言项目中clientv3并发安全包用起来非常顺手。
5. 三种实现方式对比与选型建议
5.1 多维度对比表格
下面我整理了一份对比表格,方便你根据团队技术栈和业务场景做决策。
| 维度 | 数据库锁 | Redis 锁 | ZooKeeper/etcd 锁 |
|---|---|---|---|
| 依赖组件 | 关系型数据库 | Redis | ZooKeeper 或 etcd |
| 实现复杂度 | 低,建表 + SQL | 中,命令 + 脚本 | 中高,客户端多,原理复杂 |
| 性能 | 低,每次操作走磁盘 IO | 高,纯内存操作 | 中,ZooKeeper 与 etcd 都属于高可用服务,延迟比 Redis 高 |
| 自动续期 | 无,需要自己实现 | Redisson 自带看门狗,自己实现较复杂 | 会话/租约机制天然支持 |
| 脑裂问题 | 数据库主从切换也可能丢数据,但强一致协议下风险低 | 主从异步复制会失效,Redlock 可缓解但不绝对 | 强一致性协议(ZAB/Raft),锁的可靠性强 |
| 业务可扩展性 | 锁表会占用数据库连接,不适合高并发 | 适合高并发,Redis 本身是扛高并发的 | 适合要求高可靠性的场景,但 TPS 比不上 Redis |
| 典型适用场景 | 小团队、低并发定时任务 | 缓存防击穿、防重复触发、秒杀 | 分布式事务协调、跨服务强互斥 |
5.2 选型建议
我自己的经验是:没有最好的锁,只有最合适的锁。
如果你的系统并发量不大(每秒几百次以内),团队不想引入额外中间件,数据库锁完全能撑住。等真的出现瓶颈了,再平滑迁移到 Redis,这个时间窗口足够你理解业务和控制风险。
如果你的系统已经在用 Redis,那优先考虑 Redis 锁,理由很简单:运维成本低,性能好,Redisson 帮你搞定了续期和重试这些麻烦事。使用时要做好两件事:一是设置合理的过期时间,默认 30 秒不够就调大,比如 60 秒;二是保证所有核心操作都放在finally里释放锁。
如果你的系统涉及资金、订单状态流转这类强一致业务,或者已经依赖 ZooKeeper/etcd 做服务注册发现,那就用 ZooKeeper/etcd 锁。虽然性能不如 Redis,但可靠性更好,不会因为主从切换就让锁失效。
另一个值得强调的点是锁的“可重入性”。Redisson 的RLock本身是可重入的,Curator 的InterProcessMutex也是可重入的。数据库锁如果要实现可重入,需要在锁表里记录重入次数或者持有者线程信息,实现稍繁琐。可重入性很重要,特别是你的业务方法里面嵌套调用了另一个加锁方法时,如果锁不可重入,就会死锁。
6. 实战经验:我踩过的分布式锁的坑
6.1 锁超时设置太短导致的并发问题
我第一次用 Redis 锁时,把过期时间设置成了 5 秒,想着业务逻辑很快就能执行完。结果一次促销活动中,库存扣减后还需要同步调一下优惠券系统,接口偶尔要等 3 秒。5 秒的过期时间看着足够,但那个请求从加锁到释放,恰好因为 GC 停顿被卡了几秒,锁过期了,第二个请求进来了,两个线程同时扣库存,最后库存数变成负数,线上告警直接炸了。
教训就是:锁的过期时间一定要根据实际业务耗时留足余量,最好设置成正常耗时的 5 到 10 倍以上。比如你统计接口平均耗时 200ms,那就直接设置 3000ms 甚至 5000ms,不要抠抠搜搜。如果业务逻辑确实存在慢速场景,那就要靠看门狗机制自动续期,而不是固定死一个时间。
6.2 业务耗时超过锁过期时间的续期方案
后来我养成了习惯,凡是需要持有锁超过 1 秒的业务,一律使用 Redisson 的看门狗,而不是手动设置固定过期时间。Redisson 默认的锁超时时间是 30 秒,看门狗会在 10 秒(30 秒的三分之一)时检查一次,如果锁还持有,就把过期时间重置为 30 秒。这样做的好处是:只要线程不退出,锁永远不过期;线程一旦退出,锁立即释放。
但这里有个陷阱:如果你的业务代码里显式调用了tryLock(5, 30, TimeUnit.SECONDS)这个方法,Redisson 会认为你想使用自定义超时时间,自动关闭看门狗机制,锁在 30 秒后一定会释放。如果你希望看门狗生效,就必须使用tryLock(5, TimeUnit.SECONDS)这个不带 leaseTime 参数的重载方法。这个坑很多人都会踩,官方文档里写得很清楚,但实际写代码时总是手滑带上 leaseTime。我建议团队内部在代码评审时要着重检查这一点。
6.3 锁粒度与性能之间的平衡
分布式锁加得太粗,比如给整个订单表加一把锁,会导致所有订单操作串行化,TPS 直线下降。加得太细,比如每个订单字段一把锁,又会导致锁数量爆炸,性能开销大,还容易死锁。
我见过一个同学把锁键直接设置成方法名sync-stock,所有商品库存都共用一把锁,一次下单请求阻塞了其他所有商品的下单,这就是典型的碎片化并发问题。正确做法是:锁的粒度要尽量接近业务资源的自然粒度。比如扣减某个商品的库存,锁键应该是stock:{skuId},而不是stock全局锁;处理某个订单状态流转,锁键应该是order:{orderId},不同订单不会互斥。
另外要特别注意锁键的设计必须包含业务维度,防止跨环境互相影响。比如本地联调和测试环境共享同一个 Redis 集群时,锁键前缀要加上环境标识,比如dev:stock:100,否则一个环境的锁会堵住另一个环境。
6.4 ZooKeeper 和 etcd 锁的坑
ZooKeeper 锁一个容易忽略的问题是:如果客户端持有锁时间过长,超过了 ZooKeeper 会话超时时间,ZooKeeper 会误判客户端已宕机,自动删除临时节点,导致其他客户端拿到锁,而原客户端还在继续执行。所以使用 ZK 锁时,会话超时时间一定要大于业务最大执行时间,或者配合 Curator 的重试策略来保证业务完成后锁才释放。
etcd 的租约也有类似的问题。租约 TTL 设置太短,网络抖动导致续约失败,锁就被别的客户端抢走;设置太长,一旦持有锁的节点真正宕机,锁释放会延迟很久。一般生产经验是 TTL 设置 10 秒,续约周期 3 秒,网络波动 3 次以内都能扛过去。如果你的业务要求秒级故障感知,TTL 就设置 5 秒,续约周期 1 秒,但代价是 etcd 压力会增大。
6.5 锁的公平性真的重要吗?
有时性能和正确性之间还需要做一次权衡。ZooKeeper/etcd 的锁天然是公平的(按申请顺序排队),Redis 锁默认是非公平的,后到的线程也可能抢到锁。大部分业务场景对公平性没有要求,谁先拿到锁都无所谓。但有一种情况需要特别注意:如果你的业务里大量线程都在等待同一把锁,而且持有锁的时间较长,非公平锁会导致少数线程一直抢不到锁,出现“饿死”现象。
比如我维护过一个排队写入系统,多个线程抢同一把锁去写某个文件,有一个线程等了 5 分钟都没抢到锁,服务端日志大量超时告警。后来改成使用 Redisson 的FairLock(公平锁),线程按照到达顺序依次获得锁,饿死现象立刻消失。但公平锁也有代价,等待队列会增加 Redis 内存消耗,锁释放时通知下一个线程也会多几次网络开销。所以我的建议是:默认用普通锁,只有在你明确观察到“饿死”现象时才切换到公平锁,不要一刀切。
7. 一个可落地的选型决策清单
为了方便你面对不同业务场景时快速做决定,我再给一份简单的决策清单:
- 如果团队没有中间件基础,只有 MySQL,并发量不大,选数据库唯一索引锁。
- 如果团队已有 Redis,业务容忍极端情况下锁失效,选 Redis
SET NX EX+ Lua 解锁。 - 如果业务要求强一致性,比如扣减余额、发放优惠券、防止超卖,选 etcd 或 ZooKeeper 锁。
- 如果系统同时有 Redis 和 ZooKeeper,选 ZooKeeper/etcd 锁作为核心资金的保护,用 Redis 锁做非核心场景的限流。
- 如果方法嵌套要加锁,选可重入锁(Redisson 和 Curator 都支持)。
- 如果是读多写少场景,优先考虑读写锁(Redis 的
RedissonReadWriteLock或 ZK 的InterProcessReadWriteLock)。
这套清单我一直在团队内部推行,避免开发同学凭感觉选型,效果还不错。实际判断时,你只要抓住两个核心变量:一个是业务对并发安全的容忍度,另一个是你现有的基础设施。这两个变量想清楚了,选型就不会错。
我个人在实际操作中的一个体会是:分布式锁本质上不是一种“加锁”技术,而是一种“约定”。你在 Redis、数据库或者 ZooKeeper 里加的那个标记,只是因为所有参与方都承认它有效,它才有效。所以最重要的不是锁的实现方式,而是你的团队是否在同一套约定下通信。比如锁的 key 命名规则、超时时间、续期行为、释放逻辑,都应该在代码评审的时候作为固有限制来检查,否则再强的锁也守不住混乱的调用方。