我们在做高并发系统时,迟早会碰到“分布式锁”这道坎。单机时代一个synchronized就够了,但应用拆成多个实例部署之后,同一个请求可能落到不同机器上,JVM 自带的锁根本管不住跨进程的互斥。这个标题下面藏着的,其实是三件事:锁要解决什么问题、有哪些主流方案、以及线上真正跑起来后哪些细节会坑到你。
这篇文章我把自己这些年做高并发分布式锁的选型思路、实现方案对比、以及踩过的线上问题都整理出来。无论你是刚开始接触分布式锁,还是已经在某个方案上吃过亏想换个思路,这篇内容都能给你一个比较完整的决策框架。我会直接用从业者的口吻讲,不绕弯子,把关键细节摊开说。
1. 高并发分布式锁的本质:到底要解决什么问题
1.1 单机锁失效的那一刻
先看一个最常见的问题场景:你有一个定时任务,每天凌晨要去扫表、跑批、刷新缓存。单机部署的时候,一个synchronized或者ReentrantLock就能保证同一时刻只有一个线程在执行。但系统一上微服务,实例从 1 台变成 3 台,同样的定时任务在三台机器上同时触发,这时候问题就来了——三个进程同时在跑同一个批处理任务,数据被重复处理,缓存被重复刷新,严重一点还会把不正确的数据写进数据库。
这就是分布式锁存在的根本原因:在分布式环境下,让多个进程之间对同一个共享资源的访问做到互斥。它的本质和单机锁一样,只是作用域从“进程内”扩大到了“跨进程”。
还有一类典型场景是秒杀、抢购、库存扣减。用户请求经过负载均衡分发到不同实例,如果不用分布式锁把库存操作串行化,就会出现超卖。这里有个容易混淆的点:很多人会把分布式锁和数据库事务、乐观锁混在一起用,但其实它们解决的是不同层面的问题。事务保证的是数据一致性,乐观锁保证的是更新不丢失,而分布式锁保证的是“同一时刻只能有一个执行者”这个互斥语义。
1.2 一把合格的分布式锁需要满足哪些条件
在对比方案之前,先建立一个评价基准。我在选型时基本会从六个维度去衡量一个分布式锁方案:
互斥性:任意时刻,只能有一个客户端持有锁。这条是分布式锁的底线,做不到就不叫锁。
安全性:锁不能被误删。比如 A 持有锁,结果 B 把 A 的锁删了,那互斥性就瞬间被破坏。这个问题在 Redis 方案里特别常见,后面我会详细讲。
死锁防护:客户端崩溃或者网络分区后,锁必须能被自动释放,不能死等。也就是说锁必须有“过期时间”或者“会话绑定”机制。
容错性:锁服务本身不能是单点。如果实现锁的组件挂了,不能导致所有业务全挂。
性能:获取锁和释放锁的开销要足够低,不能成为系统吞吐量的瓶颈。
可重入性:同一个线程能否重复获取同一把锁。这个要看具体业务需求,不是所有场景都需要。
这六个维度在不同的技术方案上有各自的取舍。不存在“什么都好”的方案,只有“最适合当前场景”的方案。后面的方案对比,其实就是围绕这几个维度展开的。
2. 四种主流实现方案:数据库、Redis、ZooKeeper、etcd
2.1 数据库方案:老派但依然有生存空间
数据库分布式锁是最容易想到的实现方式,因为它不需要额外引入中间件,直接用现有数据库的能力就能做。实现上分两种:悲观锁和乐观锁。
悲观锁的做法是利用数据库的行级锁,典型语句是:
SELECT * FROM t_lock WHERE resource_id = 'order_123' FOR UPDATE;这条语句执行后,其他事务再要对同一行做FOR UPDATE就会阻塞,直到当前事务提交或回滚。锁的释放依赖事务结束,这天然满足了“崩溃自动释放”的要求——只要事务超时回滚,锁就没了。
乐观锁的做法则是用版本号字段:
UPDATE t_lock SET version = version + 1 WHERE resource_id = 'order_123' AND version = 1;通过“受影响行数为 1”来判断是否抢锁成功。这种方式没有阻塞,严格来说更像是一种 CAS 操作,而不是传统的互斥锁。
数据库方案的优点非常明显:简单、可靠、无额外组件。但也有几个突出的硬伤。第一是性能瓶颈,数据库的并发处理能力远低于缓存型中间件,在 TPS 上万的高并发场景下很快会成为系统瓶颈。第二是单点风险,虽然数据库本身有主从和集群,但在锁语义下,主从切换时锁状态的一致性很难保证。第三是死锁和阻塞管理,悲观锁长时间持有时,其他请求排队等待,数据库连接池很容易被打满。
我的建议是,数据库方案适合低并发、逻辑简单的内部系统或者管理后台。高并发核心链路,尽量不要碰它。
2.2 Redis 方案:高并发场景的事实标准
Redis 分布式锁是目前互联网行业应用最广泛的方案。核心原因就两个字:快。Redis 的纯内存操作,单线程模型,加上简单的命令语义,让它在高并发场景下能保持极高的吞吐和极低的延迟。
最基础的实现是两条命令配合使用:
SET lock_key unique_value NX PX 30000其中NX表示只有 key 不存在时才设置成功,PX 30000表示 30 秒后自动过期。释放锁的时候,不是简单DEL,而是要先用 Lua 脚本比对 value 是不是自己的,防止删掉别人的锁:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end这个最简单的版本,在高并发下存在几个问题。一是锁的过期时间难设定——设短了,业务没执行完锁就自动过期了;设长了,客户端崩溃后锁要很久才能被新请求获取。二是主从切换时有锁丢失风险,Redis 主节点刚写入锁还没来得及同步到从节点就挂了,哨兵把从节点提升为主节点,新主节点上没有这把锁,别人就能再次获取同一把锁。
为了解决这些痛点,业界出现了 Redisson 这类客户端库,用“看门狗”机制做自动续期,把锁的有效期动态延长。后面第四章我会专门展开讲实现原理。至于 Redlock 算法,它在理论上追求多节点层面的互斥,但实际工程中争议很大,我自己的实践是不太推荐,后面也会细说。
2.3 ZooKeeper 方案:强一致性的牺牲品?
ZooKeeper 分布式锁基于它的临时顺序节点(EPHEMERAL_SEQUENTIAL)机制。原理是这样的:所有客户端在同一个锁目录下创建临时顺序节点,编号最小的那个节点持有锁;其他客户端监听自己前一个节点的删除事件,当前一个节点被删除时,后一个节点就去检查自己是不是编号最小的,如果是就获得锁。
这里有几个关键技术点值得展开:
临时节点意味着客户端会话结束或者断连之后,节点会被自动删除,锁自动释放,这是天然的死锁防护。
顺序节点保证了公平性,先来先得,不会有“饿死”现象。
监听机制让客户端在锁释放时能够被及时唤醒,不需要轮询。
同一条路径下的节点,创建时由 ZK 保证全局唯一且有序。这套机制的可靠性非常高,因为 ZK 的 Zab 协议保证了集群节点之间的数据强一致性,只要客户端会话活着,锁就不会丢。
但 ZK 方案的代价是性能。创建节点、删除节点、客户端监听事件,每一步都有 ZK 集群内部的同步开销,P99 延迟通常比 Redis 方案高一个数量级。另外,ZK 的客户端会话超时有一个 bug 级别的风险点:如果客户端发生长时间 GC 停顿,会话超时,临时节点被删除,锁被释放,但业务线程其实还没执行完。虽然有这个风险,但在对一致性要求极高的场景下,比如分布式事务协调、配置变更、领导选举,ZK 方案依然是值得信赖的选择。
2.4 etcd 方案:云原生时代的后起之秀
etcd 是 Kubernetes 的底层存储,它的分布式锁实现思路和 ZooKeeper 非常相似,都是基于强一致性的分布式协调服务。etcd 提供的锁能力基于三个核心机制:
Lease(租约):获取锁时创建一个租约,租约有过期时间。如果持有者异常退出,不会续租,租约到期后锁自动释放。这和 Redis 的过期时间思路一致。
Revision(修订版本):所有操作都会产生一个全局递增的 revision 号,客户端可以用它做公平排队。
Watch(监听):客户端监听 key 的变化,在锁释放时第一时间感知,减少无效轮询。
etcd 的锁实现和 ZK 类似,要求客户端在持有锁期间持续续租,否则锁会被自动释放。它比 ZK 更云原生、更轻量,如果你的基础设施已经跑在 Kubernetes 上,etcd 通常是最顺理成章的选择。不过它的问题也和 ZK 一样:吞吐量不如 Redis,且需要独立的 etcd 集群来保证可用性。
2.5 方案横向对比速查表
| 维度 | 数据库 | Redis | ZooKeeper | etcd |
|---|---|---|---|---|
| 一致性模型 | 取决于数据库隔离级别 | AP,主从切换有锁丢失风险 | CP,强一致 | CP,强一致 |
| 性能 | 低 | 极高 | 中等 | 中等 |
| 死锁防护 | 事务超时 | 过期时间/看门狗 | 临时节点+会话超时 | 租约+续租 |
| 锁丢失风险 | 主从切换有风险 | 主从切换有风险 | 低 | 低 |
| 公平性 | 不支持 | 原生不支持,可用方案模拟 | 顺序节点天然公平 | Revision 自然有序 |
| 运维成本 | 无额外成本 | 低 | 高,需要维护 ZK 集群 | 中,K8s 生态下较友好 |
| 典型场景 | 内部系统、低并发 | 互联网高并发核心链路 | 金融级强一致场景 | 云原生环境 |
这张表只是帮你快速建立一个整体印象,真正的选型还要结合业务特点,我在第三章给出具体的决策方法。
3. 选型决策框架:复杂场景下怎么选才不后悔
3.1 先看业务对一致性的容忍度
我在很多技术讨论里看到大家一上来就争论 Redis 方案和 ZK 方案谁优谁劣,这种讨论其实很低效。选型的出发点不是技术先进性,而是业务对一致性的容忍度。
先问自己一个问题:如果这把锁“失效”了,两个人同时拿到了锁,最坏的结果是什么?
如果最坏结果是多扣一次库存、多送一张优惠券、产生了资损,那这个场景对一致性要求极高,建议直接在 ZK 或 etcd 里选。
如果最坏结果只是重复推送一条消息、重复执行一次非关键任务,那 Redis 方案的成本优势和性能优势就非常突出。
很多业务的现实是:对一致性的真实要求并没有想象中那么高。做活动、抢红包、秒杀,稍微超卖一点用事后对账可以修复,这时候用强一致方案反而提高了成本、牺牲了性能。
3.2 我自己的选型四步法
这个选型框架我在团队内部推过很多次,效果还可以,分享出来供大家参考:
第一步,评估业务并发量。如果峰值 TPS 不超过一千,数据库方案完全够用,不用过度设计。如果峰值 TPS 到万级别,Redis 方案是首选。只有当单把锁的争抢频率极高,并且需要公平性时,才需要考虑 ZK/etcd。
第二步,评估锁失效的后果。用上面说的“最坏结果分析”方法,给业务场景做一个容忍度分级。容忍度低的直接排除 Redis,容忍度高的可以放心用。
第三步,评估现有的基础设施。如果团队已经运维着 Redis Cluster,使用 Redis 方案几乎零成本。如果基础设施已经全面云原生、K8s 化,etcd 是自然选择。不要为了分布式锁去专门引入一套 ZK,除非确实需要它的强一致能力。
第四步,评估团队的技术熟悉度。分布式锁实现不难,难的是出了问题后的排查。团队对哪个中间件最熟悉,出问题时能最快的定位,这也是一个不可忽视的因素。
3.3 分场景推荐清单
- 互联网高并发秒杀、库存扣减:首选 Redis + Redisson。性能高,使用简单,看门狗机制能解决大部分过期问题。对极端一致性的担忧,可以通过后续业务补偿去兜底。
- 分布式任务调度、定时任务防重:首选 Redis,方案同上。任务场景通常可以容忍锁失效后的重复执行,再配合任务表的业务幂等即可。
- 分布式事务协调、领导选举:首选 ZK 或 etcd。这类场景对一致性零容忍,且本身并发量不高,强一致方案的性能劣势可以忽略。
- 分布式配置管理、服务注册发现:首选 etcd/ZK。这不是单纯的锁问题,而是协调服务的主场。
- 内部管理系统、低并发后台:数据库方案。简单可靠,不需要额外组件,出了问题所有人都看得懂。
4. 核心实现细节剖析:好的代码和能跑的代码是两回事
4.1 Redis 锁的原子性到底怎么保证
先回到 Redis 最基础的锁实现。很多初学者会写两条独立的命令来加锁:
SET lock_key value NX EXPIRE lock_key 30000看起来没问题,但实际上这两条命令不是原子的。如果第一条执行成功、第二条因为网络抖动或者 Redis 崩溃没有执行,这个 key 就成了永不过期的死锁。正确做法是使用 Redis 2.6.12 之后引入的单命令原子写法,把SET和过期时间绑在一起:
SET lock_key unique_value NX PX 30000释放锁的时候也面临同样的问题。最忌讳的写法是:
if (redis.get("lock_key") == unique_value) { redis.del("lock_key"); }这是一个经典的先检查后操作的竞态。虽然检查通过了,但下一个瞬间锁过期了,另一个线程 B 抢到了同一把锁并写入了新的 value,然后线程 A 执行DEL,就把 B 的锁误删了。解决方式是用 Lua 脚本把“检查 value 是否一致”和“删除 key”合并成一个原子操作。为什么 Lua 脚本在 Redis 里是原子的?因为 Redis 是单线程执行命令的,Lua 脚本在执行期间不会被其他命令插入,天然具备原子性。
4.2 Redisson 看门狗机制:续期背后的原理
前面提到锁过期时间不好设,设短了业务没跑完锁就自动释放,设长了客户端崩溃后锁要很长时间才能被其他请求获取。Redisson 的解决方案是看门狗机制。
Redisson 获取锁时,默认锁的有效期是 30 秒(lockWatchdogTimeout参数可配置)。但拿到锁之后,它并不是不管了,而是启动一个后台定时任务,每 10 秒(即 30 秒的三分之一)检查一次锁是否还被当前线程持有,如果还在,就通过 Lua 脚本把锁的过期时间重置为 30 秒。
这个机制的关键在于:锁的有效期是动态续期的,只要业务线程还在执行,锁就不会过期。但要注意,看门狗只在当前客户端进程内运行,它默认使用的是 JVM 层面起的守护线程,不依赖 Redis 服务端。如果客户端进程宕机,守护线程随之消失,锁在最长 30 秒后自动过期,不会造成永久死锁。
看门狗这个设计里有几个隐藏的坑:
业务代码必须通过 Redisson 提供的 API 来调用锁,如果自己手写SET NX PX加锁,Redisson 的续期逻辑不生效,锁还是会被固定过期时间杀掉。
看门狗对长时间持有锁的业务无效。如果业务需要持锁超过几十分钟甚至几小时,靠续期维持是不现实的,需要业务侧自己评估是否有必要长期持锁。
超时会导致看门狗失效。如果用了tryLock(long waitTime, TimeUnit unit)这种带等待时间的重载方法,Redisson 会使用传入的超时时间作为锁的固定有效期,不再启动看门狗。
我在项目里常规做法是:如果业务能预估执行时间,就传入合理的超时时间,不使用看门狗;如果业务执行时间不可控,用不带超时参数的lock()方法,让看门狗做动态续期。前提是,业务本身要有兜底机制,即使锁丢了也不会造成灾难性后果。
4.3 ZooKeeper 锁的完整实现逻辑
ZK 锁的核心代码逻辑,我习惯这样写:
- 在锁根节点下创建临时顺序节点,比如
/locks/order_123/lock_。 - 获取
/locks/order_123下的所有子节点,排序后找到自己创建的节点。 - 如果自己的节点是序号最小的,说明获取锁成功。
- 如果自己不是最小序号,监听前一个节点的
delete事件,一旦前一个节点删除,重新执行判断。
这里有个非常重要的实现细节:监听的是前一个节点,而不是根节点或所有子节点。这样做有两个目的。一是避免“惊群效应”(herd effect),也就是锁释放时所有等待者都被唤醒,然后互相竞争。只监听前一个节点,锁释放时只会唤醒一个等待者,唤醒开销降至最低。二是保证公平性,编号次序就是抢锁次序,不会出现插队。
ZK 锁能防死锁的关键是临时节点。客户端与 ZK 服务端之间的会话是长连接,如果客户端崩溃或网络断开,会话超时后 ZK 会自动删除所有以该会话创建的临时节点,这就实现了“锁随会话死亡而释放”。
但与 Redis 相比,ZK 锁有另一个角度的陷阱:会话超时时间的设置。如果超时时间设得太短,客户端 GC 停顿这类的短暂不可用会被误判为会话失效,锁被提前释放;如果设得太长,客户端真的崩溃后,其他客户端要等待很久才能获取锁。实践中建议结合业务的 GC 停顿监控数据来设置,通常设置在 5 到 10 秒之间,并且要确保应用容器的 GC Pause 时间远小于这个值。
4.4 可重入锁怎么实现
单机环境里的synchronized天然支持可重入,同一个线程可以多次获取同一把锁。但分布式锁默认是不支持可重入的,因为服务端并不知道多次加锁的是不是同一个客户端。
Redis 方案实现可重入有两种思路。一是用 Redisson,它默认基于 Hash 数据结构实现可重入,结构是hash : { threadId : count },每次重入 count 加一,每次释放 count 减一,减到零才删除 key。二是自己实现,在业务里维护一个本地 ThreadLocal 记录当前线程的锁次数,加锁时如果发现已有锁是自己持有的就直接放行。
ZK 方案的可重入实现麻烦一些,因为 ZK 的节点是持久化的路径,没有“持有者计数”的概念。我的做法是在锁被获取时,把客户端标识(比如host:threadId)写入节点数据(data),再次加锁时先检查节点数据是不是自己,是则重入,否则等待。但要小心,ZK 节点数据是有大小限制的,默认 1MB,这个量级对存储线程标识来说绰绰有余。
其实我建议高并发场景优先考虑 Redisson 的可重入实现,成熟稳定,不用重复造轮子。自己实现可重入时,最容易出 bug 的地方是锁计数没有正确清理,导致锁一直无法释放,形成死锁,排查起来相当痛苦。
5. 生产环境高频踩坑与排查实录
5.1 锁过期了,业务却没执行完
这是 Redis 方案上线后最常遇到的线上问题。表现是:业务突然出现并发冲突,明明加了锁,数据还是被重复处理了。排查后大概率是锁过期时间设置得太短,业务执行时间超过了锁的有效期。
我遇到过的一个具体案例:一个库存同步任务,正常执行时间在 2 秒以内,就设置了 10 秒的超时时间。结果某天数据库慢查询,业务执行了 15 秒。第 10 秒锁自动过期,另一个实例马上拿锁执行同一任务,两个任务同时操作同一批库存,出现了超卖。
这类问题的根治思路是在业务层面对“锁被提前释放”做兜底。比如在业务执行前判断锁是否还在,或者用 Redisson 的看门狗做续期。但更关键的是要意识到:分布式锁中间的“分布式”三个字意味着,锁的有效期不可能完全覆盖业务执行时间,你永远要假设锁可能会丢。在这个假设下做幂等设计,才是高并发系统的正确姿态。
5.2 主从切换导致锁丢失
Redis 主从架构下,锁丢失的场景是这样的:客户端 A 在主节点上写入锁 key,主节点的数据还没同步到从节点,主节点挂了。哨兵把从节点提升为主节点,此时新主节点上没有这把锁。客户端 B 尝试获取同一把锁,成功了。这样 A 和 B 同时持有了锁,互斥性被打破。
为什么 Redlock 算法在这种情况下被提出?它的思路是,不依赖单个 Redis 节点,而是向集群中多个独立的 Redis 节点同时写锁,只有过半节点写入成功才算拿到锁。这样即使某个主从切换导致部分节点丢了锁,只要大多数节点上还有锁,整体上仍然能保证互斥。
但这个算法在工程界争议非常大,包括 Redis 作者和很多一线工程师都发表过反对意见。核心原因是:分布式系统里“网络分区”时,Redlock 无法同时保证安全性和可用性。我个人的实践结论是:如果业务对锁丢失零容忍,不应该用 Redis 方案,直接上 ZK 或 etcd;如果业务能容忍极小概率的锁丢失,直接用 Redis,加业务幂等兜底比用 Redlock 划算得多。
5.3 锁误删:大家都犯过的低级错误
锁误删问题在前面 4.1 已经提过原理,这里再讲一个真实的教训。有一次我们排查线上问题,发现某个热点商品的库存扣减偶发出现超卖,但概率极低。查了很久,最后定位到是一位同事在释放锁时只做了简单的DEL操作,没有比对持有者标识。
触发路径是这样的:线程 A 拿锁,设置超时 5 秒。A 业务执行了 4.9 秒,锁只剩 0.1 秒。这时候刚好发生了一次 Full GC,A 被暂停了 1 秒。锁在 A 暂停期间过期。线程 B 获取锁,开始执行业务。A GC 结束,继续执行完业务,然后执行DEL,把 B 的锁删了。线程 C 此时也来抢锁,成功拿到锁,于是 B 和 C 同时执行业务。
这是一个非常经典的“GC 暂停导致锁过期 + 误删锁”的组合场景。理解这个问题的关键是:GC 停顿会让线程的“现实时间”和“逻辑时间”脱节。锁的过期时间是基于现实时间计算的,而线程执行进度是基于逻辑时间推进的。
解决方式就是前面反复强调的:释放锁时一定要用 Lua 脚本做“先校验持有者,再删除”的原子操作。
5.4 惊群效应与连接风暴
ZK 方案中如果实现不当,容易出现惊群效应。假设有 100 个客户端在等待同一把锁,如果每个客户端都监听了锁节点的删除事件,锁一释放,100 个客户端同时被唤醒,同时去竞争创建锁节点。这不仅会造成瞬时大量 ZK 请求,增大节点压力,还会导致大量客户端同时抢锁造成不必要的网络开销和 CPU 消耗。
正确做法是只监听前一个节点。如果业务对线程唤醒的实时性要求极高,还可以考虑用 etcd 的Watch机制,它天然支持只观察前一个 revision,实现方式更优雅。另外,我在 ZK 方案里还会加一个保护机制:客户端被唤醒后不立即抢锁,而是随机加一个 50 到 200 毫秒的小抖动,错峰执行,进一步降低瞬时冲击。
5.5 常见问题速查表
| 异常现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 加了锁还是超卖 | 锁过期/锁丢失/锁误删 | 查看锁大 key 历史、开启慢查询、检查释放锁逻辑 | 用 Lua 释放锁,看门狗续期,业务幂等兜底 |
| 获取锁超时 | 网络抖动/Redis 负载高 | 查看 Redis 的 INFO 命令和慢日志 | 增加重试机制,使用连接池,优化 Redis 参数 |
| 锁一直无法释放 | 业务线程死循环/看门狗失效 | dump 线程栈,检查 Redisson 版本配置 | 使用 tryLock 带超时,兜底释放逻辑 |
| 锁被提前释放 | GC 停顿/网络分区 | 查看 GC 日志,查看 Redis 客户端重连日志 | 合理设置超时时间,考虑 ZK/etcd 方案 |
| 大量请求同时阻塞 | 惊群效应/不合理的监听策略 | 查看中间件连接数监控 | 改为只监听前驱节点,加随机抖动 |
| ZK 会话频繁过期 | 会话超时时间设置过短 | 查看客户端 GC 日志和网络状况 | 调大会话超时时间,降低 ZK 心跳频率 |
这张表是我做了多年分布式系统之后沉淀出来的高频问题集合。实际排查时,建议配合监控系统记录锁的获取耗时、持锁时间、释放耗时三个核心指标,这样一旦出现异常,可以直接定位是获取慢、持锁太久,还是释放失败。
6. 一个完整的 Redisson 接入示例
选型部分讲了很多理论,我最后给一个可以直接抄作业的 Redisson 接入示例,这样你可以更快地在项目里落地。
首先引入依赖,Maven 为例:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.20.1</version> </dependency>然后在application.yml中配置连接:
spring: redis: redisson: config: | singleServerConfig: address: "redis://127.0.0.1:6379" connectionMinimumIdleSize: 10 connectionPoolSize: 50这里要注意的是connectionPoolSize不能设置太小,否则高并发下容易出现获取连接超时。我见过很多项目用默认的 8 个连接,结果一到秒杀高峰期就报Cannot get a connection,最后都是把连接池调大才解决。
核心业务代码:
@Autowired private RedissonClient redissonClient; public void doBiz(String bizKey) { String lockKey = "lock:order:" + bizKey; RLock lock = redissonClient.getLock(lockKey); try { // 尝试获取锁,等待 3 秒,锁自动过期时间由看门狗维护 if (lock.tryLock(3, TimeUnit.SECONDS)) { // 业务逻辑 } else { throw new BizException("系统繁忙,请稍后重试"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException("获取锁被中断", e); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }这里有几个细节值得说明:
lock.isHeldByCurrentThread()非常重要。如果直接调用lock.unlock(),在锁已经过期被其他线程获取的情况下,会抛出IllegalMonitorStateException。有了这个判断,可以避免不必要的异常。
tryLock(3, TimeUnit.SECONDS)的第一个参数是获取锁的等待时间。如果超过 3 秒还没有拿到锁,直接返回 false。这个参数要结合业务的执行时间设置,不要让大量请求在锁上排队太久。
unlock如果放在finally块里,要注意它本身可能是会抛异常的网络操作。实际生产中,释放锁失败会影响后续流程,所以要么捕获处理,要么让异常抛出后由全局异常处理兜底。
如果是多实例部署的环境,要确保每个实例的RedissonClient连接的是同一个 Redis 集群。我曾经遇到过一个低级错误:测试环境只部署了一个实例,连的是 Redis 单节点,一切正常。上线后多实例部署,有人写死了 IP 连到了同一台 Redis,但网络策略只放行了其中一个实例,导致另外几个实例根本拿不到锁。这种问题排查起来很隐蔽,基本只能靠看日志发现连接异常。
最后再说两句大实话
分布式锁看着简单,用起来处处是坑。我最大的体会是:不要在方案上过度纠结,要在业务兜底上多花心思。任何分布式锁方案都不能保证 100% 的互斥性,与其追求绝对的强一致,不如假设锁一定会丢,把后续的幂等性设计做到位。真能做到这一步,你在高并发系统设计上就已经跨过一道坎了。
还有一个最后的建议:如果团队不熟悉 ZK/etcd 的运维,优先选择 Redis 方案,但一定要用成熟的开源客户端,不要自己从底层去撸造轮子。分布式锁这个领域,坑已经被踩得很透了,站在别人的肩膀上会省去很多踩坑的时间。