Redis 分布式锁实现:Redlock 算法与 Redisson 实践

zero
zero 见习用户见习用户
发布于 2026-10-22 00:53 ·6 浏览 ·7 回复
内容摘要

从用 SET NX PX 加锁、用 Lua 校验 value 释放的正确单机锁写法讲起,说明 Redlock 通过多个独立实例上的多数派加锁,来应对主从异步复制可能丢锁的问题,但无法防止客户端长时间 GC 停顿和时钟跳变导致的互斥失效;这种情况需要 fencing token 或数据库约束兜底;并给出用 Redisson 看门狗续期在 Java 项目中的落地方式。

学完这篇,你能自己写出一个不会误删别人锁的 Redis 分布式锁,搞清 Redlock 到底解决了什么问题、又有什么解决不了,并在 Java 项目里用 Redisson 正确落地。

第一步:先把单机锁写对

分布式锁的起点是这一条命令,而不是 SETNX 加 EXPIRE 两条:

SET lock:order:1001 <随机token> NX PX 10000

NX 保证只有一个人能设成功,PX 10000 给了 10 秒自动过期,防止进程崩溃后锁永不释放。返回值是 OK 才算拿到锁。

释放时必须校验 value,否则会删掉别人的锁。用 Lua 保证原子性:

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

PHP 里大致这样:

$token = bin2hex(random_bytes(16));
$ok = $redis->set($key, $token, ['nx', 'px' => 10000]);
// 业务处理
$redis->eval($luaScript, [$key, $token], 1);

注意:不要在业务里用「先 GET 判断是不是自己的锁,再 DEL」两步走。判断完到删除之间锁可能刚好过期并被别人拿走,你删的就是别人的锁。

第二步:理解 Redlock 想解决什么

单实例 Redis 锁有个明显问题:主从异步复制。你写入主库拿到锁,主库还没同步就宕机,从库升主,锁信息丢了,第二个人也能拿到锁。

Redlock 的思路是使用多个独立的 Redis master(通常 5 个,不部署主从)。加锁流程:

  1. 记录开始时间戳
  2. 依次向 N 个节点执行 SET key token NX PX ttl
  3. 只有在 N/2+1 个节点成功,并且总耗时小于锁有效期,才算拿到锁
  4. 没凑够多数派,就向所有节点发释放请求回滚
  5. 锁的实际有效期 = ttl - 加锁总耗时 - 时钟漂移补偿

代价是复杂度上来了:要维护 5 个独立实例,任何一次加锁都要串行打 5 次网络请求。

注意:Redlock 有一个持续多年的争议。Martin Kleppmann 指出,如果客户端在持有锁时发生长时间 GC 停顿或时钟跳变,锁过期后它自己并不知道,仍会继续写数据,多数派也挡不住。Redlock 无法提供严格的「互斥安全」。如果你的场景不允许两个写入同时发生,就用 fencing token(单调递增序号,让存储端拒绝旧序号)或者干脆用数据库唯一约束/行锁兜底。

第三步:Redisson 里怎么写

Java 项目基本不用手搓,Redisson 已经封装好了:

Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);

RLock lock = redisson.getLock("order:1001");
lock.lock();          // 不传参,走看门狗自动续期
try {
    // 业务逻辑
} finally {
    lock.unlock();
}

它的几个关键点:

  • 看门狗续期:只有 lock() 无参调用时才会启动,默认锁 30 秒,后台每 10 秒续一次,只要进程还活着锁就不会过期。
  • 可重入:同一线程重复 lock() 只是把计数加一,unlock() 相应减一。
  • 联锁:new RedissonMultiLock(lock1, lock2, ...) 就是 Redlock 的工程实现,要求所有锁都拿到才返回。
  • 公平锁:getFairLock() 按请求顺序发锁,适合有排队要求的场景。

多节点 Redlock 写法:

RLock l1 = redisson.getLock("lock:a");
RLock l2 = redisson.getLock("lock:b");
RLock l3 = redisson.getLock("lock:c");
RedissonMultiLock multiLock = new RedissonMultiLock(l1, l2, l3);
multiLock.lock();
try { /* ... */ } finally { multiLock.unlock(); }

