☰
分布式锁面试与实战:Redis、ZooKeeper、数据库方案对比与选型
2026/10/10 20:58:04 网站建设 项目流程

分布式锁这个话题,基本属于后端面试必考,而且问法五花八门:有时直接让你“手写一个分布式锁”,有时给你一个业务场景问“这里要不要用锁”,有时候让你比较 Redis 和 ZooKeeper 实现锁的差异。标题里的“每日面试题分享158”这个编号,说明出题人应该是希望答出条理、答出层次感,而不是上来就背一段 Redis SETNX 命令。

我做后端开发这些年,既在面试中被问过,也在实际项目里踩过分布式锁的坑。说实话,网上关于分布式锁的文章非常多,但很多要么只讲 Redis 一种方案,要么把 RedLock 吹得天花乱坠,要么直接把数据库悲观锁搬出来,容易把人带偏。这篇我就把自己在面试中会怎么答、在实际项目中会怎么选型,完整梳理一遍。

1. 先搞清楚:单机锁为什么不能直接用到分布式环境

1.1 从一段“看似没问题”的代码说起

很多新手第一次接触分布式锁,是看到线上出现超卖或者重复扣款,于是查代码,发现服务里加锁了,用的还是synchronized或者ReentrantLock,看起来没什么问题,但问题就出在“看起来”上。

举个例子,某个订单服务部署了三台机器,通过负载均衡对外提供服务。用户下单时,请求可能被分发到任意一台机器上。代码里用synchronized保护了创建订单的逻辑,这个锁在单台 JVM 内部是有效的,能阻止同一台机器上的多个线程同时进入临界区,但阻止不了另外两台机器上的线程同时进入。三台机器之间互相不知道对方有没有在跑同一段代码,这就是单机锁失效的根本原因——锁的作用域只在进程内,跨进程就管不到了。

所以,分布式锁本质上要解决的问题,是让多个独立的进程(甚至跨数据中心的进程)对某个共享资源达成“互斥访问”的一致意见。

1.2 正统分布式锁需要满足哪些条件

面试的时候,如果能主动说出分布式锁的五个必要条件,基本上就能和普通背答案的候选人拉开差距。这五个条件也是后续所有方案对比的标尺:

  • 互斥性:任意时刻只能有一个客户端持有锁,这是分布式锁最基本的要求。
  • 死锁规避:持有锁的客户端崩溃或者网络异常,锁必须能自动释放,不能永久卡死后续请求。
  • 可重入性:同一个客户端在持有锁的情况下,再次尝试获取同一把锁,应当能够成功,避免自己把自己锁死。
  • 高性能:加锁、解锁的耗时要低,不能因为引入锁导致接口响应时间出现量级上的劣化。
  • 高可用:提供锁能力的组件本身不能是单点,一旦锁服务挂了,业务也不能用。

还有一个经常被忽略但实际非常关键的属性是锁的安全性,也就是锁的持有者必须是“认领”的那个人,释放锁的时候不能把别人持有的锁误删掉。这一点后面聊 Redis 实现的时候会详细展开。

1.3 面试时怎么回答“为什么需要分布式锁”

面试官问你“分布式锁一般都怎么实现”,其实隐含了一个前置问题:你知不知道在什么场景下才需要用到它?我习惯用一个电商库存的例子来回答:

假设一个商品只有 10 件库存,100 个用户同时下单。单体应用时代,用synchronized锁住减库存的方法就行;微服务化之后,下单逻辑部署在多个实例上,库存数据放在共享的数据库或缓存里,每一个实例都必须先“抢到一把公共的锁”,才能执行“检查库存、扣减库存、创建订单”这三个动作,否则就会出现超过 10 件商品被卖出的情况。

这个例子说完,面试官基本就能确认你理解分布式锁的使用场景,而不是单纯背概念。

2. 三种主流实现方案的完整拆解

