分布式锁的"不重复执行",凭什么算被证明了?

分布式锁的"不重复执行",凭什么算被证明了?

主题:分布式定时任务锁的验证方法论

先说结论

"锁生效了"是一个观测不到的命题。 锁的价值恰恰体现在失败方 ------ 没抢到锁的那一次,而失败方的日志默认是 DEBUG 级、生产上根本看不见 。所以只看成功方那句"我修正了数据",你无法区分两种完全不同的世界 :世界 A 是互斥生效、另一个实例被锁挡住了;世界 B 是另一个实例压根没在跑。这两者产生的成功方日志一模一样。

所以「不重复执行」本身不是结论 ------ 它必须由一个可证伪的实验来支撑。我的判据是:

vbnet 复制代码
① 造场景:两个真实 JVM 抢同一把锁(不是同进程两个线程)
      ↓
② 定判据:人为制造 N 次待处理状态 → 期望【恰好 N 条】修正日志(可证伪的计数断言)
      ↓
③ 看失败方:把"未抢到锁"从 DEBUG 打开 → 失败方必须留痕(跨多个任务 = 交叉证据)
      ↓
④ 收尾断言:锁 key 无残留 + 数据回到基准值 + 注册中心里两个实例都健康

一、问题:单实例跑不出锁的价值

@Scheduled 在集群化之后会在两个实例上同时触发 ;单实例环境下锁永远不会被争用,跑一百次也证明不了任何事 ------ 它甚至不能证明锁的代码被执行过。最小可信拓扑(本地):

yaml 复制代码
MySQL 3306 / Redis 6379 / RabbitMQ 5672 / Nacos 8848(standalone,临时启动)
实例 1:10007 / Dubbo 20880 / Sentinel 8870 / QoS 22222
实例 2:10017 / Dubbo 20881 / Sentinel 8871 / QoS 22223

两个实例跑的是同一份 jar ,端口错开,且注册到同一个 Nacos ------ 注册中心里确实出现 2 个 healthy=true 的实例,才是真实集群形态,而不是"两个互不知道对方的进程"。

触发场景 :秒杀库存对账任务每 5 秒跑一次,把 Redis 里的库存键从 DB 基准值 45 人为改成 999(差异 954 远超对账阈值 2),任务就会立即把它修正回 45。


二、方案:三项断言 + 五条原则

2.1 三项断言(验什么)

# 命题 造什么场景 证据 期望判据
1 互斥 两实例同一时刻触发同一任务 失败方日志「未抢到锁,跳过本次执行」 同一时刻只有一方执行,另一方留痕
2 无重复执行 人为制造 N 次待处理状态 成功方 的修正日志条数 恰好 N 条
3 锁正确释放 跑完后停机巡检 KEYS app:seckill:lock:* 为空 ,且两实例交替拿锁

⭐ 三项缺一不可:只有 ② 会退化成"单实例也能过";只有 ① 证明不了"没重复";只有 ③ 说明不了互斥。

让结论站得住的五条原则 :① 真实竞争者 (两个真实 JVM + 真实 Redis,不用同进程线程、不 mock 掉 Redis → 那都是自证);② 可证伪判据 (先定"4 次漂移 = 恰好 4 条修正",而不是"日志看起来正常");③ 失败方留痕 (INFO 级下失败方完全静默,看不出互斥);④ 收尾可断言 (不跑完就走);⑤ 边界诚实(别把"本地过"说成"生产没问题")。

2.2 为什么"计数相等"才是硬证据

对账是幂等 的:修正一次之后 Redis 就等于 DB,再修一次不会产生第二条日志。所以:

复制代码
若无锁(两实例并发修正同一轮漂移)→ 同一轮可能产生 2 条日志 → N 次漂移会出现 > N 条
反向:恰好 N 条 ⇒ 每次处理只发生一次 ⇒ 无重复

📌 要防的是并发 ,不是"重复":串行天然安全。锁的语义是"每 tick 只有一个实例进入临界区",不是"这任务一辈子只跑一次"。

2.3 实测:4 次漂移 → 恰好 4 条修正