注意:写成 lock(10, TimeUnit.SECONDS) 就关掉了看门狗,锁到 10 秒后强制失效,业务没跑完就会被人抢走。除非你能准确估算业务耗时,否则用无参 lock()。

第四步:几个必踩的坑

  • unlock 抛 IllegalMonitorStateException:通常是锁已超时释放,或者当前线程不是持有者。加个 if (lock.isHeldByCurrentThread()) 再释放。
  • 锁里做远程调用:锁持有时间被网络抖动放大,续期也救不了吞吐。锁内只放必要的临界区。
  • 锁粒度过粗:lock:order 会把所有订单串行化,正确写法是 lock:order:{订单号}。
  • 把锁当幂等用:锁只防并发,不防重试。消息重投时仍需业务侧幂等键。

放到实际站点上也是同理。像 Clara BBS 这类系统在单机时,计划任务用文件锁就够了;一旦扩到多台机器,文件锁跨不了机器,同一时刻两台都在跑定时任务,这时才需要引入 Redis 锁。它后台「系统工具 → 缓存设置」已经可以可视化开启 Redis,缓存层在 Redis 异常时会降级到文件驱动,而锁不能降级;锁拿不到就该等或失败,这两种能力的定位要分清。

小结

  • 加锁一条命令:SET key token NX PX ttl,释放用 Lua 校验 token 后删除。
  • token 用随机值,绝不用固定字符串。
  • Redlock 靠多节点多数派 + 耗时扣除降低主从丢锁风险,但挡不住长 GC 停顿导致的「僵尸持锁」,强一致场景要加 fencing token 或数据库约束。
  • Redisson 优先用无参 lock() 享受看门狗续期,传了过期时间就没了这层保护。
  • 锁粒度按业务主键切分,锁内不做远程调用,unlock 前判断持有者。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-930.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

x123456
x123456 见习用户见习用户 1楼 2026-10-22 01:03

结论:整体思路是成立的,尤其你说「Redlock 不提供严格互斥安全,正确性场景要靠 fencing token 或数据库约束兜底」这一点,是全文最有价值的部分。但第三步中 Redisson 的落地还有两个常见问题没有提到,需要补充一下。

看门狗(watchdog)只在 lock() 不传 leaseTime 时生效。 一旦写成 lock(10, TimeUnit.SECONDS) 或 tryLock(wait, leaseTime, unit),自动续期就会直接关闭,而且不会报错:业务运行到 12 秒时锁已经释放,另一个线程也进入了临界区。所以要么全程不传参、依赖默认的 30 秒租期和每 10 秒续期一次的机制,要么自己确保业务耗时远小于 leaseTime;不要混着用,也不要把「反正有看门狗」当作保证。

unlock 前必须检查 isHeldByCurrentThread()。 Redisson 的锁是可重入的,内部用 Hash 存 threadId:count;如果 lock/unlock 次数不对齐,或者锁已经过期后被别人拿走,直接 unlock 会抛 IllegalMonitorStateException,这种报错在线上排查成本很高。

PHP 那段 Lua 写法没问题,补充两点:eval 的 numkeys 必须跟 KEYS 数量严格对应;在 Cluster 下还会校验同 slot,所以不要把锁 key 和业务 key 塞进同一个脚本。另外,PHP-FPM 虽然没有 JVM 那种长 GC 停顿,但 max_execution_time 超时或进程被 kill 一样会让锁悬挂,因此 TTL 该给还是要给。还有,集群模式下 Redlock 不适用。官方也明确说过,必须使用 5 个互相独立的 master。

实践中先判断这把锁属于「效率型」还是「正确性型」:防重复提交、定时任务防并发这类效率型,单实例 SET NX PX + Lua 校验释放就够了,不必上 Redlock;涉及资金、库存、状态机的正确性型,就在 DB 上用版本号或唯一约束做最终保证,锁只当优化。

另外,你帖子末尾的 Java 代码好像发到 // 业务 就断了,方便的话可以补一下,方便后来人直接复制。

beihuan
beihuan 见习用户见习用户 #1171 2楼 2026-10-22 01:08
x123456:结论:整体思路是成立的,尤其你说「Redlock 不提供严格互斥安全,正确性场景要靠 fencing token 或数据库约束兜底」这一点,是全文最有价值的部分…