2.1 基于数据库的实现:最容易理解但最不推荐

数据库方案是分布式锁最早期的形态,思路非常直观:利用数据库表的唯一索引来实现互斥。有一张lock表,里面记录锁的名称和持有者信息,尝试加锁就是往表里插入一条记录,因为唯一索引的存在,同一时刻只能有一个客户端插入成功。

CREATE TABLE `distributed_lock` ( `lock_key` varchar(64) NOT NULL, `holder` varchar(64) NOT NULL, `expire_time` datetime NOT NULL, PRIMARY KEY (`lock_key`) ) ENGINE=InnoDB;

加锁逻辑:

INSERT INTO distributed_lock(lock_key, holder, expire_time) VALUES ('order:1', 'hostA', DATE_ADD(NOW(), INTERVAL 30 SECOND));

如果插入成功,说明拿到锁;插入报Duplicate key错误,说明锁被其他客户端持有,等待重试。

解锁逻辑就删掉自己插入的那条记录:

DELETE FROM distributed_lock WHERE lock_key = 'order:1' AND holder = 'hostA';

这里有两个细节必须注意。第一,holder字段不能省,否则客户端 A 持锁超时后,客户端 B 抢到了锁,A 此时执行删除,会把 B 的锁误删。第二,expire_time必须加,防止持有者崩溃后锁永远不释放,形成死锁。

数据库方案最大的优点是实现简单、不用引入额外组件,业务量小的内部系统完全够用。但它的问题也很致命:数据库的并发能力有限,一旦加锁请求量上来,数据库连接和行锁竞争会成为新的瓶颈;而且如果锁表所在的数据库是单机,整个架构又引入了新的单点。

我在实际项目中见过一个比较聪明的改良写法,把纯INSERT改成INSERT ... ON DUPLICATE KEY UPDATE,在冲突时判断expire_time是否已过期,如果过期就立刻把锁“抢”过来,避免等待轮询,但这个方案需要配合应用层的自旋重试,逻辑会复杂不少。用还是能用,但确实属于“杀鸡用牛刀还未必顺手”的范畴。

2.2 基于 Redis 的实现:最主流也最容易踩坑

Redis 方案是目前业界使用最广的分布式锁实现,原因无非是 Redis 快、简单、集群部署普及率高。核心命令是SET key value NX EX,没有花里胡哨的 Lua 脚本也能实现一个基础版本。

SET lock:order:1 hostA NX EX 30

这条命令的意思是:只有当lock:order:1这个 key 不存在时才设置,并且设置 30 秒过期时间。NX保证了互斥,EX保证了自动过期,一个命令同时搞定两个核心需求。

释放锁的代码值得单独说。很多人直接写DEL lock:order:1,这是典型的错误示范。如果客户端 A 持有的锁已经过期,客户端 B 获取到了同一把锁,A 此时执行DEL,等于把 B 的锁删掉了,后面 C 又能拿到锁,互斥性瞬间崩溃。正确的做法是用 Lua 脚本,先比对持有者标识再删除,保证原子性:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

Redis 官方把SET NX EX+ Lua 解锁称为“An (un)fair lock”,意思就是这套方案能保证基本互斥,但公平性不做保证。用 Redisson 这样的客户端时,它会自动完成上述逻辑,对业务代码几乎零侵入。

// Redisson 伪代码 RLock lock = redissonClient.getLock("lock:order:1"); boolean locked = lock.tryLock(0, 30, TimeUnit.SECONDS); if (locked) { try { // 处理业务 } finally { lock.unlock(); } }

Redisson 内部做的核心增强有两个。

第一个是看门狗机制。默认leaseTime为 30 秒时,Redisson 会启动一个后台定时任务,每 10 秒为这把锁续期一次,把过期时间重置为 30 秒。这个机制解决了一个经典难题:业务执行时间超过了锁的过期时间,锁被自动释放,另一个线程拿到锁进来,两个线程同时跑临界区。但用过看门狗的人也会有疑虑,如果业务真的卡死,锁会一直被续期,永远不会释放,所以实际编码时还是要设定合理的业务超时兜底。

