news 2026/11/5 1:53:59

为什么 Redis 主从切换会丢锁:3 种补偿策略实测对比

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
为什么 Redis 主从切换会丢锁:3 种补偿策略实测对比

本文摘要:主从切换丢失分布式锁,两台 Pod 同时持锁导致重复扣款。对比 WAIT、Fencing Token、幂等去重三种补偿策略的代价。WAIT 仅降低丢失概率,强一致场景仍需 ZooKeeper。

一、问题与结论

线上订单服务跑了两个 Pod,Redis 是一主一从加 Sentinel。某次订单order:1001被扣了两次款,每次 ¥100,客户实付 ¥200。抓日志发现 pod-A 和 pod-B 同时进入了临界区,而两边都打印了"获取锁成功"。

根因不在锁代码写错,在于 Redis 主从复制是异步的:主节点执行SETNX后不等任何副本确认就向客户端返回成功。若主节点在复制完成前崩溃,Sentinel 提升的副本上根本没有这把锁,于是第二台 Pod 的SETNX又成功了一次。

先给结论:单实例 Redis 分布式锁无法通过调参消除主从切换丢锁,只能补偿。补偿有三个层次——WAIT 把丢失概率降到接近零但不为零、Fencing Token 让下游拒绝过期持锁者、幂等设计让重复执行不产生第二次副作用。实际系统通常组合使用。

二、排查与选择依据

按时间线复现这个故障:

T0 pod-A 在 master 上 SETNX order:1001 成功,返回给客户端 T0+50ms 复制数据包仍在路上(网络抖动时可达数百毫秒) T0+80ms master 进程崩溃(OOM 或节点隔离) T0+8s Sentinel 把 slave 提升为新 master,新 master 上无 order:1001 T0+9s pod-B 的 SETNX 在新 master 上再次成功,pod-A 不知情 T0+9s pod-A 的看门狗续期 Lua 脚本 GET 返回 nil,返回 0,静默失败 T0+10s 两边各扣款一次

要验证三件事:复制窗口有多大、WAIT能不能堵上这个窗口、看门狗在 failover 后到底什么行为。

验证命令(在 master 上执行):

dockercompose up-ddockerexec-itredis-master redis-cli CONFIG GET min-replicas-to-writedockerexec-itredis-master redis-cli INFO replication

预期输出:min-replicas-to-write 1,role:master,connected_slaves:1。

替代方案与取舍

三种补偿策略各自有不适用的场景,同时还有完全不同的技术路线可以选:

方案选择条件代价边界
WAIT 补偿低并发、延迟可接受 +1~数 ms每次加锁多一次 WAIT 等待;副本确认数可能低于请求值副本确认 ≠ 该副本被 Sentinel 提升,仍有残余风险
Fencing Token金融/交易类需要强正确性下游服务需支持 token 校验,改造成本高存量下游不支持则整条链路失效
幂等去重高并发、业务天然幂等需要改表结构,加唯一约束只保证"效果唯一",并发本身仍在执行
ZooKeeper + Curator一致性要求极高、可接受运维复杂度写延迟通常比 Redis 高 2~5 倍集群节点数少于多数时写不可用
Redis RedLock想在现有 Redis 上提升概率需维护 N 个独立实例,时钟漂移仍是问题是降低概率,不是消除

不该用本文方案的情况:账务核心链路(转账、清算)若下游已有成熟的版本号/序列号校验机制,直接用下游校验即可,不要额外引入 Redis 锁加补偿——多一层分布式依赖,故障面变大。反之若下游无法改造,Redis 补偿策略的残余风险不可接受,换 ZooKeeper。

三、关键原理

异步复制的窗口

Redis 官方文档在 Replication 页面明确:主节点收到写命令后本地执行完毕即返回,数据通过后台发送给副本。这意味着SETNX返回成功的那一刻,任何副本上都可能还没有这个 key。缓解项min-replicas-to-write和min-replicas-max-lag在 Redis 2.8.0 引入,作用是副本数不足时主动拒绝写入,不是警告。

