
从单体应用拆成微服务之后最先让人头疼的问题往往是代码能跑但一上多实例就出事。分布式锁就是这类问题里最典型的代表。这篇文章是一份我从实际项目中整理出来的分布式锁笔记围绕Redis分布式锁展开同时也会把数据库、ZooKeeper、etcd等主流方案放在一起对比梳理清楚各自的原理、选型依据、落地代码以及生产环境里的经典坑。适合正在做微服务改造、需要解决多实例并发问题的开发同学也适合准备面试时快速建立完整知识体系的读者。1. 从单机锁到分布式锁先想清楚锁到底在锁什么1.1 锁要解决的从来不是代码能不能跑很多人学锁的时候第一反应是锁就是防止多线程同时改数据。这个说法没错但放到分布式场景里还不够准确。单机环境里比如一台服务器上部署了一个Java进程多个线程同时操作共享变量我们使用synchronized或者ReentrantLock本质上是靠JVM内部的监视器机制让同一时刻只有一个线程进入临界区。这个锁的作用范围是一个进程内的所有线程。到了分布式系统业务服务部署了多个实例每个实例都是一个独立的进程甚至可能跑在不同的机器上。这时候原本的synchronized就彻底失效了——因为线程A拿到的锁对象在实例1的JVM里线程B在实例2的JVM里两个锁对象互不感知互斥性自然无从谈起。分布式锁解决的问题本质上是跨进程、跨节点的资源互斥。它要保证的是多个服务实例同时操作同一个共享资源比如数据库里的某条订单记录、Redis里的某个库存Key、某个文件时最终只有一个实例能成功执行其他实例要么等待要么放弃。我见过不少团队在初期没意识到这层差异直接把单机的缓存计数器搬到多实例上结果就是一个活动接口被不同Pod重复执行用户被发了多张优惠券线上事故直接教做人。1.2 单机锁在分布式场景下的失灵过程举个例子一个库存扣减接口单机部署时用synchronized锁住扣减方法一切正常。改成多实例部署后发现库存超卖了排查下来原因并不复杂请求A命中实例1请求B命中实例2实例1和实例2同时读取到库存余量为5两者各自扣减1再写回库存最终库存变成4可实际卖出去了2件。从每个实例的视角看它们的操作都没有问题因为synchronized只保证了本实例内不并发。但站在全局视角两个实例同时操作了同一个数据互斥性被打破了。分布式锁要做的就是把互斥判断从进程内搬到一个所有实例都能访问的第三方组件上。这个第三方组件可以是Redis、ZooKeeper、etcd也可以是数据库本身。所有实例在进入临界区之前都先去这个公共组件上登记一下谁登记成功谁执行执行完再注销。1.3 一套合格的分布式锁必须满足哪些条件我习惯把分布式锁的要点归纳成四个做技术选型或者写代码时反复对照这几条基本不会跑偏第一是互斥性。任意时刻只有一个客户端能持有锁。这是分布式锁的立身之本做不到这点后面所有讨论都没有意义。第二是安全性。锁只能被持有它的客户端释放。如果A客户端因为超时将锁释放了B客户端随后获取了同一把锁那么A在处理完业务后绝不能把B持有的锁给解掉。这点看起来基础实际踩坑率极高后面我会结合代码详细说。第三是可用性。加锁和解锁的操作本身要够快不能因为锁组件的性能问题拖垮业务。同时如果持有锁的节点挂了锁要能在一定时间后自动释放不能造成死锁。第四是可重入性视场景而定。同一个客户端在持有锁的情况下再次尝试获取同一把锁应该能直接成功而不是把自己卡死。有些业务场景确实需要可重入比如一个方法内部又调用了另一个加锁的方法。把这几条摆出来再去看各个实现方案思路就清晰多了。有些方案互斥性做得很好但性能一般有些方案性能极佳但在极端场景下有安全风险没有银弹只有取舍。2. 主流实现方案选型Redis、ZooKeeper、etcd、数据库各有各的命2.1 数据库方案简单可靠但性能天花板低数据库实现分布式锁是最朴素的方式也是很多老项目的起点。核心思路就两种悲观锁和乐观锁。悲观锁在MySQL里可以直接用SELECT ... FOR UPDATE实现。事务开始后对目标行的写锁会一直持有到事务提交或回滚其他事务再过来查询同一行时就会阻塞。这个方案优点非常明显——不需要额外引入组件依赖现成的数据库就行而且数据库本身对事务的支持让锁的释放和事务绑定在一起安全性和可靠性都有保障。缺点同样突出一是性能瓶颈每次加锁解锁都要走一次数据库事务高并发下数据库连接和锁等待会成为系统最大的拖累二是阻塞机制容易造成连接池被占满一旦某个事务迟迟不提交后续所有请求都会堆积在数据库连接等待上引发连锁故障三是数据库主从切换的窗口期内同样可能因为同步延迟导致锁失效。乐观锁通常用版本号或者状态字段实现。每次更新数据时带上版本条件比如UPDATE stock SET count count - 1, version version 1 WHERE id 1 AND version 5如果影响行数为0说明版本已被别人改过操作失败需要重试。乐观锁适合读多写少、冲突概率低的场景但严格来说它更像CAS重试而不是一把真正意义上的锁无法阻塞等待对冲突频繁的业务体验并不好。数据库方案我最推荐用在两种场景一是并发量本身很低、又不想多引入一套组件的内部系统二是业务数据本身就存储在数据库里用数据库锁能天然保持数据一致性省去缓存和数据库之间的一致性问题。但如果你的接口峰值QPS上千上万趁早换方案。2.2 ZooKeeper方案强一致性强实现绕不开羊群效应问题ZooKeeper实现分布式锁的原理是基于它的临时顺序节点和Watch监听机制。大致流程是这样的客户端在锁的命名空间下创建一个临时顺序节点比如/locks/order-lock/lock-0000000001客户端获取当前命名空间下的所有子节点如果自己创建的节点序号最小说明成功获取锁如果自己的节点序号不是最小的就监听自己前一个节点的删除事件阻塞等待当前一个节点被删除说明上一个客户端释放了锁或者崩溃了当前客户端收到通知后重新检查自己是否是最小序号如果是就获取锁。这个方案有个很经典的问题叫羊群效应herd effect。如果每个客户端都监听同一个父节点那么当持有锁的客户端释放锁时所有等待的客户端都会被唤醒然后同时去查询子节点列表、争抢锁这会产生大量的无用请求。规避办法就是上面流程里提到的只监听前一个节点这样释放锁时只会唤醒下一个节点对应的客户端把唤醒范围控制到最小。ZooKeeper方案的优势在于强一致性。ZNode的创建和删除是经过Zab协议达成共识的不存在主从节点数据延迟的问题因此在很多对安全要求极高的场景比如分布式调度任务里ZooKeeper比Redis更让人放心。但它的代价体现在性能和运维成本上。一次锁获取往往需要多次网络RTT还要管理和维护ZooKeeper集群在高并发短事务场景下吞吐量和Redis方案有明显的差距。适合用ZooKeeper的场景通常是并发量不大但对锁一定不能失效有严格要求的任务调度、分布式协调类业务。2.3 etcd方案带着现代分布式一致性基因的后来者etcd用Raft协议保证强一致性提供的Lease租约Txn事务Watch监听三个机制刚好能构建出一个比较优雅的分布式锁。基本思路是利用PUT操作创建一个带租约的Key通过事务比较Key的版本号或者revision只有不存在或者版本号满足条件时才能写入成功从而实现加锁持有者通过Lease的续约机制维持锁的存活客户端宕机后租约到期Key自动过期删除锁自动释放等待者通过Watch监听Key的删除事件获得通知。etcd方案结合了ZooKeeper强一致性的优点和类似Redis的简单API如果你所在团队已经有etcd在跑比如配合Kubernetes使用用它做分布式锁其实是很顺手的。缺点是对大多数非Kubernetes体系的技术团队来说它是一个额外依赖大多数人不熟悉它的运维与调优。2.4 四种方案放在一张表里看选型其实没那么纠结维度RedisZooKeeperetcd数据库一致性模型最终一致主从异步复制线性一致Zab协议线性一致Raft协议视隔离级别而定性能极高单线程IO多路复用中等中等偏高低锁自动释放支持过期时间临时节点会话超时Lease租约事务回滚/超时容灾性主从切换可能丢锁集群切换较为可靠集群切换可靠受数据库主从影响实现复杂度简单但要处理细节相对复杂中等最简单但有上限典型适用场景高并发、短事务、可容忍极低概率失效低并发、强安全、任务调度云原生环境、对一致性要求高的场景内网低并发、不想引入额外组件的团队我个人在实际项目里的选型原则很简单大多数业务场景用Redis对安全极度敏感的任务调度类用ZooKeeper或者etcd纯粹的低并发内部工具就直接用数据库乐观锁省事。情商高一点的说法是根据场景选择合适的技术大白话就是别杀鸡用牛刀也别为了省事把项目的底裤赔进去。3. Redis分布式锁落地细节从SETNX到Redisson的正确姿势3.1 加锁一条命令完成原子操作网上有很多老帖子还在教人先SETNX、再EXPIRE设置过期时间这是踩坑历史最久的写法。两行命令分开执行如果SETNX成功之后、EXPIRE执行之前服务进程突然崩溃了锁就永远不会过期成为一个永久的死锁。后来Redis支持了对Key设置过期时间的原子操作加锁的正解变成一条命令SET lock_key unique_value NX PX 30000各参数含义NX表示只有当Key不存在时才设置成功确保同一时刻只有一个客户端能加上锁PX 30000给Key设置一个30秒的过期时间单位是毫秒防止持有者崩溃后锁永不释放unique_value这个值必须是全局唯一的用来标识锁的持有者。我一般用UUID 线程ID或者UUID 实例IP拼接起来。用一条命令而不是两步操作核心原因是原子性。Redis是单线程执行命令的SET ... NX PX整体不会被打断从根上避免了中间状态。关于过期时间的取值我的习惯是先看业务接口的P99耗时然后乘上3到5倍作为过期时间。比如接口一般100毫秒内执行完那锁的过期时间设在500毫秒左右就够。设置太短业务没跑完锁就自动释放后续请求带着同一把锁进来并发保护失效设置太长如果持有者真的挂掉其他客户端要干等很久才能抢到锁系统的恢复时间会被拉长。3.2 解锁必须用Lua脚本保证原子性解锁比加锁更容易踩坑。最直观的写法是两步走先GET锁的值判断是不是当前持有者如果是就DEL删除Key。这看起来没毛病但在并发场景下存在一个非常隐蔽的时序问题客户端A执行GET拿到锁的值确认是自己持有的此时锁到了过期时间自动被Redis删除客户端B加锁成功拿到了同一个Key客户端A执行DEL把B的锁给删掉了。这就像你自己家门锁到期自动开了邻居进来换了把新锁然后你顺手把邻居的新锁也给拧断扔了这肯定不行。解决办法是让判断删除这两个动作变成原子的一步到位if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段Lua脚本先比对Key当前的值和传入的unique_value是否一致一致才删除保证了只有持有者本人能释放锁。Redis在执行Lua脚本时会整体上锁运行脚本执行期间不会被其他命令插入所以这里的检查和删除天然是原子操作。在实际编码里我强烈建议直接把Lua脚本定义为常量不要每次请求都重新加载脚本字符串。虽然Redis本身会缓存脚本但减少网络传输开销和命令拼接出错概率总是好的。3.3 锁超时了业务还没跑完靠看门狗续期超时时间设短怕业务没跑完设长怕故障恢复慢这个矛盾在长任务场景里特别突出。解决这个问题的思路是动态续期在业务执行过程中由后台任务不断给锁续期直到业务执行完成主动释放锁。Redisson的看门狗WatchDog机制就是这个思路的标准实现。默认情况下如果你没有手动指定leaseTimeRedisson会为锁设置一个30秒的默认租期同时启动一个后台定时任务每10秒检查一次如果锁还被当前线程持有就自动把过期时间重置回30秒。大白话就是看门狗一直在边上守着只要持锁线程还活着就会不断续命确保锁不会被自动释放一旦持锁线程宕机看门狗自然停止锁在最多30秒后自动过期。使用Redisson的时候有个细节要特别注意只有不传leaseTime参数时看门狗才会生效如果你手动指定了leaseTimeRedisson不会启动续期任务锁到期后不管业务有没有执行完都会直接释放。// 看门狗生效不指定leaseTime默认30秒自动续期 RLock lock redissonClient.getLock(order:pay:10001); lock.lock(); try { // 业务逻辑哪怕执行超过30秒也没问题 doBusiness(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }我见过不少人天天抱怨看门狗没用一问代码里传了leaseTime那确实不会续期。这个坑特别典型踩过的应该都懂。3.4 一套可以复用的Java实践代码我用Spring Boot Redisson封装了一套简单的分布式锁工具没有引入太多复杂的抽象够用且不容易出错Component public class RedisLockUtil { Autowired private RedissonClient redissonClient; /** * 加锁带超时时间 * * param lockKey 锁的Key * param waitTime 获取锁的最大等待时间 * param leaseTime 锁自动释放时间传-1时启用看门狗自动续期 * param timeUnit 时间单位 * return 是否获取到锁 */ public boolean tryLock(String lockKey, long waitTime, long leaseTime, TimeUnit timeUnit) { RLock lock redissonClient.getLock(lockKey); try { return lock.tryLock(waitTime, leaseTime, timeUnit); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } /** * 解锁 */ public void unlock(String lockKey) { RLock lock redissonClient.getLock(lockKey); if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }业务侧的使用模式务必统一我踩过无数次的总结就是这四行if (redisLockUtil.tryLock(order:pay: orderId, 3, -1, TimeUnit.SECONDS)) { try { // 处理业务逻辑 handleOrderPay(orderId); } finally { redisLockUtil.unlock(order:pay: orderId); } } else { // 获取锁失败根据业务返回错误码或者排队重试 throw new BizException(系统繁忙请稍后重试); }waitTime参数代表我等锁最多等多久。比如设为3秒如果3秒内没拿到锁就直接返回失败避免无限期阻塞把线程池拖垮。leaseTime传-1启用看门狗让锁跟业务执行时间走。这里有个业务设计上的建议拿不到锁的时候是抛异常让用户重试还是后台排队异步执行取决于具体场景。对于下单支付这种即时交互操作我倾向于快速失败并提示用户重试对于定时任务或者异步批量任务可以做内部重试或者把任务丢回消息队列。3.5 RedLock知道原理和争议就够用了说到Redis分布式锁绕不开Martin Kleppmann和Antirez那场经典论战以及由此诞生的RedLock算法。RedLock的核心思想是不依赖单个Redis节点而是向N个独立的Redis Master节点依次加锁只要超过一半N/21的节点加锁成功就认为整体加锁成功。这个算法的设计初衷是应对单节点故障。比如你只有一台Redis它挂了你锁全丢Dubbo的注册中心为了高可用还搞集群呢锁服务怎么能单点RedLock的想法是就算有几台Redis挂了只要大多数节点还活着锁就能保持有效。但反对者指出的问题也确实存在锁的过期时间依赖系统时钟如果某台Redis的机器时钟发生跳跃锁的过期时间会失真可能提前释放或者被其他节点覆盖再加上网络分区、GC暂停等因素RedLock并不能达到数学意义上的绝对安全。我的看法是如果你的核心诉求是极低概率的锁失效也不能接受那就老老实实用ZooKeeper或者etcd它们在一致性上有理论保障如果你只是为了提升单点Redis的可用性RedLock可以在工程上缓解问题但必须有清晰的风险认知别把它当成银弹。绝大多数业务场景单机Redis主从 看门狗 妥善的降级策略已经能在成本和安全性之间取得不错的平衡。4. 生产环境里最容易踩的五个坑4.1 主从切换导致锁丢失这是Redis分布式锁被诟病最多的一点也是很多技术面试官最爱追问的隐藏关卡。场景是这样的客户端A在Redis Master节点上加锁成功Master节点还没来得及把锁Key同步到Slave节点Master突然宕机哨兵触发主从切换Slave节点升级为新的Master因为原来的锁Key没同步过来新Master上没有这把锁客户端B来加同一把锁加锁成功A和B同时进入临界区并发保护失效。这个问题的根因是Redis主从之间的数据复制默认是异步的。要根治光靠Redis本身很难这也是上文提到ZooKeeper、etcd这类强一致组件在某些场景下更受青睐的根本原因。工程上的应对手段一是可以通过配置同步等待来缓解比如使用WAIT命令让主节点确认从节点同步完成后再返回但会牺牲性能二是接受这个极低概率的风险同时通过数据库最终校验幂等设计等兜底手段即使锁失效也不会造成脏数据。没有完美的锁但可以有兜底的业务设计。4.2 业务GC停顿导致锁提前失效这种现象在基于JVM的微服务里尤其常见而且隐蔽性极高排查起来容易把人搞疯。流程还原一下客户端A获得了锁开始执行业务。但A的JVM突然发生了一次较长的Full GC整个应用线程被暂停了几十秒。在暂停期间A无法向Redis续期锁的过期时间到了Key被自动删除。这时候客户端B加锁成功开始执行同样一段业务。然后A的GC结束恢复执行继续写数据——它完全不知道自己的锁已经没了。两个客户端同时对同一个资源做写操作分布式锁形同虚设但日志里看不到任何异常代码逻辑也没错就会非常困惑。应对策略方面首先把GC暂停落到实处合理配置堆内存、选择合适的垃圾收集器、关注GC日志里的长暂停。其次在业务侧尽量缩短临界区的执行时间锁内只做必要操作把耗时的IO操作挪到锁外。第三如果业务允许可以在写核心数据时做二次校验比如带版本号更新就算锁失效也能靠CAS保住底线。4.3 锁是不可重入的递归调用就把自己锁死了Redisson的RLock接口默认实现了可重入用起来很简单同一个线程可以多次获取同一把锁内部记录持有次数每释放一次减少一次计数直到计数归零才真正删除Key。但如果你用的是自研的SETNX方案或者直接用RedisTemplate手动写加锁逻辑可重入往往没有实现。想象一下这么个场景一个付款接口加了锁方法内部又调用了一个也带同样锁Key的对账方法同一线程第二次尝试SETNX同一把锁时会发现自己已经锁住了自己直接获取失败。轻则业务异常重则因为逻辑设计不当出现死锁。自研Redis锁时可重入的标准做法是在锁的Value里记录持有者信息和加锁次数每次重入时检查Value的持有者ID是否一致一致则把计数加一。但说实话自己写这种东西纯属重复造轮子Redisson已经把这些都实现了直接用就好。4.4 锁粒度过大引发热点冲突分布式锁本身是为了解决并发冲突但锁的粒度设计不合理反而会制造新的瓶颈。我见过最典型的反面案例是一个库存服务把所有商品的扣减操作都锁在同一个Key上比如lock:stock。结果就是用户A买手机、用户B买电脑、用户C买耳机三个风马牛不相及的请求全被同一把锁串行化Redis和数据库的压力上去了业务吞吐量却直线下降还不断有请求超时告警。锁Key的设计原则应该是尽量细粒度只锁住真正需要互斥的那部分资源。库存扣减的锁Key应该是lock:stock:{skuId}订单支付的锁Key应该是lock:order:{orderId}让不同商品的扣减并行执行同一商品的扣减互斥排队。这个优化往往不用改任何逻辑只要把Key的粒度细化性能就能有立竿见影的提升。4.5 锁的监控和告警体系缺失锁用起来容易但锁是否正常工作往往要到出了事故才被人意识到。做好分布式锁的监控主要看三类指标加锁成功率、加锁耗时、锁等待超时次数。加锁成功率如果出现异常下滑可能是Redis连接异常、网络分区或者锁Key被错误清理加锁耗时的P99如果持续抬高说明锁竞争激烈需要考虑细化锁粒度或者优化业务锁等待超时次数激增往往意味着某些持锁线程迟迟没有释放锁要么业务逻辑写得太重要么代码里漏了finally释放。我的习惯是给分布式锁工具加一层统一的日志切面在加锁、解锁的关键路径上打印耗时和结果并打好监控埋点。这样每次排查问题至少有一个清晰的入口而不是满世界找日志。5. 一例典型的锁失效排查实录与最终自查清单5.1 一次库存超卖但代码没写错的排查过程之前有个电商客户端的库存扣减接口在某次大促后出现了超卖代码评审时明明看到加了分布式锁。整个排查过程走了不少弯路我把关键节点写出来供参考。第一轮定位先看加锁的Key。发现代码里用的Key是lock:stock带上了skuId看起来没毛病。继续往下看发现加锁Key的拼写里面用了字符串拼接虽然带了skuId但拼接时调用的方法因为类型转换问题所有skuId最后都转成了同一个值等于所有商品都在抢同一把锁。这个属于编码细节问题修正后并发能力明显恢复。但超卖问题依然存在。第二轮排查把目光转向了锁的释放时机。发现有一个单元测试环境里业务逻辑执行完成后忘记调用unlock导致测试环境的锁一直不释放其他请求全部阻塞。不过这属于测试环境问题生产环境并未受影响。第三轮才真正定位到生产问题服务做了多实例部署但RedissonClient的配置没有指向同一个Redis多个实例各自连了不同的Redis。锁定同一个业务Key时不同的Redis之间互不相通加锁自然形同虚设。这种情况通常在环境迁移或者配置重组时出现非常隐蔽。后续改进我给客户端的建议是分布式锁的Key统一收敛到配置中心管理不让每个服务自己拼字符串客户端加锁的Redis连接信息做成环境级别的全局配置防止各实例配置漂移同时对锁的加锁失败、解锁异常做全链路日志和告警。5.2 分布式锁自查清单根据这些年的实战经验我整理了一份自检清单每次写完锁相关代码或者做代码评审时过一遍能筛掉80%以上的常见问题检查项检查要点是否符合加锁原子性是否使用SET key value NX PX单命令而不是SETNXEXPIRE解锁原子性是否使用Lua脚本先比对Value再删除而不是GETDELValue唯一性锁的Value是否包含客户端唯一标识超时时间是否根据业务P99耗时设置合理过期时间而不是拍脑袋定值续期机制长任务是否启用了看门狗自动续期还是任由锁提前过期异常释放所有加锁逻辑成功后是否有finally兜底解锁可重入设计是否确认所用锁支持可重入避免同一线程二次加锁死锁锁粒度锁Key是否细化到业务实体级别而不是全局一把锁监控告警是否有加锁成功率、加锁耗时、锁等待超时的监控指标故障演练是否模拟过Redis宕机、主从切换、业务长时间阻塞等场景表格里最后一项故障演练是我重点想说的。很多团队代码写得很规范但从未在线上真实制造过一次Redis宕机看看锁在这些异常场景下到底怎么表现。每年搞一两次混沌测试把最坏的情况先暴露出来比事故发生后连夜排障要划算得多。5.3 锁失效后的兜底策略不得不承认无论选哪种方案分布式锁在极端场景下都存在失效的可能。Redis有主从切换丢锁ZooKeeper有会话超时误判数据库锁有死锁和超时回滚。正因为没有绝对可靠的锁生产系统里才必须有锁失效后的兜底策略。最常用的兜底手段是数据库乐观锁。在业务表上加一个version字段更新时带上版本号条件版本不匹配就说明数据已被其他请求修改过操作失败重试。这样即使Redis锁失效导致两个请求同时进入临界区最终写入数据库时也会有一个请求因为版本冲突而失败不会产生脏数据。另一个兜底是幂等设计。接口层面保证同一个请求重复执行多次和只执行一次的效果相同比如支付回调接口通过业务单号做唯一约束重复请求直接返回成功。有了幂等兜底锁就算名存实亡业务数据也不会乱。我个人体会是分布式锁是系统设计里的重要但不唯一的安全网——不能没有但也不能全指望它。把锁的机制做严谨同时配好幂等、版本校验、监控告警这一整套防御体系才能真正做到心里有底。这份笔记写到这里核心内容基本覆盖了从理论到实践、从选型到踩坑的全过程希望能帮你少走一些弯路。