ARTICLE DETAIL

资讯详情

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

Redis分布式锁原理与实战:从主从丢锁到看门狗续期

Redis分布式锁原理与实战:从主从丢锁到看门狗续期 不知道你有没有遇到过这种诡异的上线事故本地并发测试全绿一上生产、一跑集群库存、余额、订单号就开始出幺蛾子。我之前接手过一个领券系统单机压测一点问题没有发到四台机器以后同一时刻四个人同时领同一张券数据库里就插了四条。查来查去罪魁祸首就是“每个 JVM 各守各的进程锁”。解决这类问题最常见、也最实用的方案就是今天要聊的 Redis 分布式锁。Redis 分布式锁解决什么问题一句话让跑在不同机器上的多个进程通过同一份 Redis 数据做互斥保证任意时刻只有一个客户端能进入临界区。它本身并不神秘本质就是去 Redis 执行一条带过期时间的原子命令谁抢到谁干活。这篇内容我尽量把完整链路讲透——单机锁为什么不够用、分布式锁的底层命令是怎么演进的、手写一个最小可用的实现、生产环境为什么建议直接用 Redisson、主从切换丢锁和红锁争议是怎么回事、面试怎么答以及我实测中踩过的几个坑。无论你是刚接触 Redis 的新人还是写了很多年代码的老兵看完应该都能直接拿去落地。所以要理解分布式锁得先理解为什么本地锁会“失控”。1. 为什么你的项目需要一把 Redis 分布式锁1.1 从 synchronized 到多实例的失控现场单体应用时代Java 里的 synchronized / ReentrantLock 确实够用。JVM 内置锁只对当前进程内的线程生效大家都在同一个进程里锁定了互斥区间调度器自然会保证同一时刻只有一个线程往里进。这就是为什么很多人一直没意识到锁有什么“分布式”的问题。但微服务化之后一个订单服务经常扩到 3 个实例、5 个 Pod用户请求经过负载均衡落到任意一台机器。A 机器上的线程 1 拿到锁只能阻止 A 机器上其他线程进临界区它完全没法告诉 B 机器和 C 机器上的线程“你先别动”。如果库存扣减、余额变更这类强一致操作并发执行数据就乱了。说白了分布式锁的本质就是找一个所有进程都能访问的公共状态存储把这个状态当作“门闩”。谁在门闩上写入了自己谁才有资格进门。有人会说既然有数据库为什么不直接用数据库的唯一索引或者行锁做互斥这其实是另一条路但不是同一个维度。数据库锁可靠但吞吐有限高并发抢锁时数据库压力会很难看。Redis 走内存、延迟低更适合处理高并发、短临界区的场景。这也是为什么业界默认提到分布式锁第一反应就是 Redis。1.2 几个典型的分布式锁使用场景我列几个最常见的场景你可以对照自己的项目判断到底需不需要秒杀、抢购、优惠券领取订单服务多实例并发扣减库存。如果只是简单的update stock set number number - 1 where id ? and number 0SQL 本身能防超卖但涉及预扣、下单、回滚这种多步骤流程中间需要全局互斥点防止两个实例同时对同一个订单状态做操作。定时任务防重复执行每天凌晨的结算任务、报表统计任务如果应用部署了 4 个实例到了整点 4 个实例都会触发同一个Scheduled。必须有人抢到锁、有人跳过否则报表会被跑四遍。热点缓存失效后的回源重建缓存 Key 失效瞬间大量请求同时打到数据库。用分布式锁保证只有一个请求真正去查库写缓存其他请求等待或直接读旧数据。幂等 / 防重复提交用户连续点击支付按钮同一个订单被创建多个支付单这类场景锁也能派上用场。分布式环境下写共享资源生成同一批文件、分配短链码、刷新全局配置。注意分布式锁也有自己的代价所有请求都要多一次 Redis 网络往返高并发下拿锁失败的请求还要排队或快速失败。所以它不是一个“用上了就稳了”的银弹而是权衡后的选择。1.3 不同互斥手段的边界对比方案原理优点缺点适合场景进程内锁JVM monitor / 信号量性能最高跨实例完全无效单实例内部保护共享对象数据库唯一索引 / 行锁数据库约束强一致、可靠吞吐受限可能拖垮库低频、可靠性要求极高Redis 分布式锁原子命令 TTL快、实现简单极端场景有丢锁风险高并发、短临界区ZooKeeper / etcd 锁分布式协调服务模型更严谨会话过期自动清理组件重延迟高强一致场景、配置中心我自己的习惯是“Redis 锁做前置防并发数据库幂等键做最后兜底”。后面第 4 节会展开聊这个思想。2. 一把可靠分布式锁的底层原理与协议2.1 从 SETNX 到 SET NX PX两步操作为什么会死人很多讲分布式锁的文章都会让你用SETNX这是早期 Redis 提供的“只在 Key 不存在时写入”的命令。最早一批人是这么玩的SETNX lock:order:10001 uuid-value EXPIRE lock:order:10001 30第一行设置锁返回 1 表示抢到第二行给锁加过期时间。问题出在“两步走”不是原子的。如果SETNX执行成功之后、EXPIRE执行之前服务进程突然崩溃了这个锁 Key 就永远留在 Redis 里后续所有请求都拿不到锁形成死锁。生产环境最常见的分布式锁故障之一就是这种“锁永不过期”。所以现在正确做法是用 Redis 2.6.12 之后扩展出来的SET命令一次性完成“如果 Key 不存在就写入并且设置过期时间”SET lock:order:10001 uuid-value NX PX 30000NX表示只有 Key 不存在时才会写入PX 30000表示设置 30 秒过期。返回OK就是拿到锁返回nil说明锁已经被别人持有。一个命令干完比两步走安全得多。Redis 本身是单线程事件循环单个命令天然具备原子性因此这条命令不存在“操作到一半被别人插进来”的问题。2.2 为什么 value 必须带随机标识解锁必须用 Lua 脚本很多新手加锁时 value 直接写死一个1解锁直接DEL key这在生产环境迟早出事。我们来看一个典型事故线程 A 拿到锁预期业务只要 10 毫秒结果慢查询或者 GC 让业务跑了 35 秒锁在 30 秒时自动过期。线程 B 此时发现锁没了成功拿到同一把锁开始执行自己的临界区。35 秒时线程 A 终于执行完执行DEL key把 B 的锁给删了。线程 C 又拿到锁于是 B 和 C 同时进入临界区。要避免这种情况加锁的 value 必须是一个“本次加锁会话唯一的标识”通常用 UUID 加上线程 ID、进程 ID 组合。这样解锁前可以先检查锁里的 value 到底是不是自己的。如果是自己的才删除如果不是说明锁已经被别人拿到绝不能删。检查再删除是两条命令如果分开执行还是存在竞态A 检查完发现是自己的正准备 DEL此时锁恰好过期B 马上写入锁A 的 DEL 就把 B 的锁删了。因此“比较 value 再删除”必须用 Lua 脚本在一个原子操作里完成if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本就是解锁的通用标准。Redis 在执行 Lua 脚本时不会穿插执行其他命令所以不会出现“检查完是 A 的删掉时已经变成 B 的”这种窗口。2.3 锁过期而业务没跑完看门狗续期机制最开始使用SET key value NX PX 30000时锁其实是“带超时的互斥标记”。TTL 是我们预设的而业务执行时间是动态的。如果业务逻辑执行时间超过了锁过期时间锁会被 Redis 自动清理第二个客户端就能进入临界区第一个客户端还没结束。问题本质是“锁的生命周期”和“业务生命周期”不一致。解决思路有两条。第一条把 TTL 设置得足够大大到业务几乎不可能超过比如 10 分钟。但这属于“赌运气”如果赶上服务 GC 停顿几十秒、数据库慢查询几分钟还是会出问题而且锁过期时间太长一旦持有锁的进程异常崩溃其他客户端要等很久才能接管。第二条是给锁加一个“看门狗”。后台用一个线程每隔固定时间检查锁是否还被自己持有如果是就对 Key 重新执行EXPIRE续期如果业务已经执行完主动释放锁并关闭续期线程。Redisson 的分布式锁就内置了这个机制默认lockWatchdogTimeout 30000ms每 10 秒续约一次把锁的过期时间重新设置为 30 秒。进程崩溃时续期线程也没了锁最多 30 秒后自动过期不会死锁。注意Redis 的锁必须带过期时间这是分布式锁和本地锁最关键的区别。本地锁只存在于进程内进程没了锁自然消失分布式锁的数据在 Redis 上进程崩溃后没人会记得删所以必须靠 TTL 兜底。2.4 主从切换丢锁与红锁的争议单实例的分布式锁仍然有一个很头疼的问题Redis 主从复制是异步的。当我们在主节点上执行SET key value NX PX写入了锁这条数据还没来得及同步到从节点主节点突然宕机了。哨兵或 Kubernetes 里的健康检查会把一个从节点提升为新的主节点而新主节点上根本没有这把锁的记录。于是另一个客户端跑到新主节点上也能加同一把锁原先持有锁的业务还在继续执行两个客户端同时进入临界区。这不是理论推演线上确实发生过 Redis 主从切换导致的双写事件。为了降低这个风险Redis 社区提出了 RedLock 思路不要只在一个 Redis 节点上加锁而是在 N 个互相独立、不参与主从复制的 Redis 节点上分别加锁。要求成功加锁的节点数超过 N 的一半并且整个加锁过程消耗的时间小于锁的有效期才算真正拿到锁。拿不到锁就向所有节点释放。它的本质是把“单点故障”变成“多数派一致”因此单个节点丢失不会立刻导致锁失效。但 RedLock 在业界一直有争议。网络分区、客户端 GC 停顿、时钟跳跃等场景下它依然不能提供严格的互斥保证。我的实践建议是普通业务优先选择“Redisson 合理的锁超时 数据库幂等兜底”不必自己实现 RedLock更不要盲目引入额外集群。如果业务真的要求极高强度的锁语义认真评估 etcd 或 ZooKeeper而不是在 Redis 上面堆复杂度。2.5 可重入、公平锁、读写锁成熟实现的设计思路最基础的分布式锁其实只解决“互斥”。生产环境中还会遇到三个进阶问题。一是可重入。同一个线程在已经持有锁的前提下递归方法或内部方法再次加同一把锁如果锁不允许重入就会自己把自己锁死。Redisson 内部用 Redis Hash 结构实现可重入Key 是锁名Field 是持有者标识Value 是重入次数。加锁时先判断 Hash 是否存在不存在就创建存在且 Field 是当前持有者就计数加一并刷新过期时间。解锁时递减计数减到 0 才删除 Key。整个过程用 Lua 脚本完成保证原子性。二是公平锁。默认的 Redis 锁是“抢到就是抢到”不保证先来后到。Redisson 支持FairLock底层用有序集合记录等待队列按顺序授予锁。公平锁能避免线程饥饿但等待和排序会带来额外的 Redis 操作吞吐会下降。不建议所有场景都上公平锁只在业务确实有顺序要求时用。三是读写锁。RedissonReadWriteLock把读写语义搬到分布式场景读锁之间不互斥写锁和所有锁互斥。适合类似缓存更新的场景但实现复杂度更高读多写少且对强一致要求不是特别高的时候不一定划算。3. 手写一把最小锁再换成 Redisson 生产方案3.1 快速准备一个 Redis 测试环境为了验证后面的代码你需要一个能跑的 Redis。最省事的方式是用 Docker 起一个单节点docker run -d --name redis-lock-demo -p 6379:6379 redis:7.0-alpine redis-cli -p 6379 ping如果你要测“主从切换丢锁”这类高可用问题再用 docker-compose 起一主一从加哨兵本文就不展开搭建细节了核心目标是先把锁的用法讲透。3.2 最小可用版SET Lua 实现用手写工具类是最能帮助理解原理的方式。我用 Spring Boot 的StringRedisTemplate写了一个最小实现tryLock加锁unlock解锁。加锁命令就是setIfAbsent(key, token, Duration)它会走SET key value NX PX解锁就是用前面那段 Lua。Component public class SimpleRedisLock { private final StringRedisTemplate template; public SimpleRedisLock(StringRedisTemplate template) { this.template template; } public boolean tryLock(String key, String token, long expireMs) { return Boolean.TRUE.equals( template.opsForValue().setIfAbsent(key, token, Duration.ofMillis(expireMs)) ); } public void unlock(String key, String token) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; template.execute(new DefaultRedisScript(script, Long.class), List.of(key), token); } }使用的时候注意三点第一token 从UUID.randomUUID().toString()生成最好把线程 ID 也拼进去确保唯一第二无论业务成功还是异常都要在finally里释放锁第三这个实现没有续期也不支持重入所以只适合临界区非常短的场景。String token UUID.randomUUID().toString(); String lockKey lock:order: orderId; boolean locked simpleRedisLock.tryLock(lockKey, token, 30_000); if (locked) { try { // 执行业务逻辑 } finally { simpleRedisLock.unlock(lockKey, token); } }这个工具类的最大价值是教学不是生产。拿它做核心交易系统会被 Redisson 里的看门狗、重入、集群支持打得体无完肤。3.3 生产直接选 Redisson核心用法与参数选择Redisson 是 Redis 官方推荐的 Java 客户端之一内置了分布式锁的各种成熟实现。使用方式很像 JDK 里的ReentrantLock上手成本很低。先创建 RedissonClient然后拿锁Config config new Config(); config.useSingleServer().setAddress(redis://127.0.0.1:6379); RedissonClient client Redisson.create(config); RLock lock client.getLock(lock:order: orderId); boolean locked false; try { locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { return 系统繁忙请稍后再试; } // 业务逻辑 } finally { if (locked) { lock.unlock(); } }tryLock(waitTime, leaseTime, unit)里的三个参数含义分别是等待时间、持锁时间和时间单位。等 3 秒拿不到锁就快速失败避免线程无限挂起持锁 30 秒是给锁一个硬上限即使进程卡死30 秒后也能自动过期。注意后面两个参数不是随便填的。如果你希望锁自动续期就不要传固定leaseTime用lock.tryLock(5, -1, TimeUnit.SECONDS)或者直接lock.lock()Redisson 会开启看门狗。如果你传了leaseTime30看门狗不会接管业务一旦超过 30 秒锁还是会提前失效。还有一个被很多人忽略的问题tryLock没有成功时千万不要在finally里无条件调用unlock()否则会抛IllegalMonitorStateException。这就是为什么代码里用一个boolean locked标记是否拿到了锁。3.4 业务代码的正确姿势快速失败、缩短临界区在实际项目里我见过不少把分布式锁用坏的情况最常见的有三种。第一抢不到锁就无限自旋重试。有些人习惯写while (true) { if (tryLock()) break; Thread.sleep(10); }锁没抢到线程全在空转Redis 压力飙升接口响应时间也跟着失控。正确做法是tryLock给一个不超过业务容忍度的等待时间等不到就快速返回“系统繁忙”或者走降级逻辑。第二锁粒度太大。有人图省事锁 Key 直接写成lock:order结果所有订单都抢一把锁系统并发能力瞬间掉到 1。正确的锁 Key 应该尽可能细化比如lock:order:10001、lock:user:9527让不相关的请求各抢各的锁。第三锁内做太多耗时操作。分布式锁本质是“把并发转成串行”锁内业务每多一秒等待者就多积压一批。建议把不需要在锁内执行的查询、计算尽量提前锁内只保留必须互斥的写操作网络调用超时时间也要设置合理避免一个慢节点拖死所有拿锁线程。4. 高可用、缓存治理与集群场景下的实战经验4.1 主从、哨兵、K8s 集群下锁的高可用策略我们前面说过主从异步复制会带来“主节点挂了新主节点丢锁”的窗口。在真实业务里这个问题没有绝对完美的方案只有权衡。常见的妥协策略是接受极小概率的锁失效但下游数据写入必须有防重和幂等兜底。比如支付回调场景就算分布式锁因为主从切换失效了订单表里有唯一索引(order_id, payment_id)重复创建的支付单会直接插入失败并触发告警。这比花大代价把 Redis 锁做成绝对安全更现实因为核心矛盾其实在数据库这层。Cluster 集群模式下Redis Key 通过 CRC16 算法落到一个 Slot单 Key 的读写在当前节点上是原子的但主从切换依然存在丢锁窗口。Redisson 的RedissonRedLock可以在多个独立 Master 节点上做 RedLock把风险摊小但它不是银弹。如果你在 Kubernetes 上部署 Redis还要注意 Pod 重启、漂移会导致客户端连接断开再重连若业务假死但持有锁不释放锁只能靠 TTL 兜底。另外给 Redis 设置淘汰策略时不要用allkeys-lru否则高负载下锁 Key 可能被当成普通数据淘汰掉互斥瞬间失效。建议使用volatile-lru只淘汰设置了过期时间的 Key。4.2 缓存穿透、击穿、雪崩与分布式锁的边界热搜词里有“Redis 缓存治理”这块经常和分布式锁放在一起聊因为热点 Key 失效后回源数据库的过程确实能用锁来控制。缓存击穿一个热点 Key 突然过期大量请求同时发现缓存为空一起打到数据库。可以用分布式锁让只一个线程去重建缓存其他线程等待或读旧值。伪代码模板如下String value cache.get(BizKey); if (value null) { RLock lock redisson.getLock(rebuild: BizKey); if (lock.tryLock(1, 5, TimeUnit.SECONDS)) { try { value cache.get(BizKey); // double-check if (value null) { value loadFromDb(BizKey); cache.set(BizKey, value, 60 random(10)); } } finally { lock.unlock(); } } else { Thread.sleep(50); value cache.get(BizKey); // 等锁期间可能别人已重建 } }这里需要留意如果数据库查询特别慢而锁等待时间只有 1 秒其他线程等不到锁第二次cache.get可能还是空。这种情况下不要死等一次可以考虑重试两三次或者直接返回旧值、走降级。缓存穿透是指请求查询一个根本不存在的数据缓存里没有数据库里也没有所有请求都会穿到数据库。这个问题的解法是布隆过滤器或者空值缓存分布式锁不是最优解。缓存雪崩是指大量 Key 同时过期流量直接冲垮数据库。正确做法是让过期时间加随机抖动、使用多级缓存、入口限流。分布式锁只能挡住“同时重建同一个 Key”这种并发挡不住“大量不同 Key 同时回源”的大规模流量别指望它能救雪崩。4.3 Lettuce 命令超时和锁等待互相放大的排查热词里有一条很典型的报错redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException。在分布式锁项目里这个报错出现的典型场景是大量线程拿不到锁阻塞在tryLock等待中把连接池占满后续连普通 Redis 命令也执行不了。这类问题排查思路是分层的。先看服务端是不是有慢命令用redis-cli --latency观察延迟再用SLOWLOG GET 20查慢日志。很多事故来源于有人执行了KEYS *、SMEMBERS大集合、HGETALL大 HashRedis 是单线程这些慢命令会卡住后面所有命令包括加锁命令。再看客户端连接池是否过小spring.redis.lettuce.pool.max-active默认只有 8业务线程数一多连接就容易被占满。一般建议压测时把max-active逐步调到 50 到 200同时给max-idle和min-idle配合理值。还有一个很容易忽视但性价比极高的建议把锁使用的 Redis 和业务缓存 Redis 拆开。锁 Redis 只放锁 Key数据量小、访问模型简单不会因为业务缓存的大 Key 扫描而拖垮锁命令。很多团队把“Redis 分布式锁”和“Redis 缓存”混用一个集群最后互相拖累排查起来非常痛苦。5. 常见问题排查与面试题复盘5.1 我在线上踩过的几个坑我把这些年看到的分布式锁典型事故整理成了一张表你对照自己的项目排查应该会有收获。现象根因排查思路解决锁永远不释放业务全部阻塞加锁后没设置过期时间或进程崩溃时锁还在查看 Redis Key 的 TTL看异常日志统一用SET NX PX生产用 Redisson 看门狗线程 A 把线程 B 的锁删了value 写死或先 GET 再 DEL 非原子加锁和解锁日志对比 token随机 token Lua 原子解锁同一线程重入同一把锁时死锁自研锁不支持可重入看锁 Key 的数据结构是不是 Hash换 Redisson或自己实现计数 Lua主从切换后两个线程同时进临界区主从异步复制导致锁记录丢失对齐故障转移时间线和业务日志加数据库唯一键/版本号兜底必要时上 etcd 锁锁 Key 被人为淘汰Redismaxmemory-policy配置了全淘汰策略CONFIG GET maxmemory-policy改成volatile-lru或独立锁实例大量 Lettuce 命令超时慢命令、连接池耗尽、网络抖动SLOWLOG、INFO clients、压测拆锁实例、调大连接池、优化慢查询每个案例背后都是真金白银换来的教训。比如锁 Key 被淘汰那个坑我们是在一次大促压测时发现的Redis 内存不够allkeys-lru把锁 Key 当普通缓存淘汰了瞬间两个线程同时进入库存扣减逻辑库存间接超卖。从那以后所有涉及锁的 Redis 实例都单独规划淘汰策略也明确为volatile-lru。5.2 分布式锁面试题应答要点Redis 分布式锁几乎是后端面试必考题。我梳理了面试官最爱追问的几个问题每道题给你一个可以直接用的答题框架。Q1Redis 分布式锁怎么实现加锁用SET key value NX PX保证原子地设置锁和过期时间解锁用一个 Lua 脚本先对比 value 再删除生产环境用 Redisson 的RLock它自己处理了看门狗续期、重入、集群模式适配。Q2为什么 value 必须随机防止误删。线程 A 持锁时间超过 TTL锁自动过期线程 B 拿到同一把锁如果 A 解锁时没有校验 value就会把 B 的锁删掉。用 UUID 加线程 ID 做 token解锁时只有 token 匹配才删除。Q3为什么解锁要用 Lua校验 token 和删除 Key 是两步操作分开执行会有竞态窗口。Redis 执行 Lua 时不会穿插其他命令所以“对比 token 再删锁”是原子操作。Q4Redisson 看门狗的原理默认lockWatchdogTimeout为 30 秒加锁成功后后台每 10 秒续约一次把锁的过期时间重置为 30 秒。锁被主动释放后停止续约进程崩溃后锁最多 30 秒自动过期。手动指定leaseTime时不会启动看门狗。Q5持有锁的线程发生长时间 GC 怎么办锁可能已经超时并被其他线程获取这是分布式锁在个体故障下的安全边界。要解决它没有银弹只能靠业务幂等和底层存储兜底。这也是为什么我强调“锁负责减少并发冲突数据库负责最终一致性”。Q6Redis 锁和 ZooKeeper 锁怎么选Redis 锁实现简单、性能高、适合高并发短任务ZooKeeper 使用临时顺序节点客户端会话断开后节点自动删除天然规避“锁永不过期”但部署重、延迟高。追求高一致性且对延迟容忍度高的场景可以考虑 ZooKeeper 或 etcd绝大多数业务场景Redisson 就够了。6. 一点个人经验与收尾分布式锁这个东西用好了是利器用不好是事故源头。我系统性地做完几个项目之后最大的感受是别把锁当成保护数据的唯一防线。锁只是把并发请求挡在临界区外面真正保证数据不烂的还得是数据库的唯一约束、乐观锁、版本号这些底层能力。上锁之前先问自己一句“这个临界区真的需要全局互斥吗”如果能用 Redis 原子操作比如INCR、DECR、Lua 扣库存解决问题就不必引入分布式锁。如果决定引入先把这些细节做到位加锁必须带 TTL解锁必须带 token 并且用 Lua生产环境直接使用 Redisson 而不是自己重复造轮子锁粒度尽量细临界区尽量短抢不到锁快速失败而不是无限自旋锁和缓存不要共用同一个 Redis 实例淘汰策略注意别把锁 Key 扫掉上线前压测三件事——并发争抢是否准确、锁超时后能否恢复、连接池会不会被打满。最后再分享一个小习惯我会在业务代码里记录锁的获取耗时、等待耗时、每秒冲突次数这几个指标。很多锁问题不是靠“看代码”定位的而是靠“监控曲线长什么样”判断的。等锁时间突然飙高大概率是某个慢查询把临界区拖长了锁冲突率突然上升大概率是有热点 Key 或者异常流量。有了监控你才敢说这把锁是可控的。如果你还想继续深挖下一篇文章我可以拆解 Redisson 加锁的 Lua 脚本源码以及它处理主从切换、看门狗计时器的具体实现那部分才是真正区分“会背面试题”和“真懂分布式锁”的分水岭。
返回列表