WAIT 的作用域陷阱

WAIT numreplicas timeout_ms(Redis 2.8.0+)只对当前 client connection的上一次写操作生效。这是最容易踩的坑:如果锁的 Lua 脚本走连接池的连接 A,WAIT走连接 B,WAIT等待的根本不是那把锁的复制。超时后返回值可能小于numreplicas,调用方必须判断返回值。

Redisson 看门狗的静默失败

Redisson 3.x 默认lockWatchdogTimeout = 30000,每 10 秒(timeout 的 1/3)执行一次续期 Lua 脚本:

ifredis.call("get",KEYS[1])==ARGV[1]thenreturnredis.call("pexpire",KEYS[1],ARGV[2])endreturn0

主从切换后新 master 上没有这个 key,GET返回nil,脚本返回 0。持锁客户端的业务代码不会收到任何异常——它以为自己还在锁内。这是丢锁后最容易误判的地方:日志里没有任何报错。

四、可运行示例

环境

version:'3.8'services:redis-master:image:redis:7.2command:>redis-server --min-replicas-to-write 1 --min-replicas-max-lag 10ports:-"6379:6379"redis-replica:image:redis:7.2command:redis-server--replicaof redis-master 6379depends_on:-redis-mastersentinel:image:redis:7.2command:>sh -c 'echo "sentinel monitor mymaster redis-master 6379 1" > /tmp/s.conf && echo "sentinel down-after-milliseconds mymaster 5000" >> /tmp/s.conf && redis-sentinel /tmp/s.conf'depends_on:-redis-master

依赖(pom.xml):

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.27.0</version></dependency>

配置(application.yml):

spring:data:redis:host:localhostport:6379

WAIT 补偿实现(保证 Lua 与 WAIT 同连接)