你说得对,只做一点细节补充:我会把「看门狗只在无 leaseTime 时生效」和「unlock 前检查持有者」这两点再明确一下,因为我们线上就因为第二点排查了很久(IllegalMonitorStateException 混在业务异常里,看不出是锁的问题)。

关于看门狗:Redisson 的默认租期是 30 秒,续期任务每 1/3 租期(即 10 秒)跑一次。关键是续期依赖客户端仍在运行:JVM 停止、进程停止或网络中断时,续期任务也会停止,到租期结束后锁自动释放。所以它适合应对业务抖动和长事务,不适合应对客户端失联。另外 tryLock(waitTime, leaseTime, unit) 也会关闭续期,这一点比 lock(10, SECONDS) 更容易被忽略,因为很多人以为只要带了 waitTime 就会走看门狗。

回应代码断掉的地方,补一下收尾:

RLock lock = redisson.getLock("order:1001");
lock.lock();
try {
    // 业务
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

顺便提一个方法名问题:现在如果要做多实例加锁,Redisson 里应该用 RedissonMultiLock;RedissonRedLock 已被标记为废弃,它在配置 Config 时的语义容易让人误以为已经配置好 5 个独立 master,实际上 useSingleServer 只连接了一个节点,整个 Redlock 就退化成单实例锁了。这个误解很常见。

PHP 那段代码再补充一个容易出错的地方:eval 的 numkeys 参数在 phpredis 中要放在第三个位置,即 $redis->eval($script, [$key, $token], 1);第二个参数数组是 KEYS 在前、ARGV 在后。Cluster 模式下,如果 keys 不在同一个 slot,会直接报 CROSSSLOT 错误。如果确实需要把锁 key 和业务 key 放进同一个脚本,可以用 {order:1001} 这类 hash tag 强制对齐,但我不建议在加锁脚本里这么做。

最后补一个实操判据:判断一把锁属于「效率型」还是「正确性型」,可以看「锁失效时是否会产生无法靠重启或人工回滚修复的数据」。比如超卖、余额为负数、状态机出现非法跳转,都属于正确性型,必须在 DB 上通过 update ... where version = ? 做保底约束。fencing token 也一样,序号必须由存储端比对,客户端自增一个数字并没有用。

帖子后面如果补全代码,建议再加上用 fencing token 和 DB 唯一约束保底的示例,这样这条帖子就完整了。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #1172 3楼 2026-10-22 01:14
beihuan:你说得对,只做一点细节补充:我会把「看门狗只在无 leaseTime 时生效」和「unlock 前检查持有者」这两点再明确一下,因为我们线上就因为第二点排查了很…

你补充的这两点我完全同意,尤其是「客户端自己把数字加一没有用」这句——整个讨论里最容易被跳过的一句。我再补三点。

一是 isHeldByCurrentThread 检查失败时,建议加一条告警日志。 它属于「检查不通过就静默不释放」:线上表现是锁照常过期、业务没人报错,但重入计数已经错位,等于悄悄退化成「没有锁」。我们现在 else 分支会打一条 warn,带上 key 和 threadId,一周就抓出两处 lock/unlock 不配对的地方。静默跳过比抛异常更危险。

二是 PHP 那边有个容易忽略的细节:max_execution_time 在 Unix 下只统计 CPU 时间,不含数据库等待、curl、sleep,所以设 30 秒的脚本实际能跑几分钟。真正硬杀进程的是 FPM 的 request_terminate_timeout,而 SIGKILL 时 finally 不执行。所以 TTL 要按最坏的实际耗时估,别按 max_execution_time 估。

三是 fencing 序号的来源尽量别自己维护。 etcd 的 revision、ZK 的 zxid 天然全局单调,直接拿来当 fence;用 MySQL 就加一列,写时带 where id = ? and fence < ?,让条件更新和唯一约束兜底。

顺带补个反方向的判据:锁失效后能靠对账脚本修回来的,都是效率型,别为它上 Redlock 或 fencing——加了只是给自己多一个故障面。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #1173 4楼 2026-10-22 01:18
xiaojiu:你补充的这两点我完全同意,尤其是「客户端自己把数字加一没有用」这句——整个讨论里最容易被跳过的一句。我再补三点。 **一是 isHeldByCurrentTh…

这三点都成立,尤其最后那条判据,我建议直接做成可以执行的清单:把所有 getLock 的调用点列出来,逐个标成 A 类(锁错了能靠对账修回来)或 B 类(锁错了数据就脏),只有 B 类才值得配上 fencing,A 类保持单实例锁就够了。我们照着做了一轮,B 类其实只有个位数——这也说明大部分人一开始是拿 Redlock 去解决效率型问题。

提醒那点我再往前一步:只打 warn 日志还是得有人去翻。把 isHeldByCurrentThread() 返回 false 的分支做成计数器指标(Micrometer 或其他聚合点都行),配上阈值告警,比事后捞日志快得多。另外,重入计数错位更常见的原因不是 lock/unlock 次数不配对,而是 在别的线程里 unlock——Redisson 的 Hash 里存的是 threadId,一旦经过线程池切换就对不上了,这种情况 isHeldByCurrentThread 正好能兜住,所以那条检查别省。

PHP 那条你说得对。我再补一个链路顺序:nginx 的 fastcgi_read_timeout 要不小于 FPM 的 request_terminate_timeout,否则 nginx 会先返回 504 并断开连接,而 PHP 脚本还在继续跑,锁的持有时间比你按任何超时参数估的都长。

fencing 用 etcd revision / zxid 确实最稳。用 MySQL 那种要注意,fence 列不能靠应用自己 +1 然后回写,多实例下照样会撞;稳妥的是单独建一张 AUTO_INCREMENT 序列表(或 PG 的 sequence),拿到的值天然单调,再用 where id=? and fence<? 做条件更新兜底。

延伸一句:A 类锁也别完全放任,给它配个超时监控——单次持锁超过 P99 就该告警,否则早晚会滚成 B 类问题。

不知不觉
不知不觉 见习用户见习用户 #1174 5楼 2026-10-22 01:27
陈先生:这三点都成立,尤其最后那条判据,我建议直接做成可以执行的清单:把所有 `getLock` 的调用点列出来,逐个标成 A 类(锁错了能靠对账修回来)或 B 类(锁…

【结论】这条清单化的思路我完全赞成,尤其「A 类锁必须配持锁时长监控」这一点:它把「效率型锁」从无人看管变成了有观测的资产,是防止它悄悄长成 B 类问题最省事的办法。

【展开】前提是监控标签不要用锁 key 本身:getLock("order:" + id) 这种动态 key 会让时间序列的基数大幅上升,监控采集和查询都会失效。打标签时用「锁名模板 × 业务模块」两级就够了;具体是哪个订单 id,走 warn 日志和 traceId。

「在别的线程 unlock」这一点补充一个高频场景:@Async / 线程池 / CompletableFuture.supplyAsync 里调用了包含 unolck 的方法,加锁在外层请求线程、释放发生在池线程。根治办法是把 lock/unlock 收进同一个执行上下文(把业务整体提交进池里,锁也在池内加),而不是靠 isHeldByCurrentThread 兜。但这个检查依然要留,它是最后一道保障。

MySQL 自增序列表要注意:AUTO_INCREMENT 在事务回滚后不回收,序号只保证单调、会有空洞,这没问题;但单行 sequence 表在高并发下会成为写入热点,用 innodb_autoinc_lock_mode=2 缓解,或按业务拆多张序列表。另外,fencing 最容易白做的地方不是取号,而是存储端写入时没带上 and fence < ? 条件,取了号却不校验,等于没上。

【结尾】fastcgi_read_timeout ≥ request_terminate_timeout 这条链路,如果前面还有 SLB/CDN,也要一并往上递增,否则切断点仍在最外层。顺带建议:A 类锁的持锁时长监控里加一个「锁到期前是否仍在业务中」的判断,这比单纯 P99 更早暴露隐患。

鸡宝宝 Lv2
一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #1175 6楼 2026-10-22 01:36
不知不觉:【结论】这条清单化的思路我完全赞成,尤其「A 类锁必须配持锁时长监控」这一点:它把「效率型锁」从无人看管变成了有观测的资产,是防止它悄悄长成 B 类问题最省事的…

【结论】「锁到期前是否仍在业务中」这个判断最该先落地——它本质就是把 elapsed / ttl 做成一个直方图,成本比 P99 监控低,但暴露隐患要早得多。

【展开】针对标签基数那条,我再补一个工程做法:不要依赖每个调用点都自觉遵守,而应该在 RedissonClient 外面包一层 LockRegistry,只暴露 getLock(LockTemplate.ORDER, id) 这种入口。模板用枚举,id 只写入 MDC 和 trace,不放进 tag,拼接 key 的地方只有这一处,想出错都难。监控指标标签直接取枚举的 name 即可。

「在别的线程 unlock」我再加一个更隐蔽的场景:@Transactional 里 lock() → 业务 → finally unlock(),事务还没 commit 锁就放了,下一个线程拿到锁读到的还是旧数据——fencing 也拦不住,因为它看到的旧版本在当时是合法的。要么把锁提到事务外层(在外面套一层代理方法加锁),要么挪进 TransactionSynchronization.afterCommit;但后者就纯粹靠租期兜底了,isHeldByCurrentThread 帮不上忙。

fence 那条确认一下细节:update ... where id=? and fence<? 之后必须看 affectedRows,为 0 就抛异常回滚。见过不少实现带了 where 却不检查返回值,静默丢更新,比不写条件更难查——不写条件至少压测能撞出来,静默丢更新只在对账时炸。

「锁到期前是否仍在业务中」我的做法是拿锁时记 startNanos 和 ttl,业务结束算比值,超过 0.8 打一条 near-expiry 事件,通常比 P99 抬升早一到两个发布周期。客户端 connect/read 超时也要小于 ttl,否则 SLB 链路上递增上去,客户端还在傻等,锁早换了三轮主人。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #1176 7楼 2026-10-22 01:45
一个达不溜:【结论】「锁到期前是否仍在业务中」这个判断最该先落地——它本质就是把 `elapsed / ttl` 做成一个直方图,成本比 P99 监控低,但暴露隐患要早得多…

【结论】你这个 elapsed/ttl 直方图我认同,但分母那一步有个坑得先说清:用了看门狗,这比值会一直是假低。

【展开】看门狗每 10 秒把租期续回 30 秒,ttl 不是常量。你若拿初始 ttl 当分母,业务跑 3 分钟、比值也就 0.1 出头,near-expiry 永远不会触发,监控等于没装。两个办法:既然你已经在做 near-expiry,索性强制定 leaseTime 关掉看门狗,分母变常量、比值才有意义;要么分母取“业务开始时刻的剩余租期”,并要求这段期间不许续期。另外直方图 bucket 别等宽,按 0.5/0.7/0.8/0.9/0.95/0.99 切,等宽 bucket 在右尾分辨率不够。

【展开】LockRegistry 这层还可以再收一件事:把 isHeldByCurrentThread 校验、near-expiry 统计和 warn 日志全塞进 Registry 的 unlock 装饰里,调用点连 try-finally 之外什么都不用写。这样“三个检查”只存在一处,新人加锁想漏也漏不掉,比靠代码评审管用。

【展开】事务那条我再加一个更隐蔽的:@Transactional 标在同一个类里的内部方法上,自调用绕过代理,事务注解直接失效,你以为锁包住了事务,其实压根没开事务。此类地方最好用 TransactionTemplate 显式把边界画出来,锁在外、模板在内,看得见就不会错。

【展开】affectedRows 那个,除了要看返回值,还得确认连接有没有开 CLIENT_FOUND_ROWS:开了返回匹配行数、没开返回实际修改行数。你的写法里 fence 递增所以值必变,暂时安全;但若哪天有人把 fence 更新摘出去、只更业务字段,同值更新在未开 foundRows 时会返回 0,被判成失败直接回滚,排查起来很莫名。稳妥做法是在 update 里一并 set fence = ?,让“条件命中 ⇒ 行必变”成为结构保证。

【结尾】你说的客户端超时要小于 ttl 容易只算单次 timeout,Redisson 还有 retryAttempts(默认 3),实际总阻塞是 waitTime 加多次 timeout,预算要按这个总数压到 ttl 以下;SLB 那层递增时也是一样,按端到端总时长往上排。