第二个是可重入支持。Redisson 的锁 value 不仅存持有者标识,还存了一个计数。同一个线程多次lock()时,计数递增,每次unlock()时递减,减到 0 才真正删除 key。这个特性和 Java 的ReentrantLock是同一个思路。

2.3 基于 ZooKeeper / etcd 的实现:一致性优先的可靠方案

ZooKeeper 实现分布式锁用的不是临时节点,而是临时顺序节点 + 监听机制。思路是这样的:

  • 在锁的根节点下创建一个临时顺序节点。
  • 判断自己创建的节点序号是不是当前最小的,如果是,说明拿到了锁。
  • 如果不是,监听序号比自己小的前一个节点,等它删除后,再重新判断自己是否是最小节点。

这里临时节点的意义在于,如果客户端崩溃,ZK 会自动删除与客户端会话绑定的临时节点,不需要设置过期时间,也就不会出现 Redis 方案里“业务没跑完锁却被过期释放”的问题。

etcd 的实现思路类似,但机制上是基于租约(Lease)和 revision 版本号。客户端创建一个租约,将锁绑定在租约上,租约到期后锁自动失效,同时客户端通过心跳续约。多个客户端同时创建同一个 key 时,只有 revision 最小的那个能拿到锁,其他客户端 watch 这个 key 等待释放。

这块写代码会比 Redis 方案长不少,所以实际项目中很少裸写 ZK 或 etcd 的客户端库来做锁,一般用封装好的组件。Apache Curator 提供的InterProcessMutex就是基于 ZK 的成熟实现,etcd 生态里也有对应的锁服务,比如 etcd 官方文档里基于 concurrency 包的例子。

基于 ZK/etcd 的方案最大的优势是强一致性。锁的状态变更经过了共识算法,不会出现 Redis 主从切换导致的锁丢失问题。代价则是性能不如 Redis,一次加锁需要多轮网络交互,延迟通常在几十毫秒级,而 Redis 的 SETNX 通常在毫秒以内。

3. 方案对决:面试中怎么把“为什么选它”讲清楚

3.1 一张表看清差异

面试的时候,如果只是把三个方案罗列一遍,其实是及格水平。想拿高分,得能给出清晰的选型依据。我通常用这样一张表格总结:

对比维度数据库方案Redis 方案ZooKeeper / etcd 方案
实现成本最低,直接用现有库低,需引入 Redis高,需引入独立组件
性能最差,受限于 DB最好,毫秒级中等,几十毫秒级
自动释放机制靠过期时间靠过期时间(看门狗续期)临时节点 / 租约,会话结束即释放
死锁风险过期时间设置不当会锁死过期时间设置不当会提前释放极低,会话模型天然规避
可重入需要额外字段维护Redisson 已实现Curator 的 InterProcessMutex 已实现
时钟影响无依赖本机时间,时钟跳跃有风险无,靠逻辑时钟
典型场景内部管理系统、低频任务高并发、容忍极小概率锁失效对一致性要求极高的场景,如分布式任务调度

这张表背后其实反映出一个核心观点:没有“最牛”的分布式锁方案,只有“当前场景下最合适”的方案。

3.2 热点追问:Redis 主从切换真的会丢锁吗

这是面试中我最喜欢追问的问题之一,因为 70% 的候选人答不上来。背景是:Redis 主从架构下,数据从主节点异步复制到从节点。客户端 A 在主节点上成功加锁,但主节点还没来得及把这条数据同步给从节点就挂了,哨兵把从节点提升为新的主节点。此时客户端 B 向新主节点申请同一把锁,因为数据没同步过来,B 加锁成功。于是 A 和 B 同时认为自己持有锁,互斥性被打破。

