从SETNX到Redisson:Redis分布式锁的演进与实战避坑指南
2026/9/16 2:16:37 网站建设 项目流程

1. 从 SETNX 到 Redisson,分布式锁到底在解决什么问题

先说个最直白的场景:你有个订单服务,库存只有 10 件,结果同一秒涌进来 20 个下单请求。单机时代一个 synchronized 就能锁死临界区,但系统一拆成多实例,每个实例各持一把锁,谁也锁不住谁,超卖就来了。

这时候分布式锁就该登场了。它的核心诉求就一句话:让多个进程(或者多台机器)在访问同一个共享资源时,彼此互斥。你可以把它理解成整栋楼只有一把厕所钥匙,谁拿到谁进,用完必须还回来,而且钥匙只有一把,不会出现两个人同时蹲同一个坑的情况。

分布式锁的实现方案不少,数据库、ZooKeeper、etcd 都能做,但现实中用得最广、聊得最多的还是 Redis。原因也简单:Redis 快,单线程模型天然适合做原子操作,而且公司里基本都有现成的 Redis 集群,不用额外引入一堆基础设施。

但快归快,Redis 实现分布式锁的坑是真的多。早期大家直接用 SETNX,后来发现各种边界问题,又引入了 SET NX EX、Lua 脚本、RedLock,再到后来干脆用 Redisson 封装好的 Lock 接口。这一路演进踩过的坑,我一个个说清楚。

2. 第一代方案:SETNX 的“看似简单”和它的致命伤

2.1 SETNX 的基本用法

SETNX 的全称是 SET if Not eXists,语法特别简单:

SETNX key value

如果 key 不存在,就设置成功,返回 1;如果 key 已经存在,设置失败,返回 0。

用这个命令做分布式锁的思路非常直白:

SETNX lock_order_1001 1 # 返回 1,说明拿到锁 # 返回 0,说明别人持锁,继续等待

拿到锁的线程执行业务逻辑,处理完了用 DEL 释放锁:

DEL lock_order_1001

这个方案有什么问题?我都不用细想,你光看这个流程就能感觉到一股“裸奔”的味道。核心痛点有三个,一个比一个致命。

2.2 第一个坑:死锁——锁永远不释放

如果线程在拿到锁之后、执行 DEL 之前,突然崩了、被 kill 了、或者 Redis 连接断了,那这个 key 就永远留在 Redis 里了。后面所有线程再 SETNX 都是返回 0,整个系统的这段时间内,所有需要这把锁的业务全部卡死,而且不会自动恢复。

这就相当于上厕所的人晕在里面,外面的人只能在门口等到天荒地老。

解决办法也简单,加过期时间。最早的尝试是两条命令组合:

SETNX lock_order_1001 1 EXPIRE lock_order_1001 30

但这两条命令不是原子的。想象一下这种时序:SETNX 执行成功,线程正要执行 EXPIRE,这时候 JVM 进程突然被 kill 了,EXPIRE 没执行。死锁问题照样存在——你只是把“先拿锁再崩溃”的情况变成了“SETNX 后、EXPIRE 前崩溃”的情况,窗口变小了,但没有根除。

所以 Redis 后来提供了带过期时间的原子命令:

SET lock_order_1001 1 NX EX 30

这个命令保证设置 key 和设置过期时间作为一个原子操作完成,才算是把死锁这个最大的坑填上了一大半。这也是为什么现在你几乎不应该再单独用 SETNX 命令做锁,除非你是要兼容特别老版本的 Redis。

2.3 第二个坑:业务执行时间超过锁过期时间

锁设了 30 秒过期,结果业务跑了 40 秒。到 30 秒的时候,lock key 自动过期了,这时候另一个线程 B 成功 SET 了同一个 key,拿到了锁。

你以为自己还持有锁,还在写共享资源,但实际上你已经不是唯一的持锁者了。两个线程同时在临界区干活,锁形同虚设,超卖、重复扣款、库存错乱全出来了。

这不是什么极端情况,只要是慢查询、外部接口调用、大批量数据计算,都非常容易把锁活活“拖过期”。

2.4 第三个坑:误删别人的锁

