☰
Redisson分布式锁实战:从setnx到看门狗,避开库存超卖那些坑
2026/10/16 1:01:58 网站建设 项目流程

上个月我维护的一套限时抢购系统出了笔恶性对账问题——同一用户同一秒提交的两笔订单,只有一笔能对上库存,另一笔怎么都合不上。排查到最后,根子出在锁上。服务拆成三个实例之后,项目里还在用单机时期的本地锁,每个实例各锁各的,与其说锁住了并发,不如说给每台机器分别上了一把不同的锁。这件事之后,我把 Redis 分布式锁 和 Redisson 从头到尾研究了一遍,也整理出了这篇实操记录。文章会把加锁、解锁、看门狗续期、可重入这些核心机制一层层拆开讲,再补上几个我在项目里真实踩过的坑,适合正在处理重复提交、库存超卖,或者刚接手分布式锁代码的开发者。

1. 分布式锁到底锁的是什么:从一次线上事故说起

1.1 单体时代的synchronized为什么在多实例下失灵

出事之前,抢购接口用的是ReentrantLock,拿到锁以后先查库存、再扣减、再写订单。这套逻辑在单机部署时没有任何问题,JVM 内只有一个锁对象,所有线程串行执行。但负载均衡后面挂着三台实例之后,事情就变了:A请求落到实例1拿到锁,B请求落到实例2也拿到一把锁,两个线程同时在跑临界区,库存瞬间就被扣超了。

很多人对“多实例下本地锁失效”的理解停留在概念层面,直到线上出问题才意识到严重性。这里可以类比宿舍楼的公共洗衣机:如果每层楼都放了一把管理钥匙,那“整栋楼同一时间只能洗一锅”的约束就被打破了。Java 里的synchronized和ReentrantLock锁的是 JVM 内存中的对象,而分布式环境下需要的是所有实例都能访问到的“同一把外部钥匙”。这也就是 Redis 这类中间件成为分布式锁首选载体的原因——它本身是独立部署的公共组件,所有业务实例都能访问,天然具备“全局唯一”的前提。

1.2 一把合格的分布式锁需要满足的三个条件

排查完事故之后,我在设计文档里给团队总结了三个硬性条件,后续所有方案选型都围绕这三条来验证。

第一是互斥性。任意时刻,同一把锁只能被一个客户端持有。这是分布式锁的基本盘,做不到互斥,其他都无从谈起。

第二是可重入性。同一个客户端线程在持有锁的情况下再次获取同一把锁,必须能够成功。实际业务里很常见:方法A加锁,随后调用方法B,方法B内部也针对同一资源加锁。如果锁不支持可重入,第一次还没释放,第二次获取就自己把自己堵死了,这在业务代码里几乎是必现故障。

第三是防死锁。持有锁的客户端崩溃、断网、被kill掉之后,锁最终要能自动释放,不能把其他所有线程都永久挡在外面。真实的分布式环境里,进程被杀、网络分区都是迟早会遇到的事,锁如果跟着进程一起消失,整个业务就等于停摆。

这三个条件看起来简单,但从手写方案到成熟框架之间,其实隔着一整条原子性和生命周期管理的坑。

2. 从setnx手写锁到Redisson:绕不开的原子性与过期时间难题

2.1 setnx+expire的经典死锁场景

网上关于手写 Redis 锁的教程很多,但最流传的那套setnx + expire方案,恰恰是第一个坑。早期代码长这样:

