
1. 一个线上事故让我开始认真对待分布式锁我接手过一个订单系统业务不算复杂峰值也就几千QPS但线上每隔一段时间就会出现一次“重复下单”的客诉。一开始我们以为是前端按钮没做防抖后来发现前端确实做了但依然有漏网之鱼。排查到最后问题出在一个很不起眼的地方用户在极短时间内提交了两次下单请求两个请求被负载均衡分发到了两台不同的应用实例上每个实例都跑着各自的本地锁锁了个寂寞。如果你还没有遇到过类似的事那说明你的服务要么是单实例部署要么是并发量还没到临界点。但只要你上了多实例、上了微服务本地锁比如synchronized、ReentrantLock就会立刻失效——它只锁得住当前JVM进程锁不住别的机器上的另一个进程。什么是分布式锁用一句话说就是让多个进程之间互相排斥地访问同一个共享资源把“进程内互斥”升级成“跨进程互斥”。它的核心使用场景其实很聚焦我列一下我实际经历过的大家可以对照自己的业务看定时任务防重K8s里同一个任务可能拉起多个副本到点全跑必须保证只有一台机器真正执行。这是分布式锁最典型的应用场景没有之一。库存扣减与防止超卖用户请求进来先锁库存再执行扣减逻辑避免两个请求同时读到库存还剩1件。幂等性控制同一笔支付回调可能到达多次用锁保证只有第一次请求能真正执行后续流程。缓存击穿防护热点key失效瞬间大量请求同时打到DB。与其用复杂的“互斥重建”逻辑不如直接让这些请求抢一把锁抢到的去查DB重建缓存没抢到的先返回旧值或短暂等待。分布式事务中的资源协调多个微服务需要通过某一共享资源完成全局协调比如文件处理、分布式ID生成、任务队列消费。如果你只是单机应用、单库单表、用户量不大说实话分布式锁对你来说大概率是个伪需求。但如果你已经在做微服务、容器化部署那么“进程内锁”的局限性就会像定时炸弹一样埋在你的系统里不知道哪天会在什么流量下爆掉。我自己的经验是当你的应用第一次出现两个实例同时跑定时任务的场景出现时就是你认真考虑分布式锁的时候。在动手实现之前心里必须有一张需求清单。我做分布式锁服务时列的验收标准是这几条互斥性任意时刻同一个锁key只能被一个客户端持有。可重入性同一线程可以重复获取同一把锁不至于自己把自己卡死。防死锁客户端崩溃、网络异常后锁必须能自动释放不能永久占用。高性能加锁/解锁整体开销要极低不能成为业务链路的性能瓶颈。高可用锁服务本身不能成为单点依赖的存储组件要能容忍故障。可观测性至少要能看到谁在抢锁、持锁多久、是否发生死锁或超时。后面的所有实现方案、技术选型、坑和优化本质上都是在向这六条标准靠拢。2. 锁的三条技术路线Redis、ZooKeeper与数据库的取舍网上讲分布式锁的文章特别多但多数是把方案列一下很少有人认真讲清楚“为什么在这个业务场景下选这个方案”。我先把我调研过的三条路线放在一起对比再讲我的取舍逻辑。方案核心实现优点缺点典型应用场景RedisSET NX EX或 Redisson性能极好、实现成熟、生态完善主从切换时可能丢锁过期时间设置需要经验高并发、低延迟要求的业务场景ZooKeeper临时顺序节点 Watch强一致、天然防死锁会话失效即释放性能相对差、运维成本高、客户端较重对一致性要求极高的场景如分布式协调数据库唯一索引 /for update实现简单、不用引入新组件性能最差、可能拖垮数据库低频、内部管理类任务小团队极简方案2.1 Redis方案的本质是“过期时间兜底”Redis做分布式锁的原理简单到一句话就能说清多进程争抢同一个key谁SET成功谁就拿到了锁锁的过期时间就是最长占用时间。它最大的优势也是最大的隐患都来自同一个点——锁依赖“过期时间”来自动释放。如果业务执行时间超过了预设的过期时间锁会被强行释放别的客户端就能拿到锁造成并发冲突如果设置的过期时间太长客户端崩了之后锁又要等很久才能自动释放。这个时间窗口怎么估是Redis方案里最考验经验的地方。2.2 ZooKeeper方案是“会话级锁”ZooKeeper做锁的思路和Redis完全不同。它依赖的是ZAB协议带来的强一致性多个客户端同时去ZooKeeper创建临时顺序节点谁创建的节点序号最小谁就拿到锁其他人监听比自己小的前一个节点前一个节点删除后唤醒自己。这里的关键是“临时节点”。临时节点的生命周期和客户端会话绑定客户端崩了会话断开节点自动消失锁自动释放——不需要像Redis那样操心过期时间这是它相比Redis最省心的地方。代价是每次加锁解锁都要多次网络往返性能大概比Redis差一个量级还要维护一套ZK集群。2.3 数据库方案是“兜底选项”数据库做分布式锁最常见的是利用唯一索引约束插入一条带业务标识的记录插入成功就是拿到锁删除记录就是释放锁。还有一种做法是SELECT ... FOR UPDATE行锁。好处是真没有额外组件成本坏处是性能差、数据库连接被长事务占用、一旦忘记删记录就会留下“死锁数据”需要人工介入。适合那种一天跑不了几次的内部任务拿来兜底可以扛流量不行。2.4 我的选型逻辑我们团队最终选的是Redis作为主实现数据库作为极端情况下的兜底。原因不复杂我们的核心诉求是“高并发 低延迟 现有基础设施里已经有Redis集群”引入ZK要额外养一套运维团队规模不划算。我们的业务场景里绝大部分锁的持有时间都在100ms以内Redis的过期时间兜底完全够用。我们愿意在“锁丢失”的小概率事件上做补偿——比如幂等表和状态机校验而不是让锁本身承担100%的职责。如果你做的业务对一致性要求极其严格比如金融级的资金操作我建议直接用ZooKeeper或者etcd不要纠结。在分布式系统里最终的一致性依赖往往是“锁 业务自身的幂等校验”共同完成的锁只是第一道防线。3. Redis实现的核心原理从SETNX到看门狗很多人一提到Redis分布式锁就条件反射说“SETNX”但这其实是比较古早的做法了。现在的Redis分布式锁成熟方案应该长这样。3.1 为什么不能分两步SETNX再加EXPIRE远古版本的Redis没有原子命令大家是用SETNX拿到锁之后再单独调EXPIRE设置过期时间。这里有个致命问题如果SETNX成功之后、EXPIRE执行之前客户端崩了锁就永远不释放只能用运维脚本手工清理非常被动。所以Redis官方后来提供了原子操作SET key value NX EX一条命令同时完成“设置锁 过期时间”。注意这里必须用同一命令或Lua脚本保证原子性任何分两步的实现都应该判为不合格设计。3.2 value不能随便填必须带唯一标识这也是新手最容易忽略的点。很多人写SET lock_key 1 NX EX 30然后释放的时候直接DEL lock_key。表面看没什么问题实际上存在一个锁误删的经典坑线程A拿到锁执行任务但因为某个原因阻塞了超过了30秒过期时间。锁自动过期线程B拿到锁开始执行任务。线程A终于执行完调用DEL lock_key直接把线程B的锁删掉了。线程C顺势拿到锁B和C同时执行——互斥性彻底失效。解法是value存一个当前线程/请求维度的唯一标识UUID就行释放锁时先比较value是否匹配匹配才删。而“比较删除”这两个动作也要保证原子性用Lua脚本是最好的方式-- 释放锁的Lua脚本 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这样就能保证只有锁的持有者才能释放锁释放前还会确认锁的value仍然是自己当初写入的。3.3 锁的过期时间怎么设过期时间需要结合业务执行时间来评估。我们内部有个经验法则先分析99.9%情况下的最大执行耗时然后在这个值基础上乘5到10倍。比如一个任务正常情况下几十毫秒跑完极端情况可能到100ms那锁的过期时间可以设1秒留足余量。但这里有个矛盾如果余量留得大客户端崩了锁要过很久才能被自动清理如果余量留得小业务稍微慢一点锁就提前过期互斥性被破坏。所以更进阶的方案是用看门狗机制。简单说就是拿到锁之后启动一个后台守护线程每隔一段时间自动给锁续期业务没执行完锁就永远不会过期业务执行完释放锁时把守护线程停掉。Redisson框架的“看门狗”正是这个思路默认锁的超时时间是30秒每10秒自动续期一次续期操作也是通过Lua脚本判断value是否匹配再重置过期时间。用Redisson的话核心逻辑其实非常简洁RLock lock redissonClient.getLock(order:create:10086); // 尝试拿锁最多等3秒锁自动过期时间是30秒 boolean locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (locked) { try { // 业务逻辑 } finally { lock.unlock(); } }坦白说如果你要做生产级的Redis分布式锁我强烈建议直接用Redisson不要自己造轮子。自己实现一套看门狗需要考虑守护线程的并发安全、续期失败的降级处理、锁释放和续期的竞争条件这些坑Redisson都已经踩平了。自己做一遍是为了理解原理生产使用还是用成熟轮子稳妥。3.4 可重入锁也是Redisson内置的可重入的意思是同一个线程可以重复获取同一把锁。Redisson的可重入实现思路是在value里记录持锁线程的信息和持有次数每重入一次加1每释放一次减1减到0才真正删key。自己实现的话需要用Hash结构来做比如lock_key { thread-123: 2 }加锁时对Hash里的计数字段做INCR释放时DECR判断计数归零再删除。3.5 主从切换时锁会丢这是Redis方案最深的坑Redis主从复制是异步的存在一个理论上的时间窗口主节点刚写入锁还没来得及把数据复制到从节点就宕机了从节点被提升为主节点——锁没了。此时另一个客户端可以轻松拿到同一把锁互斥性被打破。这个问题的经典解法是RedLock算法同时向多个独立的Redis节点通常5个写锁成功写入半数以上就算加锁成功。它的逻辑类似“过半数同意”设计上是为了降低单点故障带来的锁丢失概率。但我必须说清楚RedLock的争议。业界包括Redis作者自己和其他分布式系统专家对RedLock吵了很多年核心分歧是它引入了复杂度却并没有从理论上彻底解决分布式锁在“网络分区、时钟跳跃”下的安全性问题。很多团队在生产中用了RedLock也可能没有真正模拟过节点宕机的极端场景。我的建议是分为两种看待如果业务允许极端情况下出现极小概率的锁失效单Redis Redisson足够没必要上RedLock。如果业务要求锁绝对不能出现两个客户端同时持有比如资金类的业务那不要用Redis方案直接换ZooKeeper或etcd。Redis加锁再花哨也不如一个强一致组件让你睡得安稳。4. 把锁做成服务API设计、可观测性与治理标题是“分布式锁服务实现”光讲Redisson怎么用显然不够。接下来我想重点聊聊如何把一个锁工具升级成一套可以长期维护、可接入多个业务方、有治理能力的服务。这部分是我在实战中摸索出来的网上很少成体系地讲。4.1 为什么不能只提供一个工具类很多团队一开始做分布式锁的方式是把Redisson封装成一个静态工具类谁要用谁就调。这个阶段其实挺爽的代码少、接入快。但业务多了以后问题会集中爆发各业务方自定义了各种各样的锁时间参数有的设5秒有的设1分钟没有统一规范。想统计“谁持有锁最久”“哪些锁发生频繁等待”时没有任何数据来源。对锁key的命名风格全凭个人习惯日志里根本对不上号。一旦锁服务的配置需要调整比如换Redis连接所有接入方都要重新发布。所以我把它做成了一层独立服务。注意这个“服务”不一定是一个独立部署的进程更常见的是一个独立的基础组件SDK 一份统一配置中心里的配置对外提供标准API内部屏蔽锁实现细节。4.2 API设计要满足80%的业务场景我设计的API分四类基本上能覆盖日常所有需求第一类普通同步锁LockResult lock(LockParam param);LockParam包含三个核心参数锁key、等待时间、持有时间。等待时间表示抢不到锁最多等多久持有时间表示业务执行的最大时长。public class LockParam { private String key; // 获取锁最长等待时间超时直接返回失败 private Duration waitTime; // 锁自动过期时间 private Duration leaseTime; private String businessId; // 业务标识用于可观测性 }第二类允许抢不到就立刻失败适合非核心链路LockResult tryLock(LockParam param);不等待抢不到就直接返回失败。适合那种“拿不到锁就跳过本次执行”的场景。第三类异步锁CompletableFutureLockResult lockAsync(LockParam param);适合本来就用CompletableFuture编排逻辑的代码避免阻塞线程。第四类手动解锁void unlock(String lockToken);注意这里有个设计细节unlock的核心是lockToken而不是锁key因为锁key可以被后续抢到锁的客户端持有你如果只凭key去解锁极大概率会误删别人的锁。lockToken就是前面讲的唯一标识由服务生成并返回给调用方调用方存好释放时带过来。4.3 锁的可观测性比锁本身更重要我见过不少团队把“分布式锁能跑通”当作目标其实真正的分水岭在于你能不能说清楚当前系统里每一把锁的状态。我在设计服务时加了三层可观测性数据基础指标锁获取次数、获取失败次数、平均等待时间、平均持有时间、锁过期自动释放次数。这些全部通过Prometheus的Counter和Histogram采集用Grafana画趋势面板。结构化日志每次加锁/解锁都输出一条结构化日志包含锁key、业务ID、客户端IP、等待时间、持有时间、释放原因主动释放或被动过期。这是排查问题最快的手段。链路追踪把锁的获取和释放作为两个Span写入链路追踪系统比如OpenTelemetry/SkyWalking这样整个请求的调用链里能看到“在哪个环节等待了多久锁”。说句题外话可观测性建设大概率会帮你发现很多意料之外的怪问题。比如我们曾经通过监控发现某个核心锁的平均持有时间从10ms突然飙到2秒最后定位到是缓存热key引起的STW问题。如果没有数据这类问题根本无从谈起。4.4 治理能力超时降级、锁分组与热key优化把锁做成服务之后治理能力才有施展空间。我列举几个在实操中效果显著的治理策略超时降级当Redis本身出现性能抖动时锁服务的获取时间会变长。我设计了一个动态开关当Redis的延迟P99超过阈值时自动把非核心业务的“等待获取锁”改成“直接失败降级”避免所有线程阻塞在锁获取上引发线程池枯竭。核心业务则保留等待但会记录降级日志。锁分组不同业务接入时按组配置比如“交易组”“任务调度组”“营销组”。各组可以有独立的Redis实例、独立的锁过期时间规范互不影响。这样某个组的锁出问题时爆炸半径被控制在组内。热点key优化曹操有一句我很认同的话——“锁的粒度和并发度是矛盾的”。如果一把锁锁的key太粗比如整个用户维度只有一把锁那么所有针对该用户的操作都要排队。我在服务层提供了一种“分片锁”能力把原来的user:123拆成user:123:shard1到user:123:shard10按用户ID取模选择其中一把。这样同一个用户的并发热点操作可以并行执行一部分同时又不至于完全失去互斥性。注意分片锁会降低互斥强度必须结合业务容忍度谨慎使用。4.5 部署形态SDK中心化还是Sidecar模式锁服务的部署形态也是一个值得说的话题。有两种主流路线第一种是纯SDK模式所有业务方引入SDKSDK直接连Redis配置从配置中心下发。优点是性能最好少一跳网络定位问题相对容易。缺点是新版本SDK需要业务方升级发布。第二种是独立服务Sidecar/微服务模式业务方通过RPC调用锁服务锁服务统一管理Redis连接和锁逻辑。优点是逻辑收敛、多语言接入简单缺点是每加一次锁就多一次RPC性能损耗比较明显对于高频锁场景可能无法接受。我在实践中选择的是纯SDK模式 统一配置中心。锁的高频操作决定了它不能走远程RPC否则一次业务调用要多消耗0.5ms以上的网络开销。让SDK直连Redis锁服务只负责下发配置和收集监控数据感觉是对的折中。5. 高可用与脑裂RedLock争议和ZooKeeper方案剖析很多面试题里会问Redis分布式锁有什么缺点应试答案一般是“主从切换可能丢锁”。但实际搞生产系统你需要理解得更深一层丢锁只是表象背后的本质是分布式系统里的“时间”和“共识”两个难题。5.1 时间难题锁过期时间依赖服务器时钟Redis的EXPIRE、TTL这些操作依赖的是服务器本地时钟。如果Redis服务器时钟发生跳跃比如NTP同步出现了时间跳变或者服务器时钟被回拨已经设置的过期时间也会出现异常。极端案例加锁时设了30秒过期结果服务器时钟往前跳了1分钟锁“立刻”过期了。这个情况确实比较罕见但一旦发生就会引发和新主从切换类似的并发问题。规避办法通常是使用Redisson的看门狗机制把过期时间不断刷新减少对单点时钟的依赖以及监控服务器时钟偏移发现异常及时告警。5.2 RedLock过半数加锁能救Redis吗RedLock的加锁过程是这样的记录当前时间依次向5个独立Redis节点发送SET NX EX命令。如果成功写入节点数 3且整个过程消耗的时间小于锁的过期时间视为加锁成功。加锁成功后启动看门狗续期。如果加锁失败依次删除所有节点上的锁。它在设计上的确把“单点故障丢锁”的概率降到了很低但也引入了新的复杂度客户端需要同时管理多个Redis连接网络分区下的行为变得难以推理。最经典的批评是如果一个客户端在加锁过程中发生了长时间的GC停顿它持有的锁可能已经过期但其他客户端永远无法从时间戳上判断这一点。我个人的态度是绝大多数业务根本不需要RedLock。如果你的业务是“丢一把锁最多导致重复执行一次任务但任务本身有幂等性兜底”那这完全不是问题。只有“严格不能并发执行”的业务才需要考虑而这种业务我会建议直接换成强一致组件。5.3 ZooKeeper方案为什么能避免脑裂ZooKeeper方案能保证互斥性的底气来自ZAB协议。它的选举和写入流程确保同一时刻只有一个Leader处理写请求写请求需要在集群中达成多数派确认后才算成功。所以即使主节点挂了新选举出的Leader也会带着完整的数据继续服务不会出现Redis主从异步复制导致“新主节点丢失旧锁”的问题。加上前文提到的“临时顺序节点 Watch”机制ZooKeeper锁可以做到所有客户端公平排队不存在“后来的抢先”情况。客户端会话断开锁自动释放无需设置业务执行时间预估。锁的持有状态可以被集群中的多个节点共同确认不存在单点时钟问题。代价是性能。我们压测过ZooKeeper锁的平均获取耗时在10ms量级Redis锁在1ms以下。这意味着ZooKeeper适合低频但高一致性的场景。如果业务允许“宁可慢一点也要绝对安全”ZooKeeper是比Redis更让你睡得安稳的方案。5.4 等价的etcd方案如果你不想养ZooKeeperetcd是另一个强一致选择。etcd基于Raft协议思路和ZooKeeper类似而且有租约Lease机制可以实现自动续期还支持通过Watch感知锁释放。社区里也有封装好的SDK。我个人的感觉是etcd的运维体验比ZooKeeper好不少文档也更清晰新项目如果必须上强一致锁可以优先考虑etcd。6. 实战中踩过的坑锁误删、线程飘高与超时误判最后这部分是我把分布式锁服务跑了一年之后沉淀下来的几个真实坑每个都是在网上看教程学不到的。6.1 锁误删第一次线上并发事故的主角前面讲了锁误删的解决方案value带唯一标识 Lua脚本这里讲一个真实案例加深理解。我们的一个支付回调服务某天突然出现两个线程同时处理同一笔支付单的状态更新。排查日志发现线程A拿了锁但它在调用远端接口时超时等了40多秒才返回而锁的过期时间只有15秒。锁过期后线程B拿到锁开始处理此时线程A返回并执行了DEL lock_key一瞬间B的锁没了线程C又进来了。三个线程同时处理同一笔单据结果就是状态被反复覆盖、对账异常。事后修复除了加value校验和Lua脚本外还加了一句非常重要的设计锁的过期时间一定要用看门狗自动续期而不是拍脑袋设一个固定值。如果当初用了Redisson看门狗这个事故根本不会发生。6.2 看门狗线程导致的CPU飘高Redisson的看门狗好归好但有一个隐藏的坑当你业务中大量使用getLock().lock()并忘记释放时看门狗线程会无限续期同时锁持有时间被无限拉长。我们有一个业务方锁释放逻辑写在了一个异常分支里漏掉的地方导致一批锁持有了一整天最终该业务实例的CPU和内存持续走高直到我们把所有锁全部清理才恢复。所以我把“锁释放”提升到了类似于“关闭IO流”的级别能放到finally里的必须在finally里释放绝对不能依赖任何业务正常路径。并且在SDK层加了最长持有时间兜底——即使看门狗一直在续期超过一个上限比如5分钟后强制终止续期并记录告警。6.3 GC停顿导致锁超时误判这是Java体系下比较隐蔽的一个问题。假设你拿到了锁执行业务过程中发生了一次Full GCSTW时间超过了锁的过期时间那么锁就在你“不知情”的情况下失效了别的线程开始蚕食你的临界区。等你GC结束继续执行你和你同事的代码已经开始并发跑了。更麻烦的是GC停顿不是你能通过调节锁参数规避的——无论锁设置多长理论上都可能发生一次超过它的STW。所以我的经验是锁的过期时间设置绝不能卡在业务执行耗时的边际上一定要留出超过系统最坏停顿时间的余量。比如系统最差可能有1秒的Full GC停顿那锁至少也要设2秒以上。同时业务逻辑里尽量降低分配率减少长停顿GC发生的概率这是在根上优化。6.4 锁粒度太粗导致性能瓶颈我们曾经把“用户账户操作”锁做成了account:lock:{userId}结果发现同一个用户的高并发操作比如同时下单和查余额全被堵到了一把锁上。后来改成拆成两把account:update:{userId}和account:read:{userId}读写互不阻塞只有写写互相排斥。就这么一个改动接口的P99从180ms降到了65ms。这里给你一个通用的锁粒度拆分思路按操作类型拆分读锁和写锁分开或者对不同操作定义不同锁key。按资源维度拆分比如同一个用户的不同资源类型不要共用一把锁。按关键路径拆分只在真正需要串行化的代码块上加锁不要把整个方法都锁起来。6.5 试锁返回值一定要判空用Redisson之类的SDK时很多人会忽略tryLock的三返回值状态。部分实现中,如果设置了waitTime0并且锁被占用返回的是null而不是false直接拿空对象调用后续逻辑会触发NPE。我在代码审查时经常能看到这种写法if (lock.tryLock(0, 30, TimeUnit.SECONDS)) { ... }正确的是boolean locked lock.tryLock(0, 30, TimeUnit.SECONDS); if (locked) { try { // ... } finally { lock.unlock(); } }看上去是小事但在线上直接就是一大波告警。任何锁框架的返回值都应该先想一想“失败时它返回什么”再决定判断逻辑。6.6 别忘了业务本身要有兜底最后一条也是我认为最重要的一条分布式锁永远无法百分之百保证互斥性所以业务层必须有自己的幂等校验和兜底机制。我参与的每一个核心链路都不会把分布式锁当作唯一防线。通常的组合是分布式锁做第一道并发控制关卡数据库唯一索引/乐观锁做第二道兜底最终的效果校验再确认一次结果。如果你试着把锁当作唯一防线你会陷入一个死循环为了防丢锁升级成RedLockRedLock也有理论漏洞继续找更强的方案……实际上没有一个分布式锁方案能在所有极端情况下都保证安全。成熟的架构师会让锁承担99.9%的场景剩下的0.1%交给业务兜底。做了这么久分布式系统我最大的体会是分布式锁不是银弹能用队列削峰的地方优先用队列能用事务解决的就别上锁真正需要锁的场景要想清楚最坏情况并让系统即使在最坏情况下也能自愈。这也正是我把锁从工具类做成服务、加上监控和治理的初心——不追求一把绝不失效的锁而是让锁失效时系统依然稳定。