本文摘要:主从切换丢失分布式锁,两台 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:6379WAIT 补偿实现(保证 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+)