powershell 复制代码
# ① 打包 → ② 启动本地 Nacos(后台常驻)→ ③ 启动两个实例(同一 jar,端口错开)
java -Xmx512m -jar seckill-webapi.jar `
  --server.port=10007 --dubbo.protocol.port=20880 --dubbo.application.qos-port=22222 `
  --seata.enabled=false --spring.quartz.auto-startup=false `
  --seckill.reconcile.online-fixed-delay=5000 `
  --logging.level.<任务包>=debug          # ← 关键:不打开就看不到失败方
# 实例2 同理:10017 / 20881 / 22223;④ 制造漂移(循环):改大 → 等 9 秒 → 读回,期望回到 45
RedisTool set app:seckill:sku:stock:1 999 ; Start-Sleep 9 ; RedisTool get app:seckill:sku:stock:1
轮次 Redis 设定 修正者 修正后 另一实例的"丢锁"证据
基线 999 实例1 45 实例2 未在此时刻抢到(实例1 持有)
1 999 实例1 45 实例2 丢对账锁
2 999 实例2 45 实例1 丢对账锁
3 999 实例2 45 实例1 丢对账锁

修正日志原文:【秒杀库存对账】skuId=1, Redis=999, DB=45, 差异=954, 已按DB修正为 45

  • 实例1 两条 + 实例2 两条 = 恰好 4 条 → 没有任何一次被重复执行;
  • 两实例交替拿到锁(不是固定一台)→ 释放正常、竞争公平。

2.4 收尾断言(实验自己不能污染环境)

三项都要查:KEYS app:seckill:lock:* → [] (全部释放,无漏锁);Redis 库存 45 = DB 基准值 45 ;两个实例在注册中心里都 healthy=true。锁行为汇总:实例1 修正 2 / 丢对账锁 4 / 丢重试锁 0,实例2 修正 2 / 丢对账锁 2 / 丢重试锁 1。

⭐ 失败方累计 7 条「未抢到锁,跳过本次执行」 (实例1 四条 + 实例2 三条,跨两个任务 )------ 这 7 条才是"互斥"的正面证据 ;成功方合计 4 次,与漂移次数一一对应。跨两个任务的意义在于:把"互斥"从单点巧合 变成交叉证据。


三、⭐ 更深一层的认知:断言失败时,第一个要验证的对象是断言本身

这一节是我这次实验里最贵的收获。

我有一条 TTL 断言一直失败,现象看着像代码有 bug:

vbnet 复制代码
现象:断言"锁有 TTL"失败,读回来的过期时间是 0 ------ 看起来像"锁根本没有 TTL"
根因:getExpire(key, SECONDS) 对 1 秒 TTL 会向下取整 → 剩余不足 1 秒 → 秒级读数就是 0
结论:锁的代码是对的,是【断言写错了】

如果我直接相信断言,我就会把一段正确的代码改坏。 所以这条认知我写成了纪律:

断言失败时,第一个要验证的对象是断言本身。

修法很小 ------ 换成 MILLISECONDS,断言区间改成 (0, 1000]。但"先怀疑断言、再怀疑代码"这个顺序,是很多排查走弯路的根源。

同理,我原本想用 Mockito 写单测验证锁,10 个用例全报错。我没有直接下"这个项目不能用 Mockito"的结论 ,而是去找反证:项目里本来就存在、本来一直是绿的 Mockito 用例,在同一环境报同样的错 → 这是环境限制 ,不是项目缺陷(根因见 §四坑 1)。

于是我换了分工,标准只有一句话:

复制代码
被测逻辑【自己算得出来】  → 用 mock(手写假件也行)
被测逻辑【依赖中间件行为】→ 必须打真实中间件

锁的互斥语义 (SETNX 原子性 / TTL / Lua CAS)都是 Redis 服务端行为,mock 掉等于"自己造假 Redis 再自证"→ 用真实 Redis 集成测试 ;任务的编排逻辑 (抢不到锁就不扫表、异常路径仍释放)→ 用手写替身 (子类化锁工具 + 反射代理假 Mapper + 注入),只验证那些自己能算出来的分支。最终 9 个用例全绿。


四、过程中真实踩到的三个坑