Boolean ok = redisTemplate.opsForValue().setIfAbsent("lock:order", "1"); if (ok != null && ok) { redisTemplate.expire("lock:order", 30, TimeUnit.SECONDS); try { // 业务逻辑 } finally { redisTemplate.delete("lock:order"); } }

问题出在setIfAbsent和expire是两条独立命令,不是原子的。setnx成功之后如果应用突然崩溃、或者执行到expire之前抛了异常,过期时间就永远设置不上,key 会一直残留在 Redis 里。之后所有请求的setnx都返回失败,这把锁就成了一座死锁。

后来很多人用set key value nx ex 30把两条命令合并成一条,确实解决了原子性问题。但注意一个隐蔽陷阱:如果你还保留单独的expire调用,即使前面的set带了ex,死锁风险仍然存在——因为 Java 代码里手动再调一次expire,同样可能因为异常而跳过。正确做法是删掉多余的expire,让过期时间完全由set命令的一次调用完成。

2.2 过期时间与释放锁的二次困境

原子性问题解决后,下一个矛盾立刻浮出水面:过期时间到底设多少?

我一开始设的是10秒,结果有一次库存校验服务响应变慢,整个临界区跑了15秒。锁在第10秒自动释放,另一个线程马上进来扣了一次库存,前一个线程的后续逻辑还在继续执行,两条线程同时在写库存。设置太短会误伤正常业务,设置太长呢?一旦持有锁的进程崩溃,锁要等很久才能被其他线程获取,相当于长时间占着茅坑不拉屎。

更隐蔽的是释放锁这一步。假设当前线程的锁已经超时被自动释放,另一个线程随后拿到了同一把锁,此时前一个线程执行到finally里的delete,它删掉的是别人的锁。正确的释放逻辑必须改成:先比较value是不是自己当初写入的那个唯一标识,确认是自己的才删除。而“读取 value -> 对比 -> 删除”这三步如果分开执行,判断完还没删、锁却被别人抢走的情况照样会发生,所以必须用一段原子脚本包起来。

2.3 自己写Lua的边界:为什么最终还是引入Redisson

针对误删锁的问题,我手写过一个释放锁的 Lua 脚本:

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

这种方案在低并发项目里能用,但一旦深入下去,你会发现缺的东西越来越多:同一线程重复加锁时的可重入计数、锁快过期但业务还没跑完时的自动续期、获取不到锁时的阻塞等待机制、以及加锁/解锁过程本身的原子性保证。这些能力如果全部手写,代码量很快就失控了,而且每一处都是并发 bug 的高发区。

我最终选择 Redisson,理由其实很朴素:它把上面这些复杂性全部封装成了开箱即用的RLock接口,底层用的仍然是标准 Redis 命令加 Lua 脚本,不需要给 Redis 装任何额外模块,部署成本几乎为零。这也意味着,你不需要在业务代码里维护一大堆自己写的锁工具类,出问题的面小了很多。

3. Redisson加锁/解锁的完整链路与watchdog续期原理

3.1 一次lock()调用在Redis里发生了什么

很多人以为 Redisson 加锁就是对某个 key 执行一次SET NX,实际上比这复杂一点。Redisson 在 Redis 里存的不是普通字符串,而是一个 Hash 结构:field 是客户端唯一标识加线程 ID(通常是 UUID 加 threadId),value 是重入计数。

我用简化过的 Lua 脚本展示加锁的核心逻辑:

-- 加锁(简化示意) if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[2], 1) redis.call('pexpire', KEYS[1], ARGV[1]) return nil end if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1) redis.call('pexpire', KEYS[1], ARGV[1]) return nil end return redis.call('pttl', KEYS[1])

脚本里KEYS[1]是锁的 key,ARGV[1]是过期时间,ARGV[2]是 UUID 加线程 ID。锁不存在就创建并设置过期时间;锁存在且 field 属于当前线程,就重入计数加一;否则返回剩余 TTL,让客户端根据这个剩余时间决定等待多久再重试。

带waitTime的tryLock并不是一直在那儿死循环轮询。Redisson 内部用了 pub/sub 机制:等待中的客户端会订阅这把锁的解锁消息,一旦锁被释放,立刻收到通知再发起新一轮抢占,而不是傻傻地每隔几十毫秒去查一次。这个设计对性能的影响非常明显,也是 Redisson 阻塞等待效率比较高的原因。

3.2 watchdog:锁不会提前过期的关键

