
1. 从一次订单超卖事故说起先聊一个我踩过的真实事故线上商城做秒杀活动活动刚开始两分钟后台告警短信就炸了——库存扣成了负数。当时我们的订单服务有两台机器在跑扣减库存的逻辑是先查库存、再update数据库。两台机器同时读到库存剩余1件同时通过校验同时执行扣减结果库存变成了-1。加个synchronized就能解决单机并发问题但多台机器之间JVM锁是隔离开的谁也管不着谁。这时候就需要引入分布式锁让多台机器上的多个进程通过一个公共的第三方组件去排队抢一个“占用标记”抢到的人才允许执行后续业务。分布式锁的本质上就是在问一个问题同一时刻这个临界资源到底归谁用顺着这个问题往下选型你会发现市面上的方案看似五花八门真正普适的其实就三条路——基于Redis的缓存锁、基于ZooKeeper的临时节点锁、基于数据库的悲观锁或唯一索引锁。这篇文章我打算把你真正关心的东西一次讲透三种方案从底层原理到实现细节从踩坑现场到最终选型包含我实际生产环境里积累下来的判断依据。如果你是后端开发、架构师或者正在准备分布式相关的面试这篇内容应该能帮你省下不少弯路。2. Redis方案性能天花板但有个老毛病叫“丢锁”Redis做分布式锁应该是当前互联网公司里普及率最高的方案没有之一。原因很简单——它快而且接入成本极低走的是缓存那一套网络模型单实例QPS能到十万级别。但别急着欢呼Redis方案的坑也最多很多人在没理解透原理的情况下就上生产结果出问题后一脸懵。2.1 加锁的核心用法SETNX加过期时间一个命令解决Redis实现分布式锁最核心的命令是SETNX全称是SET if Not eXists语义是“只有当key不存在时才能设置成功”。加锁时执行命令来绑定一个线程标识和过期时间SET lock_key my_unique_id NX PX 30000这个命令拆解一下NX保证只有key不存在时才设置起到“互斥”作用PX设置key的过期时间为30秒防止持有锁的进程宕机后死锁my_unique_id这里不要存固定的字符串建议存当前进程的UUID或线程ID后面释放锁时要靠它做身份校验。很多人一开始写的是两条命令组合先SETNX再单独EXPIRE。这两条命令不是原子的——如果SETNX成功后还没来得及设置过期时间JVM就宕机了锁key会一直被占着后续所有请求全部阻塞。务必使用带过期参数的单一命令这是第一个最基础也最关键的注意点。释放锁时也不能直接DEL。假设线程A持有锁后处理业务超时锁已自动过期此时线程B拿到了锁开始干活。A处理完之后回来执行DEL把B的锁误删掉临界区就直接被穿透了。正确的释放姿势是这样的-- Lua脚本先校验持有者身份再删除 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段Lua脚本通过原子操作保证“校验身份删除”两步不被打断。你想想看这就好比有人进了会议室发现你根本不是会议室当前登记人就直接把门锁拆了后面的安排全乱套。误删锁的问题在生产环境是真实出现过的我见过不止一次因为释放锁不校验身份导致的库存超卖事故。2.2 锁续期到底为什么是必要的Redis锁设置过期时间的矛盾之处在于过期时间设短了业务还没执行完锁就自动释放了其他线程趁虚而入设长了一旦持有锁的节点宕机锁长时间不释放等于把整个分布式系统的可用性降级成了单机失败模式。那么真正周到的方案是引入自动续期机制。简单类比一下就是你去银行柜台办业务取号排队后系统担心超时把你的号作废于是每隔一段时间自动帮你续一次等待时间只有当你真正办完离开号才会释放如果窗口突然挂掉号在最大时限后也会自动作废不会永久占着。具体操作是加锁成功后启动一个守护线程每隔过期时间的三分之一就去执行一次PEXPIRE刷新过期时间业务执行完毕后主动释放锁并把这线程停下来。Java里Redisson框架已经把这个能力内置了叫watchdog默认给锁续期到30秒每隔10秒续一次。这套机制在Redisson里拿现成代码就能用但如果你是自己手写的锁千万别忽略续期逻辑。我再补一个极端场景持有锁的线程执行太慢到达续期节点时自身JVM发生了一次Full GC或者网络抖动导致续期请求晚到了几百毫秒锁还是提前过期了。另一个线程顺利拿锁。这时前一个线程醒来继续写数据互相覆盖。这属于Redis方案在架构层面没法根除的一个权衡你要有心理预期——它解决的是99%场景的高性能锁需求但它不是强一致性锁。2.3 主从切换导致的锁丢失最隐蔽的坑Redis集群模式下还有一桩公案客户端A向主节点写入锁key数据还没同步到从节点时主节点宕机了哨兵触发故障转移从节点升主。此时从节点上根本没有这个锁key客户端B来加锁竟然直接成功两个节点同时持有“锁”。像秒杀这种场景如果主从切换恰好发生在持锁期间那该超卖还是会超卖锁的意义直接归零。有人说用RedLock红锁方案解决——向多个独立的Redis节点同时申请锁只有超过半数节点加锁成功才算真正拿锁。这个方案在Redis作者和很多分布式系统研究者之间有争议核心反对观点是它在网络分区、进程暂停的场景下依然不能保证绝对安全。我的个人建议是如果业务允许少量重复流量就踏实用普通Redis锁性能好、代码成熟如果业务不能接受任何重复执行那干脆别用Redis直接上ZooKeeper。3. ZooKeeper方案用临时顺序节点换强一致性ZooKeeper做分布式锁的思路和Redis完全不一样它不依赖一条命令的互斥性而是依赖ZooKeeper自身的分布式一致性协议ZAB协议和节点模型。ZooKeeper本身是个分布式协调组件设计目标就是多节点之间维持一份强一致的数据视图所以由它来做锁天然比缓存系统靠谱。3.1 临时顺序节点一套清晰的排队机制ZooKeeper的节点类型里有两种比较特殊临时节点Ephemeral创建该节点的客户端会话结束主动断开、超时等后节点自动被ZooKeeper删除顺序节点Sequential创建节点时ZooKeeper会在节点名末尾追加一个单调递增的序号。把两者组合起来就得到了临时顺序节点这就是实现分布式锁的关键材料。假设锁的根路径是/locks/lockA所有客户端都在这个路径下创建临时顺序节点/locks/lockA/lock_0000000001 /locks/lockA/lock_0000000002 /locks/lockA/lock_0000000003规则是序号最小的节点代表当前持锁者其他客户端监听自己前一个节点的删除事件前一个节点被删掉时自己就去检查自己是不是最小序号如果是说明自己拿到了锁。这种“排队叫号”逻辑和Redis那种“一把锁拍下去”有本质区别。Redis方案里拿不到锁的客户端基本就放弃了或者自旋重试ZooKeeper方案里拿不到锁的客户端安心等待前面的号叫到自己即可逻辑上更公平不存在锁饥饿也不会因为频繁重试打爆Redis。3.2 Curator封装后代码到底有多简练实际操作中很少有人直接操作ZooKeeper原生API去监听事件因为原生API的连接管理、监听注册、异常重试都要自己处理工作量大且容易出错。Apache Curator框架把分布式锁封装成了一个叫InterProcessMutex的类用起来几乎是无脑的CuratorFramework client CuratorFrameworkFactory.newClient( zk1:2181,zk2:2181,zk3:2181, new ExponentialBackoffRetry(1000, 3)); client.start(); InterProcessMutex lock new InterProcessMutex(client, /locks/order_123456); lock.acquire(); try { // 这里放业务逻辑 } finally { lock.release(); }这里要提一句acquire()默认是个阻塞方法拿不到锁会一直等。你也可以使用acquire(long time, TimeUnit unit)传入等待超时避免业务无限期挂起。生产环境我强烈建议用带超时参数的版本因为ZooKeeper临时节点持锁和网络分区叠加时可能会出现锁一直不可得的极端情况宁可让请求快速失败回滚也不要让线程池全部阻塞。Curator的InterProcessMutex是可重入的同一个客户端线程可以多次acquire同一把锁内部用计数器维护重入次数。这一点内部实现非常精妙它解决了某些业务要在嵌套流程中重复对一个资源加锁的痛点。Redis的Redisson同样支持重入但数据库方案想支持重入就得自己设计额外字段复杂度会明显上升。3.3 羊群效应与超时时间你知道它为什么慢吗ZooKeeper可靠但网上很多声音说它慢。这里特别说一下“羊群效应”如果一把锁下创建的等待节点很多每个节点都去监听根节点或者前一个节点的状态变化一旦锁被释放ZooKeeper需要同时向所有等待节点推送通知。等待者越多通知风暴越严重这就是最早的“羊群效应”问题。当前主流的实现方式是只监听前一个节点把通知范围限制在相邻节点之间羊群效应被极大缓解。但是如果频繁加锁解锁ZooKeeper的写事务处理上还是有吞吐上限的。实测下来跨IDC网络环境下一次ZooKeeper加锁的全链路RTT在几十毫秒量级这和Redis的亚毫秒到一两毫秒差距明显。做高频短临界资源的锁ZooKeeper并不是好的选择做低频、但对一致性要求极高的资源它的可靠性优势就出来了。另一个常见误区是ZooKeeper会话超时参数的设置。锁的持有时间上限基本上与sessionTimeout绑定——会话超时了临时节点就没了锁自然被释放。如果你把sessionTimeout设成60秒业务却需要跑2分钟那锁在中途就丢了效果和Redis锁过期一模一样。但设太长呢持锁客户端挂掉后锁要等完整超时时间才被清理系统恢复速度慢。这块只能根据业务最大执行时间来权衡没有银弹。4. 数据库方案最朴素、最重也常被低估聊完Redis和ZooKeeper再来看数据库方案。在很多技术文章里数据库分布式锁往往被一笔带过理由是“性能太差不推荐”。这个判断不算错但过于武断。做技术选型本质是在约束条件下找最优解。如果你的系统本身已经重度依赖MySQL数据量不大并发写入量不高数据库锁会是三种方案里架构最简单、排障最直观的一个——毕竟锁的数据就躺在表里随时能查。4.1 用悲观锁的for update解决互斥数据库悲观锁依赖的是MySQL InnoDB引擎的行锁机制。实现逻辑很简单在一个事务内对目标记录的某一行执行SELECT ... FOR UPDATE这条语句会锁住该行事务提交或回滚时锁才释放。其他事务再执行同样语句时会被阻塞直到锁释放。为了用这个机制我们需要在一个独立的表里初始化出一条“锁记录”比如-- 分布式锁表 CREATE TABLE distributed_lock ( lock_key VARCHAR(64) NOT NULL COMMENT 锁标识, owner_id VARCHAR(64) NOT NULL COMMENT 持有者标识, expire_time DATETIME NOT NULL COMMENT 过期时间, PRIMARY KEY (lock_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;提前向这张表插入若干条锁记录比如lock_key order_stock_lock后续加锁事务就这么写BEGIN; SELECT * FROM distributed_lock WHERE lock_key order_stock_lock FOR UPDATE; -- 执行业务逻辑 COMMIT;这个方案的核心优势是数据一致性强依赖事务提交不存在Redis那种“主从切换丢锁”的问题。InnoDB行锁是存储引擎层面保证的和业务代码、跨节点时钟都无关只要数据库本身没有发生脑裂锁就是可靠的。但for update方案有一个必须记住的坑一定要在事务内执行。如果你用ORM框架又不小心把事务配置成自动提交那么SELECT FOR UPDATE执行完锁立刻就被释放了等于白锁。另外一个坑是它锁的是索引记录条件必须命中唯一索引或主键否则InnoDB会退化成锁整个表这个区别在小表上不直观在业务表上会引起严重的并发阻塞。4.2 唯一索引无锁化地用冲突替代等待如果你不想用事务包裹长业务逻辑还有一条基于唯一索引的旁路——利用数据库的“插入冲突”来做互斥。创建一个包含lock_key、owner_id等字段的表给lock_key建唯一索引加锁就是执行一条INSERT语句INSERT INTO distributed_lock(lock_key, owner_id, expire_time) VALUES (order_stock_lock, instance_1, 2099-12-31 23:59:59);只有插入成功的那一个客户端算拿到了锁其他客户端插入时触发DuplicateKeyException拿锁失败。释放锁就是DELETE掉这条记录。这个方法实现极其简单没有任何事务和锁等待在高并发下吞吐也很不错——因为实际上是靠数据库唯一索引的冲突检测而不是锁。不过它的痛点是没有自动过期机制。持有者宕机后记录会永久留在表里必须由业务后台或定时任务来清理僵尸记录加锁成功率直接依赖插入冲突的反馈业务上需要针对异常类型做区分判定等待间隔、重试策略都得自己写不像Curator那样有现成的监听机制。还有一种乐观锁思路是给业务表加version字段每次更新时带上期望版本号如果更新影响行数为0就说明version已被其他线程改过需要重试。但严格说乐观锁解决的是“多人并发更新同一行”的冲突检测问题它并不会阻塞其他线程去读相同的数据从互斥语义上和分布式锁并不完全等价。用不用要小心区分场景。4.3 数据库方案的性能分析与适用边界网上很多文章一说数据库分布式锁就只说“慢”这个结论是有前提的。拿MySQL来说单条SELECT FOR UPDATE的耗时正常情况下在几百微秒到几毫秒之间和Redis的网络RTT相差并不悬殊真正拖垮数据库的是锁等待——当同一行锁记录上堆积了大量并发事务时后面的请求全都阻塞在InnoDB的锁等待队列里数据库的活跃会话数会暴涨甚至拖垮整个实例。有一个通用的经验法则如果临界资源的并发争抢程度低数据库方案能扛住如果高并发、短时间几万甚至几十万的请求同时抢一把锁数据库方案会先顶不住。数据库方案更适合的场景是低频任务调度、定时任务互斥、对强一致性要求高于性能的运营后台操作。这就像一个高速路口平时车不多的时候用个简单的手动栏杆就够了非得花大价钱装一套ETC系统反而性能过剩和复杂度超标。5. 三种方案横向对比与选型地图方案之间的技术剖析已经说完了接下来把三者放在一张表里直观对照。我自己做选型时的判断标准是五个维度性能上限、可靠性、实现复杂度、运维成本、自愈能力。下面这个表格是我在业务里经常拿出来对着看的参考表对比维度RedisZooKeeper数据库性能极高毫秒以内中等几十毫秒量级低锁等待场景恶化明显可靠性存在主从切换丢锁风险高无丢锁问题高实现复杂度低Redisson直接引用中偏低Curator封装完善低SQL写好即可运维成本依赖Redis可用性需要单独维护ZK集群复用现有DB零额外组件自愈能力靠过期时间兜底临时节点随会话消失需手动清理或定时任务可重入支持支持支持需要自行设计无论做哪个选型我觉得真正决定成败的不是方案本身而是对业务场景的审视。围绕这个我再展开三类场景下的选型建议。5.1 低峰期项目优先考虑数据库对于后台管理系统、运营配置中心、定时报表生成这类业务请求并发量往往很小可能一个时间段内就只有几条线程在抢一把锁。最务实的选择就是数据库方案——不需要额外引入Redis和ZooKeeper集群不需要处理配置文件和密码SQL语句任何人都能看懂出问题时可以直接查表分析。我见过有的小团队为了给定时任务加锁专门搭了一套Redis集群最后因为监控缺失Redis挂了导致定时任务全部重复执行这其实是用一个复杂系统去解决一个简单问题。数据库方案里我特别推荐用唯一索引加插入记录来实现锁因为它的实现路径最短、可排查性最好。配合一个定时清理僵尸锁记录的Job就能稳定跑了。5.2 核心链路与二手细节并存的业务优先Redis涉及订单、库存、优惠券等核心链路的互斥场景Redis锁是最主流的选择。只要业务能容忍极低概率的重复执行比如在订单号生成、幂等写库时做二次校验Redis方案通吃。Redisson的RLock封装完善用起来很顺手内部已经解决了续期、重入、超时释放这些关键细节。但Redis锁这里有几个使用细则锁的粒度要尽量细化。比如锁订单资源时不要全局锁而是锁“订单ID对应的那个资源”并发能力差别巨大。锁的过期时间要按“业务最差执行时长”的1.5到2倍去设置而不是按平均耗时设置否则长尾请求必炸。业务执行完后记得在finally块中释放锁避免异常路径上锁永久占用。5.3 对强一致和防重复有硬性要求选ZooKeeper处理金融级别的扣款、分布式任务调度里的主节点选举、多机部署下的幂等审核等场景时我建议上ZooKeeper。虽然它的吞吐上限不如Redis但它在节点的会话管理机制上做得极其干净——临时节点随着会话一起销毁任何进程异常退出、断网超时都逃不过ZooKeeper的会话超时机制锁会被自动释放不存在“锁忘了删”这种低级问题。另外如果你们团队本来就有ZooKeeper集群大数据、Kafka等生态里的组件经常会用到它那复用的边际成本就更低了。反过来看如果为了做一把锁单独引入一个ZooKeeper集群我大概率会劝你冷静一下——毕竟它本身是AP架构系统运维门槛比Redis高不少。6. 生产环境实战加锁解锁都要讲究细节从选型落到代码再到稳定上线中间藏着的细节远比教科书多。下面几个是我在这些年实际维护分布式锁的过程中踩过坑、也最终沉淀下来的心得。6.1 Redis锁的过期时间怎么定一个容易被忽略的点很多业务执行耗时是波动的同一个接口平时十几毫秒遇到慢SQL或Full GC就能冲到好几秒。如果锁只设置了2秒过期慢请求在临界区还没走完锁就没了其他线程涌进来重复执行。建议把过期时间设置成指标数据中P99执行耗时的两倍并配合续期线程才能有安全余量。比如订单状态流转接口做完压测后P99耗时在800毫秒左右那我把锁过期时间定在2000毫秒同时开启Redisson自带的watchdog续期以防极端情况。如果业务里有些长事务周期能达到分钟级那就要慎重考虑——这种时长其实已经不适合用Redis锁扛了优先拆业务或改异步化。6.2 ZooKeeper会话超时和重试策略配置用Curator操作ZooKeeper时有两个连接层参数很重要。一个是sessionTimeoutMs它决定了临时节点的生存期另一个是connectionTimeoutMs是客户端和ZooKeeper服务端建连的超时上限。很多人把这两个混淆结果会话超时设了60秒连接超时设了3秒系统一抖动锁先丢了。重试策略我通常选ExponentialBackoffRetry设置基数为1秒重试次数为3次指数退避。不要配置成无限重试因为锁获取是一个瞬时操作如果ZooKeeper集群已经持续异常无限重试只会让线程池堆积成灾难现场。带上超时失败回退是更负责任的做法。6.3 释放锁的顺序与业务安全退出三种方案释放锁时的原则高度一致必须在finally代码块中释放绝不让异常路径绕过释放逻辑。更严谨的写法是在try之前加锁catch住所有代码不管业务成功还是失败只要能返回就给后续线程拿到锁的机会。还有一点容易被忽略释放锁之前的业务操作要确保事务已经结束。如果事务还没提交就去释放锁下一个线程进来读到的数据可能还是旧值等于锁是上了但保护的语义没保住。正确顺序是“先提交事务再释放锁”。这一点在Redis和数据库方案中我都吃过亏。6.4 加锁字段统一与幂等设计分布式锁标识即LockKey的生成要格外小心。比如用用户ID做锁就统一用同一个格式函数生成不能在A服务里生成的是字符串拼接结果到B服务里变成JSON序列化结果那样两个服务其实在抢两把完全不相干的锁。我建议在公共代码里提供统一的锁Key生成工具并加上业务前缀比如order_pay_123456和stock_deduct_10001便于排查和灰度。就算用了分布式锁核心业务也一定要保留幂等性作为兜底。锁是一道防线而不是唯一的防线接口层面再做个数据库唯一约束或者幂等表校验双重保障这样才能真正把事故窗口压缩到最小。我见过太多项目以为加了锁就万事大吉最后还是被极端并发和缓存穿透一起打了脸。7. 常见问题与排查技巧实录以下这些问题都是我实际生产和交流中见过的典型问题按出现频率排个序再附上排查思路你可以直接拿来当速查手册用。7.1 Redis锁偶尔失效业务重复执行先检查释放锁时是不是没做身份校验看看Lua脚本是不是直接拿DEL删的。再检查过期时间是不是太短业务执行时间是不是有长尾。最后看Redis集群是不是发生了主从切换。排查方法是在业务里打印加锁结果和锁标识对照日志时间线看看重复执行的两个请求之间锁到底是怎么交接的同时开启Redisson自带的日志输出锁的续期行为快速定位问题在哪一环。7.2 ZooKeeper锁长时间获取不到服务卡死排查时先看ZooKeeper集群的会话状态是不是session超时参数设得太短导致连接频繁重连。再用四字命令查看当前/locks节点下堆积的子节点数量。如果子节点数量很大说明前一个持有者释放异常或者锁Key设计得太粗一把锁被大量请求争抢。解决手段一是改用小粒度锁Key二是合理配置会话超时时间和等待超时参数三是检查持有者是不是在finally里正确释放了锁。7.3 数据库for update导致连接池打满这个问题在数据库锁方案里很常见。原因是等待锁的会话阻塞在事务里同时长事务还占着连接不释放连接池被耗尽。排查时通过数据库的信息模式表查当前活跃事务和锁等待关系定位到具体行锁记录。根治方法有两条一是把锁记录拆细比如按用户维度拆锁记录同一把锁的争抢量就少了二是增加获取锁的超时机制不要无限期等待等待超时即抛错返回让请求快速失败降级。8. 我的最终建议没有最好的方案只有最适合的方案分布式锁的选型脱离了业务场景谈优劣都是耍流氓。我的个人经验是这样多数互联网业务链路里Redis锁是综合性价比最高的解因为它足够快、足够成熟、生态足够完整核心资金操作或强一致场景宁可牺牲一点性能也要换ZooKeeper的确定性而系统体量小、并发量低、不想多维护任何组件时数据库方案反而是最稳妥的选择。还有一个小技巧分享给后面接手的同学无论选了哪种方案都要在落地之前画一份“锁的分布地图”——每个业务模块用了哪把锁、锁的保护范围是什么、锁的生命周期由谁管理、异常兜底是什么。这份图不一定要画得多么专业哪怕是写在文档里的一条条列表也好。等线上真的出了故障这份地图能让你少熬好几个通宵。分布式锁本身不复杂复杂的是用它保护的业务逻辑所以请永远保持对临界区代码的敬畏。