尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Redis分布式锁实战:SETNX、Redisson与Lua脚本方案深度解析

Redis分布式锁实战:SETNX、Redisson与Lua脚本方案深度解析 1. 项目概述与核心价值在分布式系统里锁是个绕不开的话题。想象一下一个秒杀活动库存就剩最后一件成千上万的请求同时涌来如果不用锁很可能这件商品会被好几个人同时“抢到”导致超卖。在单机时代我们用synchronized或者ReentrantLock就能搞定但到了微服务遍地走的今天服务部署在多台机器上这些本地锁就完全失效了。这时候我们就需要一个所有服务节点都能“看见”和“遵守”的锁——分布式锁。Redis凭借其高性能、丰富的数据结构和原子操作成为了实现分布式锁的热门选择。但“用Redis实现分布式锁”这句话说起来简单背后却藏着不少门道。是直接用SETNX命令还是用 Redisson 客户端又或者用 Lua 脚本不同的方案在可靠性、性能、复杂度上天差地别。选错了可能在测试环境风平浪静一到线上高并发场景就“翻车”出现锁失效、死锁或者性能瓶颈。今天我就结合自己踩过的坑和项目实战经验来深度拆解三种主流的 Redis 分布式锁实现方案基于 SETNX 和 EXPIRE 的基础命令方案、基于 Redisson 的看门狗方案以及基于 Lua 脚本的原子操作方案。我会把每种方案的原理、实现步骤、参数怎么设、坑在哪里、怎么避都掰开揉碎了讲清楚。目标是让你不仅能“抄作业”把锁用起来更能理解背后的设计权衡在面对复杂场景时能做出最适合自己业务的选择。2. 三种方案的核心原理与设计权衡在动手写代码之前我们必须先搞清楚一个合格的分布式锁应该满足哪些基本条件以及 Redis 是如何利用其特性来满足这些条件的。这决定了我们方案设计的出发点。2.1 分布式锁的四大核心要求互斥性这是锁最基本的功能在任意时刻只能有一个客户端持有锁。这是实现资源安全访问的基石。防死锁即使持有锁的客户端崩溃或者发生网络分区锁也必须在某个时间点被自动释放以避免系统永久阻塞。这通常通过给锁设置一个过期时间TTL来实现。容错性只要 Redis 集群中的大部分节点还存活客户端就能正常获取和释放锁。这要求我们的方案不能过度依赖单个 Redis 实例的可用性。解铃还须系铃人锁只能由加锁的客户端来释放。防止其他客户端误删了不属于自己的锁导致锁安全机制失效。Redis 的SET命令配合NXNot eXist和PX毫秒过期时间参数可以原子性地完成“不存在则设置值并设置过期时间”的操作完美满足了互斥性和防死锁的前两个要求。这也是所有方案最底层的依赖。2.2 方案选型背后的核心考量为什么会有三种方案因为它们分别解决了不同层面的问题基础命令方案SETNX EXPIRE这是最直观、最接近 Redis 原生命令的方案。它的优势是简单、轻量不引入额外依赖。但它的致命缺陷在于SETNX和EXPIRE是两个独立的命令不是原子操作。如果在执行完SETNX后、执行EXPIRE前客户端崩溃就会导致锁永远不会过期形成死锁。虽然可以用SET key value NX PX timeout这个原子命令来规避但它仍然无法解决锁的自动续期和可重入性问题。Redisson 方案Redisson 是一个在 Redis 基础上实现的 Java 驻内存数据网格客户端。它提供的分布式锁RLock封装了看门狗Watchdog机制。简单说就是当你获取锁时它会启动一个后台线程在锁过期前的一段时间默认是过期时间的1/3去自动续期直到你主动释放锁。这完美解决了业务执行时间可能超过锁过期时间的问题。同时它原生支持可重入锁、公平锁等多种锁类型功能非常强大。代价是引入了 Redisson 这个较重的客户端需要理解其运行机制。Lua 脚本方案Redis 支持 Lua 脚本且保证一个脚本在执行时是原子性的不会被其他命令打断。我们可以将获取锁判断设置值设过期时间和释放锁判断值是否为自己设置删除的逻辑分别写成两个 Lua 脚本。这确保了整个操作的原子性是比基础命令更严谨的实现。它比 Redisson 轻量又弥补了基础命令的非原子性缺陷是一种折中的、需要一定开发量的方案。选择哪种方案取决于你的业务场景对可靠性要求极高业务执行时间不确定且团队愿意引入和维护一个功能丰富的客户端选Redisson。追求轻量业务执行时间可控且短希望代码完全可控愿意自己实现原子性选Lua 脚本。用于简单的、非核心的同步控制或者快速原型验证可以谨慎使用基础命令务必用原子SET命令。3. 方案一基于 SET 命令的基础实现与深度避坑我们先从最基础也是坑最多的方案开始。这里我直接使用原子命令SET resource_name random_value NX PX 30000来讲解避免SETNXEXPIRE的经典陷阱。3.1 核心实现步骤与代码解析假设我们使用 Spring Boot 和Spring-boot-starter-data-redis。1. 定义锁工具类方法Component public class RedisLockUtil { Autowired private StringRedisTemplate stringRedisTemplate; private static final String LOCK_PREFIX “LOCK:”; private static final long DEFAULT_EXPIRE_TIME 30000L; // 默认30秒 /** * 尝试获取分布式锁 * param lockKey 锁的Key * param requestId 请求标识推荐使用UUID用于保证“解铃还须系铃人” * param expireTime 锁的过期时间毫秒 * return 是否获取成功 */ public boolean tryLock(String lockKey, String requestId, long expireTime) { String key LOCK_PREFIX lockKey; // 核心使用SET命令的NX和PX参数进行原子操作 Boolean result stringRedisTemplate.opsForValue() .setIfAbsent(key, requestId, expireTime, TimeUnit.MILLISECONDS); // Boolean.TRUE.equals() 是为了安全处理null值 return Boolean.TRUE.equals(result); } /** * 释放分布式锁 * param lockKey 锁的Key * param requestId 请求标识 * return 是否释放成功 */ public boolean unlock(String lockKey, String requestId) { String key LOCK_PREFIX lockKey; String currentValue stringRedisTemplate.opsForValue().get(key); // 判断当前锁的值是否与自己加锁时设置的值一致 if (requestId.equals(currentValue)) { // 一致则删除Key释放锁 Boolean delete stringRedisTemplate.delete(key); return Boolean.TRUE.equals(delete); } // 不一致说明锁可能已过期或被其他客户端持有本次释放无效也可能已超时 return false; } }2. 业务中使用示例Service public class OrderService { Autowired private RedisLockUtil redisLockUtil; public void createOrder(String productId) { String lockKey “CREATE_ORDER:” productId; // 锁粒度细化到具体商品 String requestId UUID.randomUUID().toString(); // 生成唯一客户端ID boolean locked false; try { // 尝试获取锁设置10秒过期 locked redisLockUtil.tryLock(lockKey, requestId, 10000L); if (!locked) { throw new RuntimeException(“系统繁忙请稍后重试”); } // 执行业务逻辑例如查询库存、创建订单、扣减库存 doBusiness(); } finally { // 务必在finally块中释放锁 if (locked) { redisLockUtil.unlock(lockKey, requestId); } } } }3.2 关键细节与致命陷阱这个方案看似简单但每一步都暗藏玄机1. Value 必须使用唯一标识如 UUID这是实现“解铃还须系铃人”的关键。如果你用固定的值比如“1”考虑以下场景客户端A获取锁成功SET lock:order 1 NX PX 10000。客户端A业务执行了12秒超时了锁被Redis自动删除。客户端B此时获取锁成功SET lock:order 1 NX PX 10000。客户端A终于执行完调用unlock它检查lock:order的值发现是“1”和自己当初设置的一样但它不知道这个锁已经换主人了于是删除了锁。结果客户端B的锁被客户端A意外释放锁的互斥性被破坏。 使用 UUID 作为 value可以绝对区分不同客户端的锁。2. 释放锁的逻辑不是简单的delete必须先get再compare and delete上面的unlock方法展示了这个过程。但请注意这里的get和delete仍然是两个独立命令不是原子操作这会产生一个新的竞态条件客户端A执行get(lockKey)得到自己的 requestIduuid-a。就在此时锁过期了TTL变为0。客户端B成功获取了锁SET lock:order uuid-b NX PX ...。客户端A继续执行delete(lockKey)。结果客户端B刚获得的锁又被客户端A删除了。 要解决这个问题必须让get和delete原子性执行这正是Lua 脚本方案要解决的核心问题之一。基础命令方案在此处存在理论上的安全隐患。3. 过期时间expireTime的设置是门艺术设置过短业务没执行完锁就过期了导致其他客户端进入数据一致性被破坏。设置过长客户端崩溃后锁需要很长时间才能自动释放系统恢复能力变差。最佳实践这个时间应该略大于业务逻辑的平均执行时间。你需要通过监控和压测来确定这个值。例如你的创建订单逻辑 99% 的请求在 800ms 内完成那么你可以设置锁过期时间为 2-3 秒提供一个安全缓冲。对于执行时间波动很大的业务这个方案就力不从心了此时Redisson 的看门狗才是更优解。踩坑实录早期我们有个对账任务执行时间受数据量影响从几分钟到几十分钟不等。当时图省事用了固定30秒过期的基础锁结果经常出现任务执行到一半锁过期另一个节点又启动了一个任务导致数据被重复处理对账结果完全混乱。后来不得不重构为 Redisson 锁。4. 方案二基于 Redisson 的高可靠可重入锁当你被基础方案的原子性、过期时间难题困扰时Redisson 就像一个开箱即用的瑞士军刀它帮你处理了所有底层复杂性。4.1 Redisson 锁的核心机制看门狗Watchdog这是 Redisson 锁最精髓的部分。其工作流程如下你通过redissonClient.getLock(“myLock”)获取一个RLock对象并调用lock()或tryLock()。在 Redisson 内部它向 Redis 发送一个 Lua 脚本原子性地完成“判断加锁设过期时间”默认30秒。关键一步加锁成功后Redisson 会启动一个后台定时任务看门狗这个任务会每隔过期时间的 1/3即10秒去检查客户端是否还持有锁通过判断 Redis 中该锁的 hash 结构中是否存在当前客户端的标识。如果客户端还持有锁即业务没执行完看门狗就会通过 Lua 脚本将锁的过期时间重置为初始值30秒实现自动续期。当客户端主动调用unlock()时Redisson 会先停止看门狗线程然后再发送释放锁的 Lua 脚本到 Redis。这样一来只要客户端进程没有挂掉且没有主动释放锁这个锁就会一直被持有业务就不会因为执行时间过长而出现锁过期的问题。客户端挂掉后看门狗线程也随之停止锁在最终的过期时间到达后会被 Redis 自动清理防止了死锁。4.2 集成与使用实战1. 引入依赖与配置dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.0/version !-- 请使用最新稳定版本 -- /dependency在application.yml中配置单机模式也支持集群、哨兵等模式spring: redis: host: localhost port: 6379 # database: 0 # password: # Redisson 配置 (如果starter不支持自动装配需手动配置Bean)2. 在业务中直接使用Service public class InventoryService { Autowired private RedissonClient redissonClient; // 由starter自动注入 public boolean deductStock(String itemId, int quantity) { String lockKey “STOCK_LOCK:” itemId; RLock lock redissonClient.getLock(lockKey); // 方式1阻塞式加锁直到成功或中断 // lock.lock(); // try { ... } finally { lock.unlock(); } // 方式2尝试加锁最多等待10秒锁持有时间设为30秒看门狗会续期 boolean isLocked false; try { isLocked lock.tryLock(10, 30, TimeUnit.SECONDS); if (isLocked) { // 成功获取锁执行业务 return doDeductStock(itemId, quantity); } else { // 获取锁失败处理逻辑如返回错误信息 return false; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 恢复中断状态 throw new RuntimeException(“获取锁被中断”, e); } finally { if (isLocked lock.isHeldByCurrentThread()) { lock.unlock(); } } } }4.3 Redisson 锁的高级特性与注意事项可重入性RLock继承了java.util.concurrent.locks.Lock接口并且是可重入的。这意味着同一个线程可以多次获取同一把锁而不会把自己阻塞死。内部通过 Redis 的 Hash 结构记录客户端ID和重入次数。公平锁redissonClient.getFairLock(“myFairLock”)可以获取一个公平锁它按照请求的顺序来分配锁避免了某些线程饥饿。但性能会比非公平锁稍差。联锁MultiLock、红锁RedLockRedisson 提供了更复杂的锁机制来处理极端情况比如需要同时锁定多个资源或者需要更高的可靠性以应对单个 Redis 实例故障。红锁算法RedLock旨在解决在Redis主从架构下主节点宕机可能导致锁丢失的问题它要求客户端向超过半数的独立Redis实例申请锁成功才算获取锁。但请注意RedLock 算法本身在分布式系统领域存在争议比如时钟漂移问题使用前需充分评估。务必在 finally 块中判断后解锁lock.isHeldByCurrentThread()这个判断很重要特别是在使用了tryLock(waitTime, leaseTime, unit)且业务中可能发生中断的情况下确保不会释放其他线程的锁。性能与资源消耗看门狗机制意味着只要持有锁就会有一个后台线程在运行并定期与 Redis 通信。在大规模、高并发的应用中成千上万的锁会对应成千上万的定时任务对客户端和 Redis 都会产生一定的压力。对于执行时间非常短且确定的业务可以考虑使用tryLock(0, 10, TimeUnit.SECONDS)并指定一个较短的leaseTime第二个参数这样 Redisson 就不会启动看门狗锁在10秒后自动过期。5. 方案三基于 Lua 脚本的原子操作实现如果你觉得 Redisson 太重又对基础命令方案的非原子性不放心那么自己编写 Lua 脚本是一个很好的折中方案。它让你对锁的控制粒度更细且保证了核心操作的原子性。5.1 Lua 脚本的原子性保障Redis 保证当一个 Lua 脚本在执行时不会有其他命令或脚本被插入执行。这相当于在这个脚本的执行过程中你拥有了一个“单线程”的环境。我们可以利用这一点将判断和删除操作打包。5.2 获取锁与释放锁的 Lua 脚本实现1. 获取锁的 Lua 脚本 (lock.lua):这个脚本的目标是如果key不存在则设置它并设置过期时间如果key已存在则检查value是否与当前请求相同支持可重入简单版这里先实现互斥。-- KEYS[1]: 锁的key -- ARGV[1]: 锁的value (唯一标识如UUID) -- ARGV[2]: 锁的过期时间毫秒 if (redis.call(‘exists’, KEYS[1]) 0) then -- 键不存在可以加锁 redis.call(‘set’, KEYS[1], ARGV[1]) redis.call(‘pexpire’, KEYS[1], ARGV[2]) return 1 -- 返回1表示加锁成功 end -- 键已存在检查value是否为自己加的可重入逻辑基础 if (redis.call(‘get’, KEYS[1]) ARGV[1]) then -- 如果是自己持有的锁可以刷新过期时间或直接返回成功这里选择刷新时间 redis.call(‘pexpire’, KEYS[1], ARGV[2]) return 1 -- 返回1表示加锁成功重入 end return 0 -- 返回0表示加锁失败注意这个简单脚本实现了基本的互斥和防误删但可重入计数等复杂功能需要更复杂的Hash结构这里为简化先不展开。2. 释放锁的 Lua 脚本 (unlock.lua):这个脚本的目标是只有锁存在且value匹配时才删除锁。-- KEYS[1]: 锁的key -- ARGV[1]: 期望的锁的value if (redis.call(‘get’, KEYS[1]) ARGV[1]) then -- 值匹配删除锁 return redis.call(‘del’, KEYS[1]) else -- 值不匹配说明锁可能已过期或不属于当前客户端返回0表示释放失败或无需释放 return 0 end5.3 在 Java 中集成与调用 Lua 脚本我们可以利用StringRedisTemplate的execute方法来执行脚本。Component public class RedisLuaLockUtil { Autowired private StringRedisTemplate stringRedisTemplate; private static final String LOCK_PREFIX “LUAlock:”; // 使用DefaultRedisScript封装Lua脚本 private static final DefaultRedisScriptLong LOCK_SCRIPT; private static final DefaultRedisScriptLong UNLOCK_SCRIPT; static { // 初始化加锁脚本 LOCK_SCRIPT new DefaultRedisScript(); LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource(“lua/lock.lua”))); LOCK_SCRIPT.setResultType(Long.class); // 脚本返回数字类型 // 初始化解锁脚本 UNLOCK_SCRIPT new DefaultRedisScript(); UNLOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource(“lua/unlock.lua”))); UNLOCK_SCRIPT.setResultType(Long.class); } public boolean tryLockWithLua(String lockKey, String requestId, long expireTime) { String key LOCK_PREFIX lockKey; // 执行脚本参数脚本对象Key列表可变参数对应ARGV Long result stringRedisTemplate.execute( LOCK_SCRIPT, Collections.singletonList(key), // KEYS 数组 requestId, String.valueOf(expireTime) // ARGV 参数 ); // 根据脚本定义返回1成功0失败 return result ! null result 1L; } public boolean unlockWithLua(String lockKey, String requestId) { String key LOCK_PREFIX lockKey; Long result stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(key), requestId ); // 脚本返回删除的key数量1表示成功释放0表示释放失败锁已过期或非己持有 return result ! null result 1L; } }5.4 Lua 脚本方案的优缺点与适用场景优点原子性保证获取锁和释放锁的核心逻辑在一个原子操作中完成彻底解决了基础命令方案中get和del之间的竞态条件问题。轻量灵活不依赖像 Redisson 这样的重型框架只依赖 Redis 本身和简单的客户端。脚本逻辑完全由你控制可以根据业务需要定制比如实现简单的可重入、读写锁等。性能优异Lua 脚本在 Redis 服务端执行减少了网络往返次数RTT对于简单操作性能比多次发送命令更好。缺点与挑战功能需要自实现像 Redisson 提供的看门狗自动续期、公平锁、红锁等高级功能都需要你自己在 Lua 脚本和业务逻辑中实现复杂度陡增。脚本管理与调试Lua 脚本需要作为资源文件管理调试不如 Java 代码方便。脚本写错了可能导致 Redis 阻塞或逻辑错误。集群环境下的注意点在 Redis 集群模式下Lua 脚本中操作的所有 key 必须位于同一个哈希槽hash slot中否则会报错。对于分布式锁锁的 key 本身是确定的所以通常没问题。但如果你的脚本涉及多个 key就需要使用{tag}语法来确保它们被分配到同一个槽位。适用场景适用于对性能和控制力有要求且业务锁模型相对固定不需要复杂的自动续期、公平锁等团队有一定 Redis 和 Lua 脚本开发能力的项目。它是介于“简单但不安全”和“安全但较重”之间的一个优雅平衡点。6. 生产环境常见问题排查与实战技巧无论选择哪种方案在线上环境都可能遇到各种问题。这里我总结了一份排查清单和实战技巧。6.1 问题排查速查表问题现象可能原因排查思路与解决方案锁获取失败率突然升高1. Redis 服务端压力大响应变慢。2. 业务执行时间变长锁持有时间变久竞争加剧。3. 网络波动导致命令超时。1. 监控 Redis CPU、内存、网络IO、连接数。检查是否有慢查询。2. 在业务代码中打点统计锁内业务逻辑的执行时间分布。检查是否有外部依赖变慢。3. 检查客户端与 Redis 服务器之间的网络延迟。出现数据不一致超卖等1.锁过期业务未执行完锁已释放。2.锁误释放其他客户端释放了不属于自己的锁基础命令方案风险。3.锁未释放客户端异常退出未执行 finally 块中的 unlock。1.针对锁过期使用 Redisson 看门狗或根据业务监控合理调大过期时间。关键技巧锁的过期时间应 业务平均执行时间 网络与GC抖动时间。2.针对误释放确保使用唯一标识UUID作为 value并使用 Lua 脚本或 Redisson 等原子性方案释放锁。3.针对未释放确保 unlock 逻辑在finally块中。对于非阻塞锁要处理中断异常。Redis CPU 占用率高1. 大量客户端频繁争抢同一把锁热点锁。2. Redisson 看门狗续期任务过多。3. Lua 脚本过于复杂或存在死循环。1.细化锁粒度不要用一把大锁锁住整个库存而是锁到具体的商品ID甚至库存行ID。2. 对于执行时间短的锁使用 Redisson 时指定较短的leaseTime并关闭看门狗tryLock(0, 短时间, TimeUnit.SECONDS)。3. 审查 Lua 脚本逻辑确保其复杂度为 O(1) 或 O(N) 且 N 很小。客户端报连接超时或命令超时1. 网络问题。2. Redis 服务端阻塞如执行慢查询、RDB/AOF持久化。3. 客户端连接池配置不当。1. 检查网络连通性。2. 检查 Redis 日志排查是否有SLOWLOG或MONITOR发现的慢命令。3.优化连接池合理配置maxTotal最大连接数、maxIdle最大空闲连接和minIdle最小空闲连接。在 Spring Boot 中可以通过LettucePoolingClientConfiguration或JedisPoolConfig配置。6.2 实战进阶技巧锁粒度控制锁的粒度越细并发度越高。例如秒杀场景下锁“stock_lock”会导致所有商品串行处理性能极差。应该锁“stock_lock:product_1001”。但也要避免过度细化导致需要管理海量的锁 key。非阻塞锁与快速失败在高并发场景下如果获取锁失败不要一直重试除非是公平锁场景这会给 Redis 和网络带来巨大压力。通常采用“快速失败”策略直接给用户返回“系统繁忙”等友好提示。或者可以使用tryLock(timeout, unit)设置一个合理的等待超时时间。锁与事务的结合注意在 Spring 的Transactional注解方法中使用分布式锁时锁的释放 (unlock) 应该在事务提交之前还是之后如果之后事务提交耗时较长锁持有时间变长。如果之前可能出现锁释放了但事务还没提交其他线程读到旧数据。一个常见的做法是将锁的范围控制在事务之外或者使用编程式事务精细控制。监控与告警对分布式锁的关键指标进行监控是必不可少的。锁等待时间从尝试获取锁到成功获取的时间。如果这个时间持续增长说明竞争激烈。锁持有时间业务在锁内执行的时间。如果远超过你设置的过期时间就需要预警。锁获取失败率失败率异常升高往往是系统出现问题的前兆。可以在加锁和释放锁的地方通过 AOP 或手动埋点将 metrics 发送到 Prometheus、SkyWalking 等监控系统。7. 方案对比总结与选型建议最后我将三种方案的核心区别整理成表格并给出我的选型建议。特性维度基础命令方案 (SET NX PX)Redisson 方案Lua 脚本方案实现复杂度极低低开箱即用中需编写和维护脚本可靠性低释放锁非原子有竞态风险高看门狗、原子操作高核心操作原子功能丰富度仅有基本互斥极高可重入、公平锁、联锁、红锁可自定义但需自实现自动续期不支持支持看门狗机制不支持需自实现性能高中有后台线程开销高脚本原子执行RTT少依赖仅 Redis 客户端Redisson 客户端仅 Redis 客户端支持Lua适用场景简单的、非核心的同步控制快速原型验证中大型分布式系统对锁可靠性、功能要求高的生产环境对性能和可控性有要求且愿意投入开发维护的中型项目我的个人选型经验对于绝大多数 Java 后端项目我首推 Redisson。它几乎解决了分布式锁的所有痛点社区活跃文档丰富虽然引入了一个依赖但带来的可靠性和开发效率的提升是巨大的。除非你的应用对 jar 包大小和依赖数量有极其苛刻的要求。如果你在一个非 Java 技术栈或者团队对 Redis 和 Lua 非常熟悉且锁模型简单固定Lua 脚本方案是一个很棒的选择。它轻量、高效、可控。基础命令方案我只会在写一些临时脚本、Demo 或者对一致性要求极低的场景下使用并且一定会使用原子的SET ... NX PX命令并在释放时进行 value 校验。分布式锁没有银弹选择哪种方案最终取决于你的业务场景、团队技术栈和运维能力。理解每种方案的原理和 trade-off才能做出最适合当前项目的决策。希望这篇近万字的深度解析能帮你彻底掌握 Redis 分布式锁在分布式系统的世界里稳稳地锁住你的关键资源。
返回列表