高并发下分布式锁选型与避坑指南:Redis、ZooKeeper、etcd深度对比

发布时间:2026/9/16 4:19:28
高并发下分布式锁选型与避坑指南:Redis、ZooKeeper、etcd深度对比 高并发下分布式锁选型我这些年踩过的坑和最终方案做高并发系统分布式锁几乎是一道绕不过去的坎。不管是电商秒杀、库存扣减、订单幂等还是定时任务防重只要涉及多个服务实例同时操作同一份资源就得靠分布式锁来兜底。我在实际项目中用过的方案不少从早期的数据库锁到后来的Redis、ZooKeeper、etcd都深度接触过踩过超卖、死锁、锁失效这些坑也总结出了一套自己的判断逻辑。这篇文章会把我的选型思路和实现经验完整写下来给正在做技术选型或者被分布式锁折磨的同学一个参考。先说结论没有完美的分布式锁方案只有适合你业务场景的选择。Redis锁胜在性能适合大多数高并发业务ZooKeeper/etcd锁胜在强一致适合对数据准确性要求极高的场景数据库锁适合低并发兜底。但真正的难点不在选型而在实现细节——锁的原子性、防误删、可重入、续期、容灾每一个点处理不好都会出大问题。1. 分布式锁的适用边界与需求拆解1.1 什么场景真正需要分布式锁很多人一上来就说“我要分布式锁”但先得搞清楚你的业务是不是真的需要单机应用用JVM自带的synchronized或ReentrantLock就够了只有在多实例部署、多进程访问共享资源时才需要跨节点的互斥机制。我在早期评审过不少方案有些业务单机锁就能解决非要引入Redis锁结果引入了一大堆运维复杂度。判断是否需要用分布式锁我会从三个方面来评估第一是否真的有多个实例在处理同一个业务第二业务对数据准确性的容忍度是多少比如库存扣减是允许超卖还是绝对不允许第三对性能的要求有多高比如Redis锁一次操作通常在1ms以内而数据库锁可能要到10ms级别。说白了分布式锁交易的是“一致性”和“可用性”之间的平衡。如果你的业务允许最终一致那很多场景稍微改改设计就能避免锁——比如把并发扣减改成异步队列串行化比如用数据库乐观锁的版本号。这些设计很多时候比分布式锁更优雅、更可靠。1.2 分布式锁必须具备的四个核心特性不管选什么方案分布式锁的基本特性是统一的。我在和团队沟通时习惯用这四个维度来评估互斥性、安全性、死锁避免、容错性。互斥性是底线同一时刻只能有一个客户端持锁。安全性指的是锁必须只能被持有者释放不能出现A线程加的锁被B线程误删的情况。死锁避免要求锁一定有超时机制即使客户端崩溃也能自动释放。容错性则要求锁服务自身是高可用的不会因为Redis集群某台机器挂了就导致业务全挂。这里我要强调一下安全性和死锁避免的区别。很多初学者会把它们混为一谈但实际是两件事。比如Redis的SET lock EX 10虽然解决了死锁问题但如果不做value校验就直接DEL就会出现锁被其他线程释放的安全问题。这两个点是实现分布式锁最容易出错的地方后面我会结合实际代码详细讲。注意选型时不要只看功能更要看方案在异常情况下能做成什么样。分布式锁的代码路径很短但异常路径很长真正拉开差距的往往是异常处理能力。2. 主流分布式锁方案横向对比与选型逻辑2.1 五大方案的优缺点与适用场景我把主流的分布式锁方案梳理成了下面这个表格这是我在项目选型时的基础判断框架。需要注意这些评价是基于常规使用场景不排除有团队通过大量定制化改造来做特殊优化。实现方案性能单次锁操作可靠性实现复杂度典型适用场景数据库唯一索引100ms级依赖数据库可行低低频业务、已有数据库、可接受悲观锁数据库乐观锁版本号100ms级依赖数据库一般低更新类竞态、冲突率较低Redis单机/主从亚毫秒~毫秒级主从切换有锁丢失风险中高并发、可容忍极小概率锁失效Redis RedLock毫秒级理论优于单机仍存争议中高对锁安全要求较高的Redis方案ZooKeeper毫秒级高顺序节点Watch中高强一致场景、写多于读、配合ZK生态etcd毫秒级高Raft协议中高云原生环境、K8s生态、强一致场景从性能上看Redis方案最优但它的可靠性在极端情况下是存疑的——比如Redis主从切换的时候锁数据还没同步到从节点就发生故障就可能导致两个客户端同时拿到锁。这个概率极低但在金融级、对账级业务里可能就是不可接受的。ZooKeeper和etcd走的是AP/CP路线中的CP路线锁数据强一致不会出现双持锁的问题。代价是性能不如Redis而且如果锁操作特别频繁ZK的顺序节点创建和删除会让ZooKeeper集群压力较大。etcd因为用了Raft协议写性能比重启后的ZK要好一些而且在云原生环境天然友好。2.2 选型决策树从业务诉求推导方案选型不是一个并列对比而是根据业务约束条件逐步推导的。我给自己总结了一个决策路径分享给你参考。第一步确认“是否需要真正的分布式锁”。如果只是单实例部署直接JVM锁如果只是防止重复提交用幂等表可能更轻量。第二步如果必须用分布式锁看业务对锁可靠性的要求。我通常把业务分为三类可容忍极小概率锁失效的秒杀、非核心活动完全不能容忍锁失效的资金类、对账类、极端严格且要求锁服务自治的需要自建锁服务的平台型公司。第三步结合现有技术栈。项目里已经有Redis就直接用Redis已经重度使用ZK就用ZK不用为了锁单独引入一个新组件。运维成本和团队成员对组件的熟悉程度也是选型的一部分。第四步评估团队对锁方案的掌控度。分布式锁的Bug往往在极端场景才会爆发团队如果没有对某组件的深入理解建议选择更简单、社区更成熟的方案。我个人在大多数高并发业务场景下的默认选择是Redis但会通过Redisson框架来规避直接撸Redis命令的细节坑同时用合理的value设计和续期机制来提升安全性。只有遇到强一致诉求时才会切换到etcd或ZK。3. 高并发场景下的核心实现方案深度解析3.1 Redis实现方案从原生命令到Redisson封装Redis实现分布式锁最常见的写法是用SET key value NX EX timeout原子命令来加锁这个命令保证了“加锁超时”的原子性避免了旧版本中用SETNX和EXPIRE分开执行可能出问题的坑。加锁代码如下// 加锁key为锁资源名value为唯一标识NX保证互斥EX设置过期时间 String lockKey product:123:lock; String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);这段代码看起来简单但有几个关键点必须理解。requestId是客户端唯一标识它的作用是释放锁时校验“这是我的锁”防止误删别人的锁。30是锁的过期时间这个值不能设得太短否则业务还没处理完锁就自动释放了也不能太长否则Redis宕机后锁无法快速恢复。释放锁这边不能只是简单DEL必须先校验value再删除而且校验和删除必须是原子操作。Redisson用Lua脚本解决这个问题if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end上面的Lua脚本已经是“判断删除”原子执行的可以避免并发场景下误删锁的问题。如果你不用Redisson而是自己写原生Redis命令一定要把这个Lua脚本复制到你的工程里用起来。再来说Redisson。Redisson封装了完整的分布式锁对象其中最核心的价值在于看门狗自动续期机制。默认情况下Redisson的RLock锁默认过期时间是30秒但看门狗会每10秒检查一次如果锁还在被持有就自动把过期时间续到30秒。这就能避免一个问题业务代码执行时间超过了锁的过期时间导致锁提前释放其他线程趁虚而入。RLock lock redissonClient.getLock(product:123:lock); try { // 尝试加锁最多等待5秒锁自动过期30秒看门狗自动续期 boolean locked lock.tryLock(5, 30, TimeUnit.SECONDS); if (!locked) { // 返回抢锁失败响应 return; } // 业务逻辑... } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }tryLock(5, 30, TimeUnit.SECONDS)里的5是等待时间30是锁的租约时间。outines设置leaseTime后看门狗就不会续期了这一点非常关键。如果你传了leaseTimeRedisson认为你明确知道锁要持有多久不会再做看门狗续期如果你不传leaseTime默认用30秒并开启看门狗。我建议在绝大多数业务场景用不传leaseTime的方式让看门狗来自动续期避免业务长耗时导致锁提前释放。3.2 ZooKeeper实现方案临时顺序节点与Watch机制ZooKeeper实现分布式锁原理可以这样理解多个客户端在同一个持久锁节点下创建临时顺序节点序号最小的节点代表持有锁其他节点监听自己前一个节点的删除事件。这套机制的优势在于第一临时节点的生命周期和会话绑定客户端如果挂了会话超时后节点自动删除锁必然释放不会死锁第二顺序节点让竞争过程有序化客户端按序号排队不会出现惊群效应第三监听前一个节点而非所有节点每个客户端只需关注一个KeyZK集群的压力远小于传统分布式锁监听所有节点的做法。我用Java原生ZK客户端封装时核心逻辑大致是这样的public boolean tryLock(String lockPath, long timeout, TimeUnit unit) { String lockNode zkClient.createEphemeralSequential(lockPath /lock-, null); ListString children zkClient.getChildren(lockPath, false); Collections.sort(children); if (lockNode.endsWith(children.get(0))) { // 当前客户端创建的是最小序号节点拿锁成功 return true; } // 找到前一个节点并注册Watcher等待其删除 String prevNode findPrevNode(lockNode, children); CountDownLatch latch new CountDownLatch(1); zkClient.exists(lockPath / prevNode, event - { if (event.getType() Watcher.Event.EventType.NodeDeleted) { latch.countDown(); } }); // 阻塞等待超时则返回失败 return latch.await(timeout, unit); }这套实现最关键的参数是会话超时时间。如果设置太短比如5秒客户端一旦发生GC停顿或者网络抖动ZooKeeper就会认为客户端挂了主动删除临时节点导致锁被提前释放。如果设置太长比如60秒客户端真正挂掉后锁要等60秒才能释放这段时间其他客户端全部等待。我的经验是结合业务和网络环境通常设置在10到15秒比较合适。实际生产中直接裸写ZK原生API的情况不太多了Curator框架提供了现成的InterProcessMutex分布式锁内部封装了临时顺序节点的创建、排序、监听和重试逻辑并且支持可重入。用Curator的话十行代码就能完成锁的使用CuratorFramework client CuratorFrameworkFactory.builder() .connectString(zk1:2181,zk2:2181,zk3:2181) .sessionTimeoutMs(15000) .retryPolicy(new ExponentialBackoffRetry(1000, 3)) .build(); client.start(); InterProcessMutex lock new InterProcessMutex(client, /locks/product_123); if (lock.acquire(5, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.release(); } }3.3 etcd实现方案Raft共识下的租约机制etcd是云原生场景下的首选也是很多K8s组件内部使用的一致性存储。它实现分布式锁的方式和ZK类似核心逻辑是Lease租赁机制和Revision单调递增。在etcd的实现中客户端需要先创建一个租约Lease这个租约带有TTL然后通过事务方式在指定的锁目录下创建key并将key绑定到租约上。因为etcd采用Raft协议所有写操作都要经过多数派节点确认所以不存在主从切换导致的锁丢失问题。真正让etcd锁体现出全文强大的地方在于它的Revision机制。etcd中的每一次写操作都会分配一个全局递增的Revision客户端创建锁后比较自己key的Revision是不是所有竞争者中最小的如果是最小的就拿到了锁否则就Watch前一个Revision的key等待释放。这个机制天然就是一个公平锁而且客户端挂掉后租约会自动过期锁也会被清理。Go语言生态下用etcd实现锁通常会直接用clientv3的Lock接口cli, _ : clientv3.New(clientv3.Config{ Endpoints: []string{etcd1:2379, etcd2:2379, etcd3:2379}, DialTimeout: 5 * time.Second, }) defer cli.Close() // 创建5秒TTL的租约 lease, _ : cli.Grant(context.Background(), 5) // 使用租约加锁 ctx, cancel : context.WithCancel(context.Background()) defer cancel() // 内部实现在 /locks/product_123 路径下创建 key并通过事务和Revision保证互斥 lock, _ : cli.Lock(ctx, /locks/product_123, clientv3.WithLease(lease.ID)) defer cli.Unlock(ctx, lock)这套方案我用在过一个K8s平台的数据同步组件上它的优点很明确部署在K8s里的服务天然适合etcd交付链路短强一致性换来的是“要么获取到锁、要么明确失败”没有中间模糊地带。缺点是需要额外运维etcd集群如果公司没有现成的etcd而Redis又已经大规模使用那用etcd的性价比就低了。3.4 基于数据库的方案唯一索引与乐观锁数据库实现分布式锁有两种常见路径唯一索引和乐观锁。唯一索引方案是在数据库建一张锁表锁名作为唯一索引需要拿锁就插入一条记录插入成功代表拿到了锁释放锁就删除这条记录。这个方案的优点是简单、完全依赖现有数据库缺点是频繁插入删除会带来行锁竞争和索引碎片性能天花板很明显高并发下数据库会先成为瓶颈。CREATE TABLE distributed_lock ( lock_key VARCHAR(64) PRIMARY KEY, request_id VARCHAR(64) NOT NULL, expire_time DATETIME NOT NULL ); -- 加锁插入成功即拿到锁 INSERT INTO distributed_lock (lock_key, request_id, expire_time) VALUES (product:123, uuid-xxx, DATE_ADD(NOW(), INTERVAL 30 SECOND)); -- 释放锁只删除自己request_id对应的记录防止误删 DELETE FROM distributed_lock WHERE lock_key product:123 AND request_id uuid-xxx;乐观锁方案是给数据表加一个version字段更新时带上version条件如果版本号不匹配就说明数据已被其他事务修改需要重试。这种方式非常轻量适合库存扣减、余额变更等针对单行数据的操作。我在一些低并发场景用它替代分布式锁省去了维护Redis和ZK的成本。从长期运维角度看数据库方案适合作为兜底或者用在低频管理后台操作上。真正的高并发路径我不会走数据库锁因为数据库行锁等待、连接池耗尽、死锁检测这些连锁反应会拖垮整个数据库集群。4. 锁粒度与性能调优高并发下分布式锁的优化方向4.1 锁粒度控制——从整表锁到按业务维度拆分高并发场景下分布式锁最容易被忽略的问题就是锁粒度。很多人一上来给整个订单表加锁结果所有订单的并发请求互相等待系统吞吐量直接被按在地上摩擦。锁粒度越细并发能力越强但实现复杂度也越高。以商品库存扣减为例如果给所有商品用一个锁product:all:lock那么即便不同商品的购买请求完全没有资源冲突也会被锁串行化。正常情况下应该把锁的Key设计成业务维度的粒度比如product:123:lock表示商品123的库存锁。如果单个商品内的并发仍然很高还可以用分段锁把库存拆成多个仓库段每段用独立的锁// 库存分段锁按商品ID和仓库编号生成锁Key String lockKey product: productId :warehouse: warehouseId :lock;这样拆分后同商品不同仓库之间的扣减互不影响单商品的总吞吐量就上去了。当然分段锁要额外处理库存汇总的问题复杂度上升一个级别需要结合业务判断值不值得。在保持业务正确的提前下还有几个降低锁粒度的常见做法第一把锁放在资源更新阶段而不是整个业务流程的入口第二锁内只做必要操作把远程调用、IO操作挪到锁外面第三能用读锁和写锁区分的场景用读写锁替代互斥锁读多写少时并发能力会有质的提升。4.2 缓存优化与锁内耗时压榨锁的持有时间直接决定了分布式锁的性能上限。我做过一个优化监控发现一个订单创建流程中的锁持有时间是120毫秒但真正必须串行的部分只有15毫秒其余105毫秒都消耗在了外部接口调用、Redis写日志、消息队列发送上。把不必要的逻辑移出锁的范围后接口TPS提升了将近4倍。锁内慢调用是性能杀手包括远程RPC调用、数据库大批量操作、网络等待。遇到这种场景优先考虑把慢操作异步化在锁内只做状态标记或版本校验锁外再执行真正耗时的业务逻辑。这在库存场景非常典型先在锁内预占库存把库存扣减结果返回给用户然后异步推送消息到MQ后台任务再执行后续的发货等流程。另一个优化点是减少持锁期间的Redis操作次数。有些同事在锁内用一个for循环处理批量数据每处理一条就写一次Redis锁持有时间自然被放大。比较好的做法是把锁内要操作的数据先聚合对Redis做批量写、Pipeline或者Lua脚本化处理把多次网络往返压缩成一次。Redis Pipeline批处理的示意如下// 在锁内使用Pipeline批量更新库存减少网络RTT ListObject results redisTemplate.executePipelined((RedisCallbackObject) connection - { for (Item item : items) { connection.hIncrBy(stock:info:.getBytes(), item.getId().toString().getBytes(), -item.getCount()); } return null; });4.3 高并发压测与锁性能评估方法选型前做一轮针对性的压测比看多少篇文章都有用。我在做分布式锁方案对比的时候会用同一个业务方法分别挂上Redis锁、ZK锁、等不同方案的锁用压测工具模拟100、500、1000、2000并发线程去抢同一个锁记录TPS、P99耗时、等待超时次数这些指标。压测中我特别关注三个数据P99耗时、抢锁超时率和锁提前失效次数。P99耗时反映了大部分请求的真实体验平均耗时容易被极值掩盖抢锁超时率可以看到锁服务和业务负载是否匹配锁提前失效次数则是可靠性指标需要结合业务日志来判断。测试时要注意模拟真实场景的锁持有时间。如果业务锁内平均执行50毫秒压测就必须让线程持有锁50毫秒再释放而不是空转拿锁放锁。空转测试只能验证锁组件的极限性能不能代表业务性能。我记得有一次压测一个Redis锁方案在2000并发下P99耗时飙到了300毫秒排查了半天发现是Redis连接池配置太小大量线程阻塞在获取连接上。调大连接池参数后P99立刻降到了40毫秒。锁的性能问题很多时候根本不是锁本身的问题而是底层资源配比问题。5. 常见问题与避坑指南分布式锁实践中真实踩过的雷5.1 Redis锁失效的三类场景与应对Redis分布式锁最大的争议就在数据一致性上。我遇到过、也看过别人踩过的典型故障主要有三类。第一类是锁提前失效。原因很简单业务执行时间超过了锁的过期时间锁自动释放其他线程拿到了锁并进入临界区。解决办法就是Redisson看门狗自动续期或者严格评估业务耗时来设置足够大的过期时间。如果自己实现续期逻辑我建议用Timer定时广播但要比Redisson更谨慎地处理网络抖动导致续期失败的情况。第二类是误删锁。A线程拿到锁后执行时间较长锁过期自动释放B线程此时拿到同一个锁并进入临界区A线程执行完调用DEL命令把B线程的锁删了B线程的互斥瞬间失效。解决方法是锁的value必须是唯一标识释放前先通过Lua脚本判断value是否一致再删除。这正好对应了我在前面1.2节里强调的“安全性”问题。第三类是Redis主从切换导致的双持锁。当Redis采用主从架构时A线程在主节点上成功设置锁但锁数据还没来得及同步到从节点主节点突然宕机从节点被提升为新的主节点此时B线程上来也能设置同一个锁两个线程同时持锁。RedLock方案的思路是向多个独立Redis节点申请锁超过半数写入成功才认为加锁成功但关于RedLock的理论争议也不少我没法在这里下定论只能建议你如果锁失效的后果非常严重优先考虑ZooKeeper或etcd而不是在Redis里试图修补。提示如果你的团队对Redis的理解还没有深入到可以自如判断锁的异常路径在重要业务上宁可选择ZK/etcd。分布式锁的坑往往不是第一次上线暴露而是在某次网络抖动或故障演练中突然爆发。5.2 锁超时与死锁问题的定位思路我在生产环境排查过好几个“锁一直卡住”的问题共同的核心现象是有人持有锁但一直没有释放。定位这类问题我会按这个顺序来查。先用Redis的TTL命令或者ZK的节点状态查看锁的剩余时间和持有者信息判断锁还没过期是被续期了还是设置的过期时间太长。然后看持有锁的节点日志确认对应线程是在正常执行业务还是卡在某个依赖上。最常见的是锁内调用外部接口外部接口响应超时线程一直等待其次是线程池资源耗尽任务排队迟迟不执行锁就这么一直攥着。最后检查是否在finally里漏写了释放代码尤其是异常路径我在团队的Code Review中多次看到加锁后有一段复杂逻辑冒异常返回返回前没有释放锁。这里有一个很实际的建议团队里应该约定锁内代码必须用try/finally包裹释放动作Proxy切面或者AOP统一加锁释放的做法会更规范可以避免散落的锁代码里漏掉释放。Redisson的RLock配合tryLock和unlock时加锁和释放也要保持在同一线程内。5.3 锁与事务的纠缠数据库连接被占满的惨痛教训分布式锁结合Spring事务时有一个非常有名的坑事务还没提交锁已经释放了。这种情况可能会导致另一个线程在第一个事务尚未提交时就读到了旧数据看起来像是并发安全被破坏。这个问题的根因是Spring事务的提交动作发生在方法返回之后、由事务管理器统一处理而你的加锁解锁往往就包在同一个方法内。如果你在方法内先释放锁再返回事务还没提交下一个拿到锁的线程读到的数据还是旧版本。我踩过一次秒杀场景下用Redis释放锁后马上发MQ消息消费者立刻查库发现库存还没扣成功数据不一致直接导致用户投诉。解决思路有两种一是把锁的范围扩展到事务提交之后比如在调用方加锁让事务方法只处理数据写逻辑二是利用事务同步器在afterCommit阶段释放锁保证锁释放一定发生在事务提交之后。第二种方式用起来比较顺手Spring的TransactionSynchronizationManager.registerSynchronization可以做到。// 在事务内执行业务并提交后再释放分布式锁 try { // 业务操作 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { lock.unlock(); } }); } catch (Exception e) { lock.unlock(); throw e; }这个细节如果你没经历过很难提前想到但一旦在高并发场景下出现查问题会查得人崩溃。希望看到这里的同学直接避开这个坑。5.4 公平锁与非公平锁在高并发场景的选择另一个容易被忽略的设计点是锁的公平性。Redis的SETNX锁本质上是一个非公平锁——谁网络快、谁先到Redis谁就能拿到锁这可能导致某些客户端连续获得锁而其他客户端长时间饥饿等待。如果业务对公平性有要求可以用ZooKeeper或etcd的公平锁实现它们的顺序节点监听机制天然保证了先到先得。Redis想实现公平锁则要依赖Redisson的FairLock内部基于Redis的List和ZSet结构维护一个等待队列每次释放锁时从队列中取出等待时间最长的客户端。用不用公平锁要看业务场景。防止优惠券超发可以接受非公平因为谁抢到都无所谓但是像分布式任务调度多个节点去抢一个定时任务长时间让同一个节点执行会对该节点造成压力偏高这时公平锁会更适合。5.5 监控告警与可观测性没有监控的分布式锁是失控的分布式锁对业务的影响很多时候是渐进式的。刚开始流量低问题是偶发流量上来后锁等待变长、超时变多但你不太容易第一时间感知到。所以给分布式锁加上监控告警是非常值得的投入。我在Redis锁方案上会打四类指标加锁耗时、解锁耗时、锁等待时长、锁持有时间。这四个指标配合Redis的慢查询和连接池监控能快速确认问题出在锁实现本身还是底层依赖。锁持有时间可以用Redis的TTL变化来估算也可以在Redisson的监听事件里记录更好的方式是通过AOP拦截加锁和解锁逻辑统一埋点。另外锁资源最好纳入统一治理。我在项目中会维护一个锁Key清单标注每个锁使用的业务场景、过期时间、负责人。这样一旦有锁问题根据日志里的锁Key就能直接找到对应团队和代码。运维的同学对这个清单也很喜欢排查问题不用再满世界问人了。6. 具体场景下的方案落地参考6.1 秒杀库存场景Redisson分段锁实操记录去年我做一个电商秒杀项目库存只有500件但瞬间涌入的流量有数万级。直接用单一商品的Redis锁会让请求全部排队TPS上不去用户看到的就是页面一直转圈。最终我们采用了两级优化组合。第一级在入口处做了限流通过Sentinel对秒杀接口设置QPS阈值把超出阈值的请求快速失败并返回“已售罄”。第二级在真正扣库存时使用Redisson的读锁不对整单加锁而是对库存分段加锁。具体来说我们在Redis里为每个商品维护了N个库存桶// 商品123的库存拆分为10个桶每个桶一个锁Key int bucket threadLocalRandom.nextInt(10); String lockKey product:123:bucket: bucket :lock; RLock lock redissonClient.getLock(lockKey); boolean locked lock.tryLock(0, 30, TimeUnit.SECONDS); try { if (!locked) return; // 扣减该桶库存如果桶库存不足则快速返回失败 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这样做最大的变化是原来所有秒杀请求都抢同一把锁现在平均分散到10把锁上并发能力几乎翻了10倍。最终压测结果显示单商品秒杀的TPS从原来的2000提升到了15000以上且P99耗时保持在50ms以内。不过分段锁伴随的副作用是库存汇总的不确定性。我们在用户下单前先查所有桶的总剩余库存用于展示而真正扣减时不管哪个桶扣到为止。如果某一个桶先被抢空了就返回“已售罄”不再尝试其他桶宁可少卖也不能超卖。6.2 定时任务防重ZK锁在任务调度中的应用定时任务防重是分布式锁使用频率很高的场景。团队早期用的是ShedLock它基于数据库表做的锁任务多了之后数据库压力也不小。后来我们把任务调度系统切到了XXL-Job对于跨节点不能重复执行的高优先级任务使用ZK的暂定顺序锁来防重。ZK在这个场景下的优势是任务的执行时间往往几十秒到几分钟锁的持有时间长Redis的过期时间很难估准而ZK的临时节点天然适合这种“进程在线即持锁”的语义。使用Curator的InterProcessMutex每隔几秒做一次抢锁判断抢到了就执行任务执行完释放锁。如果有节点挂了ZK会话过期自动清除临时节点锁也就释放了不会影响下一个周期。public void executeTask(Long taskId) { InterProcessMutex lock new InterProcessMutex( curatorClient, /schedule/task_ taskId); try { if (lock.acquire(3, TimeUnit.SECONDS)) { // 执行定时任务逻辑 logger.info(任务 {} 已获取锁开始执行, taskId); } else { logger.warn(任务 {} 抢锁超时跳过一次执行, taskId); return; } } catch (Exception e) { logger.error(任务 {} 执行异常, taskId, e); } finally { try { lock.release(); } catch (Exception e) { // 明确失败不必影响下次心跳任务 } } }有一点必须提醒如果lock.acquire()时ZK的会话超时被触发Curator会抛出异常如果锁还没创建成功就进入了catch块并返回。这段代码里锁没抢到就直接跳过本次任务对于定时任务是可接受的。如果业务要求“本次任务必须某台机器执行”那就需要在catch块里做重试或告警。6.3 云原生环境下的选择etcd锁与K8s的天然适配最近两年越来越多的服务开始往K8s迁移。在云原生环境里很多团队已经部署了etcd供K8s使用那么在分布式锁选型时直接复用etcd就不用再额外引入Redis或者ZK这也是etcd锁在云原生项目里变热门的原因。etcd锁还有一个特点在K8s的Operator或控制器场景下分布式锁不只是为了防重而是为了控制器选主和故障转移。我们开发自研Operator时直接用github.com/etcd-io/etcd/client/v3的选主Election接口结合K8s的LeaderElection实现多副本控制器中只有一个活跃Leader其他副本处于Standby状态。// 在K8s中复用etcd进行选主 func run() { cli, _ : clientv3.New(clientv3.Config{Endpoints: endpoints}) election : concurrency.NewElection(concurrency.NewSession(cli), /controllers/my-operator/leader) ctx, cancel : context.WithCancel(context.Background()) defer cancel() go election.Campaign(ctx, leader-id) for range election.Honor(ctx) { // 成为Leader后执行控制逻辑 } }在这种场景下etcd锁的价值不仅仅是互斥更是把“谁是活跃副本”这个状态持久化在一致存储中让整体集群的选主逻辑非常透明便于排查问题。7. 最终观点与经验性的总结回头看我这些年在分布式锁上的迭代最早是数据库唯一索引然后切到Redis的SETNX再后来是Redisson中间也深度用过ZK和etcd。每个方案都有自己最擅长的位置也都有让自己难堪的死角。选型最忌讳的一件事是“拿着锤子看什么都是钉子”——团队里Redis熟就什么锁都用Redis完全不评估业务对锁可靠性的要求。我在技术决策中一直坚持一个原则分布式锁是“最后的互斥手段”而不是“唯一方案”。能用业务设计避免的锁尽量用设计避免能按维度拆分的锁尽量拆散能在锁外完成的耗时操作绝对不放锁内。分布式锁的管理成本远比你想象得高每多一个锁就意味着多一类线上问题的可能性。给后来者一个相对朴素的建议如果业务刚起步、QPS不高、团队不大直接选数据库乐观锁或者Redis的简单锁先跑起来比什么架构都重要当业务进入高速增长期QPS上来了、团队规模也大了切换到Redisson或者ZK/etcd这类成熟的锁方案同时把监控和治理体系补齐。这套Roadmap我自己就是这么走过来的希望对你有用。分布式锁的技术本身并不复杂真正考验的是你面对异常情况时的判断力和预案能力。每次线上锁故障都是一次非常宝贵的学习机会。做好方案对比、深刻理解每一个实现细节远比记住某个框架的一行API调用有价值。