"为什么工作 10 年都没遇过分布式锁?"
如果在一个技术社区抛出这个问题,底下大概率会吵起来。有人说自己一直在做传统企业项目,连 Redis 都没用过几次,分布式锁离得太远;有人说自己明明在写微服务,但拆来拆去都是 CRUD,并发量小到不需要锁;还有人直接吐槽:分布式锁就是面试造火箭,工作拧螺丝。我觉得这个问题问得很好,它表面上是技术疑问,实际上是职业体检——为什么别人会遇到,而你十年都没遇到?
先说结论:没遇过分布式锁,不代表分布式锁不重要,也不代表你的技术路线正确。它至少说明,你深度参与的业务场景里,还没有出现"多个独立进程同时修改同一份共享资源"的强竞争情况。这句话听起来很简单,但很多人工作多年都说不清楚。下面我会从原因、判断标准、实现原理、方案选型和工程踩坑这几个角度,把这堂课补上。
其实写这篇文章的初衷是:我面试过不少工作五到十年的候选人,简历上都写了"熟悉分布式",但问到分布式锁时,两眼一抹黑。其中一两个是真的很努力,只是业务场景确实没碰到。所以我想把"为什么没遇到"和"真的遇到时该怎么办"一次性讲透,既给新人做认识铺垫,也给自己做技术复盘。
1. 十年没遇到分布式锁,通常卡在哪一类项目里
1.1 单体应用加单库单表:分布式锁没有舞台
工作十年的老工程师,很大一部分时间是在传统单体应用中度过的。这种项目的特点是:一个应用包部署在一台服务器上,业务数据集中在一个数据库里,应用的并发能力由进程内线程池和数据库连接池决定。在这种架构下,多个线程访问共享资源时,用的是 synchronized、ReentrantLock、数据库事务和唯一索引,这些都是"进程内锁"或"数据库锁"。
进程内锁只能锁住当前 JVM 里的线程。只要系统还是单进程部署,这个锁就能生效。问题在于,现在很多单体应用实际上也会做多实例部署:同一个 WAR 包放到两台机器上,前面挂一个 Nginx 做负载均衡。这时候进程内锁就彻底失效了。假设秒杀扣库存接口加了 synchronized,两个请求分别打到两个实例上,每个实例的锁都觉得自己拿到了锁,于是两个实例同时去数据库扣减库存,超卖就发生了。很多人工作多年没遇到分布式锁,有很大概率是项目做了多实例部署,但业务接口没有出现这种跨实例并发写,或者并发量低到数据库行锁就把冲突消化了。
所以,单体应用不等于不能用分布式锁,而是"在单进程内竞争资源"这个前提不成立,分布式锁没有舞台而已。
1.2 读多写少或天然串行的业务:并发竞争被业务稀释
另一种常见情况是业务本身就是读多写少。比如一个企业官网、一个内容管理后台、一个报表展示平台,绝大多数请求是读数据,写操作寥寥无几。读操作天然不互斥,不需要锁;写操作频率很低,两个用户同时写同一个数据的概率极小,即使真的撞上了,最后一次覆盖也无所谓。这种业务里引入分布式锁,属于典型的"用大炮打蚊子"。
还有一些业务天然串行。比如线下业务的人工审核流程,一个工单同一时间只可能被一个审核员领取;比如银行柜面业务,一个客户在同一时刻只能有一个柜员操作;再比如批量结算任务,系统本来就有状态字段控制流程。当业务流程本身就把并发排除了,分布式锁自然也就没有出现的必要。
我说这些不是给"没遇到过"找借口,而是想强调:分布式锁是一种解决并发冲突的工具,不是所有系统都需要。业务形态决定架构需求,没有需求时你不会想到它,这与技术能力无关,但与技术视野有关。
1.3 分布式系统里绕开了分布式锁:用数据约束"设计掉"锁
还有一类人,系统确实是分布式的,多个服务多个实例,但他们依然没写过分布式锁。原因往往是架构师在设计时已经把并发冲突"设计掉了"。
最典型的做法是按业务键做路由。比如用户端的请求都通过网关根据 userId 取模,把同一个用户的所有请求固定到同一台后端实例。这样同一个用户的数据只可能被一台实例处理,服务内部用进程内锁就能搞定,服务间不再需要锁。它本质上是用路由把分布式问题还原成了单机问题。
另一种做法是乐观锁替代。在数据库表里加一个 version 字段,更新时执行 update table set value = ?, version = version + 1 where id = ? and version = ?,受影响行数为 0 说明版本冲突,业务重试或报错。这样既不阻塞,也不需要跨进程锁。
这些方案在数据量可控、路由规则稳定时非常有效,但都有隐患。路由依赖的实例如果做缩容、扩容,旧路由规则可能漂移;乐观锁在高冲突场景下会导致大量重试,反而没有分布式锁稳定。所以"设计掉了"不等于永远不需要,而是把分布式锁放到了更后面的兜底位置。
1.4 遇到过但没意识到:隐式分布式锁的常见形态
还有一种情况最容易被忽略:你其实用过分布式锁,只是没意识到它的名字。
举几个常见的例子。用 Redis 做接口幂等,往 Redis 里 SETNX 一个请求唯一标识,设置成功才继续执行业务,这不就是一个分布式锁吗?用数据库唯一索引防重,插入成功才处理,插入失败说明重复请求,这也是分布式锁的变体。定时任务调度框架选主,多个实例启动时竞争同一个分布式锁,谁拿到锁谁执行任务,这更是教科书级的分布式锁场景。
甚至很多消息队列消费逻辑里的"消费幂等表",本质上也是分布式锁思想:用数据库记录某个业务 ID 是否处理过,处理过的直接跳过,处理中的等待或标记。这些都是分布式锁在业务中的变体,只不过实现载体不是 Redis,大家也就没叫它分布式锁。
所以,如果你真的一把 Redis 锁都没写过,我很建议你回看一遍自己负责过的系统,大概率能找到类似结构。这就像你天天用 HashMap,却说自己没用过数据结构,不是没遇到,是没有抽象。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 判断一个业务到底需不需要分布式锁的四个问题
2.1 资源是否会被多个进程同时修改
判断是否需要分布式锁,第一个问题永远是:是否有多台机器上的多个线程,在同一时刻写同一个资源。
这里"资源"不一定是数据库记录,也可能是内存缓存、文件、对象存储里的某个对象,甚至第三方接口的调用权限。比如两台服务器上的定时任务,同一时刻向同一个文件追加内容,文件内容就会被写乱;两套服务同时调用同一个第三方对账接口,就可能导致重复对账。这些都是分布式锁的用武之地。
反过来,如果资源只在单机内存中存在,或者所有写请求都经过同一个单点组件,那分布式锁的需求就不成立。很多人没遇到分布式锁,很可能是因为自己负责的模块只是"被调用",真正的共享资源在别的服务或数据库里,竞争已经被更底层的机制消解了。
2.2 丢更新或重复执行,业务是否可以接受
第二个问题是:如果并发发生时,数据更新丢失或动作重复执行,业务能不能接受。
有些场景天然幂等。比如把某份数据刷新到缓存,后写的覆盖先写的,最终值是正确的,那就不需要锁。又比如发送通知,允许重复发送几条的话,可以用业务去重替代锁。但有些场景完全不能容忍。比如扣减用户余额,两个请求同时读到余额 100,各自扣减 20 后写回,最终余额是 80 而不是 60,这就是典型的丢失更新,必须用锁或原子操作。
判断标准很简单:把并发的两个请求设想成同时执行,看结果是否错误。如果错误,你就需要一种机制来保证"只有一个请求能执行",分布式锁是其中一种。
2.3 数据库本身的约束能不能解决问题
第三个问题是:数据库层有没有更轻量、更可靠的替代方案。
我记得之前参与过一个用户开卡项目,需求是一个用户只能开一张卡。最初团队方案是加 Redis 分布式锁,后来发现根本不用,因为只要在用户 ID 字段上加唯一索引,插入时数据库就会拒绝第二条记录。唯一索引本身就是一把完美的分布式锁,而且是数据库引擎层实现的,比任何应用层锁都更可信。
类似地,更新库存可以用 update stock set num = num - 1 where num > 0,数据库的行锁和条件判断直接保证不会超卖;状态机流转可以在 SQL 里加 where status = 预期值;甚至转账可以用事务和行锁解决。这些方案没有额外的中间件依赖,也没有运维成本,在绝大多数业务系统里比分布式锁更合适。
只有当你发现数据库约束已经无法满足需求(比如资源不在数据库里、需要跨多个数据库加锁、需要锁等待和排队),才需要考虑应用层的分布式锁。
2.4 团队的技术栈和维护成本是否匹配
最后一个问题很现实:你们团队有没有能力维护分布式锁依赖的组件。
Redis 分布式锁的前提是 Redis 稳定可用。如果公司连 Redis 都没有标准化运维,只有一台裸奔的单机 Redis,那么锁服务本身就会成为单点,Redis 挂了整个业务都锁不了,反而比不用锁更危险。ZooKeeper 和 etcd 也一样,引入一套分布式协调组件,就意味着要有人负责它的监控、升级、容灾。
我见过不少项目为了"技术先进性"硬上分布式锁,最后锁没锁住几次,Redis 先成了运维噩梦。我的建议是:能用数据库约束解决就别上锁,能引入已有中间件就别新造组件,必须新造也要评估团队是否有能力维护。分布式锁是工具,不是勋章。
3. Redis 分布式锁的细节:从 SETNX 到 Redlock
3.1 最原始的 SETNX 写法为什么会在线上翻车
既然聊到分布式锁,绕不开 Redis,而 Redis 分布式锁里最容易翻车的,是那些从网上抄来的"两行代码搞定分布式锁"。
很多教程会告诉你:加锁用 SETNX lock_key unique_value,返回 1 表示抢到锁,返回 0 表示锁被占用;执行完业务后 DEL lock_key 释放锁。这段关键逻辑没有什么问题,但直接放到生产环境,至少埋了两个雷。
第一,锁没有过期时间。如果拿到锁的线程在执行业务时抛了未捕获异常,或者所在的服务器直接宕机,DEL 命令永远不会执行,这把锁就变成了僵尸锁,所有后续请求都会卡死在等待锁上。
第二,即使你单独执行 EXPIRE lock_key 30 给锁设置了过期时间,SETNX 和 EXPIRE 是两条命令,不是原子的。如果 SETNX 执行成功,但应用在 EXPIRE 之前突然宕机,锁依然没有过期时间,问题还是一样。
所以,任何不带过期时间、或加锁和过期分离的写法,本质上都是不完整的分布式锁。这不是小概率事件,在高并发场景下,实例宕机和异常退出是家常便饭,你必须在设计阶段就把这种情况考虑进去。
3.2 正确姿势:SET NX EX 加 Lua 脚本释放
正确做法其实不复杂。加锁时直接用一条命令:
bash复制SET lock_key unique_value NX EX 30
这条命令同时完成了三件事:设置 key、只有当 key 不存在时才有效(NX)、设置过期时间 30 秒(EX)。原子性由 Redis 命令本身保证,不需要担心中间状态。
释放锁时不能简单 DEL,因为可能会出现这种情况:线程 A 拿到锁后执行时间过长,锁已经因为超时被 Redis 释放;线程 B 此时拿到锁开始执行业务;A 终于执行完,直接 DEL lock_key,删掉的其实是 B 的锁。A 删完锁,C 又拿到锁,B 和 C 同时在临界区里执行,分布式锁形同虚设。
解决这个问题的标准做法是让 value 带上一个唯一标识,释放前先判断锁的 value 是否等于自己的标识,只有相等才删除。判断和删除需要保证原子性,所以一般用 Lua 脚本:
lua复制if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
在 Java 里配合 Jedis 或 Lettuce 调用这套脚本,就是一套最朴素、最安全的 Redis 分布式锁核心。网上几乎所有成熟锁工具的底层都是这个套路,区别只是封装程度不同。
3.3 锁超时、自动续期和"看门狗"机制
解决了加锁和释放的问题,还没完。第三个经典问题:锁的过期时间设多久?
假设业务逻辑需要执行 5 秒,你随手把过期时间设成 3 秒。结果锁在第 3 秒到期,另一个线程 B 拿到锁进入临界区,而线程 A 还没执行完,两个线程同时操作共享资源。这种锁超时引发的互斥失效,比死锁更隐蔽,因为系统不会报错,只会偶尔出现奇怪的数据错乱。
最简单的规避方式是把过期时间设置为业务耗时的数倍,比如 5 倍,确保正常情况下锁不会被提前释放。但这只是"经验值",业务耗时波动时照样出问题。更稳妥的方案是自动续期:在持锁期间,用一个后台定时任务每隔一段时间检查一次锁是否还是自己的,如果是,就把过期时间延长。Redisson 里的"看门狗"就是干这个的:默认锁的 leaseTime 是 30 秒,如果业务没有执行完,看门狗每 10 秒会自动把锁续期到 30 秒,业务结束后再释放。
自己实现续期也完全可以,但一定要记得续期前检查 value 是否还是自己持有的锁。如果不检查,一旦锁已经丢了,你的续期线程会不断给别人持有的锁续期,那比不续期还可怕。
3.4 Redlock 的争议与适用边界
Redlock 是 Redis 作者提出的一种多节点锁算法,设计目的是解决 Redis 单点故障导致锁失效的问题。
它的思路是:部署多个相互独立的 Redis 节点,客户端加锁时向所有节点依次发送 SET key value NX EX 命令,只有超过半数节点加锁成功才认为加锁成功;释放锁时向所有节点发送释放脚本。这样即使一个主节点挂掉,其他节点上的锁仍然有效,不会出现单点故障导致的锁丢失。
但 Redlock 从诞生那天起就有争议。著名的分布式系统专家 Martin Kleppmann 专门写过文章,指出 Redlock 在以下场景下可能仍然失效:GC 暂停导致持锁进程暂停很长时间,锁过期后另一个进程拿到了锁,原来的进程恢复后继续执行,仍然会冲突。而且 Redlock 依赖所有节点的时间一致,这一点在 Redis 里并没有强保证。
我的观点很务实:如果你的业务是金融账务、订单支付这类强一致场景,别用 Redlock,直接用 etcd 或 ZooKeeper 更省心;如果业务是防止重复优惠、定时任务重复调度这类能容忍极端小概率冲突的场景,用 Redis 主从加 Redlock 问题不大。技术选型不是越复杂越好,是匹配场景才好。
4. 三种主流分布式锁方案选型对比
4.1 数据库唯一约束做锁:低并发下的朴素方案
先看最朴素的数据库锁。实现方式是建一张锁表,比如:
sql复制CREATE TABLE distributed_lock (
lock_name varchar(64) PRIMARY KEY,
owner varchar(64) NOT NULL,
expire_time datetime NOT NULL
);
获取锁时插入一行记录,插入成功就是拿到锁;释放锁时删除对应记录。因为 lock_name 是主键,数据库层面保证了同一时间只能插入一条相同记录,天然互斥。而且可以加一个 expire_time 字段,由后台定时任务清理过期记录,解决宕机死锁问题。
这个方案的优点是强一致,只要数据库不丢数据,锁就不会丢;缺点也很明显:性能上限低,每次加锁释放都要走一次数据库事务,数据库连接池容易被锁操作拖垮;如果公司用的是分库分表,锁表本身也要考虑跨库一致性问题。所以它只适合低并发、对分布式锁依赖不高的场景,或者作为兜底方案。
4.2 Redis 分布式锁:性能与交付速度的平衡点
Redis 分布式锁是当前使用最广的方案,核心原因就是简单高效。Redis 单机 QPS 可以达到 10 万级别,加锁释放操作都是微秒到毫秒级,远高于数据库。而且绝大多数业务系统本来就在用 Redis 做缓存,不需要额外引入新组件,部署成本几乎为零。
它最大的隐患是可靠性依赖 Redis 本身。如果 Redis 是单机部署,Redis 宕机时所有用锁的业务都会不可用;如果是主从架构,主节点宕机发生主从切换时,可能出现"主节点上锁还没同步给从节点,从节点被提升为主节点,另一个客户端通过新主节点又拿到同一把锁"的情况。Redlock 算法可以缓解,但不能彻底消除。
所以,如果你的业务并发量高、QPS 大、能接受极小概率的锁失效,Redis 分布式锁就是性价比最高的选择。我自己的项目里,90% 的分布式锁场景都是这么解决的。
4.3 ZooKeeper/etcd 分布式锁:一致性优先的场景
ZooKeeper 和 etcd 是分布式协调组件,本身就提供强一致能力,非常适合做分布式锁。
ZooKeeper 的锁实现基于临时顺序节点。客户端在锁目录下创建临时顺序节点,如果自己是序号最小的节点,就认为自己拿到了锁;否则监听前一个节点的删除事件,等待前一个节点释放。临时节点的好处是:如果客户端会话断开,节点自动消失,锁自动释放,不需要担心死锁。
etcd 基于 Raft 协议,锁能力通过 Lease 和 KeyValue 实现。获取锁时创建 Key,并绑定一个 Lease 作为租约,租约到期后 key 自动删除。客户端需要定期续租,一旦进程崩溃,租约过期,key 删除,锁自动释放。它的实现和 Redis 很像,但一致性由 Raft 保证,不会有主从切换丢锁的问题。
这两者的代价是运维复杂度和性能。ZooKeeper/etcd 的吞吐量通常低于 Redis,而且需要额外维护一套集群。所以它们的定位很明确:对一致性要求非常高、愿意用一部分性能换可靠性的场景,比如分布式事务协调、选主、任务调度等。如果只是业务接口防重,上 ZooKeeper 大概率是过度设计。
下面是一个简单对比:
| 方案 | 一致性 | 性能 | 运维成本 | 典型场景 |
|---|---|---|---|---|
| 数据库唯一约束 | 强一致 | 低 | 低 | 低并发防重、简单互斥 |
| Redis 分布式锁 | 最终一致(极端场景弱) | 高 | 低 | 接口幂等、定时任务互斥、高并发场景 |
| ZooKeeper/etcd | 强一致 | 中 | 高 | 选主、分布式事务、强一致要求场景 |
5. 线上分布式锁踩坑实录:五个高发问题
5.1 锁没有过期时间,服务重启后变成僵尸锁
先说一个我见过多次的真实事故。某个内部系统用 Redis SETNX 做了定时任务互斥,但代码只写了加锁和释放,没有设置过期时间。某天凌晨定时任务执行过程中,服务器因为内存溢出被运维重启,释放锁的那行代码永远没有执行。早上上班后,所有定时任务都发现锁还在,全部跳过执行。业务方盯着应该产出报表的数据发愣,排查了一个小时才发现是 Redis 里躺着一把两周前的僵尸锁。
这个坑的本质是:你写代码时默认"释放锁这段代码一定会执行",但现实是进程可能在任何一行代码处崩溃。所以,任何分布式锁都必须有兜底机制。Redis 里就是过期时间,或者看门狗续期;ZooKeeper 里是临时节点;etcd 里是 Lease 租约。没有兜底的锁,就是在埋雷。
5.2 业务执行时间超过锁过期时间,锁提前释放
更隐蔽的坑是锁过期时间设置太短。很多人在上一条教训后学会了设置过期时间,但设多久全凭感觉。业务平时只要 1 秒,就随手设了 3 秒,结果赶上一次数据量暴增,某个任务跑了 10 秒。第 3 秒时锁到期,另一个节点拿到锁也执行同一任务,两个任务同时跑,最后数据重复。
这个问题在业务耗时不确定时尤其突出。解决办法我在前面已经说过:要么把过期时间设置成业务耗时的几倍,要么做自动续期。如果用的是 Redisson,可以直接依赖看门狗;如果是自己实现的 Redis 锁,写一个简单的续期定时任务也不复杂,核心就是定期执行 Lua 脚本检查并更新过期时间。
5.3 删除锁时删掉了别人的锁
这个坑在 3.2 提到过,但值得单独拉出来讲,因为它太常见了。两个线程 A 和 B,A 拿到锁,因为某种原因锁先过期了,B 拿到锁,A 业务执行完后直接 DEL,把 B 持有的锁删掉,于是进程 C 也进来了,最终 ABC 三个线程同时执行临界区。
之前有个负责积分系统的朋友就遇到过:用户重复调用积分发放接口,同一个用户同时进来两条请求,Redis 锁都被拿到,但因为 A 释放时误删了 B 的锁,最终用户积分被加了两次。排查的时候,如果你只盯着业务代码,根本找不到问题,因为你看到的 Redis 锁加锁释放逻辑都非常标准。直到把 value 改成唯一 ID,释放前做校验,问题才消失。
这告诉我们:释放锁不是 DEL 一个 key 那么简单,必须确认锁的持有者是当前调用方。很多成熟的分布式锁工具都已经内置了这个逻辑,自己实现时一定要用 Lua 脚本保证"校验+删除"的原子性。
5.4 锁粒度太大,把并发系统变成串行系统
还有一个容易忽略的方向问题:锁的粒度。一个库存服务,所有商品 SKU 共用一把锁,秒杀期间所有商品的库存扣减全部串行排队,QPS 直接被打到个位数。这种"一把锁锁死所有商品"的用法,虽然保证了正确性,但把并发能力彻底牺牲了。
正确做法是按业务维度拆锁。比如锁的 key 是 stock:lock:{skuId},而不是 stock:lock。类似地,用户账户维度的操作可以用 user:lock:{userId},订单维度的操作可以用 order:lock:{orderId}。只有真正共享同一份资源的请求才会竞争同一把锁,其他请求完全并行。
这也是面试官喜欢追问的点:你用了分布式锁,怎么保证性能?合格的回答不是"Redis 够快",而是"锁的粒度设计得足够合理"。锁粒度决定了
