
打开多线程这把锁之后分布式环境里怎么再锁一次做过几年后端的人都懂这种痛单体应用里synchronized和ReentrantLock用得飞起订单扣库存、用户领券、任务调度都能稳稳锁住。结果一到微服务架构服务拆成三五个实例部署同一段代码同时在多个进程里跑原本的锁瞬间失效——因为JVM的锁本质上只对当前进程内的线程有效每个服务实例各有一把锁谁也不认识谁。高并发一上来重复下单、超卖、任务重复执行Bug一个接一个冒出来。这就是分布式锁存在的意义在多个进程之间协调对共享资源的互斥访问。它解决的核心问题只有四个字避免并发。今天我把分布式锁的三种主流实现方式——基于数据库、基于Redis、基于Zookeeper——从原理到代码到踩坑一次性讲透顺便把这些年在生产环境里掉过的坑、面试官爱问的陷阱点都告诉你。1. 先把分布式锁的底层逻辑理清楚1.1 为什么单体锁到了分布式环境就失效单体应用里synchronized、Lock这些锁的实现依赖JVM内置的对象头、监视器机制它们的作用范围就是当前的JVM进程。服务拆成多个实例后用户A的请求打到实例1用户B的请求打到实例2这两个实例的锁互不相干各自认为这资源归我了。结果就是两个请求同时执行了本该互斥的逻辑。还有个常见的误区有人觉得自己用 Redis 存个标志位就算分布式锁了比如如果 key 不存在就 set 成功否则失败。这个思路本身没问题但落地时细节极多一个环节没考虑到锁就失效或出事故了。1.2 一把合格的分布式锁必须满足哪些条件在做方案选型和技术方案评审时我会用下面这张清单逐一核对这也是面试官最爱问的分布式锁要解决哪几个问题条件说明例子互斥性任意时刻只能有一个客户端持有锁两个订单服务同时扣减同一件商品库存防死锁持有锁的客户端崩溃后锁必须能自动释放服务进程突然宕机锁不能永远不释放可重入可选同一客户端可以多次获取同一把锁一次业务请求中嵌套调用多个加锁方法容错性锁服务本身发生故障时不能导致大面积不可用Redis主节点挂掉、Zookeeper集群抖动锁的释放必须安全只能释放自己持有的锁不能误删别人的锁A服务把B服务的锁给删了造成并发冲突这里每个条件背后都有对应的实现细节。防死锁需要给锁加过期时间或者依赖会话自动清理安全释放要求锁的 value 携带客户端唯一标识容错性则直接关系到第二种和第三种方案选型的核心矛盾——要性能还是要一致性。1.3 三条实现路线的本质区别三种方式对应三种分布式协调的理论路径基于数据库把锁建模成数据库里的一行记录用行锁或者 CAS 条件更新来保证互斥。基于Redis利用单线程命令队列的原子性通过SETNX这类命令实现谁抢到就是谁的。基于Zookeeper利用临时顺序节点 Watch机制构建一个按创建顺序排队的公平锁。它们各自依赖的底层中间件特性完全不同没有绝对的最好只有针对业务场景的最合适。这也是为什么我遇到不少人问能不能只学一种不能——因为你不知道下家公司的基础设施是什么。后面我把每种方案的实际代码和坑都摊开讲。2. 基于数据库的分布式锁最朴素但容易歪楼2.1 方案一悲观锁直接拿数据库行锁当锁用思路很简单既然select * from table where id ?在事务里会给行加锁那我们可以建一张锁表把所有需要互斥的资源都定义成一行记录并发控制直接用数据库的行锁来兜底。实现大概是这样的-- 创建锁表 CREATE TABLE distributed_lock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, resource_name VARCHAR(128) NOT NULL UNIQUE, owner VARCHAR(64) NOT NULL, expire_time DATETIME NOT NULL, create_time DATETIME NOT NULL ); -- 获取锁 BEGIN; SELECT * FROM distributed_lock WHERE resource_name order:12345 FOR UPDATE; -- 检查 owner 和 expire_time判断是否有效无效则更新为自己的 owner UPDATE distributed_lock SET owner service-a-001, expire_time DATE_ADD(NOW(), INTERVAL 30 SECOND) WHERE resource_name order:12345; -- 执行业务逻辑... COMMIT;核心就是SELECT ... FOR UPDATE。只要事务不提交其他事务对这个行的FOR UPDATE查询就会被阻塞。锁的生命周期与事务绑定事务提交或回滚锁自动释放。这一点比手动删除锁记录的方式安全因为即使代码抛异常导致没有走到释放锁的逻辑只要事务回滚了数据库也不会一直锁住这条记录。但注意这个方案有一个边角陷阱FOR UPDATE必须命中索引否则会升级为表锁。锁表设计的resource_name字段务必加唯一索引否则两条不同的资源也可能产生锁冲突同时会出现并发量一大数据库先垮了的问题。2.2 方案二乐观锁用版本号或条件更新替代行锁乐观锁的思路是先修改提交时检查是否冲突。典型的做法是在资源表上加一个version字段-- 获取资源时带出版本号 SELECT id, stock, version FROM inventory WHERE id 1001; -- 扣减库存时带上版本号条件 UPDATE inventory SET stock stock - 1, version version 1 WHERE id 1001 AND version 5;如果UPDATE影响的行数为0说明版本号已变别人改过了需要重试或报错。这就是 CASCompare And Swap思想。它的实现成本极低不需要额外的锁表也不依赖事务。但它的本质不是锁而是冲突检测——在高并发下频繁重试会放大数据库压力并且ABA问题需要人工规避比如把版本号换成时间戳或生成一个随机的token每次更新时替换。2.3 数据库锁的优缺点和典型适用场景优点实现成本最低没有额外中间件依赖事务本身提供了较强的容错基础。缺点却非常明显性能天花板低。数据库行锁的竞争开销远高于内存操作压测到上千QPS就已经能感觉到明显延迟。锁表变成了单点。如果锁表所在库宕机所有依赖这把锁的业务全部瘫痪。乐观锁在高并发下废重试率很高悲观锁则会长时间占用数据库连接。没有天然的可重入和锁续期机制需要自己维护 owner、过期时间容易出Bug。适用场景内部管理系统、后台任务调度等低并发场景或者团队基础设施比较简陋、只有一套MySQL的环境。如果你高并发超过几千甚至上万别选数据库方案。我在一家传统企业里见过用这种方式做定时任务防重一天几百次的调用量完全没有问题但到了促销活动流量上来后确实撑不住。3. 基于Redis的分布式锁高并发场景下的主力方案3.1 基础版一条 SET 命令搞定加锁现在正规的加锁操作长这样SET lock:order:12345 service-a-001 NX PX 30000这条命令的完整语义是仅当lock:order:12345不存在时设置它值为service-a-001并设置30秒过期时间。NX保证互斥PX保证自动过期二者在一条命令里完成避免了分两步执行SETNXEXPIRE在中间某个时刻宕机导致永不过期的问题。很多老代码里还流行这样两步走SETNX lock:order:12345 1 EXPIRE lock:order:12345 30这个写法有个致命伤如果第一次SETNX成功后、执行EXPIRE前进程挂了这个key就永远不会过期锁变成死锁。当年Redis官方甚至专门发文提醒过这个问题就是这个原因——所以现在务必用一条命令完成加锁和过期时间的设置。对应的Java代码以Jedis为例// 加锁 String result jedis.set(lockKey, requestId, NX, PX, 30000); if (OK.equals(result)) { // 获取锁成功执行业务逻辑 } // 释放锁 String script if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end; jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));释放锁的代码里用了一个Lua脚本它的作用是先判断当前锁的 value 是不是自己的requestId是才删除不是就不删。为什么要多此一举因为存在一种极端情况A加锁后业务执行时间超过30秒锁自动过期了B拿到锁开始执行此时A执行完了执行DEL如果不做判断A就把B的锁误删了于是C又能拿到锁并发冲突瞬间出现。用requestId作为锁的value就相当于给这把锁打上了所属人的标记。3.2 进阶版Redisson看门狗是怎么续期的基础版有个明显的隐患锁过期时间设短了业务还没跑完锁就自己断了设长了都在等这把锁的客户端要等待更久。更关键的是你根本无法预估业务到底需要多少毫秒。Redisson 的看门狗机制专门解决这个问题。在我自己的项目里直接用Redisson拿锁RLock lock redissonClient.getLock(lock:order:12345); boolean locked lock.tryLock(0, 30, TimeUnit.SECONDS); if (locked) { try { // 正常业务逻辑不用担心锁中途过期 } finally { lock.unlock(); } }tryLock(0, 30, TimeUnit.SECONDS)的意思是等待锁的时间为0立即拿到就立即返回拿不到就立即返回falseleaseTime传30秒但如果你传了-1Redisson会启动一个后台定时任务——看门狗。默认每10秒执行一次只要锁还持有且业务线程还活着就自动把锁的过期时间刷新成30秒。这就像酒店续房一样只要你还在住前台就自动帮你延期根本不用操心超时问题。看门狗的本质是用一个定时任务反复调用 Lua 脚本执行PEXPIRE续期。它在绝大多数场景下都能保证业务没执行完锁不过期。但注意它也有边界——如果持有锁的进程发生长GC停顿看门狗线程也跑不了锁依然会过期。3.3 争议很大的 RedLock要不要用我只说结论Redis分布式锁还有一个享誉圈内又饱受争议的算法——RedLock。它的思路是同时对多个独立的Redis节点执行SET只要超过半数节点加锁成功就算获取锁成功。理论上有吸引力因为避免了单个Redis节点宕机导致锁失效。实际上在生产环境用的人很少原因有两个一是多个Redis节点要相互独立成本高二是 Redis 的异步复制机制意味着即使在主节点写入成功从节点没有及时同步时主节点挂了锁照样会丢失。关于这个点业内著名的几位工程师专门写过文章辩论过结论很耐人寻味RedLock在分布式系统里既不保证安全也不保证活性但它提供的是一个概率性的保证。大多数公司用主从复制 哨兵的模式已经足够真对一致性要求高的场景应该直接去用Zookeeper方案而不要幻想靠RedLock来拯救。3.4 Redis锁的适用场景与隐患清单Redis锁适用的场景非常清晰高并发、热点资源、对锁可用性要求高但对极端一致性要求稍宽的场景。比如秒杀系统的库存扣减、用户签到领积分、接口幂等性控制。但它有几处隐患是必须提前知道的锁过期时间TTL设置需要和业务最大执行时间对齐。如果业务偶尔出现超长执行锁可能被提前释放。解决办法是用Redisson的看门狗。不要用固定的字符串作为锁的value必须用全局唯一的请求ID否则无法安全释放锁。集群模式下主从切换可能导致锁丢失。比如A在主节点加锁成功主节点还没来得及同步到从节点就挂了从节点顶上后发现根本没有这把锁B就轻松地加锁成功了。很多人会低估最后一条——锁丢失不是概率问题而是时间问题。所以如果你平时的业务对并发重复极其敏感比如账务扣款、发券、资金操作请认真考虑Zookeeper方案。4. 基于Zookeeper的分布式锁一致性强代价也大4.1 核心原理临时顺序节点让每个人排队Zookeeper是一种强一致性的分布式协调组件它维护的是一个层级节点树。做分布式锁用到的是它两个特性临时节点和顺序节点。临时节点EPHEMERAL在创建它的客户端会话断开后会被自动删除这天然解决了持有锁的进程崩溃后锁无法释放的问题。顺序节点SEQUENTIAL会在节点名后追加一个递增序号谁先创建谁序号小。把这两个特性组合起来就可以实现一个公平锁多个客户端同时尝试创建同一个父节点下的临时顺序节点序号最小的客户端获得锁其他客户端监听自己前一个节点的删除事件前一个节点消失后下一个节点获得锁。这就像银行取号排队号最小的先办理办完了叫下一个号。4.2 用 Curator 的 InterProcessMutex 几十行落地生产环境一般直接用 Curator 框架封装好的InterProcessMutexCuratorFramework client CuratorFrameworkFactory.newClient( zk1:2181,zk2:2181,zk3:2181, new ExponentialBackoffRetry(1000, 3) ); client.start(); InterProcessMutex lock new InterProcessMutex(client, /locks/order-12345); boolean acquired lock.acquire(3, TimeUnit.SECONDS); if (acquired) { try { // 执行业务逻辑 } finally { lock.release(); } }InterProcessMutex内部做的事正好是上面我说的往指定路径下面创建临时顺序节点然后判断自己是不是子节点中最小的那个。如果不是就注册Watcher监听排在自己前面的那个节点。等前一个节点因为锁释放或者会话超时被删除自己就会收到通知再检查一次自己是否成为最小节点。这套机制比Redis轮询友好得多相当于有人在门口喊你到你了。4.3 和Redis方案比较CP和AP的取舍在分布式系统理论里Zookeeper属于 CP 系统优先保证一致性Redis属于 AP 系统优先保证可用性。两者对锁的理解路径不同维度Redis分布式锁Zookeeper分布式锁一致性模型最终一致可能会锁丢失强一致分布式锁不会丢失防死锁依赖过期时间需看门狗临时节点随会话自动清理释放锁安全需要Lua脚本判断value只能由持有者删除节点性能极高毫秒级延迟较高ZAB协议广播开销QPS远低于Redis羊群效应无无羊群效应接替机制更平滑运维成本低Redis常见高需要单独维护Zookeeper集群我特意把羊群效应列了出来说明一下Redis如果只是轮询等待锁释放几十个线程同时在那重试一旦锁释放大家一起涌入造成瞬时压力峰值。Zookeeper由于每个客户端只监听自己前一个节点锁释放后只有一个节点被唤醒不会有集群式打鸡血的情况。4.4 Zookeeper锁的短板Zookeeper锁最大的短板是性能和扩展性。它的写操作要经过Leader节点并且ZAB协议要求写入需要超过半数节点确认一个创建节点操作在3节点集群中延迟大约在几十毫秒高并发下QPS上不去。另一个问题是集群会话超时时间sessionTimeout的设置如果某客户端因为GC停顿导致Zookeeper认为会话超时锁会被自动释放其他客户端就能加锁成功业务感受就是锁莫名其妙被抢走了。还有一点容易被忽略Zookeeper的节点数量会影响性能频繁创建和删除临时节点会产生大量的Sync和Proposal操作让集群压力不小。如果你每天就几千个锁请求无所谓但如果是几万甚至几十万的请求量建议先压测看看集群的吞吐是否吃得消。适用场景一句话对数据一致性要求严苛、对锁可靠性要求极高、请求量适中每秒几百到几千的业务。典型场景包括分布式事务中的资源锁、配置中心更新、任务调度中的防重调度。5. 实战选型与高频面试追问5.1 三个方案选哪个我给一套可落地的决策逻辑不要一上来就问哪个方案最强要根据业务特征做取舍。我一般按这个优先级决策如果并发量极低、没有Redis或Zookeeper基础设施选数据库。如果并发量高、业务可以容忍极小概率的重复执行选Redis Redisson。如果系统已有Zookeeper集群、或者业务对一致性要求极高比如资金、订单防重复选Zookeeper。还有一个很现实的考量点团队对中间件的掌握程度。Redis锁的坑几乎全部藏在线程模型、网络异常和GC里排查难度大Zookeeper锁的坑藏在会话管理和性能里但逻辑更直观。整体上中等团队我会推荐Redis Redisson用看门狗把超时这个最大的隐患解决掉性价比最高。5.2 面试必杀的三个追问建议背下来第一问Redis锁到期了但业务没执行完怎么办这题考的就是锁续期。答可以用Redisson的看门狗机制后台每隔一段时间自动续约保证业务执行期间锁不会过期。如果不用Redisson也要在业务代码里手动开启一个守护线程去续期。第二问怎么防止锁误删这题考的是你是否想过锁的归属问题。答加锁时在value里存唯一标识UUID或业务ID释放锁时先判断value是否一致一致才执行DEL并通过Lua脚本保证判断删除两步操作的原子性。第三问Zookeeper和Redis分布式锁的区别这题不是考你背诵而是考察你对CAP的理解。答Redis是AP追求高可用和性能极端情况下可能丢锁Zookeeper是CP保证每把锁只会被一个客户端同时持有因为创建节点需要集群中多数节点确认临时节点依赖会话会话断开锁自动释放。这三个问题在面试中出现频率极高能流畅回答清楚面试官对这块就基本满意了。5.3 线上事故复盘我踩过的那次锁到期的坑提一个我自己经历过的真实事故。某个服务用基本的SETNX做定时任务锁业务里有一个步骤是调用外部接口拉取数据外部接口偶尔延迟到40秒而锁过期时间只设置了20秒。第一次遇到外部接口超时加长锁没有及时续约另一台机器上的定时任务就启动了两个任务同时处理同一批数据数据错乱事后花了一个多星期去修复脏数据。这次事故让我彻底明白了两个道理第一锁的过期时间不是一个可以随便拍脑袋设置的参数你要对业务的非功能性表现有预期接口的P99耗时、极端超时时间都要纳入考虑第二永远不要在业务代码放到锁里之后还保留锁会自动释放的侥幸心理要用看门狗或者直接选用支持自动续期的方案。还有一个小技巧加锁后最好在 finally 块里释放锁并且释放时要检测当前线程是否持有锁避免释放别人的锁。这个在Redisson的tryLock返回false的场景下尤其重要——你没拿到锁就千万别调用unlock()否则会误删别人刚拿到的锁。5.4 写给自己团队的四条铁律经过多轮踩坑之后我们团队总结了一套分布式锁的使用规范这里分享出来加锁必须有超时不能允许永久锁。KPI上必须看到这个Key的TTL。加锁和释放锁的代码必须对称获取锁成功了才释放释放时确保自己的身份。锁的粒度尽量小比如只锁具体订单ID而非整个订单表把并发冲突范围压到最小。锁内部不要执行耗时过长的操作尤其是远程IO调用。锁能包住非业务逻辑也能被非业务逻辑拖垮。最后说点真心话从我个人的经验来看分布式锁没有银弹。数据库方案适合低频率的内部系统Redis方案高并发下表现最好Zookeeper方案在强一致性场景最稳。选型的核心永远是业务本身有多不能容忍重复执行——如果重复执行的后果是发错一毛钱、数据写错一条那就选CP如果最多是多扣一次积分、多发一张可有可无的券Redis完全够用。分布式锁只是工具知道工具适用的边界才算真正掌握它。