这个坑和第二个坑经常叠加出现。线程 A 的锁过期了,线程 B 拿到锁,开始干活。结果 A 的业务终于跑完了,执行 DELETE lock_order_1001,把 B 的锁删了。

这下局势彻底失控:B 以为自己还持锁,C 也能 SET 成功拿到锁,三个线程同时在临界区干活。数据不乱才怪。

有人说,那我删除之前先 GET 一下,看看 value 是不是自己的,是才删。但这又要面临“判断 + 删除”两步操作不是原子的老问题。你必须用 Lua 脚本把“比较 value 和删除 key”做成一个原子操作,才能避免误删:

if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end

这算是一个能用的方案,但手动管理 value、过期时间、续期,复杂度已经上来了。每个业务方都得自己复制这段逻辑,一旦写得不一样,排查问题的成本剧增。

2.5 为什么 SETNX 方案还能在面试里出现

不是因为它现在好用,而是因为它能很好地考察候选人对“分布式锁到底要考虑哪些边界问题”的理解。面试官看你回答 SETNX,会接着问:死锁怎么办?过期时间设多少?业务超时怎么办?误删怎么办?续期怎么做?一条线问下来,答得越深入,越说明你真正处理过并发场景。

在实际生产项目中,纯 SETNX 方案几乎已经被淘汰了。如果你要说用 Redis 做分布式锁,至少得往 SET NX EX + Lua 脚本 + 随机 value 这个程度去写,才勉强有点说服力。

3. 演进中间态:SET 原子命令 + Lua 脚本的“手动挡”方案

3.1 一个还算规范的手写锁长什么样

再回答那个经典问题:不借助 Redisson,你自己写一个可用的 Redis 分布式锁,最低限度要怎么设计?

我直接给你一个参考实现(Java 伪代码层面):

加锁:

// value 必须是全局唯一的,比如 UUID String value = UUID.randomUUID().toString(); // SET key value NX EX,原子性的“不存在才设置 + 过期时间” String result = jedis.set("lock:order:1001", value, "NX", "EX", 30); if ("OK".equals(result)) { // 拿到锁,执行业务 }

释放锁:

-- Lua 脚本,保证比较和删除的原子性 if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

这个方案解决了三个老坑:原子加锁防死锁、随机 value 防误删、Lua 脚本保证释放锁的原子性。

3.2 但你的业务超时续期怎么办?

注意,我上面还留着一个大坑没填:锁过期时间设 30 秒,业务跑 40 秒怎么办?

手写方案里,常见的处理方式是开一个后台守护线程,在锁快要过期的时候自动续期,每过 10 秒检查一次,如果锁还在,就把过期时间再往后推 30 秒。业务执行完,主动释放锁,同时通知守护线程停止。

这听起来不复杂,但实现起来细节极其繁琐:

  • 守护线程什么时候启动,什么时候停止,怎么保证不泄漏?
  • 续期操作本身失败了,是重试还是放弃?放弃的话锁可能瞬间失效。
  • 业务线程和守护线程之间的通信怎么设计,别搞出新的并发问题。
  • 分布式环境里,每个节点的时钟如果不一样,会影响过期判断吗?

这些问题不是不能解决,但你写一遍得花不少精力,而且容易写出隐蔽 Bug。更关键的是,你在项目里每换一个业务场景,都得把这些逻辑再复制一遍,维护成本滚雪球一样涨。

3.3 集群模式下的新问题:主从切换丢锁

上面聊的都是单机 Redis,或者主从架构里所有操作都在主节点的情况。可一旦主节点挂了,哨兵把从节点提升为主节点,你就要面对一个残酷的事实:Redis 主从复制是异步的。

线程 A 在主节点上 SET 锁成功,但这个 key 还没来得及同步到从节点,主节点就宕机了。哨兵把从节点升为主节点,这个从节点上根本没有锁 key。线程 B 这时候再去加锁,加锁成功。A 和 B 又同时拿到了锁。

这就是著名的“异步复制丢锁”问题,也是 RedLock 算法诞生的背景。不过 RedLock 也不是银弹,Redisson 本身支持 RedLock 方案,但实际生产中如果你们没到特别极端的场景,比如金融级强一致,一般也不会轻易上 RedLock。因为它需要至少 5 个独立 Redis 节点,运维成本高,而且 RedLock 本身在分布式系统领域也一直有争议。