针对这个问题,Redis 官方提出了 RedLock 算法:要求客户端向集群中大多数(一般是 5 个独立节点中的 3 个以上)同时加锁,只有超过半数成功才算加锁成功。RedLock 在工业界的争议很大,一部分人认为它并不能在真正意义上解决安全性问题,反而增加了复杂度和延迟。

我在面试中会给出一个工程取向的回答:如果业务场景允许极低概率的并发冲突(比如限流、幂等校验中多一次查询不至于产生严重后果),那么 Redis 单机加锁完全够用;如果场景绝对不能接受两个客户端同时持有锁(比如资金类操作),那么应该优先考虑 ZK/etcd 方案,因为它的共识机制从底层保证了锁状态的一致性。这个回答的好处是诚实、有取舍、有判断力,面试官通常会认可。

4. 实战经验:实现与使用中的六个关键坑

4.1 坑一:锁过期时间设置多少才合理

这是分布式锁实战里最容易翻车的地方。过期时间设短了,业务还没跑完,锁先释放了,造成并发穿透;设长了,持锁方宕机后,其他客户端要等很久才能获得锁,可用性降低。

常规做法是设置一个保守值,比如 30 秒,同时配合 Redisson 的看门狗自动续期。如果项目没有用 Redisson,而是自己基于 SETNX 实现,那么单纯依赖一个固定过期时间是很危险的。我见过一个实际案例,某定时任务在极端情况下执行超过 60 秒,而锁过期时间恰好是 60 秒,结果两个实例同时跑了同一批重活,产生了大量重复数据。后来改成“过期时间 = 业务预估最大耗时 × 2 + 5 秒缓冲”,并把任务内部拆小,遇到长时间任务主动续期,问题才解决。

4.2 坑二:解锁必须比对持有者标识

前面已经反复强调过,释放锁的 Lua 脚本必须校验持有者。这里再补充一个容易忽略的细节:持有者标识不能只用一个 UUID 字符串。在微服务架构里,同一个服务有多个实例,同一实例又有多个线程,锁标识最好是“实例 ID + 线程 ID + UUID”的组合,确保唯一性,防止两台不同实例的相同线程号互相解锁。

4.3 坑三:可重入锁在嵌套调用中必不可少

业务开发时,经常出现一个方法加了锁,内部又调用另一个也加了同一把锁的方法。如果锁不支持重入,第二次会把自己卡死,或者因为等待锁超时抛异常。使用 Redisson 和 Curator 时这个坑基本不存在,但如果是自己写 Redis SETNX 的极简实现,就一定要在 value 里维护重入计数。

4.4 坑四:锁的粒度比你想的更值得关注

分布式锁的 key 粒度直接影响系统吞吐量。如果所有订单请求都用同一个 keylock:order,那么即使订单之间毫无关联,也会被强行串行化,性能大打折扣。更合理的做法是尽量把锁粒度降到业务对象维度。比如按订单号加锁lock:order:100001,按用户维度加锁lock:user:9527。锁的粒度越细,并发度越高,但过细的粒度也会导致锁数量膨胀,占用 Redis 内存。这个平衡需要在实际业务里反复调整,并没有统一答案。

4.5 坑五:高并发下的羊群效应

用 ZooKeeper 实现锁时,如果 100 个客户端同时等待同一把锁,简单方案是每个客户端都监听同一个节点,锁释放时 100 个客户端全部被唤醒,然后同时竞争,这叫羊群效应。正确做法是只用监听前一个节点,形成一条等待链,锁释放时只有下一个节点被唤醒,竞争范围缩小到最小。这个细节在面试中能答出来,说明你真的看过实现源码。

4.6 坑六:业务逻辑中加锁的位置