坑 1:Mockito inline 无法 attach。 第一版 10 个用例全 ERROR:Could not initialize plugin: interface org.mockito.plugins.MockMaker → Could not self-attach to current VM using external process。排查分三段,每段排除一种可能:① 查依赖树,Mockito 5.7.0 + ByteBuddy 1.14.13 + JDK 21 版本全兼容 ,排除;② 读 surefire 报告拿到真根因:沙箱禁止把 agent self-attach 到当前 JVM (Windows 上走命名管道);③ 关键反证 :项目里早就存在、本来就绿 的 Mockito 用例在同一环境同样失败 → 环境限制,不是项目缺陷(处置见 §三:真实 Redis 集成测试 + 手写假件,9 例全绿)。

坑 2:TTL 断言不能用秒 ------ 是脚本错了,不是代码错了。 见 §三:getExpire(key, SECONDS) 对 1 秒 TTL 取整成 0 → 断言 >0 失败;改用 MILLISECONDS + 区间 (0,1000]。

坑 3:RedisTemplate.execute 二义性(编译不过)。

scss 复制代码
现象:redisTemplate.execute(connection -> ...) 编译报"引用不明确"
根因:execute(RedisCallback) 与 execute(SessionCallback) 两个重载,lambda 下无法推断
处置:显式转型 (RedisCallback<String>) connection -> connection.ping()

生产代码没踩到 ------ 那边传的是 RedisScript 实例 (execute(script, keys, args)),重载可以唯一解析。


五、踩坑清单

# 认知 / 坑 说明
1 ⭐ "不重复执行"必须由失败方证明 只看成功方日志,无法区分"互斥生效"与"另一实例根本没跑"。本次失败方 7 条
2 ⭐ 判据要可证伪 4 次漂移 → 恰好 4 条修正 (而非"日志看起来正常")。幂等任务让"重复执行"表现为条数 > N
3 ⭐ 验证脚本/判据本身也要被验证 TTL 断言用秒 → 1 秒 TTL 取整成 0 误判。先怀疑断言,再怀疑代码
4 ⚠️ "抢不到锁"日志默认 DEBUG,生产看不见 必须显式调级才有证据;生产要么调级、要么换 Redis MONITOR 抓真实命令
5 ⚠️ 环境限制 ≠ 项目缺陷,要有反证 判定依据是"已有的、本来绿的用例在同一环境同样失败"
6 ⚠️ 不要用进程外用同一性证据 两实例启动耗时巧合一致曾让人以为跑了同一个进程 ------ 判同一性看 PID / 端口 / 注册中心
7 ⚠️ 收尾状态必须可断言 锁 key 残留 + 数据基准值 + 注册中心健康,三项都查;否则实验本身污染环境
8 🔑 本地只能证明"机制",不能证明"容量" 跨机注册可达性 / 哨兵切换 / 网关轮询 / 带宽表现,本地一律测不了
9 🔑 锁不是正确性的唯一保证 主从异步复制 下主挂瞬间可能锁双持 (Redlock 争议)→ 三层兜底:状态标记(幂等)+ 条件扣减 + 唯一索引;锁只负责降低并发概率,验证目标也应是"降低并发"而非"绝不重复"

六、一句总结

「锁生效了」是观测不到的 ------ 想证明它,判据必须落在失败方,而且得是一个可证伪的计数;在此之前,先确认你的断言自己没有说谎。


本文基于一个个人微服务电商演示项目的真实实施记录整理,涉及的环境信息已做脱敏处理。

相关推荐
深入云栈1 小时前
Netty 4.2.x 源码深度解析 (十九):ChannelPool 连接池 —— 连接复用与资源管理
java·后端·架构
yunwei371 小时前
eBPF 教程:BPF 迭代器用于内核数据导出
linux·后端·性能优化
silvamux_1 小时前
Spring Boot 调用大模型 api 最小可跑教程
java·spring boot·后端
今年下半年1 小时前
【Spring Boot 】通过 OpenFeign 请求第三方接口
java·spring boot·后端·openfeign
UrbanJazzerati2 小时前
AutoHotkey v2 零基础快速入门 Part 3 像素亮度检测完全指南
后端
浮链序2 小时前
Shopify 把 10 亿商品开放给任意 agent 了:我用零依赖 Python 撸了个带预算闸门的代购网关
后端·代码规范
Qlly2 小时前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝2 小时前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
花涧鹤2 小时前
学完循环我才发现,我写的不是死循环,是死机
后端