1. 分布式锁到底解决什么问题
在开始动手之前,有必要先把分布式锁这件事的来龙去脉说清楚。很多人一听到“分布式锁”就觉得是个高大上的中间件,或者以为买个组件装上就能一劳永逸,其实不是这么回事。分布式锁本质上是多个进程之间互斥访问共享资源的一种同步机制,它解决的是分布式系统里“多个节点同时操作同一份数据”的竞争问题。
1.1 单机锁为什么在分布式场景下失效
从单体应用时代过来的开发者,对锁的理解一般停留在 synchronized 或者 ReentrantLock 上。这类锁的核心逻辑是:在同一个JVM进程内,多个线程竞争同一个锁对象,谁拿到锁谁就执行,执行完释放锁。单个进程内,JVM能保证所有线程都在自己的内存模型里看到一致的锁状态,所以一切正常。
但到了分布式环境下,业务部署变成了多台机器,用户请求可能分别落在不同的服务器上。假设有两台机器同时收到了“给用户A扣减100元余额”的请求,如果不加锁,两台机器上的代码都会先读余额,再扣减,最后写回,在并发情况下就很容易出现数据不一致。这时候单机锁完全帮不上忙,因为机器1上的锁和机器2上的锁根本就是两个对象,互相感知不到对方的存在。
一句话总结:单机锁是线程级别的,分布式锁是进程级别的,两者解决的问题域不同,不能混为一谈。
1.2 什么样的业务场景必须用分布式锁
实践中常见的场景有三类。第一类是秒杀和库存扣减,这类业务对一致性要求极高,不允许多个请求同时把最后一件商品的库存扣成负数。第二类是分布式任务调度,比如定时任务在多台机器上部署,必须保证同一时刻只有一个节点在跑某个任务,否则数据会被重复处理,消息会被重复发送。第三类是分布式事务中的幂等控制,比如订单支付的回调、MQ消息的消费,同一个事件可能被触发多次,需要通过锁来保证只有一次有效处理。
判断一个场景是否需要分布式锁,可以从两个维度来思考:并发冲突程度有多高,以及冲突后造成的损失有多大。如果一个接口每秒几百上千的并发量,操作的是同一个热点ID,同时冲突后的影响是用户资金或订单状态错乱,那基本上就必须上分布式锁了。
1.3 分布式锁的三个核心特性
一个合格的分布式锁,我认为至少要满足以下三点:
- 互斥性:任意时刻,只能有一个客户端持有锁。
- 死锁防护:持有锁的客户端宕机或网络异常时,锁必须能被自动释放,不能一直卡死其他请求。
- 可重入性(视场景而定):同一个客户端在持有锁的情况下再次获取同一把锁,应该能够成功,避免业务内嵌套调用时把自己锁死。
除此之外,在生产环境里还得考虑锁的性能开销、实现成本和运维成本。分布式锁不是越复杂越好,适合当前业务形态、能稳定运行、出问题时好排查,这才是最重要的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主流实现方案对比与选型思路
从工程实践来看,分布式锁的主流落地方案大致有三种:基于关系型数据库、基于Redis、基于ZooKeeper(以及类似ZooKeeper的Etcd)。每种方案都有自己适合的场景,也都有自己的坑。
2.1 基于数据库的分布式锁
用数据库实现分布式锁,思路非常朴素:在数据库里建一张锁表,锁的key做唯一索引,获取锁就是往表里插入一条记录,释放锁就是删除这条记录。
sql复制CREATE TABLE `distributed_lock` (
`lock_key` varchar(64) NOT NULL COMMENT '锁的key',
`owner` varchar(64) NOT NULL COMMENT '持有者标识',
`expire_time` datetime DEFAULT NULL COMMENT '过期时间',
PRIMARY KEY (`lock_key`)
) ENGINE=InnoDB;
获取锁时执行 INSERT INTO distributed_lock(lock_key, owner, expire_time) VALUES ('order_123', 'node_01', '2025-xx-xx xx:xx:xx'),如果insert成功就代表拿到了锁;释放锁时执行 DELETE FROM distributed_lock WHERE lock_key='order_123' AND owner='node_01'。
好处是简单直接,依赖关系型数据库,不需要引入额外组件,对已有MySQL体系的项目非常友好。坏处也很明显:性能上限低,每次加解锁都是一次数据库IO;数据库单点故障时锁直接不可用;如果应用持有锁时间较长,数据库连接池极易被占满。这套方案的底线是“能用,但只适合低并发、对一致性要求不极端的内部系统”,比如管理后台的任务调度。
2.2 基于ZooKeeper的分布式锁
ZooKeeper实现分布式锁利用了它的临时顺序节点和Watch机制。核心流程如下:
- 客户端在锁目录下创建临时顺序节点,比如
/lock/order_123/lock_000000001。 - 客户端获取锁目录下所有子节点,如果自己创建的节点序号最小,则获取锁成功。
- 如果节点序号不是最小,则对前一个序号节点注册Watcher,等待监听事件。
- 前一个节点被删除时,客户端收到通知,再次检查自己是否最小,重复第2步。
- 释放锁时删除自己的临时节点即可;客户端宕机后,ZooKeeper也会自动删除临时节点,从而避免死锁。
ZooKeeper方案的优势在于:临时节点天然的也能避免持锁客户端宕机造成锁无法释放的问题;节点顺序保证了公平性,先请求的先获取;实现机制上更贴近“分布式协调”的思路,可靠性较高。
它的劣势是引入ZooKeeper本身就是个不小的运维负担,同时加锁解锁过程中节点创建和监听会带来一定的网络开销,在高并发场景下,性能不如Redis方案。
2.3 基于Redis的分布式锁
Redis分布式锁是目前生产环境里用得最广泛的一种方式,主要原因就是性能好、落地快、Redis本身已经深度集成在很多团队的中间件体系里。基于Redis的核心API是 SET key value NX EX seconds,这个命令组合了一次原子性的写入操作,if not exists则写入并且同时设置过期时间,完全满足互斥和自动释放的要求。
我参与过的项目里,库存扣减、活动领取、重复消息控制,用的基本都是这套方案。性能实测很简单用一句话概括:Redis单实例的加锁QPS可以到十万级别,这在绝大多数业务场景里完全不是瓶颈;而ZooKeeper方案做到几千QPS还得好好调优。
2.4 三种方案的取舍建议
下面的表格基本可以帮你快速根据业务场景做选型:
| 方案 | 性能 | 可靠性 | 运维成本 | 适用场景 |
|---|---|---|---|---|
| 数据库 | 低 | 一般 | 最低 | 内部系统、低并发任务调度 |
| Redis | 高 | 推荐配合Redlock或高可用模式 | 低 | 高并发、秒杀、幂等控制 |
| ZooKeeper/Etcd | 中 | 高 | 高 | 对一致性要求极高的分布式协同场景 |
补充一点,如果你所在团队的技术栈里已经有ZooKeeper了,那用它实现分布式锁是顺手的,没必要强行替换。如果团队只有MySQL和Redis,就优先选Redis,省心而且够用。
3. Redis分布式锁的完整落地实现
这部分我讲得稍微细一点,因为你光知道 SETNX 是不行的,真正上线前还会遇到很多细节问题。这里提供一套经过了生产验证的写法,配合必要的代码说明,你照着调整就能用。
3.1 加锁的正确姿势
很多老项目的代码里还能看到 setnx() 和 expire() 两个命令分开调用的写法,这其实是个隐患。假设执行完 setnx() 之后、执行 expire() 之前进程宕机了,锁就永远不释放了,其他请求全都进不来。因此官方推荐的写法就是原子命令:
bash复制SET lock_key unique_value NX PX 30000
参数说明一下,lock_key 是你要锁定的资源,unique_value 是客户端唯一标识,可以用UUID或者业务请求ID生成,NX 表示只有当key不存在时才设置成功,PX 30000 表示锁的过期时间是30秒。加锁返回OK就说明锁拿到了,返回nil就说明锁被其他客户端持有。
在Java里用Redisson或者Lettuce封装之后,看起来就像这样(以Spring Boot环境为例):
java复制String lockKey = "product:stock:12345";
String requestId = UUID.randomUUID().toString();
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofSeconds(30));
if (!locked) {
throw new BusinessException("系统繁忙,请稍后再试");
}
注意 requestId 为什么不能省。后面释放锁的时候,必须校验 requestId 是不是自己的,只有匹配才删除,防止误删别人持有的锁。
3.2 释放锁必须用Lua脚本
释放锁其实就是删除key,但“直接删”是踩坑重灾区。想象一个场景:线程A持有锁之后业务执行超过了预设的过期时间,锁自动过期了,线程B拿到了锁开始执行,此时线程A才慢吞吞地执行完毕,直接执行 del lock_key,结果把线程B的锁删掉了。线程C趁虚而入拿到锁,又跟线程B产生竞争,问题就像滚雪球一样扩大。
正确的做法是:释放锁前先判断value是否还是自己的标识,是则删除,不是则不动。由于“判断+删除”必须是原子的,所以要用Lua脚本:
lua复制if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
配合Java代码大概是这样:
java复制String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
List.of(lockKey), requestId);
这段Lua脚本会把“判断持有者”和“删除锁”两个步骤合并成一个原子操作,Redis在执行Lua脚本时不会穿插其他命令,这一点很关键。
3.3 关于过期时间的取舍
锁的过期时间定多少,是个玄学问题,但也最需要结合实际业务来定。定短了,业务没执行完,锁就先过期了,起不到互斥效果;定长了,一旦持有锁的节点真挂了,其他节点要等很久才能重新拿到锁。
我的经验是:先评估业务执行时间。比如一个典型的库存扣减方法,内部只是查库存、算数量、更新DB,正常耗时不超200毫秒,那过期时间给2到3秒就很宽裕。如果业务内部有远程调用或者批量处理,建议给一个5到10秒的缓冲。更稳妥的做法是引入“看门狗”续约机制,Redisson里的 getLock() 和 lock() 方法默认就带了看门狗逻辑,锁默认过期时间是30秒,每10秒自动续约一次,业务没执行完就不会提前释放。如果不用Redisson,得自己在任务里开个守护线程定时执行续约命令。
这里要提醒一下,锁的过期时间千万不要拍脑袋定一个很大的值,比如一小时,一旦出问题就是灾难。
3.4 业务代码里如何正确使用锁
锁的使用范围也是个高频翻车点。锁的范围太大,性能差,用户排队严重;范围太小,该互斥的资源没互斥,锁形同虚设。这里的准则是:锁的粒度应该精准覆盖临界区,不要把无关操作包进来。
正确的伪代码是这样的:
java复制boolean locked = tryLock(lockKey, requestId, expireTime);
if (locked) {
try {
// 只锁住真正需要互斥的业务逻辑
checkStock();
deductStock();
createOrder();
} finally {
unlock(lockKey, requestId);
}
}
务必在 finally 里释放锁,因为中间任何异常都会中断执行,如果不在finally模块兜底,锁会一直卡到过期时间到,期间业务阻塞。
还有一点,获取锁失败的处理方式也要根据业务特点设计。秒杀场景下获取失败可以直接返回“抢购失败”;但有些场景并不适合立刻失败,比如后台批处理,多个节点竞争同一个任务锁,失败方等几秒再重试更合理。
4. 生产环境里那些让人头疼的“灵异事件”
这里专门把我在实际运维中踩过的坑集中整理出来,很多都是测试环境跑一年都没事、一上线就出妖蛾子的典型问题。
4.1 时间漂移与键过期导致的重复执行
有一天活动运营跑来找我说,同一个优惠券活动,同一个用户居然领了两次。排查发现,问题出在Redis主从切换期间:线程A在主节点加锁成功,还没同步到从节点时主节点宕机,从节点被提升为新的主节点,此时锁数据丢失,线程B去新主节点加锁就成功了,两个线程同时拿到了同一把锁。
遇到过类似问题的团队应该都知道,这就是经典的Redis主从异步复制问题。要解决,要么用Redlock算法(后面细说),要么就在业务层面做兜底,比如在数据库里对订单号、领券记录加唯一索引,就算锁失效,数据库也会拦住重复数据。我自己的建议是优先做数据库幂等约束,因为简单可靠,百试百灵。
4.2 持锁时间太长,锁被自动清理
有个朋友的项目里出现过这样的现象:业务主流程偶尔会变慢,一个批量导出的操作要跑十几秒,但锁过期时间设的是5秒。结果就是线程A还在导数据,锁就被过期清掉了,线程B立刻进入,两个线程同时导出同一批数据,然后前端报表里的数据就对不上了。
排查时看着日志又抓不到现场,因为耗时太长被过期释放这事情,肉眼排查真的挺费劲。后来我在代码里给加锁、解锁和业务执行分别加了耗时日志,才定位到业务耗时不稳定的问题。解决办法其实就是前面提到的两招:过期时间按业务最慢场景放宽,或者引入看门狗续约。
4.3 误删别人的锁,排查起来特别难
误删锁的现象在上线初期偶尔能遇到。一个典型的现场是:线程A的锁过期了,线程B加锁成功,线程A业务结束执行finally里的unlock,如果不校验value直接del,就把线程B的锁删了。线程B的互斥失效,两个线程并行操作,数据库出现脏数据。
这个坑我在第3.2小节里已经给出了解法:释放锁之前判断持有者标识。但这里要再补充一个细节:判断持有者标识的“标识”一定要全局唯一且不可预测,不能简单用IP加线程名,最好用UUID或者带业务请求ID的字符串。
4.4 锁的可重入问题
有的业务代码是递归结构,比如订单状态流转,内部可能调用同一个加锁方法。如果锁是不可重入的,递归进入时就会发现锁被自己占着,直接失败。解决方式有两种:一种是用Redisson,它的默认锁就是可重入的;另一种是自己在锁的value里记录重入次数,每次重入加一,释放时减一,减到零才删key。
不过我的建议是尽量简化业务逻辑,避免在一个锁内嵌套调用同一个锁。如果实在绕不开,再考虑重入设计。
5. 从单点走向高可用:Redlock与更稳的替代方案
单机Redis的性能是足够的,但它的安全性承担了主从切换和故障转移的风险,所以如果你的业务对锁的安全性要求又高又严,就有必要了解Redlock和ZooKeeper这类更硬的方案了。
5.1 Redlock算法的基本思路
Redlock的思路是:不依赖单个Redis实例,而是在多个互相独立的Redis节点上同时加锁,只有超过半数节点加锁成功,才认为最终获取锁成功。它的加锁流程大概是:
- 获取当前时间戳。
- 依次向所有节点发送加锁命令,每个节点的加锁命令使用相同的key和value,且加锁超时时间很短,比如几十毫秒。
- 计算整个加锁过程消耗的时间,如果成功节点数大于节点总数的一半,并且总耗时小于锁的过期时间,就认为加锁成功。
- 加锁失败时,向所有节点发送释放锁命令,清理可能已经加上的锁。
Redlock解决的问题是主节点宕机后锁丢失的问题,但它自身也存在争议,主要是它会依赖各节点之间严格的时间同步,而且在极端GC(垃圾回收)场景下还是可能出现多客户端同时持有锁的情况,所以理论界也有不少批评声音。工程上,如果你的节点是真实隔离的物理机或者至少是独立容器,并且你能接受理论上的小概率风险,Redlock是可以用的。
使用上,Redisson提供了现成的多锁实现,不用自己从零写。
5.2 ZooKeeper在锁场景下的可靠性表述
相比Redis,ZooKeeper锁的核心优势是:临时顺序节点具备“会话绑定”属性,客户端会话断了节点就自动消失,不会出现锁无法释放的问题;同时它利用ZAB协议保证了多节点间的数据一致性,正常情况下不会出现锁状态的分裂。
如果让我给个一锤定音的结论:对锁安全性要求极高、业务量又不是特别大的内部核心系统,直接用ZooKeeper或Etcd更省心;对性能要求极高、业务基本可靠场景,用Redis加Lua脚本足够。
5.3 兜底方案永远要有
不管用哪种分布式锁,我都建议在核心业务链路上再叠一层兜底。分布式锁不是万能的,实际生产里很难保证它百分之百没问题,出问题的时候数据库的唯一索引、状态机的CAS更新、幂等表的insert校验,这些都能帮你把最终一致性的底线守住。
比如一个用户领券的接口,分布式锁保证同一时刻只有一个线程在处理同一个用户的领券请求,但万一锁出问题了,两个线程同时到达数据库插入领券记录,如果用户ID和券模板ID建了联合唯一索引,数据库会直接拒绝第二条插入。兜住底之后,锁更多是“性能优化”和“减少冲突”的角色,而不是“唯一防线”。
6. 最后想说的实在话
分布式锁这个话题,网上一搜一大把,但真正落地的时候往往被各种环境细节绊住。我见过太多团队上来就引一堆组件,结果一个不小心就在主从切换、锁过期、误删锁这些地方栽跟头。所以我的习惯一直是这样:首先想清楚业务到底能不能接受小概率的并发重叠,能接受就用最简单的Redis锁,规矩写对,有效期调好,配合数据库兜底;不能接受,就直接上ZooKeeper这类强一致性方案,别贪图接口顺手。
还有一个小技巧分享一下:生产环境一定要把加锁失败的日志和持锁时间监控都打出来。经验证明,分布式锁的问题几乎都是黑天鹅型的,平时不冒头,一出事就得靠日志和监控来还原现场。早点把加锁耗时、锁等待耗时、业务持锁时长这些指标接到监控里,比事后猜来猜去要高效得多。
分布式锁说到底是个工具,不是目的。理解它背后的原理和局限,根据自己的业务场景做取舍,才不会在真正遇到问题的时候被它反咬一口。