lock()方法默认的锁过期时间是30秒,但这30秒并不是一个固定值。Redisson 有一个默认开启的 watchdog 看门狗机制:只要你不显式传leaseTime,一旦线程成功拿到锁,后台定时任务就会每隔大概10秒检查一次,看这把锁是否还被当前线程持有。只要仍然持有,就把过期时间重新刷回30秒。

这个机制解决了一个手写方案根本绕不过去的核心矛盾:过期时间设短了,业务没跑完锁就释放了;设长了,进程崩溃后锁要很久才能自动过期。watchdog 的做法是“动态平衡”——业务活着,它就持续续期;业务所在进程崩溃,定时任务自身也随之消失,锁最多撑30秒后自动释放。这两个效果并不矛盾,反而同时满足了互斥和防死锁两个条件。

但是有一个反直觉的点:一旦你调用了tryLock(waitTime, leaseTime, TimeUnit.SECONDS)并显式传了leaseTime,watchdog 就会被关闭。很多人不知道这个细节,给某把锁设置了10秒自动释放,结果业务跑到了20秒,第10秒锁没了,另一个线程闯进来,他们第一反应是 Redisson 出 bug 了。实际上不是 bug,是你亲手关掉了看门狗。

3.3 解锁不是简单的del,Redisson在最后做了什么

解锁的逻辑同样走 Lua 脚本,不能直接在业务代码里delete:

-- 解锁(简化示意) if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then return nil end local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1) if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[1]) return 0 else redis.call('del', KEYS[1]) redis.call('publish', KEYS[2], ARGV[3]) return 1 end

这段脚本做了三件事:先把当前线程对应 field 的重入计数减一;如果计数仍大于零,说明外层持锁还在,重置过期时间后直接返回;只有计数降到零,才真正删除 key,并publish一条解锁消息去唤醒正在等待锁的客户端。为什么不能直接del?因为要删除的 key 可能已经被别的线程接管,删除前必须确认这把锁的持有者是你自己。手写方案里“判断 value 再 del”的两步操作具备原子性风险,Redisson 用同一段 Lua 脚本把这个问题彻底解决了。

4. 可重入、公平锁、读写锁和红锁:功能矩阵与真实选型

4.1 可重入锁:同一个线程重复拿锁的底层逻辑

由于 Redis 里存的是 Hash,且 value 是重入次数,同一线程再次lock时可以直接走hincrby让计数加一。API 层面,Redisson 的RLock继承了 Java 并发包里的Lock接口,所以同一个线程可以反复加锁。

但这里有个极其常见的 bug:重入几次,就要解锁几次。我见过项目里方法A加锁一次,中间调用方法B,方法B又加锁一次,最后只在方法A的 finally 里解锁一次。重入计数一直卡在1,锁永远释放不掉,其他线程全部阻塞。排查时看代码怎么都对,直到加了日志看锁的 Hash 结构和 field 数量才发现问题。

我的建议是:不要让锁跨越太多方法层级,加锁和解锁尽量收敛在同一个方法边界内,避免“外层方法控制生命周期、内层方法又去加锁”这种容易数错次数的结构。如果确实需要内层再拿锁,可以在代码注释里写明重入层级和对应的解锁次数。

4.2 公平锁与读写锁:各自适用的场景

Redisson 的公平锁基于 Redis 的 zset 做排队,严格按申请顺序分配锁。这种设计看起来很美好,但代价是每次加锁都要额外操作队列结构,性能和普通锁相比有明显差距。我一般只在顺序敏感的外部排他资源里用它,比如定时任务里的批处理分片、跨节点文件导出之类,常规秒杀和库存扣减完全不需要。

读写锁的价值在读多写少的场景。比如缓存中的配置数据:多个线程同时读没有任何冲突,但写线程必须和所有读线程互斥。Redisson 的读写锁沿用 JavaReentrantReadWriteLock的语义,但有一个容易忽略的策略:它默认是写优先的。也就是说,一旦有写线程在排队,新来的读线程往往拿不到锁,这是为了避免写线程一直被读线程饿死。如果你的业务读多写少但写操作延迟敏感,需要提前评估这个策略能不能接受,否则可能因为等锁时间变长而出现新的超时问题。