这一点已经不算技术坑,更像设计坑。我见过不少同事把分布式锁加在 controller 层,结果锁内还要调用远程接口,响应时间被拉长,锁一直被占用。我更推荐的做法是:锁应该包裹真正的临界区,而且是越窄越好。能用乐观锁(如版本号 CAS)解决的场景,比如单纯更新一个状态字段,就不要上分布式锁;必须上锁的场景,也要把网络 IO 尽量移出临界区。

5. 面试回答的整体框架与话术参考

单说知识点零散,面试容易答得没条理。我总结了一个三段式回答框架,可以覆盖大部分面试场景。

第一段,先讲使用场景,把“为什么要用分布式锁”用一个业务例子说清楚。第二段,列出常见方案,并说明各自的原理和优劣,这里可以直接使用上一节的对比表格逻辑。第三段,抛出深度思考,比如提到“Redis 主从切换丢锁”“RedLock 的争议”“ZK 与 Redis 方案的安全性差异”。

模拟一下回答中的关键段落:

“我会根据业务的一致性要求来选型。如果是对一致性要求极高、不允许任何并发穿透的场景,我会优先考虑基于 ZooKeeper 或 etcd 的实现,因为临时节点和租约能做到会话级别的自动清理,不存在过期时间设置不合理的问题。如果是高并发、追求低延迟的场景,我会选择 Redis + Redisson 的组合,利用 SETNX 加 Lua 脚本解锁,打开看门狗来自动续期。另外无论选择哪种方案,我都会在业务侧做幂等兜底,因为分布式环境下没有任何锁能做到绝对安全,最终防线一定是业务本身。”

这段话基本把知识储备、选型思路和工程经验都展示了。如果面试官不打断,顺着这个思路往下讲,整场回答会非常有说服力。

6. 顺着这个题目还能延伸的两个面试话题

如果时间充裕,我会继续延伸两个相关方向,因为分布式锁本质上只是分布式协同的一个子集。

第一个是分布式幂等和分布式锁的关系。锁和幂等侧重点不同:锁是为了避免“同时执行”,幂等是为了容忍“重复执行后结果一致”。实际项目中我倾向于优先做幂等,因为幂等是无锁设计,扩展性比加锁好。例如使用数据库唯一索引、状态机前置校验或者 RedisSETNX做幂等标记,都比锁更加轻量,而且能规避锁的很多副作用。

第二个是最终一致性和锁的关系。有些业务场景其实不需要强一致互斥,只需要“最终只有一个成功”,比如多个服务同时处理同一个消息,我们可以让它们先抢一个 Redis 的 key,抢到的处理、没抢到的直接丢弃,这样比持锁等待更高效,也更符合分布式系统“宁可重试不可阻塞”的设计哲学。

把这两个话题延伸讲完,不仅证明了分布式锁的知识掌握,还能展示对整个分布式系统设计思维的深度,面试观感会非常好。

7. 日常开发中的最终建议

聊到落地的层面,说说我个人这些年反复验证过的几条建议,算是给文章收个尾,也希望真的能帮到在做架构选型的读者。

如果你在维护一个中等规模的后端系统,Redis 已经是基础组件,Redisson 的分布式锁是默认选择,没有必要为了“更可靠”去引入 ZK/etcd。引入一个新组件会给运维和监控带来额外成本,而大部分业务场景根本触发不到主从切换丢锁这个极端事件。

如果你在做支付、对账这类资金敏感业务,分布式锁只应该作为最后一道闸门,核心还得靠数据库的唯一约束和事务。纯靠 Redis 锁保证资金安全,我是无论如何不敢这么设计的。

另外,无论用哪种方案,一定要给锁加上监控。我在生产环境维护过一套分布式锁组件,后来专门给每个锁 key 加了获取耗时、等待耗时、释放失败次数这几个指标,一旦发现某个锁的平均等待时间异常飙高,就立刻排查是否存在锁粒度过粗或者持锁时间过长的问题。没有监控的分布式锁,就像没有仪表盘的飞机,能飞,但你不知道什么时候会出事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询