@ComponentpublicclassWaitBasedLock{privatefinalRedisClientclient;privatefinalStatefulRedisConnection<String,String>conn;privatefinalRedisCommands<String,String>cmd;privatestaticfinalStringACQUIRE_LUA="if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then "+" redis.call('pexpire', KEYS[1], ARGV[2]) "+" return 1 "+"else return 0 end";publicWaitBasedLock(){this.client=RedisClient.create("redis://localhost:6379");// 必须用共享连接:WAIT 只对当前连接的上一次写操作生效this.conn=client.connect();this.cmd=conn.sync();}publicbooleantryLock(StringlockKey,StringclientId,longleaseMs,intwaitReplicas,longwaitTimeoutMs){Objectresult=cmd.eval(ACQUIRE_LUA,ScriptOutputType.INTEGER,newString[]{lockKey},clientId,String.valueOf(leaseMs));if(!Long.valueOf(1).equals(result)){returnfalse;}Longacked=cmd.waitReplication(waitReplicas,waitTimeoutMs);System.out.println("锁已获取,副本确认数="+acked+",期望="+waitReplicas);if(acked<waitReplicas){cmd.del(lockKey);System.out.println("副本确认不足,主动释放锁");returnfalse;}returntrue;}publicvoidunlock(StringlockKey,StringclientId){cmd.eval("if redis.call('get', KEYS[1]) == ARGV[1] then "+"return redis.call('del', KEYS[1]) end return 0",ScriptOutputType.INTEGER,newString[]{lockKey},clientId);}}

Redisson 看门狗路径演示

@ComponentpublicclassRedissonLockDemo{privatefinalRedissonClientredisson;publicRedissonLockDemo(RedissonClientredisson){this.redisson=redisson;}publicbooleantryLock(StringlockKey){RLocklock=redisson.getLock(lockKey);try{// 不传 leaseTime,走看门狗路径,默认 lockWatchdogTimeout=30000booleanok=lock.tryLock(1,java.util.concurrent.TimeUnit.SECONDS);if(ok){System.out.println("获取成功: "+lockKey);}returnok;}catch(InterruptedExceptione){Thread.currentThread().interrupt();returnfalse;}}publicvoidunlock(StringlockKey){RLocklock=redisson.getLock(lockKey);if(lock.isHeldByCurrentThread()){lock.unlock();}}}

幂等去重实现

CREATETABLEorder_processed(order_idBIGINTPRIMARYKEY,amountDECIMAL(10,2),processed_atDATETIME);
@TransactionalpublicvoidprocessOrder(longorderId,BigDecimalamount){try{jdbc.update("INSERT INTO order_processed (order_id, amount, processed_at) "+"VALUES (?, ?, NOW()) ON DUPLICATE KEY UPDATE order_id = order_id",orderId,amount);}catch(DuplicateKeyExceptione){System.out.println("订单已处理,跳过: "+orderId);return;}System.out.println("处理订单: "+orderId+", 金额: "+amount);}

五、验证结果与边界

验证方法:docker compose up -d启动集群后,用docker kill redis-master模拟主节点崩溃,同时轮询INFO replication观察新 master 上是否存在order:1001这个 key。

预期输出(WAIT 策略正常场景):

锁已获取,副本确认数=1,期望=1 处理订单: 1001, 金额: 100

若 master 在SETNX之后、WAIT返回之前崩溃,waitReplication返回 0 或抛连接异常,锁不被授予。

实际输出(docker kill redis-master后,依据 Redis 官方文档所述语义):

-- WAIT 已确认复制后 master 崩溃 -- Sentinel 提升的副本上存在 order:1001,值为 pod-A 的 clientId -- pod-B 的 SETNX 返回 0,无法获取锁 -- 订单只扣款一次 -- master 在 WAIT 返回前崩溃 -- waitReplication 返回 0 或抛出连接异常 -- 调用方判定获取失败,不进入临界区

常见失败与修复:WAIT返回 0,即使副本正常存活。原因通常是 Lua 脚本和WAIT走了连接池里不同连接,WAIT等待的不是锁的那次写入。修复:像示例中那样用StatefulRedisConnection共享连接,或在同一 pipeline 里执行两条命令。另一个常见现象是 Lettuce 在 master 切换后自动重连到新 master,此时旧连接上的操作可能报Connection reset——这不是 bug,是 failover 的正常表现,调用方需捕获后重试加锁。

未验证项:以下内容需实际跑 Sentinel 才能给出数值,本文不做推断:

  • Sentinel 从 master 崩溃到新 master 可用的实际耗时(取决于down-after-milliseconds配置)
  • WAIT 1 100在本地 Docker 环境的实际延迟
  • 高并发(1000 TPS)下三种策略的 P99 延迟对比

边界:三种策略都不是"修复"主从丢锁,而是降低概率(WAIT)、让过期持锁者失效(Fencing Token)、让重复执行不产生第二次副作用(幂等)。需要彻底消除丢锁可能性的场景,用基于多数派协议的 ZooKeeper 或 etcd。

参考资料

Redis Replication 官方文档(Redis 7.x)
Redis WAIT 命令文档(Redis 2.8.0+)
Redisson Wiki — 分布式锁与看门狗机制(Redisson 3.x)
Redis min-replicas-to-write 配置文档(Redis 2.8.0+)

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 1:53:45

python:3.12 镜像 1.2GB,多阶段构建后只剩 95MB:省了什么丢了什么

本文摘要&#xff1a;Python 服务用全量镜像部署&#xff0c;体积 1.2GB&#xff0c;CI 构建超过 12 分钟。多阶段构建配合 slim 基底可将最终镜像压至 95MB&#xff0c;关键机制是只保留最后 FROM 阶段的层。 一、问题与结论 现场环境为 Docker 26 compose v2。一个仅依赖 f…

作者头像 李华