4.3 红锁要不要上:多节点锁的争议与我的落地选择

红锁解决的是 Redis 主从切换导致的锁丢失问题。单主节点写入锁后,如果还没来得及同步到从节点,主节点就挂了,哨兵把某个从节点提升为主节点,新主节点上并没有这把锁。此时另一个客户端也能加同一把锁,两个客户端同时持锁,这是分布式锁在故障场景下最真实的破绽。

红锁的思路是向 N 个相互独立的 Redis 节点分别加锁,超过 N/2+1 个成功才视为加锁成功。这个算法争议很大:有人认为它在分布式系统不稳定的时钟、节点暂停等条件下,并不能提供严格的安全性保证;也有人认为工程上能做到的最好方案也就是这样了。我的落地选择比较务实:绝大多数中低并发的业务,我接受“极端故障下锁可能丢失”这个风险,同时在数据库层增加唯一索引或版本号做兜底,保证即便锁失效也不至于产生脏数据。只有当锁保护的资源一旦被并发访问后果极其严重,且手头有多余的独立 Redis 节点时,才值得引入红锁。别为了“看起来严谨”而过度设计,分布式锁本身只是整个并发防线中的一层。

5. 项目实践中的五大避坑配置与编码细节

5.1 锁粒度:把全局锁改成业务维度锁

我踩过最蠢的一个坑,是给整个抢购接口加了一把全局锁。结果所有商品的请求全部串行化,Redis 和业务服务都被拖得喘不过气,库存还没扣完,接口超时率先上来了。正确做法是按业务维度拆锁 key,比如lock:seckill:sku_10086,不同商品完全并行,只有同一商品才互斥。锁粒度要细到“真正需要互斥的那个共享资源维度”,粒度越大,并发能力损失越严重。

5.2 持锁时间与临界区:不要在锁里做远程调用

很重要的一条原则:分布式锁解决的是互斥问题,不是性能问题。临界区越长,锁的竞争就越激烈,吞吐量下降得越明显。我自己在一次重构里把库存中心的远程调用放进了锁内,结果对方接口抖动3秒,所有抢购请求全部堵在等锁上,最终一批超时。后来定下的铁律是:锁内只做内存判断加上 Redis 和数据库的短操作,任何 HTTP 或 RPC 远程调用都必须移出临界区。如果某个操作确实不能移出去,至少要在锁内先更新数据库状态,再异步触发远程逻辑。

5.3 指定leaseTime会悄悄关掉watchdog

这一点前面提过,但在项目里还是值得单列。只要调tryLock(waitTime, leaseTime, TimeUnit.SECONDS)传了leaseTime,看门狗就不再续期。曾经有同事对一把锁想加个“最长持有时长”限制,把代码从lock()改成tryLock(3, 10, TimeUnit.SECONDS),结果业务跑到12秒左右锁自动释放,出现了偶发的重复扣减。如果确实需要leaseTime限制最长持锁时间,业务侧就必须额外设计幂等机制来兜底,不要默认看门狗还在帮你续期。

5.4 主从切换与GC停顿:极端条件下不能假装不存在

主从切换的锁丢失场景,我在红锁那一节已经说了,我的选择是接受风险并用数据库兜底。另一个容易被忽略的是 GC 停顿。如果持有锁的线程发生了一次超过30秒的 Full GC,看门狗线程也会一并暂停,锁很快超时被自动释放,另一个线程就能闯入临界区。这种问题在 Java 应用中真实存在,缓解手段包括给 JVM 容器预留足够内存、减少 Full GC 频率,以及尽量别把默认过期时间调得过短。软件层面只能降低概率,没法彻底消除,所以数据库兜底几乎是必须的。

5.5 连接超时与网络抖动:客户端参数别乱调

