说到分布式锁,后台写代码的同学应该都不陌生。无论你是准备面试还是正在做系统改造,这几个字绕不开。聊聊我的真实感受:这三套方案我都在生产环境里真刀真枪上过手,基于数据库的、基于Redis的、基于ZooKeeper的,各有各的脾气,也各有各的坑。这篇就把它们的实现原理、关键代码、踩坑经验和面试常问的追问口径一次性说清楚,适合正在设计高并发接口的同学,也适合准备三面深度问题的朋友。
先放一个结论:没有十全十美的分布式锁。选择方案的本质是在性能、可靠性、实现成本和运维成本之间做取舍。所以光会写代码不行,还得知道每种方案为什么这么设计、在什么场景下会失效、退出策略怎么兜底,这才是真正拉开差距的地方。
1. 先弄清要解决什么问题,再谈锁怎么实现
1.1 单机锁为什么在分布式系统里失效
很多同学第一次接触分布式锁时都有个疑问:我在接口上用个synchronized不就行了吗?这招在单实例部署下确实有效,但一旦服务做了多实例部署,业务请求负载均衡到两台甚至更多的节点上,每个节点的synchronized只能保证自己进程内的线程互斥,跨进程、跨节点之间完全失控。也就是说,同一个资源可能被两个不同实例同时修改,单机锁就成了摆设。
分布式锁要解决的就是这个场景:多个进程之间,对一个共享资源进行互斥访问。它本质上借用第三方组件作为“协调者”,让所有客户端都遵守同一个约定——抢锁、持锁、释放锁。这个协调者可以是数据库、Redis、ZooKeeper,也可以是 etcd 这类一致性组件。只要所有客户端都通过同一个外部组件来判断“现在锁被谁持有”,互斥目标就达成了。
1.2 核心特性先列出来,后面所有方案都围绕这个评价
理解分布式锁优劣之前,先建立评判标准。一套合格的分布式锁至少得满足四个特性。
第一是互斥性,任何时刻只能有一个客户端持有锁,这是锁最基本的功能。第二是防死锁,客户端拿到锁后无论发生什么情况(宕机、网络异常、进程 GC),锁最终都要能被释放,否则其他客户端永远等下去。第三是性能,加锁和解锁本身不能成为系统瓶颈,掉几个数量级,业务承受不了。第四是高可用,锁组件不能有单点故障,协调者挂了不能导致所有业务全部不可用。
此外还有两个容易被忽略的点:可重入性(同一个客户端能在持锁后再次获取锁)和阻塞性(获取不到锁时是直接返回失败还是排队等待)。这些特性在不同方案里呈现出来的差异很大,下面逐个拆开讲。
2. 基于数据库的锁:三种写法背后各有取舍
数据库实现分布式锁是这三种里最直观、也最容易上手的方案。它不依赖额外的中间件,已有的 MySQL 就能干,但缺点非常明显。先讲原理,再说坑。
2.1 悲观锁写法:select ... for update
最经典的思路就是在事务里先锁住一行数据,再业务判断。比如订单处理场景,根据业务主键 ID 查订单,加上for update,这一行的行锁就落到了当前事务上。其他事务再来执行同样的查询会被阻塞,直到锁释放。
-- 开启事务 BEGIN; -- 对目标记录加行锁 SELECT * FROM order_info WHERE order_id = 10086 FOR UPDATE; -- 执行业务逻辑:扣库存、改状态等 UPDATE order_info SET status = 2 WHERE order_id = 10086; -- 提交事务,释放锁 COMMIT;这个写法有一个容易被忽略的技术细节:for update走的是 InnoDB 的行锁机制,但锁的范围取决于查询条件是否命中了索引。如果查询条件没有走索引,InnoDB 会锁住全表扫描到的所有记录,相当于把整张表锁了,并发能力直接归零。所以条件字段必须建索引,这是硬要求。
2.2 乐观锁写法:version 字段
如果对“锁冲突”要求没那么严,可以用乐观锁替代。流程是在数据表里加一个version字段,每次更新时带上版本号比对,只有 version 匹配才更新成功。
-- 先查询拿到版本号 SELECT id, version FROM inventory WHERE sku_id = 10086; -- 更新时检查版本号,同时将 version + 1 UPDATE inventory SET stock = stock - 1, version = version + 1 WHERE sku_id = 10086 AND version = 1;更新影响行数为 0 说明有人抢先改了这行数据,当前操作需要重试或者放弃。乐观锁的精髓是不阻塞其他线程,冲突发生时才察觉。但它有个明显限制:只是对“同一条记录修改”的互斥,没法用来做“全局互斥”或控制一段代码的访问权限,适用范围比悲观锁窄。
2.3 唯一索引写法:用一张表做锁
还有一种做法是专门建一张锁表,利用数据库唯一索引保证同时只能插入一条记录。加锁就是往表里插入一条lock_name等于固定值的记录,解锁就是删除这条记录。
CREATE TABLE distributed_lock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, lock_name VARCHAR(64) NOT NULL, acquire_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_lock_name (lock_name) ); -- 加锁 INSERT INTO distributed_lock(lock_name) VALUES('ORDER_10086'); -- 解锁 DELETE FROM distributed_lock WHERE lock_name = 'ORDER_10086';插入成功即视为拿到锁;插入时违反唯一约束,说明锁被占用。释放锁只需删除记录。看起来简洁优雅,但需要自行处理锁超时,比如客户端宕机后锁记录永远留在表里,其他客户端再也拿不到锁。解决思路是自己不停轮询检查 acquire_time 超过阈值的记录并清掉,或加一个定时任务清理过期锁,总之复杂度蹭蹭往上涨。
2.4 数据库方案硬伤和自己踩过的坑
数据库方案在流量小、并发不高的系统里可以快速上线,但硬伤也很直观。
性能上,数据库文件系统的随机 IO 和行锁、事务开销,天然没法跟 Redis 这种内存操作比。生产环境实测下,简单select for update加锁解锁一次大概在几十毫秒量级,而 Redis 锁通常不到 1 毫秒。其次,锁的超时释放很难做,MySQL 连接异常、事务回滚、客户端长时间挂起,都会产生“锁泄漏”。
另外我用select for update踩过一个坑:在事务里执行长时间的远程调用。锁一直握着不释放,后面大量请求全部事务排队,数据库连接池被打满,接着一堆连接超时报警。从那以后在数据库锁方案里我非常克制地只做本地快速操作,任何 IO 调用都得挪出锁范围。
3. Redis 分布式锁:性能最好,但细节最讲究
如果说数据库方案是“三个里面最容易实现的”,Redis 方案就是“三个里面对细节要求最高的”。网上资料很多,但真正把 SETNX、过期时间、续约、主从切换讲透的很少。这部分值得仔细看。
3.1 第一版代码:SET NX PX 和 Lua 解锁
Redis 从 2.6.12 起支持在一条命令里同时设置过期时间和互斥条件,也就是SET key value NX PX。NX 表示只有当 key 不存在时才设置成功,PX 指定过期毫秒数。这是当前最推荐的加锁写法,一条命令保证原子性。
# 加锁,超时时间 30000 毫秒 SET lock:order:10086 7a8f2c77-9e6a-4f22-9a7a-1fcd3a4b5c6d NX PX 30000value 必须是一个全局唯一的随机值(比如 UUID),不能写死。为什么?因为解锁时要用这个值做身份验证,避免发生“我的锁被别人删了”的误删问题。
再看看解锁,很多初学实现直接用DEL key,这样有严重 bug。考虑一种情况:线程 A 持锁超过业务时间,锁过期自动释放;此时线程 B 抢到了锁;A 业务执行完执行DEL,把 B 的锁删掉了。这直接违背了锁的互斥语义。正确做法是先用 GET 判断 value 是否自己持有,匹配再 DEL,这两个操作不能用两步普通命令,必须放到一个 Lua 脚本里保证原子性:
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end用 Lua 脚本的原因很明确:GET 和 DEL 两步之间如果插入其他请求,可能会出现判断时锁还在、删除时锁已经是别人的情况。Redis 执行 Lua 脚本时不会被其他命令插队,原子性有保证。这条经验我在代码评审时讲过很多次,99% 的初级分布式锁事故都跟这里有关。
3.2 过期时间与看门狗续期
加锁时设了过期时间,就面临一个矛盾:时间设短了,业务还没执行完锁就过期了;设长了,万一客户端宕机,锁要等很久才能被其他线程拿到。
解决这个矛盾的是“看门狗”机制,典型实现是 Redisson 的lock()方法默认开启自动续期。底层逻辑是:加锁成功后,后台启动一个定时任务,每隔三分之一过期时间(默认 30 秒过期,那就每 10 秒续一次)检查锁是否还在,如果还在就把过期时间重置为 30 秒。只要持有锁的客户端一直活着,锁就永远不过期;客户端一旦宕机,定时任务停止,锁等待过期后自动释放。
自己实现看门狗时可以用ScheduledExecutorService,每次续期还是在 Lua 里完成,避免并发问题。但必须注意一个陷阱:看门狗续期只能解决“客户端正常运行时锁过期”的问题,解决不了“Redis 主从切换导致锁丢失”的问题。下面展开说。
3.3 主从切换、RedLock 争议和使用场景
这是 Redis 锁方案中最有争议的一块。Redis 主从复制是异步的,写入主库成功不代表从库同步成功。假设线程 A 在主库 ADD 锁成功,主库还没来得及把数据同步到从库就宕机了;哨兵选主,将从库提升为新主库。此时因为锁数据没同步过去,新主库上没有这把锁,线程 B 再来加锁能成功——两个线程同时持有锁,互斥彻底失效。
为了应对这种极端情况,Redis 官方提出了 RedLock 算法:部署多个互相独立的 Redis 实例(官方建议 5 个),加锁时向所有实例发送 SET NX PX,只要半数以上实例加锁成功就算拿到锁。这样单个主库挂掉,其他实例上锁依然存在,就不会出现锁丢失。
但 RedLock 从 API 就有人提出尖锐怀疑,分布式领域大牛也专门写过分析。核心矛盾在于:RedLock 依赖系统时钟的一致性,如果某个实例的时钟跳跃,锁的有效期会被缩短或延长。另外,真正要求“绝对互斥”的场景往往需要配合业务事务回滚,这时锁本身再快也没法兜底。我的态度是:大多数互联网业务场景不需要 RedLock,单个 Redis 主从集群配合适当的过期时间足够用;但对账等高强一致场景,更稳妥的是使用 ZooKeeper 或 etcd。
3.4 实战中值得注意的一个问题:业务时间超过锁过期时间
这是我在实际项目里踩过的最深的坑之一。当时有个库存扣减接口,Tedis 客户端加锁后执行业务逻辑,结果业务逻辑里有远程调用、重试、甚至是同步的 MQ 发送,整套下来花了十几秒。锁过期后另一个线程进来,两边同时扣库存,库存直接变成负数。
排查时发现两个问题叠加:一是锁的超时时间设置得太随性,拍脑袋定了 10 秒;二是没有续约机制。后来改为通过看门狗自动续期,并把锁粒度从“全库存链”缩小到“单个 SKU 维度”,扣减前再查一次库存做二次校验,才彻底解决。这个经验说明一个道理:锁的实现再完善,也要在业务设计上留好后路,不迷信任何锁组件。
4. ZooKeeper 实现:临时顺序节点,每个细节都是为可靠设计的
Redis 锁解决的是“性能优先”场景,ZooKeeper 锁解决的则是“可靠性优先”场景。它用临时顺序节点,天然规避了客户端宕机导致锁永远不释放的问题,这也是面试里容易出彩的方案。
4.1 ZK 锁的底层原理
ZooKeeper 加锁的完整流程依赖两个核心概念:临时节点(Ephemeral Node)和顺序节点(Sequential Node)。
临时节点的生命周期与客户端会话绑定,客户端断开连接后节点自动删除。基于这个特性,拿到锁的客户端只要不释放锁,锁就在;客户端一旦宕机,会话超时后节点自动消失,锁自动释放,不存在“锁永远被占”的问题。
顺序节点则是 ZK 在创建节点时自动追加一个递增序号,例如lock/order_0000000001、lock/order_0000000002。同一条路径下的节点序号全局有序。加锁的过程就是所有客户端在同一父节点下创建自己的临时顺序节点,然后比较序号大小:序号最小的那个就是锁的持有者,其他客户端则监听自己前一个节点。
为什么用临时顺序节点而不是所有客户端都抢一个临时节点?如果只用临时节点,当持锁客户端释放锁时,ZK 需要通知所有等待方去抢锁,这叫“惊群效应”,在高并发下会把 ZK 连接瞬间打爆。用顺序节点后,每个客户端只监听比自己序号小一号的节点,持锁方释放时只通知等待队列里的下一个客户端,通知量大幅下降。
4.2 用 Curator 直接拿锁,别自己造轮子
生产环境自己实现 ZK 锁要考虑大量细节:监听逻辑、重连处理、会话恢复、分布式环境下节点清理。这些坑 Curator 客户端已经封住了一层。项目里直接依赖 Curator 的InterProcessMutex即可:
CuratorFramework client = CuratorFrameworkFactory.newClient( "zk-1:2181,zk-2:2181,zk-3:2181", new ExponentialBackoffRetry(1000, 3)); InterProcessMutex lock = new InterProcessMutex(client, "/locks/order/10086"); try { if (lock.acquire(30, TimeUnit.SECONDS)) { // 执行需要互斥保护的业务逻辑 } } finally { lock.release(); }InterProcessMutex设计得很完整,它支持可重入,同一个客户端线程可以多次 acquire 而不会死锁。底层实现正是临时顺序节点加监听机制。使用acquire(time, unit)时要注意,它最多等待指定时间,若超时返回 false,可以自行决定是否返回失败。
4.3 ZK 锁的代价和适用场景
ZooKeeper 锁在可靠性上的优点很多,但代价也摆在面上。首先是性能:每次加锁解锁都要创建节点、监听事件、触发通知,完整一轮涉及多次网络 RTT,吞吐量比 Redis 慢一个量级。在每秒几千次甚至上万次加锁的场景下,ZK 集群很容易成为瓶颈。
其次是运维成本。ZooKeeper 通常至少需要 3 台机器组成集群,还要保证它的磁盘、网络和 JVM 参数不出问题。相比 Redis 哨兵集群,ZK 的运维经验在团队里更稀缺。
所以 ZK 锁适合那些对一致性要求极高但对吞吐量不那么敏感的业务,比如分布式定时任务调度、全局唯一 ID 设计里的互斥组件、配置更新的串行化处理等。这些场景锁被持有的时间本来就短,但一旦出现并发重叠,后果很严重。
5. 三种实现方式对比,以及我的选型参考
5.1 一张表看清三种方案的差别
| 维度 | 数据库方案 | Redis 方案 | ZooKeeper 方案 |
|---|---|---|---|
| 实现复杂度 | 低,SQL 即可 | 中,需要处理续期和 Lua 脚本 | 偏高,集成客户端并理解底层机制 |
| 性能表现 | 慢,依赖磁盘 IO 和事务 | 极快,内存操作,微秒级 | 中等,多轮网络交互 |
| 可靠性 | 中,依赖事务和锁超时兜底 | 中,主从切换存在锁丢失风险 | 高,临时节点天然防死锁 |
| 适用并发量 | 低并发场景 | 高并发、追求吞吐量的场景 | 中高并发但更看重一致性场景 |
| 关键问题 | 锁释放机制难做、性能瓶颈 | 过期时间/续约/主从切换 | 集群资源成本高、运维复杂 |
选型时有一个标准可以拿锚:如果锁丢失的代价是不是“资金损失”或“严重数据错误”,如果是,优先考虑 ZK;如果只是保证“热门资源不重复操作”这种辅助性质,Redis 足够。
5.2 按场景选方案,我自己的判断原则
阿里内部有句话叫“扛不住就加锁,但加锁之前先优化业务”。我自己的经验是三步走。
第一步,看并发压力。日活几千、秒级并发个位数,数据库方案完全够用,直接 select for update 或者加 version 字段,不用引入新组件,省心。第二步,看锁的价值密度。锁保护的代码如果期间有远程调用、网络等待、循环重试,Redis 锁就要做好续期,否则即使性能再好也白搭。第三步,看对“极端情况”的容忍度。业务负责人如果明确说:“绝对不允许两个实例同时读同一个文件”,那就别纠结性能差异,直接上 ZooKeeper 或 etcd,省得以后背锅。
6. 面试题现场还原和知识延伸
6.1 高频题目与参考回答口径
“说说分布式锁有哪几种实现方式”是面试经典题。回答时建议按“特性—方案—取舍—我的实践”展开。
可以先讲分布式锁要解决的四个核心特性:互斥性、防死锁、高性能、高可用。再引出三种方案:数据库方案实现简单但性能和释放机制弱;Redis 方案要处理过期时间和主从切换的锁丢失问题;ZooKeeper 方案靠临时顺序节点天然规避死锁问题,更可靠但性能中等。把自己在项目中选型和改造过程中遇到的 case 讲出来,比罗列知识点来得更有说服力。
如果面试官追问“Redis 锁怎么解决过期时间问题”,可以答:设置合理的超时时间加看门狗续期。继续追问“哨兵切换时锁丢了怎么办”,可以答:引入 RedLock 但要注意时钟问题,生产上更常见的是用 ZooKeeper 承担强一致场景。
6.2 深度题目:RedLock 的争论给了我们什么启发
面试到二面或三面时,面试官常抛出“你对 RedLock 怎么看”这类问题。不仅要背结论,还要表达出理解它的局限性。
RedLock 的问题不在加锁流程,而在“每个实例的过期时间都依赖本地时钟”这一隐含假设。一旦某台实例的时钟被运维人员往前调了一大段,可能出现一种情况:实例 A 上锁的剩余时间被错误缩短,提前过期;此时 RedLock 在“剩余节点过半”的判定中依然成功,锁生命周期就被裁剪了。真正想避免这个问题,分布式系统里只能靠逻辑时钟或一致性协议,单纯锁组件的改良无法根除。
我在实践中的体会是:业务上通过“锁内的操作全部设计成幂等 + 可重试”来兜底,比在锁本身死磕更实用。分布式环境下永远存在极端情况,锁之外的自愈体系才决定系统的真实韧性。
7. 最后分享一个实战细节
前面讲的都是原理和方案比对,最后说一个实操中的小习惯。我在所有分布式锁的加锁解锁关键路径里都会打上结构化日志:谁拿的锁、锁 key 是什么、持锁多久、解锁结果如何。线上排查问题时这份日志就是救命稻草——能直接看出锁冲突发生在哪一段、加锁耗时是不是异常、有没有解锁失败导致锁泄漏。
另一方面,锁的 key 命名也有讲究。统一用lock:业务线:领域:资源标识的格式,例如lock:trade:order:10086,这样 Grafana 统计时可以直接按前缀聚合,排查问题时也能一眼看出是订单链路还是库存链路的锁。多人协作时,一套统一的命名规范比任何代码注释都更管用。
分布式锁没有标准答案,只有适合自己业务场景的答案。把三种实现方式的原理、优劣吃透,再结合你系统的并发量和一致性要求去选型,踩过几次坑后就会形成自己的判断体系。希望这篇能帮你少走一些弯路。