1. 一个锁引发的线上事故:为什么要聊分布式锁
先说个我自己的真实经历。几年前做电商订单系统,用户下单后要扣库存、发优惠券、生成履约单,好几个服务要协同改数据。单机时代靠synchronized就能锁住临界区,后来拆了微服务,库存服务单独部署了三台实例,问题立刻来了。
某次大促,同一个商品SKU的库存瞬间被打穿,超卖了几十单。查日志发现,三个实例同时执行了库存扣减逻辑,synchronized锁住的只是各自JVM内部的线程,跨进程完全失控。那次事故之后,我花了整整一周研究分布式锁,踩了不少坑,也总结出一套相对成熟的落地思路。
所谓分布式锁,本质就是在多个进程、多台机器之间达成"互斥"共识的一种机制。它要解决的核心问题很直接:当多个客户端同时操作共享资源时,如何保证同一时刻只有一个客户端能拿到执行权限。
这类问题几乎每个做后端开发的都会遇到,尤其是涉及订单、支付、库存、优惠券这类强一致场景。不管你是刚入门分布式系统的新人,还是正在准备面试的候选人,把分布式锁的原理和实现吃透,都是一个绕不开的核心技能点。
这篇文章不打算罗列一堆理论,而是从实际踩坑经验出发,把分布式锁的几种主流实现方案、核心原理、完整落地步骤,以及我在生产环境里遇到的各种诡异问题,一次讲清楚。无论你是要用Redis、ZooKeeper还是数据库实现,读完都能直接上手去搭一套能用的方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型背后:Redis、ZooKeeper、数据库到底怎么选
2.1 为什么Redis能成为分布式锁的主流选择
先聊聊技术选型。目前业界主流的分布式锁实现方案有三种:基于Redis、基于ZooKeeper、基于数据库。我见过很多团队一上来就用Redis,理由是"我们本来就有Redis集群,顺手就用了"。这个理由听起来随意,但实际从原理上看,Redis确实是性价比最高的选择。
Redis实现分布式锁的核心依赖是它的单线程命令执行模型和原子操作。Redis是单线程处理命令的,多个客户端的命令到达服务端后会排队依次执行,这天然就避免了并发竞争问题。再配合SETNX(SET if Not eXists)这类原子指令,多个客户端同时抢锁时,Redis能保证只有一个客户端设置成功,互斥性就有了着落。
另一个关键点是性能。分布式锁的获取和释放通常频率很高,Redis基于内存操作,单次加锁、解锁的耗时在毫秒级别,对业务请求的影响微乎其微。
对比其他方案:
- 基于ZooKeeper的锁,依靠临时顺序节点和watcher机制实现,可靠性确实更强,但ZK集群的部署运维成本高,一次会话建立、节点创建、监听通知的完整链路,耗时比Redis多一个量级,高并发场景下不占优势。
- 基于数据库的锁,通常用唯一索引或
for update行锁实现,实现简单、没有额外组件依赖,但性能是三者中最差的,而且数据库本身就是瓶颈高发点,拿它做锁,等于给核心链路又加了一层压力。
我对选型的建议是:业务优先、组件次之。如果系统里本来就有Redis,且对性能要求高,选Redis;如果对可靠性要求极高,比如涉及资金类的强一致场景,且能接受ZK的运维成本,选ZK;如果只是低并发的内部管理系统,不想引入额外组件,数据库唯一索引方案也完全够用。
2.2 Redis分布式锁要满足的四个核心条件
无论用哪种方案,一个合格的分布式锁都必须满足四个条件,这也是面试官最常考的点:
互斥性。同一时刻只能有一个客户端持有锁。这是分布式锁的立身之本,做不到互斥,整个方案都是空中楼阁。
安全性。锁只能被持有者自己释放,不能出现客户端A的锁被客户端B释放的情况。这个条件非常容易被忽略,很多生产事故都是因为释放锁时没有校验持有者身份导致的。
死锁避免。锁必须设置过期时间,防止客户端获取锁后崩溃或网络异常,导致锁永远无法释放,后续所有请求全部阻塞。
容错性。只要Redis集群中的大部分节点正常运行,客户端就能正常加锁、解锁。这一点在集群模式下尤其重要,如果主节点挂掉后锁数据还没同步到从节点,就可能出现"锁丢失"的经典问题。
后面讲代码实现时你会发现,每一个条件都对应着一个具体的代码细节,缺一个都会在特定场景下翻车。这也是我建议大家在设计阶段就把这四个条件写在文档里的原因,写代码时逐条对照,能避开至少一半的坑。
3. 从SETNX到Redlock:Redis分布式锁的原理拆解
3.1 SETNX加锁的演进过程与参数设计
Redis加锁的API经历了一个演进过程。早期版本的SETNX key value只能设置键,无法直接设置过期时间,所以标准的加锁代码长这样:
java复制// 早期写法:两步操作,非原子
boolean locked = redisTemplate.opsForValue().setIfAbsent(key, value);
if (locked) {
redisTemplate.expire(key, 30, TimeUnit.SECONDS);
}
这段代码有一个致命问题:setIfAbsent和expire是两个独立的Redis命令,中间如果客户端宕机,锁就永远没有过期时间,直接变成死锁。
后来Redis官方针对这个问题,在2.6.12版本后支持了SET key value NX EX seconds的多参数形式,把"不存在才设置"和"设置过期时间"合并成一个原子操作。对应的Spring Data Redis写法是:
java复制// 推荐写法:原子操作
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(key, value, timeout, TimeUnit.MILLISECONDS);
这个接口底层执行的就是SET key value NX EX timeout,一次网络请求完成加锁和过期时间设置,彻底规避了死锁风险。
这里有两个细节要重点说明。第一个是关于value的设计。value必须是一个能标识客户端唯一身份的字符串,通常用UUID或IP + 线程ID生成。目的是为了释放锁时做持