Redisson 客户端的timeout、retryAttempts、retryInterval这些参数,如果照抄网上的“优化配置”,很容易出问题。我把timeout调成200毫秒,本想加快失败响应,结果 GC 停顿或网络抖动时 Redis 命令还没执行完就被判定超时,抢锁异常率明显上升。建议根据你自己的网络基线设置超时,默认值通常是一个相对安全的起点。不要为了“调优”而把超时设得过于激进,那样换来的不是性能,而是偶发故障。

6. 从选型到落地:我现在的Redisson使用姿势

6.1 一段可以直接参考的加锁代码模板

下面这段代码是我在项目里常用的模板,注释里写清楚了每个参数的实际效果:

RLock lock = redissonClient.getLock("lock:seckill:" + skuId); boolean acquired = false; try { // waitTime=2:最多阻塞等待2秒,抢不到就放弃 // 不传leaseTime,watchdog生效,锁不会被提前释放 acquired = lock.tryLock(2, TimeUnit.SECONDS); if (!acquired) { return "系统繁忙,请稍后重试"; } // 业务临界区:查库存、扣减库存、生成订单 // 锁内只放短操作,不做远程调用 } finally { if (acquired) { lock.unlock(); } }

这里有两个关键习惯。第一,不要用不带参数的lock()无限期等待,线上一旦锁出了问题,所有请求都会堵死,连个退路都没有。tryLock带一个合理的waitTime,抢不到就快速失败,用户体验更好,系统也更稳。第二,finally里必须判断acquired为 true 再解锁。如果你根本没抢到锁却调用了unlock,Redisson 会因为当前线程不持有这把锁而抛异常。

6.2 Redisson的基础配置与参数说明

我这里用的是一套单节点部署的配置,对大多数中低并发项目足够了:

singleServerConfig: address: redis://127.0.0.1:6379 connectionPoolSize: 64 connectionMinimumIdleSize: 24 timeout: 3000 retryAttempts: 3 retryInterval: 1500

几个关键参数的说明和我的建议:

参数作用我的建议
connectionPoolSize连接池上限不需要开很大,连接池过大反而增加 Redis 端CPU压力
connectionMinimumIdleSize最小空闲连接数根据 QPS 调整,一般24左右够用
timeout命令执行超时不要低于1秒,给GC和网络抖动留余量
retryAttempts失败重试次数默认3次够用,不要盲目调到10
retryInterval重试间隔1500毫秒左右,太短会放大抖动

集群环境如果是 Sentinel 模式,可以用sentinelServersConfig,参数思路类似,只是加上了主从节点的配置。换配置格式很容易,核心还是想清楚每个参数对应的风险边界。

6.3 压测观察与我的调参经验

在本地测试环境(8核16G,Redis 和业务服务在同一台机器内网)里做了一组参考压测:200个线程同时抢同一把锁,加锁一次平均耗时大约0.3毫秒,P99 大概在4毫秒附近。这个数字会因机器、网络和 Redis 版本不同而有浮动,但至少可以说明一点:Redisson 加锁本身的性能开销并不是瓶颈,瓶颈通常在临界区的业务逻辑和锁竞争强度。

我也和之前自己手写setnx + Lua的方案做过对比,最终结论是性能差别不大,但代码可维护性完全不是一个级别。手写方案里每次加锁、解锁都要自己处理重入、续期、异常路径,出一次线上问题排查的成本就够引入 Redisson 十次了。

最后再分享一个我现在的习惯:锁内代码尽量短,waitTime给到2到3秒,leaseTime不显式传,依赖看门狗续期;对极端故障,在数据库层再叠一层唯一约束。这套组合打下来,分布式锁在绝大多数场景下都能把并发重复操作挡在门外。希望你下次部署多实例服务时,第一件事就是检查项目里的锁是不是还停留在本地锁的阶段——如果还是,那这篇文章里的坑,你大概率会亲身经历一遍。

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

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

立即咨询