ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

分布式锁原理详解:Redis、ZooKeeper、etcd三大方案与生产实践

分布式锁原理详解:Redis、ZooKeeper、etcd三大方案与生产实践 1. 从一台服务器到多台服务器锁为什么突然不灵了先说一个我实际遇到的场景。早期做一个订单系统时所有服务都部署在一台机器上为了防住用户重复提交订单代码里用了一个很简单的姿势private static final Object LOCK new Object(); public void createOrder(OrderDTO order) { synchronized (LOCK) { // 校验、落库、扣库存 } }单机时代这套完全够用JVM内部保证同一时刻只有一个线程能拿到这个对象锁。但后来的事情大家也都知道——业务量上来单台机器扛不住了服务拆成多实例部署同样的代码在不同机器上各跑一份。问题立刻出现用户在同一时刻点了两次提交请求被负载均衡分到了两台不同的服务器上每个JVM里各自有一把LOCK锁两个线程同时进到临界区订单重复创建。这就是分布式锁要解决的最本质问题多个JVM进程之间怎么实现对同一个共享资源的互斥访问。所以理解分布式锁第一步要把思维从线程级别切换到进程级别。线程之间抢锁靠的是JVM内存里的对象监视器而分布式场景下根本没有共享内存多个进程之间唯一的沟通渠道就是网络。我们需要的是一把所有人都看得见、都认可的锁这把锁不能放在任何一个应用进程内部必须放在一个所有节点都能访问到的第三方组件上。这个思路确定了后面所有方案设计都围绕一个核心问题展开用哪个中间件来充当锁的载体。目前工业界认可的主流选择无非三个Redis、ZooKeeper、etcd。三者的实现原理有本质差异后面的章节会逐一拆解先把概念地基打牢。2. 一把合格的分布式锁必须满足这几个硬性条件很多人写分布式锁上来就写setnx写完了就以为完事了。但真要放到生产环境里扛流量一把锁需要满足的条件远比想象中多。我把它拆成五个维度逐个说清楚。2.1 互斥性最基础但不是用了setnx就有互斥性指的是同一时刻只能有一个客户端持有锁。Redis的setnx命令单从语义上能满足这一点但需要注意互斥性依赖的是命令原子性一旦你把判断和设置拆成两步互斥性立刻失效。后面会专门讲这个经典错误。2.2 防死锁必须有过期时间锁的持有者可能会在持锁期间宕机、网络断开、被强制kill。如果锁没有自动释放机制那所有其他客户端永远拿不到锁整个系统直接瘫痪。解决方式就是给锁设置过期时间TTL到期自动释放。但引入TTL又会带来新的问题——锁被提前删掉怎么办这又牵扯出续期机制后面单独讲。2.3 可重入性同一线程能否重复获取一个方法加了锁方法内部又调用了另一个也加了同一把锁的方法这时候如果锁不可重入就会自己把自己锁死。单机锁如synchronized天然支持重入分布式锁则需要设计时专门考虑。常见的做法是给锁的value里存一个线程标识和计数器每次重入时计数加一。2.4 锁的持有者校验防止误删别人的锁这不是所有方案必需但属于生产环境的必备防御。A客户端拿到锁后处理超时锁到期自动释放B客户端获取到锁开始处理。此时A终于处理完了主动调用释放锁的逻辑如果不做持有者校验A就会把B持有的锁删掉——这就是经典的锁误删事故。所以释放锁之前必须先确认这把锁确实还是我的。2.5 高性能与高可用锁组件本身不能拖垮业务分布式锁挂在业务链路的必经路径上每一次加锁/解锁都是同步的RPC调用。如果锁组件本身延迟高或者频繁超时业务性能会被直接拖垮。一般要求单次加锁操作P99在毫秒级同时锁组件自身需要做高可用部署比如Redis的哨兵/集群、ZooKeeper的集群。性能和高可用往往存在trade-off这也是选型时要重点权衡的地方。以上五条基本就是面试官口里分布式锁的设计要求的标准答案。但注意这些要求不是孤立的它们彼此牵制——比如互斥性要求锁不能自动释放但防死锁又要求必须自动释放比如可重入需要额外存储状态但存储状态又会增加复杂度。后面讲各种实现方案时你会看到每个方案都是在这些约束之间做取舍。3. Redis实现方案SETNX的进阶用法与经典大坑Redis实现分布式锁是全网讨论最多、也是最容易踩坑的方案。不是Redis本身不好而是大多数人只用了最浅的一层就上了生产坑都在深层。3.1 最基础版本SETNX EXPIRE 的问题在哪最朴素的写法是两步# 加锁 SETNX lock_key unique_value # 解锁 DEL lock_key为什么不行如果加锁之后、设置过期时间之前进程宕机了锁永远不释放死锁。所以后来演进成把过期时间合到命令里SET lock_key unique_value NX PX 30000这个命令的含义是只有lock_key不存在时才设置同时带上30秒的自动过期。这个姿势在Redis 2.6.12之后是原子性的比分开两步安全得多。这一步算是最基本的正确用法。3.2 释放锁必须用Lua脚本为什么不能先GET再DEL锁的value里通常存的是客户端的唯一标识比如UUID释放锁时逻辑应该是读一下当前值如果是自己的标识才执行删除。但如果用先GET再DEL两段式操作中间一旦发生GC停顿或者网络延迟锁可能已经过期被别的客户端拿走此时再执行DEL就把别人的锁删了。正确做法是通过Lua脚本保证检查删除的原子性if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本的意思是只有当lock_key的value等于调用方自己的唯一标识时才执行删除。这样即使A处理超时、锁过期后B获取了锁等A醒过来执行这段脚本时因为value对不上删除操作不会生效B的锁得以保留。3.3 看门狗续期解决业务没跑完锁先过期的问题设置TTL是个两难设置太短业务没跑完锁就过期其他客户端进入临界区互斥性失效设置太长持有锁的客户端宕机后其他客户端要等很久才能拿到锁。Redis官方推荐的Redisson方案给了个思路——看门狗机制。默认锁的lease time是30秒但Redisson在拿到锁之后会启动一个后台定时任务每隔10秒lease time的三分之一检查一次锁是否还在持有中如果还在就把过期时间重置为30秒。这样锁的TTL其实变成了动态的业务没结束锁一直续期业务结束锁被释放客户端宕机锁最多30秒后自动过期。这个机制把锁活多久从固定值变成了跟随业务生命周期动态调整。实际写业务的时候用Redisson的RLock比手写SETNX舒服太多了代码就是RLock lock redissonClient.getLock(order:create: userId); boolean locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(操作太频繁请稍后重试); } try { // 业务逻辑 } finally { lock.unlock(); }注意tryLock的第三个参数是leaseTime如果传了固定值看门狗机制就不会工作。只有不传leaseTime或者传-1时才使用默认的30秒看门狗逻辑。这是我实际经验里的高频困惑点。3.4 RedLock理论完备但实践中有争议Redis官方其实推过RedLock算法——在多个独立的Redis节点上依次加锁超过半数节点加锁成功才认为获取到了锁。理论层面它试图解决Redis主节点宕机导致锁丢失的问题。但在实际工程里RedLock用得并不多原因有二一是它引入了多个Redis实例的部署成本和运维复杂度对大多数业务来说性价比不高二是很多技术大牛包括Martin Kleppmann等公开质疑过它的安全性认为它在时钟跳跃、GC停顿等极端场景下依然无法保证绝对互斥。我的建议是在绝大多数业务场景下使用单Redis实例主从同步的部署方式配合Redisson的看门狗机制已经能满足需求。真正对安全性要求极高的场景比如金融支付中的幂等控制应该考虑ZooKeeper或etcd方案而不是硬上RedLock。4. ZooKeeper方案临时顺序节点与watch机制如果说Redis是靠内存操作过期时间来模拟锁ZooKeeper就是天生适合做分布式协调的组件。它实现分布式锁的核心依赖两个东西临时节点和顺序节点。临时节点会话结束即删除。客户端和ZooKeeper之间维持一条长连接session客户端崩了、网络断了session超时后所有该客户端创建的临时节点全部自动清除。这个特性天然解决了持有者宕机后锁无法释放的问题根本不需要像Redis那样设计过期时间。顺序节点创建节点时自动追加自增序号比如lock_0000000001、lock_0000000002。序号的大小天然形成了排队的先后顺序。ZooKeeper分布式锁的加锁流程是这样的客户端在/locks路径下创建一个临时顺序节点比如/locks/lock_0000000003。获取/locks下所有子节点检查自己创建的节点序号是不是最小的。如果是最小的认为自己获得了锁执行业务。如果不是最小的则对前一个序号的节点注册Watcher监听然后阻塞等待。前一个节点被删除时Watcher触发唤醒当前客户端重新执行第2步检查。这个方案有一大堆肉眼可见的优点。互斥性有ZooKeeper的节点唯一性保证可重入可以通过检查会话和节点关系实现释放锁就是删除节点原子性由ZooKeeper保证死锁问题被临时节点天然规避——一旦客户端会话失效节点自动消失后一个节点被唤醒拿到锁。用Curator客户端的话代码比Redisson还简洁InterProcessMutex lock new InterProcessMutex(client, /locks/order_ orderId); lock.acquire(); try { // 业务逻辑 } finally { lock.release(); }ZooKeeper方案的主要缺点是性能。每条路径下面所有节点都做了顺序一致性保证写入路径上的请求都要经过Leader节点的提案和过半确认Zab协议延迟比Redis的内存操作高一个量级。如果业务对锁的获取频率很高比如每秒几千次ZooKeeper会成为一个瓶颈。一般在秒杀、订单、库存这类高频互斥场景大家宁可接受Redis方案在极端情况下的风险也不愿意承受ZooKeeper的吞吐上限。反过来在配置管理、分布式任务调度、幂等控制这类低频但对一致性要求极高的场景ZooKeeper是最稳的选择。5. etcd方案分布式锁的现代最佳实践etcd是CNCF旗下的分布式键值存储是Kubernetes的核心组件本身也内置了分布式锁的原生支持。它是这几年的新宠因为它某种程度上兼顾了Redis的性能和ZooKeeper的一致性。etcd实现分布式锁的核心机制是Revision全局版本号和Lease租约。加锁时创建一个key系统自动为这个key分配一个全局递增的revision。客户端通过比较自己创建的key的revision是不是当前最小的来判断是否获得锁。租约机制则确保了持有者宕机后锁自动过期释放。etcd的Raft协议与ZooKeeper的Zab协议类似但工程实现上做了很多优化尤其在读路径上etcd支持从Follower节点读取数据一定程度缓解了性能问题。在Kubernetes生态盛行的今天如果公司基础设施本身就运维了etcd用它做分布式锁不需要额外引入新组件运维成本为零。不过etcd实现分布式锁的实际代码比Redis和ZooKeeper方案略繁琐因为在并发场景下需要正确处理watch和revision的比较逻辑。好在有成熟的客户端库做了封装——比如etcd-client配合jetcd库或者直接使用concurrency包示例代码try (EtcdLock lock etcdLockClient.newLock(/locks/order_ orderId)) { if (lock.tryLock(3, TimeUnit.SECONDS)) { // 业务逻辑 } }etcd方案的生产级应用已经非常成熟在做技术选型时如果你们的微服务体系已经重度依赖Kubernetesetcd几乎是顺理成章的选择。6. 三种实现方案对比没有最好的锁只有最合适的锁三种主流方案的对比我用一个表格直观呈现方便大家按需选型。维度RedisZooKeeperetcd锁的可靠性主从切换可能丢失锁高Zab协议保证强一致高Raft协议保证强一致死锁规避TTL 看门狗临时节点session过期Lease租约互斥性保障依赖Redis实例状态依赖ZooKeeper集群协商依赖etcd集群协商性能高纯内存操作中Zab写入延迟较高中高读优化可重入需自行设计支持支持运维依赖常见、门槛低需要独立集群若与K8s共用则零额外成本典型场景秒杀、缓存一致性、业务幂等分布式任务调度、配置管理云原生环境、服务发现协调那么实际选型我的经验法则是对一致性要求高、并发量中等每秒几百次以下优先ZooKeeper。典型例子是分布式定时任务多个实例抢同一个任务执行权抢不到就等这种低频操作使用ZooKeeper非常稳。对吞吐要求高、能容忍极端情况下的短暂锁失效优先Redis。典型例子是秒杀场景的库存扣减、接口幂等控制性能优先极端情况下多放进来一两个请求也能通过后端其他手段补偿。已有Kubernetes环境、不想引入额外组件优先etcd。云原生改造过程中etcd是天然的分布式协调者。这个对比表放到面试里也是很好的加分项——能主动说出各方案的适用边界远比单纯背Redis用setnxZooKeeper用临时节点显得有经验。7. 从使用场景倒推设计什么时候你真的需要分布式锁很多人学会了分布式锁的各种API但一到实际项目里就用得乱七八糟。要么该用锁的地方没用导致数据错乱要么不该用锁的地方硬加白白拖垮性能。这里我按场景类型梳理一下。7.1 需要用的场景跨进程操作共享资源去重、防重、幂等。比如用户重复提交订单同一账号并发多开在创建订单前先获取lock:order:create:{userId}拿到锁的请求才允许继续走下单流程。还有库存扣减、账户余额扣减等在对同一份数据做读-改-写操作时用分布式锁保证操作的串行化。缓存击穿的防御也可以利用分布式锁——缓存不存在时只允许一个线程去查询数据库并回填缓存其他线程阻塞等待避免同时打到数据库。7.2 不该用的场景把锁当万能灵药有些场景用分布式锁是舍近求远。比如纯数据库层面的幂等用唯一索引加ON DUPLICATE KEY就能兜底比引入Redis锁简单得多。比如库存扣减用数据库的乐观锁UPDATE stock SET version version 1 WHERE id ? AND version ?也能做不一定非要走分布式锁。再比如高并发下读多写少的场景用读写分离和缓存就能扛住没必要加锁。7.3 分布式锁配合事务使用时的顺序问题这是个大坑。很多人在Service方法上加了Transactional然后在事务里操作分布式锁结果是锁在事务提交之前就被释放了。比如拿到锁、执行了数据库更新、释放锁、最后事务才提交。锁释放后另一个事务立刻获取锁但它读到的还是上一个事务未提交的数据取决于隔离级别产生脏读。正确姿势是把锁的释放放到事务提交之后——所以实践中更推荐在调用方先获取锁执行完所有业务逻辑包括事务提交后再释放锁而不是把加解锁逻辑塞进事务方法内部。8. 生产环境的分布式锁我踩过的坑和最后的建议文章最后一部分聊聊分布式锁在生产环境中的一些细节问题。这些都是我实际踩过或看过别人踩的坑每一个背后都有血泪教训。第一个坑GC停顿导致锁全部失效Java服务发生Full GC时应用线程会全部暂停。如果恰好在这个暂停期间Redis里锁过期了其他客户端过来获取锁并开始执行而暂停的线程醒过来后还在继续跑旧业务两个持有锁的线程同时操作共享资源数据就乱了。这个问题在ZooKeeper和etcd方案中同样存在——session心跳超时或租约过期都会触发类似问题。业界对此没有完美解法只能说尽量优化GC减少停顿概率以及控制持有锁时间不要太长。第二个坑时钟跳跃对Redis锁过期时间的干扰Redis的过期时间依赖服务器系统时钟如果Redis所在机器的时钟发生大幅跳跃比如NTP校准可能导致锁提前或延迟过期。RedLock遭到质疑的原因之一就是这个。而ZooKeeper和etcd不依赖物理时钟判断超时依赖的是会话状态和逻辑时钟的revision所以在这方面更稳。第三个坑锁的粒度太粗一个常见的错误是把锁粒度设成全局唯一的key比如字符串拼接所有业务类型加一个公共前缀。这样会导致严重串行化锁的含义完全不同但被强行绑在一起。正确做法是锁的粒度尽量细订单锁就按订单ID加用户锁就按用户ID加库存锁就按SKU加。锁key设计得好并发度才能上去。第四个坑Redis主从切换瞬间的锁丢失客户端A在Master上拿到了锁Master还没来得及把数据同步给Slave就宕机了哨兵把Slave升为新的Master此时新Master上没有这把锁的记录客户端B来获取同一把锁直接成功。两个客户端同时持锁互斥性被打破。这个场景概率很低但一旦发生就是资损级别的。这就是为什么金融级业务不推荐纯Redis方案。最后给一个总建议分布式锁的核心不是会用某个中间件的API而是理解每种方案在极端故障下的行为表现。内存里的synchronized出现问题时最多只是一个进程内的线程踩踏分布式锁一旦失效影响的是整个集群的数据一致性。所以选型和设计时多想想如果Redis挂了/GC停顿了/主从切换了我的系统还能不能保证安全比背一百个API都有用。
返回列表