ARTICLE DETAIL

资讯详情

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

Redis分布式锁实战:从SET NX到RedLock,搞懂高并发正确加锁

Redis分布式锁实战:从SET NX到RedLock,搞懂高并发正确加锁 先讲一个我印象非常深刻的线上事故。当时我们做秒杀活动压测到第三轮订单服务突然出现了大量重复下单数据库里同一个用户在同一秒下了两单。代码里明明加了synchronized怎么还会超卖查了半天才发现订单服务部署了三个节点synchronized锁住的只是单个 JVM 内部的 Monitor节点 A 的锁根本管不住节点 B 的线程。从那天起我就把 Redis 分布式锁当成了高并发场景的必修课。这篇是 Redis 进阶系列的第八篇专门把分布式锁这件事讲透从最基础的SET NX用法到 Lua 原子释放、可重入锁、看门狗续期、RedLock 的争议再到 Spring Boot 里的落地代码和生产环境踩过的坑。内容偏实战也适合准备面试时系统梳理一遍读完可以直接抄作业。1. 为什么单机锁撑不住分布式场景先别急着写代码想明白为什么需要分布式锁比会用 API 重要得多。这一章我用一个事故现场来拆解再对比一下市面上常见的几种分布式锁方案你就知道 Redis 方案的优势和代价在哪里了。1.1 扣库存的事故是怎么发生的我们当时的扣库存逻辑大概是这样的先从 Redis 里读出剩余库存判断大于 0 就执行扣减再写回。单机部署的时候一切正常因为所有线程共享同一个 JVM 内存synchronized能保证同一时间只有一个线程进入临界区。但是拆成三个节点之后三份 JVM 内存是互相隔离的每个节点各有一把锁等于三个门卫各管各的门客人从另一个门进去你根本拦不住。更隐蔽的是哪怕你用了数据库的行锁或者乐观锁在高并发下也会遇到连接池被打满、大量重试堆积的问题。分布式锁的本质是把多个进程之间的互斥这件事交给一个所有进程都能访问到的第三方组件去裁决。1.2 三种主流分布式锁的取舍我在不同项目里用过数据库锁、ZooKeeper 锁和 Redis 锁简单说下感受方案原理优点缺点数据库悲观锁select ... for update实现简单不用引入新组件性能差占用数据库连接高并发下容易拖垮库数据库乐观锁版本号 / 唯一索引无锁竞争适合读多写少写冲突时需要重试不适合强互斥场景ZooKeeper 锁临时顺序节点 Watch 机制可靠性高天然无惊群效应有超时自动清理需要维护 ZK 集群吞吐量不如 RedisRedis 锁SET NX PX/ RedLock性能最好实现轻量生态成熟极端情况下可能丢锁可靠性弱于 ZK从性能上看Redis 单实例大概能扛几万到十几万的 QPS而 ZK 的写入路径需要多数节点确认吞吐会低不少。所以大部分互联网场景会选择 Redis 分布式锁代价是你要能接受它的边界问题——比如主从切换瞬间的锁丢失。后面我会专门聊这个问题怎么规避。2. Redis 分布式锁的最小正确实现网上搜分布式锁能搜到一堆版本但很多是错的。这一章我从最早期写法一路讲到最小可用写法每一步都说明为什么不能那样写。2.1 加锁为什么必须是SET NX PX单命令很多人最早接触的是SETNX单独一条命令设置锁。当时我也这么写过代码长这样SETNX lock_key 1 EXPIRE lock_key 30问题在于这两条命令不是原子的。如果SETNX执行成功后进程突然崩溃或者网络断开EXPIRE还没来得及执行这把锁就永远不会释放其他线程会永久阻塞。这就是典型的死锁场景。正确做法是 Redis 2.6.12 之后提供的SET命令扩展参数SET lock_key unique_value NX PX 30000一个命令完成不存在才设置和设置过期时间两个操作从根上消除了时间窗口。在 Spring Boot 里用StringRedisTemplate对应的方法就是String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMillis(30_000)); if (Boolean.TRUE.equals(locked)) { // 拿到锁执行业务 }这里有两个容易翻车的细节setIfAbsent返回的是Boolean对象网络异常时可能返回null直接用if (locked)会空指针所以要用Boolean.TRUE.equals(locked)。requestId必须是全局唯一的一般用 UUID。后面释放锁要靠它确认我的锁否则会误删别人的锁。2.2 释放锁一条 Lua 脚本搞定检查 删除释放锁是坑最多的地方。最朴素的写法是if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); }看起来没毛病但先 get 比较再 delete是两条独立命令。假如线程 A 拿着锁业务执行时间超过了过期时间锁自动释放线程 B 拿到了锁。这时 A 才执行到get比较发现 key 还在且 value 是自己的其实已经是 B 的锁然后 A 执行delete把 B 的锁删掉了。正确的释放锁必须保证判断持有者和删除 key是原子的所以要用 Lua 脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endJava 侧调用DefaultRedisScriptLong unlockScript new DefaultRedisScript( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end, Long.class ); Long result redisTemplate.execute(unlockScript, List.of(lockKey), requestId);注意 Redis 单线程执行 Lua 脚本期间不会插入其他命令所以这个判断和删除天然原子。KEYS[1]放锁的 keyARGV[1]放 requestId千万不要拿 requestId 拼到 Lua 脚本字符串里去否则会被 SQL 注入那样被攻击。2.3 过期时间设置背后的博弈锁的过期时间设多少是一个典型的运维取舍设短了业务还没执行完锁就没了其他线程会进来重复执行。设长了持有锁的节点宕机后其他线程要干等很久。我的经验值是先统计业务在临界区内的 P99 耗时然后乘以 3 到 5 倍。比如扣库存逻辑一般几十毫秒设 5 秒就够如果是批量任务可能要设 1 分钟。如果拿不准最好配合续期机制我后面会专门讲看门狗。这里还有个原则要记住锁是协同工具不是事务替代品。锁超时自动释放是为了防死锁不是让你把业务硬塞在过期时间之内。真正高可靠的做法是让业务在释放锁之前保证执行完毕并用续期兜底。3. 进阶特性可重入、续期与锁粒度控制基础版分布式锁能用但距离生产环境还有距离。这一章聊三个高频需求同一个线程能不能重复拿锁业务没执行完锁过期了怎么办锁的粒度怎么设计才能不牺牲并发3.1 可重入锁用 Hash 结构记录重入次数普通的SET NX锁不具备可重入能力。比如一个方法里先加了锁然后又调用了另一个也加同一把锁的方法第二次SET NX就会失败。有的业务场景确实需要可重入这时候可以参照 Redisson 的思路用 Redis 的 Hash 结构实现-- 加锁锁不存在创建 hash 并记录次数 1 if redis.call(exists, KEYS[1]) 0 then redis.call(hset, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return 1 end -- 锁存在且当前线程已持有则重入次数加 1 if redis.call(hexists, KEYS[1], ARGV[1]) 1 then redis.call(hincrby, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return 1 end -- 锁被其他线程持有返回失败 return 0对应的解锁脚本-- 当前线程持有锁重入次数减 1减到 0 才删除锁 if redis.call(hexists, KEYS[1], ARGV[1]) 0 then return 0 end local count redis.call(hincrby, KEYS[1], ARGV[1], -1) if count 0 then redis.call(pexpire, KEYS[1], ARGV[2]) return 1 else redis.call(del, KEYS[1]) return 1 endhash 的 field 存线程唯一标识value 存重入次数。每次重入加一每次解锁减一减到 0 才真正删除锁这样锁的“租约期”也会随最后一次重入而刷新。3.2 看门狗续期锁别在业务跑了一半时消失我见过最磨人的故障就是锁过期时间设短了。业务逻辑里有外部接口调用平时 200 毫秒结果对方服务抖动一次响应等了两秒锁早就过期了另一个线程进入临界区两个线程同时处理同一笔订单。解决这个问题的标准做法是“续期”也就是 Redisson 里的看门狗机制。当你用lock.lock()且不指定leaseTime时Redisson 默认给锁设置 30 秒过期同时启动一个后台定时任务每 10 秒检查一次如果锁还在就把过期时间重新刷回 30 秒。RLock lock redissonClient.getLock(seckill:goods:1001); lock.lock(); // 使用看门狗自动续期 try { // 业务代码哪怕执行 5 分钟也不怕锁过期 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }看门狗让锁的存活时间跟随业务执行时间动态延长业务没跑完锁就不会过期。它的代价是如果持有锁的节点发生长时间 GC看门狗也可能会停锁依然存在过期风险——这是分布式锁在进程暂停场景下的宿命后面讲 RedLock 时还会提到。3.3 锁粒度别把所有用户锁进一个桶很多人用分布式锁时习惯用一个全局 key比如lock:order结果就是所有下单请求串行化QPS 直接跌到个位数。更好的做法是根据业务维度拆分锁 key。比如扣减库存锁的粒度应该是商品维度String lockKey lock:goods: goodsId;比如用户结算锁的粒度应该是用户维度String lockKey lock:user: userId;这样不同商品、不同用户之间完全并行同一商品、同一用户才排队。锁 key 的设计要遵循一个原则锁的粒度越细并发度越高但也要考虑锁的碎片成本。如果某个商品本来就是爆款热点那商品维度的锁其实还是会成为瓶颈这种情况下通常是用库存预扣 乐观重试来缓解单纯加锁解决不了热点问题。4. RedLock 与主从切换它真的可靠吗讲到这里基础的分布式锁已经很完整了。但如果你去面试或做架构评审一定会被问到Redis 分布式锁在 Redis 主从切换时会不会丢锁这一章把这个问题拆透顺便聊聊 RedLock 的原理和争议。4.1 锁丢失的完整链路Redis 的主从复制是异步的。假设锁写入 mastermaster 还没来得及把数据同步给 slavemaster 就宕机了哨兵会提升一个 slave 成为新 master。而此时新 master 上并没有这把锁其他客户端就能成功加锁互斥被打破。举个例子T1 客户端A 在 master 上 SET lock NX PX 30000 T2 master 宕机锁数据没同步到 slave T3 slave 提升为新 master内存中没有 lock T4 客户端B SET lock NX PX 30000 - 成功于是 A 和 B 同时认为自己拿到了锁这就是分布式锁失效。这个问题不是 Redis 特有的任何主从异步复制 自动故障转移的系统都存在这个窗口。要缓解可以拉长主从同步延迟窗口但无法从根上消除。4.2 RedLock 的思路Redis 作者 antirez 提出了 RedLock 算法思路很简单既然单点会丢锁那就让多个完全独立的 Redis 节点参与只要过半节点成功加锁就认为加锁成功。具体流程是这样的准备 N 个互相独立的 Redis 节点通常 N5每个节点没有主从关系。客户端以相同的 key 和 value依次向所有节点执行SET key value NX PX加锁操作需要设置一个远小于锁过期时间的获取锁超时时间比如加锁本身最多等待 50 毫秒。计算整体耗时 最后一个节点返回的时刻 - 开始加锁的时刻。如果成功加锁的节点数大于N/2且整体耗时小于锁的有效时间才算真正加锁成功。释放锁时向所有节点发送 Lua 删除脚本不用管哪些节点加成功。RedLock 的意义在于单台故障不会导致锁丢失必须同时超过一半节点发生故障或网络分区锁才真正不可用。相比单实例可靠性确实上了一个台阶。4.3 Martin 与 antirez 的经典争论以及我的判断分布式系统专家 Martin Kleppmann 写过一篇著名的文章《How to do distributed locking》核心批评有两点依赖系统时钟判断锁是否有效但进程停顿比如 Full GC会让持有锁的时间超过锁有效时间此时客户端仍然以为自己持有锁RedLock 也救不了。网络分区时客户端无法完成过半加锁但已经被授予锁的客户端可能还在执行冲突依旧存在。antirez 后来写了《Is Redlock safe?》回应大意是这些场景在很多业务里概率极低而且没有完美方案RedLock 在工程上已经够用。我个人在实际项目里的倾向是如果系统对一致性要求极高金融支付、强事务别用 Redis 分布式锁直接用 ZooKeeper 或者 etcd它们的分布式共识协议在语义上更严谨。如果只是防并发重复执行秒杀、防重、任务调度单实例 Redis 看门狗 合理过期时间完全够用叠加 RedLock 的成本是五倍资源和复杂的可用性维护。如果架构上 Redis 本身就做了高可用可以再加一个双写保护或者数据库幂等兜底把锁失效的影响控制在最小这比追求锁的绝对可靠更符合工程实际。5. Spring Boot 里的实操落地与代码前面讲了理论这一章给出可以直接复制的工程代码。我会先讲依赖和序列化坑再给一个封装好的锁工具类最后用一个秒杀扣库存的例子串起来。5.1 依赖选择和 RedisTemplate 序列化坑先说明一点如果你只是想快速用锁直接引入 Redisson 是最省事的因为看门狗、可重入、公平锁都内置了。如果你的项目不想引额外依赖那就用 Spring Data Redis 自带的StringRedisTemplate手写一版。dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependency手写版要注意一个非常典型的坑默认的RedisTemplate使用的是JdkSerializationRedisSerializervalue 存进去是带类型头和二进制字节的。这样你在 Lua 里get出来的内容和传入的requestId字节表示不一致比较永远返回 false锁删不掉。所以手写加锁直接用StringRedisTemplate它的 key 和 value 都走 String 序列化Lua 脚本比较字符串就能对上。5.2 一个清爽的分布式锁工具类我倾向于把加锁、解锁封装成两个方法业务代码只关心两行调用Component public class RedisLockService { private final StringRedisTemplate redisTemplate; private static final DefaultRedisScriptLong UNLOCK_SCRIPT new DefaultRedisScript( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end, Long.class); public RedisLockService(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public boolean tryLock(String lockKey, String requestId, long expireMillis) { Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMillis(expireMillis)); return Boolean.TRUE.equals(locked); } public boolean releaseLock(String lockKey, String requestId) { Long result redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), requestId); return result ! null result 0; } }业务侧使用String lockKey lock:goods: goodsId; String requestId UUID.randomUUID().toString(); boolean locked redisLockService.tryLock(lockKey, requestId, 30_000); if (!locked) { return 当前购买人数过多请稍后重试; } try { // 真正的扣库存逻辑 } finally { redisLockService.releaseLock(lockKey, requestId); }这里有个细节finally 里的解锁一定要用同一个 requestId如果业务报错也能保证锁被释放。以前见过有人把解锁代码写在 try 中间一旦抛异常锁就留下直接拖垮整条链路。5.3 从手动管理切换到 Redisson 的平滑迁移如果后续项目要支持可重入、续期、公平锁这些特性我会直接把手动方案换成 Redisson至少代码会清爽一个数量级RLock lock redissonClient.getLock(lock:goods: goodsId); boolean locked false; try { // waitTime 0 表示拿不到锁立即返回leaseTime -1 表示启用看门狗自动续期 locked lock.tryLock(0, -1, TimeUnit.SECONDS); if (!locked) { return 当前购买人数过多请稍后重试; } // 业务代码随便跑多久看门狗会续期 } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } }tryLock(0, -1, TimeUnit.SECONDS)里两个参数的含义要搞清楚第一个 waitTime 是拿锁的等待时间0 表示不排队第二个 leaseTime 是锁的持有时间-1 表示使用看门狗自动续期。如果你希望锁固定 10 秒后无条件释放那就把第二个参数设成 10。6. 生产环境排错实录与面试高频考点最后用我实际踩过的坑和一些面试常问的问题收尾。这些内容书上未必写全但都是线上真实会遇到的。6.1 三个真实故障的排查链路故障一业务里报了attempt to unlock a locked lock, not locked by current thread。现象是解锁时抛异常排查后确认是锁过期了。当前线程在 finally 里解锁时锁已经到期被别人拿走Redisson 拒绝释放不属于自己的锁。解决办法是加看门狗续期或者把leaseTime设成明显长于业务耗时同时在业务侧做好幂等。故障二手动版 Lua 删锁不生效。我在测试环境用默认RedisTemplate存锁get 出来总是对不上查了半个小时才发现是 JDK 序列化把字符串变成了二进制后来统一换成StringRedisTemplate就好了。这类序列化问题线上特别容易出排查思路是先redis-cli里get一下锁 key看存进去的到底是个什么形态。故障三主从切换导致两个线程同时进临界区。当时正好赶上 Redis 节点内存抖动触发主从切换秒杀订单里出现了极少数重复创建。我们用 RedLock 尝试过但成本高最终改成Redis 锁 数据库唯一订单号兜底在订单表上建唯一索引锁偶发失效时数据库拦最后一层问题彻底解决。6.2 排错方法先看锁 key再看过期时间我的固定排查套路是三步用redis-cli查看锁 key 的 TTL 和 value确认锁是否还在、属于谁。看业务日志里加锁和解锁的时间差比对锁过期时间判断是不是业务慢导致锁失效。观察 Redis 主从复制延迟指标。如果锁丢失发生在主从切换时间点基本可以锁定是复制窗口问题。线上出了问题不要慌这三点足够定位绝大多数场景。6.3 面试常问的三个与锁有关的问题如果你在准备面试除了前面的原理还可能被问到这三个高频问题SETNX 和 SET NX PX 有什么区别SETNX是单独的旧命令和EXPIRE分开执行有死锁窗口SET key value NX PX是原子命令推荐使用。为什么释放锁要用 Lua 脚本因为检查持有者和删除锁需要原子性Lua 脚本在 Redis 单线程中执行期间不会被其他命令打断能防止误删别人的锁。锁过期了业务还没执行完怎么办两个方向一是用 Redisson 的看门狗自动续期二是根据业务耗时合理设置过期时间并做好幂等兜底。这三个问题答清楚基本就能把分布式锁的深度展示出来。6.4 一个容易被忽略的扩展锁失败后的降级策略最后补充一个生产必备思维加锁失败不代表业务要直接失败。比较蠢的做法是拿不到锁就抛异常返回错误。更稳妥的做法是设计降级策略比如拿不到锁时用本地ConcurrentHashMap做短暂的去重快速失败。对可延后的任务写入 MQ由消费端串行处理。对读多写少的场景可以只用分布式锁保护写操作读操作走缓存副本。分布式锁的价值不在于让系统永不并发而在于把不可控的并发窗口收敛到一个可控的范围内。剩下的边界交给幂等、重试和数据库约束去兜底。从最初被synchronized坑过到后来手动写 Lua 脚本、再换成 Redisson 看门狗这条路线我走了很久。现在回头看最深刻的体会是分布式锁的难点从来不是会用 API而是搞清楚它的失效边界然后为每一种失效场景预先设计兜底方案。如果大家在实践中有更好的锁分片思路或者事故案例也欢迎交流下一篇进阶内容我会继续聊 Redisson 源码和公平锁的实现细节。
返回列表