这里我不展开 RedLock 的争论文战,只说结论:如果你的系统允许偶尔的锁失效(大多数互联网业务其实都能接受),用 Redisson 的单实例或主从模式就够了。如果绝对不允许,那应该认真考虑 ZooKeeper 或 etcd,而不是在 Redis 上拼命打补丁。

4. Redisson 是怎么把这些坑打包解决的

4.1 Redisson 锁的基本用法

Redisson 是一个基于 Redis 的 Java 客户端框架,它最大的价值不是 Redis 操作 API 多好用,而是它把分布式锁做成了一套标准的、开箱即用的 Java Lock 接口实现。

用法简单到令人感动:

RLock lock = redissonClient.getLock("lock:order:1001"); try { // 尝试加锁,最多等 10 秒,锁自动过期时间 30 秒 boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS); if (locked) { // 执行业务逻辑 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }

你都不用去管什么 Lua 脚本、随机 value、续期线程,Redisson 全给你封装好了。

4.2 Redisson 的看门狗机制(Watchdog)

这是 Redisson 最核心的特性,没有之一。

当你调用lock.lock()或者lock.tryLock()不指定 leaseTime 的时候,Redisson 会启动一个后台定时任务,默认每 10 秒执行一次,检查当前线程持有的锁是否还存在,如果存在,就把锁的过期时间重置为默认的 30 秒。

这就像你去借书,图书馆管理员每隔 10 分钟看你还在看,就自动帮你把借书期限往后再推 30 分钟,直到你把书还回去为止。这就完美解决了业务执行时间超过锁过期时间的问题。

有个点必须强调:看门狗只在未指定 leaseTime 时生效。如果调用tryLock(waitTime, leaseTime, TimeUnit)时显式传了 leaseTime,Redisson 不会开续期线程,到点就自动释放锁。这个设计是为了满足那些“硬性要求锁必须多长时间内自动释放”的场景——比如你有非常严格的超时控制,不希望锁被无限续期下去。

4.3 锁的可重入性

RLock实现了 Java 的Lock接口,所以天然支持可重入。同一个线程在持锁状态下再次调用lock(),计数器加一,对应每次unlock()计数器减一,直到归零才真正释放锁。

这个特性有多重要?你写业务的时候很容易出现 A 方法加锁之后调 B 方法,B 方法里面又加了同一把锁的情况。如果锁不可重入,你直接死锁。Redisson 把这一点也封装得干干净净,你甚至不需要感知它的存在。

4.4 失败重试与等待时间

tryLock(waitTime, leaseTime, unit)的第一个参数 waitTime 表示获取锁的最大等待时间。如果你设置 waitTime=10 秒,意味着锁被别的线程持有时,当前线程会最多等待 10 秒再去尝试获取锁,超过 10 秒还不成功,就放弃并返回 false。而lock()不带参数时,会一直阻塞等待直到拿到锁(配合看门狗非常顺滑)。

这个设计对业务友好度极高。你可以根据自己的业务容忍度自由选择是“抢不到就快速失败”还是“抢不到就排队”。

4.5 Redisson 解决误删问题的底层逻辑

Redisson 删除锁的时候,也是通过 Lua 脚本先比较 value 再删除,保证原子性。但这个 value 不是简单的 UUID,而是一个基于线程 ID 和 UUID 生成的唯一标识,同时保存了锁的持有线程信息。它在释放锁时,不仅检查 value 是否匹配,还会检查当前解锁线程是否真的是锁的持有线程。

这层设计彻底堵死了“线程 A 删除线程 B 的锁”这种 bug。你拿RLock的时候根本不用传 value,Redisson 内部管理好了这一切。

5. Redisson 也不是万能药:说说它没帮你解决的事

5.1 看门狗续期失败会导致什么

看门狗续期靠的是 Redis 连接。如果业务线程和 Redis 之间的网络出现了长时间抖动,续期请求发不出去,锁到期就会自动释放,其他线程就能拿到锁。这时候你的业务还在跑,锁却没了,照样会出现并发进入临界区的情况。

Redisson 能做的只是尽量降低这种情况发生的概率,它做不到像分布式事务一样彻底杜绝。因为分布式环境下,网络分区是不可完全避免的。

5.2 Redisson 锁在 Redis 主从架构下的固有缺陷

前面说的主从切换丢锁问题,Redisson 单实例模式照样存在。Redisson 官方给的建议是用 RedLock 算法,但 RedLock 本身实现复杂度高,需要多节点协调,而且在极端情况下也存在争辩点。大多数团队在实践中的选择是:能接受极小概率的锁失效,用 Redisson 单节点模式;完全不能接受,直接换 ZooKeeper——因为 ZooKeeper 的顺序节点加 Watcher 机制在这种场景下的强一致保证比 Redis 更可靠。

5.3 锁粒度太大导致性能雪崩

Redisson 解决的是锁“正确性”问题,但它解决不了你“锁效率”的问题。如果你给每个订单都加同一把全局锁,那所有订单的写入操作全被串行化了,QPS 直接断崖式下降。

正确的做法是尽量缩小锁的粒度。比如处理库存扣减,不锁整个订单,只锁商品 ID 维度:

RLock lock = redissonClient.getLock("lock:stock:" + skuId);

这样不同商品的锁互相独立,并发能力才能上去。我看到很多团队的生产故障都是锁粒度设计不合理导致的,Redis 本身没锅,锅都在设计上。

5.4 Redisson 配置的注意点

很多新人被 Redisson 默认配置坑过。这里我列几个关键配置项,来源于我实际生产环境的总结:

  • lockWatchdogTimeout:看门狗默认超时时间,默认是 30000 毫秒。你要是设置得太小,比如 5000 毫秒,业务稍微卡顿就可能出现锁提前过期;设置得太大,万一节点挂掉锁迟迟不释放,严重影响恢复速度。按默认 30 秒就够大多数场景。

  • retryAttempts:加锁重试次数,默认 3。这个参数控制的是加锁失败后重试多少次,而不是业务请求失败的重试次数,别混淆。

  • retryInterval:重试间隔,默认 1000 毫秒。如果你对加锁延迟很敏感,可以调小这个值,但会增加 Redis 负载。

  • Codec:序列化器,默认是 Kryo 或者 Jackson,具体版本不一样。有些团队在项目里同时用了多个 Redis 客户端,发现锁 key 在 Redis 里看到的 value 是二进制乱码,甚至出现 A 客户端存的锁 B 客户端读不到的情况,大概率是 Codec 配置不一致。建议统一使用 StringCodec 或者在项目中全局只用一个 RedissonClient。

6. 分布式锁的实战落地配置与避坑经验

6.1 一个直接可用的 Redisson 配置示例

用 Spring Boot 的话,你可以直接这么配置:

spring: redis: host: 10.0.0.10 port: 6379 password: xxxxx timeout: 3000ms redisson: config: | singleServerConfig: address: redis://10.0.0.10:6379 password: xxxxx connectionMinimumIdleSize: 10 connectionPoolSize: 32 idleConnectionTimeout: 10000 connectTimeout: 3000 timeout: 3000 retryAttempts: 3 retryInterval: 1500 codec: !<org.redisson.codec.Kryo5Codec> {}

自定义 RedissonClient Bean 时可以这样写:

@Configuration public class RedissonConfig { @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://10.0.0.10:6379") .setPassword("xxxxx") .setConnectionPoolSize(32) .setConnectionMinimumIdleSize(10) .setRetryAttempts(3) .setRetryInterval(1500) .setTimeout(3000); config.setCodec(new org.redisson.codec.Kryo5Codec()); return Redisson.create(config); } }

这里要特别提醒:生产环境一定要设置好连接池和超时。默认的连接池比较小,在高并发加锁场景下,可能因为连接数不够导致加锁等待时间变长,进而引发连锁故障。

6.2 用线程池模拟并发验证锁的效果

每次写完分布式锁,我都要用一个简单的并发验证脚本确保锁真的生效:

写一个 Java 方法,模拟 100 个线程同时去抢同一把锁,每个线程拿到锁后 sleep 500 毫秒然后释放。如果锁逻辑没问题,这 100 个线程的执行总耗时应该接近 50 秒;如果锁失效,总耗时会出现明显缩短,同时你可以在临界区里放一个计数器,看最终结果是否出现并发冲突。

这段验证代码不值得贴全,核心思路就是:制造并发压力,然后检查共享变量的最终一致性。如果最终计数器和理论值不一致,锁必然有问题。

6.3 锁的 key 命名规范

这个坑我从生产事故中总结出来的。锁 key 的命名要遵循一个原则:业务域 + 业务对象 + 唯一标识。比如:

  • 订单支付锁:lock:pay:order:1001
  • 库存扣减锁:lock:stock:sku_88776655
  • 用户迁移锁:lock:user:migrate:123456

千万别直接用用户 ID 或者订单 ID 裸奔,也别把所有锁都叫lock。否则将来排查问题时,你根本分不清这个 key 是哪个业务留下的,还可能出现业务间锁冲突。

6.4 解锁的标准姿势

解锁一定要在 finally 块中执行,这个不用多说了,关键点在于两点:

第一,调用unlock()前要判断isHeldByCurrentThread(),这是为了避免锁已被看门狗释放或者被其他逻辑释放后,不经判断就删除了别人的锁。

第二,如果业务逻辑里主动删除了锁,然后还继续做耗时操作,这就是纯粹给自己挖坑。一旦锁提前释放,其他线程就可以进入临界区,后面的操作全部处于无锁保护状态。

6.5 锁内不要做太耗时的远程调用

这是我反复强调的分布式锁使用的第一原则:不要让锁内的代码执行时间逼近或者超过锁过期时间。哪怕有看门狗续期,看门狗默认 30 秒,如果业务里嵌套了外部接口调用、数据库慢 SQL、大量 IO,锁的稳定性会大幅下降。

如果确实没法避免,试着把锁的粒度拆小。只对真正需要保护的临界代码加锁,比如“校验库存 + 扣减库存”这一段,而不是把整个下单流程全部锁住。

7. 生产环境常见问题与排查思路

下面这些问题都是我实际遇到过的,我整理成了一个速查表,省得大家再踩一遍。

现象可能原因排查方法与解决方案
锁设置了过期时间但业务频繁报“获取锁失败”锁过期时间太小,业务还未完成锁就释放了调大锁默认过期时间,或利用 Redisson 看门狗
业务很少加锁失败,但数据仍然错乱锁 key 在不同业务之间冲突,如订单锁和库存锁撞车检查 key 命名空间,确保业务隔离
Redis 主从切换后出现重复执行和超卖主从复制延迟导致锁 key 丢失评估是否升级 RedLock,或换 ZooKeeper 方案
unlock 时报IllegalMonitorStateException当前线程不是锁的持有者,却调用了 unlock检查是否误删锁,按 finally 规范解锁
Redis 连接异常导致锁不可用连接池耗尽,或 Redis 连接超时调大连接池、设置合理超时,增加 Redis 服务端排查
Redisson 锁 key 在 Redis 中显示为乱码Codec 不一致或者自定义序列化配置错误统一 Codec,建议使用 StringCodec 进行调试
业务线程在持有锁时被中断,锁没被释放没有处理中断异常,finally 未正确执行检查锁释放逻辑中的异常处理,确保 finally 块执行

7.1 排查锁问题时最常用的几个命令

有时候你没法直接看 Java 日志,需要到 Redis 命令行去验证锁是否还在。这几个命令非常实用:

# 查看锁是否存在,以及剩余 ttl TTL lock:stock:88776655 # 查看锁 key 的值,判断是不是当前线程的 GET lock:stock:88776655 # 查看当前 Redis 里有多少个锁相关的 key(谨慎使用 KEYS,大数据量下会阻塞) SCAN 0 MATCH lock:* COUNT 100

我用 TTL 排查过一个很诡异的问题:锁的 TTL 一直是 -1,也就是没有过期时间。后来发现是团队里有人直接用 SETNX 命令加锁,然后忘了设置过期时间。这完全暴露了没有统一规范使用 Redisson 带来的隐患。

7.2 千万别动不动就清 Redis key 来“解围”

我曾经见过一个运维事故:线上 Redis 锁莫名不生效,大量请求打到数据库导致系统几乎雪崩。负责人情急之下直接执行了FLUSHALL,把整个 Redis 上的缓存数据全清了,结果线上缓存血崩,数据库被压垮,事故级别直接升级。

锁 key 异常时,先判断是哪个业务的锁,锁的键名是否有业务标识。如果是临时紧急恢复,可以用DEL指定 key,但不到万不得已别清库。真正的根源是锁设计或配置问题,必须从代码层面修复,不能靠清 key 救火。

7.3 一个让我印象深刻的线上事故复盘

有一年双十一备战,我们的订单服务集群从 3 个节点扩到 10 个节点后,突然出现大量下单失败。查日志发现所有请求都卡在获取库存锁上。进一步排查后发现,锁 key 用了商品 ID 作为粒度,但流量全部集中在几个爆款商品上,锁竞争极其严重,而且 Redisson 默认的连接池只有几个连接,在 10 节点的高并发下瞬间被打满,获取连接超时。

后来我们做了两件事:一是把锁粒度从 sku 维度细化到“sku + 库存扣减批次”维度,大幅度分散了锁竞争;二是把 Redisson 连接池调大,并加了合理的等待超时。改完再压测,加锁成功率和接口耗时都回到正常水平。

这个案例告诉我们:分布式锁不是光选个实现方案就完事,锁粒度、连接池、并发模型都要通盘考虑。否则你可能在锁机制没出错的情况下,因为它封装得太好了而根本意识不到它成了瓶颈。

8. 关于锁的运维监控和压测建议

锁这个组件平时不出问题感觉不到它存在,一出问题就是大事故。所以生产环境必须做基本监控。

锁的监控指标我建议关注以下几点:

  • 锁请求失败数:看是否有大量加锁失败,一般意味着业务可能在等待锁或锁冲突严重。
  • 锁等待时间:统计 tryLock 的平均等待时间。如果等待时间持续增长,说明锁竞争加大,需要审视锁粒度或 Redis 性能。
  • Redis 实例 load 和连接数:锁操作是 Redis 的写入操作,频繁加锁/释放锁会增加 Redis 负载,如果 Redis 本身已成为瓶颈,锁性能也会跟着崩。
  • 锁异常数量:比如 Redisson 抛出的 unlock 异常、看门狗续期异常等,这些都是生产事故的先兆。

压测方面,建议在测试环境用一个和线上相近的并发模型,按正常业务提交流量放大 5 到 10 倍,跑半个小时以上。因为有些问题只在长时间运行后才会出现,比如连接池内存泄漏、锁 key 堆积、看门狗线程异常泄漏等,短时间压测根本测不出来。

9. 从 SETNX 到 Redisson,我们应该得到的启发

回头看这一路演进,其实非常有代表性。

SETNX 本身没有错,它只是一个命令,错的是我们指望用它一个命令解决所有分布式锁问题。后来我们通过 SET NX EX 解决了死锁,通过随机 value 加 Lua 脚本解决了误删,通过看门狗解决了过期时间不可控。每一步都是在解决前一步留下的问题,同时也暴露出新的问题。

Redisson 的价值不在于它发明了什么新理论,而在于它把分布式锁的“正确性细节”沉淀成了一个标准组件,让业务方不需要重造轮子。但 Redisson 也不是终点,主从切换丢锁的硬伤它也没完全解决。真要追求强一致,分布式协调服务才是更匹配的方案。

我在实际项目中最大的体会是:分布式锁的选型不是越复杂越好,而是要和你的业务一致性容忍度匹配。大多数互联网业务,偶尔因为节点异常导致锁失效是可以接受的,Redisson 单实例到主从的演进就够用了;而资金、交易等强一致性场景,不要犹豫,直接考虑 ZooKeeper 或者 etcd 那一套。

最后分享一个小技巧:不管用哪种分布式锁,你一定要在代码里对“锁获取失败”的场景做清晰的降级处理,是直接返回失败,还是走削峰限流,还是重试等待,提前想明白。锁只是保证并发安全的手段,真正的业务稳定靠的是整体架构设计,而不是一把锁单打独斗。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询