大二的我手写了一把内存锁补充篇五个被我压进草稿的设计细节阅读说明本文为系列补充篇 ——文章首发于掘金精简版本文为 CSDN 系列补充版专门收录了上下篇主线里故意压下去、但又忍不住深挖的设计细节。掘金精简版首发https://juejin.cn/post/7647372847381200922全文完整版含完整实验代码、Redis / Redisson 对比实验数据、JMeter 日志、更细推导在我的个人博客公开笔记广场有一篇同名笔记https://middleware.jacolp.dpdns.org/guest/notes建议先读上篇和下篇本篇是对主线的侧面展开不单独构成完整叙事。本篇覆盖五个专题DCL 为什么不一定需要 volatile、单机锁在多实例部署下为什么会失效、LockSupport 为什么比 wait/notify 更适合这里、批量加锁为什么必须固定 key 顺序、SQL CAS 和 Java 锁分别承担什么角色。 本篇核心收获本篇是上下篇主线的“硬核技术延伸”专门收录了主线里为了阅读体验而故意压下去、但又极具技术深度的 5 个底层细节。在本篇中你将重点掌握【JVM 并发深挖】经典 DCL双重检查锁定一定需要volatile吗拆解依靠Hashtable的happens-before语义实现的隐式可见性。【线程调度内幕】为什么在自定义锁框架中LockSupport.park/unpark比经典的wait/notify机制更合适、更安全【分布式演进避坑】单机内存锁走向多实例部署时为什么会失效【算法防死锁】在批量加锁场景下“固定 Key 排序”破坏死锁循环条件的数学推导与代码落地。写在前面上篇和下篇各自有一条主线分别是锁从 V1 到 V3 的演进和LockOperator 不是守门人、MVCC 才是兜底。但写那两篇的时候我一直压着几个问题没展开因为一旦展开就会把读者从主线拉走太远。这篇补充篇就专门把它们一个一个捡回来。专题一这里的 DCL 为什么不一定需要 volatile经典 DCL 单例的半初始化问题先从经典问题说起。很多讲 DCL 的文章都会提到这个场景publicclassSingleton{privatestaticSingletoninstance;// 没有 volatilepublicstaticSingletongetInstance(){if(instancenull){synchronized(Singleton.class){if(instancenull){instancenewSingleton();// ← 问题在这里}}}returninstance;}}instance new Singleton()这行代码在 JVM 层面可以被拆成三步在堆上分配内存调用构造函数完成对象初始化把引用写回instance字段。JVM 允许对指令做重排序优化所以步骤 2 和步骤 3 可能被重排成先写引用再初始化。一旦这样另一个线程在第一次检查时看到instance ! null直接拿走使用但这个对象其实还没有完成初始化就会出现半初始化的引用被返回出去。加了volatile之后volatile的写操作会插入一个 StoreStore 屏障禁止步骤 3 被提前到步骤 2 之前从根本上堵死这个问题。V1 里的 DCL 和经典单例 DCL 有什么本质不同再看上篇 V1 里的代码privateReentrantLockgetLock(LonguserId){ReentrantLockuserLocklockMap.get(userId);if(userLocknull){try{lock.lock();if((userLocklockMap.get(userId))null){userLocknewReentrantLock();lockMap.put(userId,userLock);// ← 发布入口}returnuserLock;}finally{lock.unlock();}}returnuserLock;}这里的发布路径是Hashtable.put(userId, userLock)读取路径是Hashtable.get(userId)。Hashtable的put和get都是synchronized方法每次调用都会进入同一个监视器再退出。根据 happens-before 规则一个synchronized块的 unlock 先于后续对同一监视器 lock 的 lock。这就意味着写入线程在put内部完成了对userLock的初始化退出同步块时 unlock读取线程在get内部获取同一个监视器 lock然后才读取返回值。如果还没有听懂的话来看看这里userLocknewReentrantLock();// locklockMap.put(userId,userLock);// unlock其他线程获取是需要这样获取的// locklockMap.get(userId);// unlock中间有一对 unlock-lock 建立了 happens-before写线程对userLock的完整初始化对读线程是可见的。不会出现经典单例里引用已可见但对象未完成初始化的情况。这个点并不是作者第一次写 V1 的时候就注意到的是我回来复盘的时候我的改动日志的时候我才发现当时这个地方没有使用volatile关键字并对其进行了思考。那 V1 真正的问题是什么不是volatile缺失而是Hashtable本身的集合级synchronized太粗所有用户获取锁表项时都要竞争同一个监视器外层的总锁lock和Hashtable内部的锁在逻辑上有一些重复锁对象的生命周期太重用完不删依赖定时清理。这些才是 V1 真正让我不满意的点。volatile那个坑在这个具体结构里反而不是真正的危险所在。专题二单机锁为什么一到多实例部署就会失效先从绅士协议说起我理解锁的本质不是一道物理屏障而是一套绅士协议。把所有遵守锁协议的并发单元都比喻成绅士。绅士之间协调公共资源靠的不是一堵把所有人都挡在外面的墙而是一套大家都认可的社会契约进门之前先去门口查看并挂上自己的名牌看到名牌已经存在就在外面等待不强行闯入离开时把自己的名牌取下来。只要所有人都遵守这套协议互斥就能成立。信息可见性的边界被打破在单 JVM 里所有工作线程都生活在同一个共享公告栏里。这个公告栏就是 JVM 堆内存。线程 A 把名牌LOCK_MAP里的key-owner映射贴上去线程 B、C、D 都能立刻看到。信息是实时共享的协议执行的基础因此得以成立。但当服务从单机走向多实例部署了多个 JVM 进程时问题发生了质变。多实例部署相当于这个系统里同时出现了多个相互隔离的公告栏。JVM-A 里的线程只能看到 JVM-A 的堆JVM-B 里的线程只能看到 JVM-B 的堆。两个 JVM 之间信息是完全断绝的。这并非是哪个 JVM 里的线程不守规矩而是他们压根看不到另一个进程的公告栏。当 JVM-A 的线程已经把storage:user:1写进了自己的LOCK_MAPJVM-B 的线程毫不知情它也尝试写入同一个 key协议就失效了。这正是操作系统进程是最小资源分配单位这个设计在并发下的副作用OS 强制隔离不同进程的内存空间这是系统安全的基石但也恰好成为跨进程协调时必须跨越的鸿沟。Redis 分布式锁做了什么事它没有试图让每个 JVM 进程去打通内存隔离而是在所有 JVM 之上建立了一个公认的公共广场。所有线程无论来自哪个 JVM都不再以各自的本地LOCK_MAP为准。它们共同约定去 Redis 这块公共广场上张贴和查看名牌。广场上有且只有一块公告板所有人看到的信息是一致的。自此单机下已经建立的那套协议在更广阔的维度上重新成立了。这就是从LockOperator到 Redis 分布式锁的本质跨越不是加了个更好用的库而是把锁的信息可见性边界从单个 JVM 进程拓展到了整个分布式系统。专题三LockSupport 为什么比 wait/notify 更适合这里上篇里有一段LockOperator的阻塞唤醒机制选择了LockSupport.parkNanos()和LockSupport.unpark()而不是Object.wait()和notify()。这里展开说清楚。wait/notify 的限制notify()没有提前通知的语义。如果notify()在wait()之前发出这个信号会直接丢失等待线程将永久阻塞直到下一次有人再调用notify()。LockSupport 的 permit 机制LockSupport.unpark(thread)可以先于park()发生。每个线程都持有一个 permit许可证初始值为 0。unpark把 permit 置为 1park消耗 permit如果 permit 已经是 1就直接返回不阻塞否则阻塞直到 permit 被置为 1。也就是说即使唤醒先于睡眠发生permit 也会被保留下来线程后面调用park时会立刻消耗掉这个 permit直接返回而不阻塞。这对LockOperator里的时序问题很关键。上篇里分析过一个场景线程 B 第一次 tryLock 失败 → 线程 A 此刻恰好释放锁并 unpark 队头但 B 还没入队 → 线程 B 入队后 park如果用wait/notify线程 A 的 notify 在 B 调用 wait 之前发出信号丢失B 永久阻塞。如果用LockSupport线程 A 的unpark(B)把 B 的 permit 置为 1B 后面调用park时permit 已经是 1直接消耗返回不会真正阻塞。这就是上篇里说的双重保险的第二层入队后再次尝试tryLock是第一层LockSupport的 permit 机制是第二层。两层合在一起把先唤醒后睡眠造成永久阻塞的风险降到最低。专题四批量加锁为什么必须固定 key 顺序死锁的构造条件死锁的四个经典必要条件里有一个叫循环等待线程 A 持有资源 1等待资源 2线程 B 持有资源 2等待资源 1两者形成环形等待谁也不肯先放手。批量加锁场景正好容易触发这个条件。假设管理员两次批量删除第一次删了用户 1 和用户 2 的文件第二次同时也删了用户 1 和用户 2 的文件。如果两次操作几乎同时进来线程 AtryLock(storage:user:1) 成功 → 等待 tryLock(storage:user:2) 线程 BtryLock(storage:user:2) 成功 → 等待 tryLock(storage:user:1)双方都持有一把锁都在等对方释放形成死锁。固定顺序如何打破死锁解法非常朴素所有批量加锁操作在尝试加锁之前先对 key 集合做排序然后按照统一的顺序比如字典序升序逐个加锁。ListStringsortedKeysnewArrayList(keys);Collections.sort(sortedKeys);for(Stringkey:sortedKeys){if(!tryLock(key,owner,...)){releaseBatch(acquiredKeys,owner);returnnull;}acquiredKeys.add(key);}这样一来所有线程都按同一个顺序拿锁就不可能出现环形等待。还是上面的例子排序后两次操作都是先尝试storage:user:1再尝试storage:user:2。线程 A 先拿到用户 1 的锁线程 B 在用户 1 的锁上等待线程 A 依次拿到用户 2 的锁执行完毕释放线程 B 才能继续。是严格的串行没有环形等待。为什么 V2 版本没有考虑这个问题最大问题就是作者当时漏掉了这个问题因为当时写这个地方的时候整个架构耦合度比较高我的注意力大部分集中在了如何正确传递上下文防止 AOP 内存泄露或者是批量操作锁的时候怎么处理好一点在写 V3 的时候由于一些问题已经在 V2 的时候定好了思路所以注意力发生转移的时候我认真思索了一下我就发现这里似乎存在了未排序的“死锁”问题了。专题五SQL CAS 和 Java 锁分别承担什么角色这是整个并发配额方案里我认为最值得单独说清楚的一个分层问题。两层防护的分工上篇负责解释 Java 锁的演进下篇里通过 MVCC 分析揭示了 Java 锁并不是最终守门人。这里把分工关系再梳理一遍Java 锁LockOperator承担的是应用层串行化和削峰。它的目标是在同一个用户的存储变更请求进入数据库之前先在应用层做一轮串行化。同一个用户的上传、修改、删除请求同一时间只允许一个进入后续流程其余的要么等待、要么返回系统繁忙。它能做到减少无效 DB 请求等待中的请求不会重复打出 SELECT 和 UPDATE减少 InnoDB 行锁竞争同一时间真正执行 UPDATE 的请求数量少得多让绝大多数明显不该过的并发请求在 JVM 层就被挡住。它做不到从根本上保证不超卖。原因在下篇里已经讲清楚了Java 锁边界和事务边界不完全对齐RR 隔离级别下的快照读可能导致后续事务仍然读到旧值。SQL 条件更新UPDATE … WHERE承担的是数据库层的正确性兜底。UPDATEsys_userSETused_storage_bytesGREATEST(used_storage_bytes#{deltaStorageBytes}, 0),update_timenow()WHEREid#{id}ANDmax_storage_bytesused_storage_bytes#{deltaStorageBytes}这条 SQL 的 WHERE 子句里的used_storage_bytes不是从 Java 层 SELECT 出来的旧值而是 UPDATE 执行时 InnoDB 当前读拿到的最新版本。即使前面的 Java 层 SELECT 读到了used400只要 UPDATE 时最新版本已经是used600WHERE 条件666 600 200就不成立affected rows 0更新失败Java 层感知到失败并抛出异常整个业务回滚。它能做到无论 Java 层出现什么时序边界问题只要数据库层的当前读结果不满足条件超卖就写不进去。它做不到替代 Java 锁的削峰作用。如果没有 Java 锁所有并发请求都会打进数据库SELECT 和 UPDATE 都会同时执行InnoDB 行锁竞争会把这些请求串行化但代价是更多的 DB 连接、更多的行锁等待、更高的数据库压力。为什么两层都不能省省掉 Java 锁所有并发请求都打进数据库高并发场景下 DB 压力会显著上升尾部延迟也会变高。下篇里的对比时序图展示了有锁和无锁两种情况下 DB 请求量的差异。省掉 SQL 条件更新只靠 Java 锁下篇里分析了 RR 快照读的问题。即使 Java 锁已经把请求串行化如果 Java 锁边界和事务边界没有完全对齐后续请求仍然可能基于旧的快照读结果执行 UPDATE超卖写进去了都没人拦。所以这套方案的正确理解是Java 锁是前置过滤SQL CAS 是最终守门。两者各自只解决自己层面的问题不能互相替代。补充篇小结这五个专题是主线里故意压下去的五段岔路。捡回来之后整个方案的逻辑链条大概是这样的用户存储配额保护需要并发安全 → 引入用户维度锁 → V1 的 Hashtable DCL 能用但并发度受限DCL 本身在这里不需要 volatile原因在于 Hashtable 的 synchronized 建立了 happens-before→ V2 换成 ConcurrentHashMap同时收口所有存储变更入口批量加锁固定顺序防死锁→ V3 把锁抽象成 LockOperator用 key-owner 协议代替常驻锁对象阻塞唤醒用 LockSupport 而非 wait/notify → 当前单机阶段不上 Redis 因为成本不值得等多实例部署再换→ 但 Java 锁本身不是正确性的最终兜底在 RR 快照读 事务边界不完全对齐的情况下真正挡住超卖的是 UPDATE … WHERE 的当前读条件判断。每一层都只解决它自己层面的问题。系列导航上篇业务背景与锁从 V1 到 LockOperator 的完整演进下篇LockOperator 不是守门人MVCC 当前读才是兜底补充篇本文五个被压进草稿的设计细节掘金精简版首发https://juejin.cn/post/7647372847381200922全文完整版 / 公开笔记广场同名笔记https://middleware.jacolp.dpdns.org/guest/notes
大二的我手写了一把内存锁(补)
发布时间:2026/6/9 11:24:33
大二的我手写了一把内存锁补充篇五个被我压进草稿的设计细节阅读说明本文为系列补充篇 ——文章首发于掘金精简版本文为 CSDN 系列补充版专门收录了上下篇主线里故意压下去、但又忍不住深挖的设计细节。掘金精简版首发https://juejin.cn/post/7647372847381200922全文完整版含完整实验代码、Redis / Redisson 对比实验数据、JMeter 日志、更细推导在我的个人博客公开笔记广场有一篇同名笔记https://middleware.jacolp.dpdns.org/guest/notes建议先读上篇和下篇本篇是对主线的侧面展开不单独构成完整叙事。本篇覆盖五个专题DCL 为什么不一定需要 volatile、单机锁在多实例部署下为什么会失效、LockSupport 为什么比 wait/notify 更适合这里、批量加锁为什么必须固定 key 顺序、SQL CAS 和 Java 锁分别承担什么角色。 本篇核心收获本篇是上下篇主线的“硬核技术延伸”专门收录了主线里为了阅读体验而故意压下去、但又极具技术深度的 5 个底层细节。在本篇中你将重点掌握【JVM 并发深挖】经典 DCL双重检查锁定一定需要volatile吗拆解依靠Hashtable的happens-before语义实现的隐式可见性。【线程调度内幕】为什么在自定义锁框架中LockSupport.park/unpark比经典的wait/notify机制更合适、更安全【分布式演进避坑】单机内存锁走向多实例部署时为什么会失效【算法防死锁】在批量加锁场景下“固定 Key 排序”破坏死锁循环条件的数学推导与代码落地。写在前面上篇和下篇各自有一条主线分别是锁从 V1 到 V3 的演进和LockOperator 不是守门人、MVCC 才是兜底。但写那两篇的时候我一直压着几个问题没展开因为一旦展开就会把读者从主线拉走太远。这篇补充篇就专门把它们一个一个捡回来。专题一这里的 DCL 为什么不一定需要 volatile经典 DCL 单例的半初始化问题先从经典问题说起。很多讲 DCL 的文章都会提到这个场景publicclassSingleton{privatestaticSingletoninstance;// 没有 volatilepublicstaticSingletongetInstance(){if(instancenull){synchronized(Singleton.class){if(instancenull){instancenewSingleton();// ← 问题在这里}}}returninstance;}}instance new Singleton()这行代码在 JVM 层面可以被拆成三步在堆上分配内存调用构造函数完成对象初始化把引用写回instance字段。JVM 允许对指令做重排序优化所以步骤 2 和步骤 3 可能被重排成先写引用再初始化。一旦这样另一个线程在第一次检查时看到instance ! null直接拿走使用但这个对象其实还没有完成初始化就会出现半初始化的引用被返回出去。加了volatile之后volatile的写操作会插入一个 StoreStore 屏障禁止步骤 3 被提前到步骤 2 之前从根本上堵死这个问题。V1 里的 DCL 和经典单例 DCL 有什么本质不同再看上篇 V1 里的代码privateReentrantLockgetLock(LonguserId){ReentrantLockuserLocklockMap.get(userId);if(userLocknull){try{lock.lock();if((userLocklockMap.get(userId))null){userLocknewReentrantLock();lockMap.put(userId,userLock);// ← 发布入口}returnuserLock;}finally{lock.unlock();}}returnuserLock;}这里的发布路径是Hashtable.put(userId, userLock)读取路径是Hashtable.get(userId)。Hashtable的put和get都是synchronized方法每次调用都会进入同一个监视器再退出。根据 happens-before 规则一个synchronized块的 unlock 先于后续对同一监视器 lock 的 lock。这就意味着写入线程在put内部完成了对userLock的初始化退出同步块时 unlock读取线程在get内部获取同一个监视器 lock然后才读取返回值。如果还没有听懂的话来看看这里userLocknewReentrantLock();// locklockMap.put(userId,userLock);// unlock其他线程获取是需要这样获取的// locklockMap.get(userId);// unlock中间有一对 unlock-lock 建立了 happens-before写线程对userLock的完整初始化对读线程是可见的。不会出现经典单例里引用已可见但对象未完成初始化的情况。这个点并不是作者第一次写 V1 的时候就注意到的是我回来复盘的时候我的改动日志的时候我才发现当时这个地方没有使用volatile关键字并对其进行了思考。那 V1 真正的问题是什么不是volatile缺失而是Hashtable本身的集合级synchronized太粗所有用户获取锁表项时都要竞争同一个监视器外层的总锁lock和Hashtable内部的锁在逻辑上有一些重复锁对象的生命周期太重用完不删依赖定时清理。这些才是 V1 真正让我不满意的点。volatile那个坑在这个具体结构里反而不是真正的危险所在。专题二单机锁为什么一到多实例部署就会失效先从绅士协议说起我理解锁的本质不是一道物理屏障而是一套绅士协议。把所有遵守锁协议的并发单元都比喻成绅士。绅士之间协调公共资源靠的不是一堵把所有人都挡在外面的墙而是一套大家都认可的社会契约进门之前先去门口查看并挂上自己的名牌看到名牌已经存在就在外面等待不强行闯入离开时把自己的名牌取下来。只要所有人都遵守这套协议互斥就能成立。信息可见性的边界被打破在单 JVM 里所有工作线程都生活在同一个共享公告栏里。这个公告栏就是 JVM 堆内存。线程 A 把名牌LOCK_MAP里的key-owner映射贴上去线程 B、C、D 都能立刻看到。信息是实时共享的协议执行的基础因此得以成立。但当服务从单机走向多实例部署了多个 JVM 进程时问题发生了质变。多实例部署相当于这个系统里同时出现了多个相互隔离的公告栏。JVM-A 里的线程只能看到 JVM-A 的堆JVM-B 里的线程只能看到 JVM-B 的堆。两个 JVM 之间信息是完全断绝的。这并非是哪个 JVM 里的线程不守规矩而是他们压根看不到另一个进程的公告栏。当 JVM-A 的线程已经把storage:user:1写进了自己的LOCK_MAPJVM-B 的线程毫不知情它也尝试写入同一个 key协议就失效了。这正是操作系统进程是最小资源分配单位这个设计在并发下的副作用OS 强制隔离不同进程的内存空间这是系统安全的基石但也恰好成为跨进程协调时必须跨越的鸿沟。Redis 分布式锁做了什么事它没有试图让每个 JVM 进程去打通内存隔离而是在所有 JVM 之上建立了一个公认的公共广场。所有线程无论来自哪个 JVM都不再以各自的本地LOCK_MAP为准。它们共同约定去 Redis 这块公共广场上张贴和查看名牌。广场上有且只有一块公告板所有人看到的信息是一致的。自此单机下已经建立的那套协议在更广阔的维度上重新成立了。这就是从LockOperator到 Redis 分布式锁的本质跨越不是加了个更好用的库而是把锁的信息可见性边界从单个 JVM 进程拓展到了整个分布式系统。专题三LockSupport 为什么比 wait/notify 更适合这里上篇里有一段LockOperator的阻塞唤醒机制选择了LockSupport.parkNanos()和LockSupport.unpark()而不是Object.wait()和notify()。这里展开说清楚。wait/notify 的限制notify()没有提前通知的语义。如果notify()在wait()之前发出这个信号会直接丢失等待线程将永久阻塞直到下一次有人再调用notify()。LockSupport 的 permit 机制LockSupport.unpark(thread)可以先于park()发生。每个线程都持有一个 permit许可证初始值为 0。unpark把 permit 置为 1park消耗 permit如果 permit 已经是 1就直接返回不阻塞否则阻塞直到 permit 被置为 1。也就是说即使唤醒先于睡眠发生permit 也会被保留下来线程后面调用park时会立刻消耗掉这个 permit直接返回而不阻塞。这对LockOperator里的时序问题很关键。上篇里分析过一个场景线程 B 第一次 tryLock 失败 → 线程 A 此刻恰好释放锁并 unpark 队头但 B 还没入队 → 线程 B 入队后 park如果用wait/notify线程 A 的 notify 在 B 调用 wait 之前发出信号丢失B 永久阻塞。如果用LockSupport线程 A 的unpark(B)把 B 的 permit 置为 1B 后面调用park时permit 已经是 1直接消耗返回不会真正阻塞。这就是上篇里说的双重保险的第二层入队后再次尝试tryLock是第一层LockSupport的 permit 机制是第二层。两层合在一起把先唤醒后睡眠造成永久阻塞的风险降到最低。专题四批量加锁为什么必须固定 key 顺序死锁的构造条件死锁的四个经典必要条件里有一个叫循环等待线程 A 持有资源 1等待资源 2线程 B 持有资源 2等待资源 1两者形成环形等待谁也不肯先放手。批量加锁场景正好容易触发这个条件。假设管理员两次批量删除第一次删了用户 1 和用户 2 的文件第二次同时也删了用户 1 和用户 2 的文件。如果两次操作几乎同时进来线程 AtryLock(storage:user:1) 成功 → 等待 tryLock(storage:user:2) 线程 BtryLock(storage:user:2) 成功 → 等待 tryLock(storage:user:1)双方都持有一把锁都在等对方释放形成死锁。固定顺序如何打破死锁解法非常朴素所有批量加锁操作在尝试加锁之前先对 key 集合做排序然后按照统一的顺序比如字典序升序逐个加锁。ListStringsortedKeysnewArrayList(keys);Collections.sort(sortedKeys);for(Stringkey:sortedKeys){if(!tryLock(key,owner,...)){releaseBatch(acquiredKeys,owner);returnnull;}acquiredKeys.add(key);}这样一来所有线程都按同一个顺序拿锁就不可能出现环形等待。还是上面的例子排序后两次操作都是先尝试storage:user:1再尝试storage:user:2。线程 A 先拿到用户 1 的锁线程 B 在用户 1 的锁上等待线程 A 依次拿到用户 2 的锁执行完毕释放线程 B 才能继续。是严格的串行没有环形等待。为什么 V2 版本没有考虑这个问题最大问题就是作者当时漏掉了这个问题因为当时写这个地方的时候整个架构耦合度比较高我的注意力大部分集中在了如何正确传递上下文防止 AOP 内存泄露或者是批量操作锁的时候怎么处理好一点在写 V3 的时候由于一些问题已经在 V2 的时候定好了思路所以注意力发生转移的时候我认真思索了一下我就发现这里似乎存在了未排序的“死锁”问题了。专题五SQL CAS 和 Java 锁分别承担什么角色这是整个并发配额方案里我认为最值得单独说清楚的一个分层问题。两层防护的分工上篇负责解释 Java 锁的演进下篇里通过 MVCC 分析揭示了 Java 锁并不是最终守门人。这里把分工关系再梳理一遍Java 锁LockOperator承担的是应用层串行化和削峰。它的目标是在同一个用户的存储变更请求进入数据库之前先在应用层做一轮串行化。同一个用户的上传、修改、删除请求同一时间只允许一个进入后续流程其余的要么等待、要么返回系统繁忙。它能做到减少无效 DB 请求等待中的请求不会重复打出 SELECT 和 UPDATE减少 InnoDB 行锁竞争同一时间真正执行 UPDATE 的请求数量少得多让绝大多数明显不该过的并发请求在 JVM 层就被挡住。它做不到从根本上保证不超卖。原因在下篇里已经讲清楚了Java 锁边界和事务边界不完全对齐RR 隔离级别下的快照读可能导致后续事务仍然读到旧值。SQL 条件更新UPDATE … WHERE承担的是数据库层的正确性兜底。UPDATEsys_userSETused_storage_bytesGREATEST(used_storage_bytes#{deltaStorageBytes}, 0),update_timenow()WHEREid#{id}ANDmax_storage_bytesused_storage_bytes#{deltaStorageBytes}这条 SQL 的 WHERE 子句里的used_storage_bytes不是从 Java 层 SELECT 出来的旧值而是 UPDATE 执行时 InnoDB 当前读拿到的最新版本。即使前面的 Java 层 SELECT 读到了used400只要 UPDATE 时最新版本已经是used600WHERE 条件666 600 200就不成立affected rows 0更新失败Java 层感知到失败并抛出异常整个业务回滚。它能做到无论 Java 层出现什么时序边界问题只要数据库层的当前读结果不满足条件超卖就写不进去。它做不到替代 Java 锁的削峰作用。如果没有 Java 锁所有并发请求都会打进数据库SELECT 和 UPDATE 都会同时执行InnoDB 行锁竞争会把这些请求串行化但代价是更多的 DB 连接、更多的行锁等待、更高的数据库压力。为什么两层都不能省省掉 Java 锁所有并发请求都打进数据库高并发场景下 DB 压力会显著上升尾部延迟也会变高。下篇里的对比时序图展示了有锁和无锁两种情况下 DB 请求量的差异。省掉 SQL 条件更新只靠 Java 锁下篇里分析了 RR 快照读的问题。即使 Java 锁已经把请求串行化如果 Java 锁边界和事务边界没有完全对齐后续请求仍然可能基于旧的快照读结果执行 UPDATE超卖写进去了都没人拦。所以这套方案的正确理解是Java 锁是前置过滤SQL CAS 是最终守门。两者各自只解决自己层面的问题不能互相替代。补充篇小结这五个专题是主线里故意压下去的五段岔路。捡回来之后整个方案的逻辑链条大概是这样的用户存储配额保护需要并发安全 → 引入用户维度锁 → V1 的 Hashtable DCL 能用但并发度受限DCL 本身在这里不需要 volatile原因在于 Hashtable 的 synchronized 建立了 happens-before→ V2 换成 ConcurrentHashMap同时收口所有存储变更入口批量加锁固定顺序防死锁→ V3 把锁抽象成 LockOperator用 key-owner 协议代替常驻锁对象阻塞唤醒用 LockSupport 而非 wait/notify → 当前单机阶段不上 Redis 因为成本不值得等多实例部署再换→ 但 Java 锁本身不是正确性的最终兜底在 RR 快照读 事务边界不完全对齐的情况下真正挡住超卖的是 UPDATE … WHERE 的当前读条件判断。每一层都只解决它自己层面的问题。系列导航上篇业务背景与锁从 V1 到 LockOperator 的完整演进下篇LockOperator 不是守门人MVCC 当前读才是兜底补充篇本文五个被压进草稿的设计细节掘金精简版首发https://juejin.cn/post/7647372847381200922全文完整版 / 公开笔记广场同名笔记https://middleware.jacolp.dpdns.org/guest/notes