欢迎光临
我们一直在努力

大二的我手写了一把内存锁(下)

大二的我手写了一把内存锁(下):LockOperator 不是守门人,真正兜底的是 MySQL 的 MVCC + 当前读


📌 阅读说明

本文为系列拆分版 —— 文章首发于掘金(精简版),本文为 CSDN 系列下篇。

  • 掘金精简版:https://juejin.cn/post/7647372847381200922
  • 全文完整版(含完整实验代码、Redis / Redisson 对比代码、JMeter 日志、更细推导)在我的个人博客公开笔记广场,有一篇同名笔记:https://middleware.jacolp.dpdns.org/guest/notes

建议先读上篇,上篇讲了业务背景与锁从 V1 到 V3(LockOperator)的完整演进。

本篇聚焦:为什么 Java 锁管不住数据库的读版本、MVCC / RR 隔离级别的核心机制、UPDATE 当前读如何真正兜底,以及 LockOperator 与 SQL CAS 各自承担什么角色,最后附内存实验结论、Redis 对比实验结论和 JMeter 压测复盘。

💡 本篇核心收获

注意:本篇情节发生重大反转。 即使我们在应用层写出了不错的 Java 内存锁,在面对数据库事务时,依然可能出现细节漏洞。

在本篇中,你将重点掌握:

  • 【硬核反转】:看透为什么 Java 层的锁“管不住”数据库的读版本——深度剖析锁边界与事务边界未对齐导致的超卖惨案。
  • 【MySQL 内幕】:硬核拆解 InnoDB 在 RR(可重复读)隔离级别下的快照读局限性,以及为什么 UPDATE 触发的**“当前读”**才是真正的最终守门人。
  • 【组合拳架构】:理解“Java 锁(前置过滤/削峰) + SQL 条件更新(底层 CAS 兜底)”如何完美协作,既保护 DB 压力,又保证绝对的数据安全。
  • 【数据说话】:参考作者在 JMeter 实测下的压测日志、吞吐量对比,以及内存残留实验数据。

承接上篇:锁成型了,但故事没有结束

上篇里,LockOperator 已经成型——用 ConcurrentHashMap<String, String> 实现了 key-owner 协议,支持阻塞唤醒和批量加锁防死锁。同一个用户的上传、修改、删除、批量删除,都必须围绕同一个 storage:userId 竞争同一把锁。

这时候,我心里冒出了一个很自然的问题:

如果 Java 层锁已经把同一个用户的请求串行化了,那它是不是就一定能保证存储空间不会超卖?

我一开始以为答案是"是"。因为同一个用户的请求被 LockOperator 串行化了,后一个请求必须等前一个请求释放锁之后才能继续执行。按照这个直觉,线程 B 进来时,线程 A 的修改应该已经完成了,B 读到的自然是 A 写完之后的最新值。

但后来排查事务顺序时,我发现这里藏着一个很容易被忽略的点:Java 锁控制的是 JVM 里的进入顺序,事务能看到哪个数据版本,则是 InnoDB 根据隔离级别和 ReadView 决定的。

这两件事并不等价。


实验插播:LockOperator 的内存表现比我预期的更有意思

在进入 MVCC 的讨论之前,先把上篇末尾提到的两个实验的结论交代清楚——它们是在 MVCC 讨论之前做的,结论本身影响了我后续对 LockOperator 定位的判断。

实验代码仓库:https://github.com/qing-del/Test

实验一结论:单条更重,但残留归零

我原本以为 LockOperator 会是一个很漂亮的双赢:单条 entry 更低,峰值也更低。结果跑完才发现,事情没那么简单。

静态堆积(100000 个用户锁,不释放):

LockMap used = 12 MB
LockOperator used = 19 MB

LockOperator 在这个场景下反而更重。原因也很现实:它要保存 String key、String owner,还要维护 ConcurrentHashMap<String, String> 这种结构,单条记录的字节数比 ReentrantLock 还大。

模拟 10000 个用户业务生命周期(每个请求持锁 20ms ~ 50ms,业务结束后释放):

LockMap entry bytes ~= 122.25 B
LockOperator entry bytes ~= 165.78 B

==== LockMap business lifecycle ====
avg resident entries ~= 7096.88
peak resident entries = 10000
estimated avg resident memory ~= 0.83 MB
estimated peak resident memory ~= 1.17 MB
heap delta after GC = 8.92 MB
remaining entries = 10000 ← 业务结束,锁还挂在 Map 里

==== LockOperator business lifecycle ====
avg resident entries ~= 3773.64
peak resident entries = 9117
estimated avg resident memory ~= 0.60 MB
estimated peak resident memory ~= 1.44 MB
heap delta after GC = 0.07 MB
remaining entries = 0 ← 业务结束,锁表清空

⚠注意:entry bytes是粗略估计,它不是 JVM 对象布局的精确值,只是用 堆内存前后差值 / 记录数量 算出来的近似值。

这组数据说明了一件事:LockOperator 的优势不是单条记录天然更小,而是生命周期更短、残留更少。

ConcurrentHashMap<Long, ReentrantLock> 更像是"给每个来过的用户都留一把专属钥匙"——只要这个用户曾经上传过一次,它对应的 ReentrantLock 就会继续占着内存,直到定时任务把它清理掉。

LockOperator 更像是"请求来了临时登记,业务结束后立即注销"——释放锁时把 key-owner 记录直接移除,业务跑完之后锁表里的残留数量回到了 0。

对于上传这种锁持有时间只有几十毫秒的业务,生命周期有时候比对象大小本身更关键。

这个实验也教育了我:不要只单凭直觉,还是得看看实验数据来说话的。


实验二结论:当前单机阶段,Redis 额外成本不值得提前承担

这次实验用本机 Docker 里的 Redis 容器(127.0.0.1:6379)做裸对比,测两种锁方案:

  • 本地内存锁:LockOperator
  • 手写 Redis 锁:SET key owner NX PX leaseMs + Lua 脚本释放

测试参数:8 线程,每线程 50 次操作,共 400 次,业务耗时固定 30ms,超时 60000ms。

场景一:多用户无竞争(每个操作使用不同 key)

clientsuccessfailwall(ms)throughput(ops/s)avg(ms)p50(ms)p95(ms)p99(ms)
LockOperator 400 0 1606.28 249.02 31.72 31.31 32.11 44.88
Redis SET NX PX 400 0 2162.09 185.01 42.40 46.01 47.15 47.39

无竞争场景下,本地锁的平均耗时接近 30ms(符合预期);Redis 的平均耗时变成了 42.40ms,吞吐从 249.02 ops/s 降到了 185.01 ops/s。在 30ms 的业务耗时下,网络路径成本并不能完全被业务稀释掉。

场景二:单用户热点竞争(所有操作使用同一个 key)

clientsuccessfailwall(ms)throughput(ops/s)avg(ms)p50(ms)p95(ms)p99(ms)
LockOperator 400 0 13157.19 30.40 251.16 298.22 433.84 499.53
Redis SET NX PX 400 0 15712.72 25.46 273.56 46.70 1260.06 1953.19

400 次操作全部竞争同一个 key,本质上必须排队执行。每次业务固定 30ms,理论下限约 400 * 30ms = 12000ms;LockOperator 实际耗时 13157ms,接近理论下限;Redis 的 15712ms 说明轮询抢锁 + 网络往返把串行队列进一步拉长了。

更值得注意的是尾部延迟:

LockOperator p99 = 499.53ms
Redis SET NX PX p99 = 1953.19ms

Redis 的 p50 只有 46.70ms,看起来比本地锁更好,但 p99 被拉到接近 2 秒。原因是手写 Redis 锁没有 Redisson 那种等待通知机制,只能 sleep 后再重试,"谁醒得早谁抢到"的轮询模型在热点竞争下会带来明显的尾部抖动。不能只看 p50,p99 往往才是用户真正感知的那一次。

结论: Redis 当然不是不能用——项目进入多实例部署之后,本地 LockOperator 就不够用了,因为不同 JVM 实例之间看不到彼此的内存状态,那时候 Redis 或 Redisson 是合理的演进方向。但当前单机阶段,引入 Redis 会提前带来网络 IO、owner 校验、过期时间、Lua 脚本、连接池等一系列额外成本,这些成本在单机部署下并不值得承担。

单机锁为什么一旦多实例部署就会立刻失效,其中的本质原因("绅士协议"与信息可见性边界),在补充篇有完整展开。

这两个实验做完之后,LockOperator 的定位更加清晰了:当前阶段的合理选择,但不是最终守门人。而真正让我意识到"它不是守门人"的,是后面盯着事务边界看时发现的那个坑。


MVCC 原理 & RR 隔离级别:为什么 Java 锁管不住数据库的读版本

反转:锁明明生效了,为什么我还是不敢只相信它?

后来我仔细想了想这个问题:

如果 Java 层锁已经把同一个用户的请求串行化了,那后一个请求一定能读到前一个请求最新写入的数据吗?

表面上看应该是——线程 B 等线程 A 释放 Java 锁之后才进来,A 已经把 used 从 400 改成了 600 并提交,B 再读理论上应该能读到 600。

但 MySQL 的 RR(Repeatable Read)隔离级别不是这样工作的。

RR 下,普通 SELECT 走的是快照读(Snapshot Read)。也就是说,事务会在第一次执行普通 SELECT 时创建一个 ReadView,此后的普通 SELECT 都复用这个 ReadView,沿着版本链往回找,找到"对当前事务可见"的那个版本——而不是每次都读最新的物理行。

这就意味着:即使线程 B 是在线程 A 释放 Java 锁之后才进入业务的,如果线程 B 的事务在 A 还没提交时就已经开启了第一次普通 SELECT,那么 B 的 ReadView 里记录的"创建快照时还在活跃的事务集合"就包含了 A;此后 B 再读这行数据,看到的就是 A 提交之前的旧版本 used=400,而不是 A 提交之后的 used=600。

这就是我觉得最"怪"的地方:Java 锁确实生效了,但它没有自动等价于"后一个事务一定能读到前一个事务的最新提交结果"。

这里只补本文需要的 MVCC 知识

这里不打算把 MVCC 讲成一套完整教材,只补本文需要用到的几个点。

InnoDB 不是每次普通 SELECT 都直接读取最新物理行。为了支持并发事务,它会给一行数据维护版本链。可以先把用户存储空间这行数据理解成下面这样:

#mermaid-svg-JGaUNEr3g4nYTDlC{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JGaUNEr3g4nYTDlC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JGaUNEr3g4nYTDlC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JGaUNEr3g4nYTDlC .error-icon{fill:#552222;}#mermaid-svg-JGaUNEr3g4nYTDlC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JGaUNEr3g4nYTDlC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JGaUNEr3g4nYTDlC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JGaUNEr3g4nYTDlC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JGaUNEr3g4nYTDlC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JGaUNEr3g4nYTDlC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JGaUNEr3g4nYTDlC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JGaUNEr3g4nYTDlC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JGaUNEr3g4nYTDlC .marker.cross{stroke:#333333;}#mermaid-svg-JGaUNEr3g4nYTDlC svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JGaUNEr3g4nYTDlC p{margin:0;}#mermaid-svg-JGaUNEr3g4nYTDlC .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-JGaUNEr3g4nYTDlC .cluster-label text{fill:#333;}#mermaid-svg-JGaUNEr3g4nYTDlC .cluster-label span{color:#333;}#mermaid-svg-JGaUNEr3g4nYTDlC .cluster-label span p{background-color:transparent;}#mermaid-svg-JGaUNEr3g4nYTDlC .label text,#mermaid-svg-JGaUNEr3g4nYTDlC span{fill:#333;color:#333;}#mermaid-svg-JGaUNEr3g4nYTDlC .node rect,#mermaid-svg-JGaUNEr3g4nYTDlC .node circle,#mermaid-svg-JGaUNEr3g4nYTDlC .node ellipse,#mermaid-svg-JGaUNEr3g4nYTDlC .node polygon,#mermaid-svg-JGaUNEr3g4nYTDlC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JGaUNEr3g4nYTDlC .rough-node .label text,#mermaid-svg-JGaUNEr3g4nYTDlC .node .label text,#mermaid-svg-JGaUNEr3g4nYTDlC .image-shape .label,#mermaid-svg-JGaUNEr3g4nYTDlC .icon-shape .label{text-anchor:middle;}#mermaid-svg-JGaUNEr3g4nYTDlC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JGaUNEr3g4nYTDlC .rough-node .label,#mermaid-svg-JGaUNEr3g4nYTDlC .node .label,#mermaid-svg-JGaUNEr3g4nYTDlC .image-shape .label,#mermaid-svg-JGaUNEr3g4nYTDlC .icon-shape .label{text-align:center;}#mermaid-svg-JGaUNEr3g4nYTDlC .node.clickable{cursor:pointer;}#mermaid-svg-JGaUNEr3g4nYTDlC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JGaUNEr3g4nYTDlC .arrowheadPath{fill:#333333;}#mermaid-svg-JGaUNEr3g4nYTDlC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JGaUNEr3g4nYTDlC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JGaUNEr3g4nYTDlC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JGaUNEr3g4nYTDlC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JGaUNEr3g4nYTDlC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JGaUNEr3g4nYTDlC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JGaUNEr3g4nYTDlC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JGaUNEr3g4nYTDlC .cluster text{fill:#333;}#mermaid-svg-JGaUNEr3g4nYTDlC .cluster span{color:#333;}#mermaid-svg-JGaUNEr3g4nYTDlC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JGaUNEr3g4nYTDlC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JGaUNEr3g4nYTDlC rect.text{fill:none;stroke-width:0;}#mermaid-svg-JGaUNEr3g4nYTDlC .icon-shape,#mermaid-svg-JGaUNEr3g4nYTDlC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JGaUNEr3g4nYTDlC .icon-shape p,#mermaid-svg-JGaUNEr3g4nYTDlC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JGaUNEr3g4nYTDlC .icon-shape .label rect,#mermaid-svg-JGaUNEr3g4nYTDlC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JGaUNEr3g4nYTDlC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JGaUNEr3g4nYTDlC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JGaUNEr3g4nYTDlC :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

id:1, used:400, max:666 ← 最新版本

id:1, used:200, max:666

id:1, used:0, max:666

每个版本都带着两个隐藏字段,本文只需要关心:

  • DB_TRX_ID:最后修改这个版本的事务 id;
  • DB_ROLL_PTR:指向上一个旧版本的指针。

普通 SELECT 在 RR 隔离级别下走快照读。事务第一次执行普通 SELECT 时,会创建一个 ReadView;后续普通 SELECT 会复用这个 ReadView,沿着版本链往回找,直到找到一个对当前事务可见的版本。

ReadView 可以粗略理解成"当前事务看世界时的一张名单",它大致关心:

  • m_ids:创建快照时还没提交的活跃事务 id 集合;
  • min_trx_id:活跃事务里的最小事务 id;
  • max_trx_id:下一个即将分配的事务 id(也就是"未来事务"的起点);
  • creator_trx_id:当前事务自己的 id。

这些字段最终都是为了回答一个问题:版本链上的某个版本,对当前事务来说到底可不可见?

用 Java 伪代码理解快照读

把这个可见性判断逻辑翻译成 Java 伪代码,大概是这样:

class DataLine {
long dbTrxId; // 最后一次修改这行数据的事务 id
DataLine rollPointer; // 指向上一个旧版本
}

class ReadView {
Set<Long> activeTrxIds; // 创建快照时还没提交的事务 id 集合
long minTrxId; // activeTrxIds 里的最小事务 id
long maxTrxId; // 下一个即将分配的事务 id
long creatorTrxId; // 当前事务自己的 id
}

public DataLine readVisibleVersion(long userId, ReadView readView) {
DataLine current = DataBaseManager.getLatestVersion("sys_user", userId);

while (current != null) {
long rowTrxId = current.dbTrxId;

// 1. 如果这个版本就是当前事务自己改出来的,自己当然能看到
if (rowTrxId == readView.creatorTrxId) {
return current;
}

// 2. 如果这个版本来自"未来事务"(快照创建时它还不存在),不能看
if (rowTrxId >= readView.maxTrxId) {
current = current.rollPointer;
continue;
}

// 3. 如果这个版本来自很早以前已经提交的事务,可以看
if (rowTrxId < readView.minTrxId) {
return current;
}

// 4. 如果修改这个版本的事务不在活跃集合里,说明快照创建前它已提交,可以看
if (!readView.activeTrxIds.contains(rowTrxId)) {
return current;
}

// 5. 否则这个版本不可见,继续沿版本链往旧版本找
current = current.rollPointer;
}

return null;
}

⚠️ 提示: 此处是作者基于个人对MVCC的理解,写下来为大家便于理解 InnoDB MVCC 可见性判断逻辑的工程简化版伪代码,实际底层实现更为复杂。

这就解释了为什么线程 B 明明拿到了 Java 锁,却仍然可能读到 used=400:它读的不是"当前最新值",而是"当前快照里可见的值"。

下面这张时序图把整个过程画出来:

Threads

JavaLock

InnoDB

事务B

事务A

LockOperator

InnoDB

事务B

事务A

LockOperator

InnoDB

#mermaid-svg-VDaNqNdsPFcRWEEp{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VDaNqNdsPFcRWEEp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VDaNqNdsPFcRWEEp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VDaNqNdsPFcRWEEp .error-icon{fill:#552222;}#mermaid-svg-VDaNqNdsPFcRWEEp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VDaNqNdsPFcRWEEp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VDaNqNdsPFcRWEEp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VDaNqNdsPFcRWEEp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VDaNqNdsPFcRWEEp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VDaNqNdsPFcRWEEp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VDaNqNdsPFcRWEEp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VDaNqNdsPFcRWEEp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VDaNqNdsPFcRWEEp .marker.cross{stroke:#333333;}#mermaid-svg-VDaNqNdsPFcRWEEp svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VDaNqNdsPFcRWEEp p{margin:0;}#mermaid-svg-VDaNqNdsPFcRWEEp .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VDaNqNdsPFcRWEEp text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-VDaNqNdsPFcRWEEp .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VDaNqNdsPFcRWEEp .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-VDaNqNdsPFcRWEEp .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-VDaNqNdsPFcRWEEp .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-VDaNqNdsPFcRWEEp #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-VDaNqNdsPFcRWEEp .sequenceNumber{fill:white;}#mermaid-svg-VDaNqNdsPFcRWEEp #sequencenumber{fill:#333;}#mermaid-svg-VDaNqNdsPFcRWEEp #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-VDaNqNdsPFcRWEEp .messageText{fill:#333;stroke:none;}#mermaid-svg-VDaNqNdsPFcRWEEp .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VDaNqNdsPFcRWEEp .labelText,#mermaid-svg-VDaNqNdsPFcRWEEp .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-VDaNqNdsPFcRWEEp .loopText,#mermaid-svg-VDaNqNdsPFcRWEEp .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-VDaNqNdsPFcRWEEp .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VDaNqNdsPFcRWEEp .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-VDaNqNdsPFcRWEEp .noteText,#mermaid-svg-VDaNqNdsPFcRWEEp .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-VDaNqNdsPFcRWEEp .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VDaNqNdsPFcRWEEp .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VDaNqNdsPFcRWEEp .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VDaNqNdsPFcRWEEp .actorPopupMenu{position:absolute;}#mermaid-svg-VDaNqNdsPFcRWEEp .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-VDaNqNdsPFcRWEEp .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VDaNqNdsPFcRWEEp .actor-man circle,#mermaid-svg-VDaNqNdsPFcRWEEp line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-VDaNqNdsPFcRWEEp :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

当前 used=400, max=666

版本链: v3(400) → v2(200) → v1(0)

生成 v4(600),尚未提交

版本链: v4(600, 未提交) → v3(400) → …

v4(600) 已提交

基于旧快照结果写入,生成 v5(600)

获取 storage:user:1

Success

开启事务 tx_id=100

SELECT used_storage_bytes

快照读 used=400,读取 v3

业务校验 400 + 200 < 666,通过

UPDATE used_storage_bytes = 600

释放 Java 锁

Success

获取 storage:user:1

Success

开启事务 tx_id=101 / 第一次快照读

RR 快照读仍看到 used=400,读取 v3

提交事务 tx_id=100

业务校验 400 + 200 < 666,通过 ← 这里仍然基于旧快照!

UPDATE used_storage_bytes = 600

这个图里最关键的不是 A 和 B 谁先拿到 Java 锁,而是这两个动作并不是同一件事:

  • Java 锁释放:说明 JVM 层的临界区让出来了;
  • 事务提交:说明 InnoDB 层的新版本对其它事务真正可见了。

如果这两个边界没有完全重合,就会出现一种微妙情况:线程 B 已经拿到了 Java 锁,但它的普通 SELECT 仍可能在 RR 快照读里看到旧版本 used=400。


那我为什么不直接让 Java 锁包住事务?

看到这里,一个很自然的问题来了:既然问题是 Java 锁边界和事务边界没有完全对齐,那我为什么不直接让 Java 锁包住整个事务?

理论上最理想的顺序是:先拿 Java 锁 → 再开启事务 → 再执行业务 → 再提交事务 → 最后释放锁。这样 Java 锁边界和事务边界就完全对齐,后续的快照读问题也就不存在了。

但我的业务入口不只有普通上传。麻烦的就是管理员批量删除这类业务,它形成了一个别扭的依赖环:

#mermaid-svg-a0VlY48UeaUBBknP{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-a0VlY48UeaUBBknP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-a0VlY48UeaUBBknP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-a0VlY48UeaUBBknP .error-icon{fill:#552222;}#mermaid-svg-a0VlY48UeaUBBknP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-a0VlY48UeaUBBknP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-a0VlY48UeaUBBknP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-a0VlY48UeaUBBknP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-a0VlY48UeaUBBknP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-a0VlY48UeaUBBknP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-a0VlY48UeaUBBknP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-a0VlY48UeaUBBknP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-a0VlY48UeaUBBknP .marker.cross{stroke:#333333;}#mermaid-svg-a0VlY48UeaUBBknP svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-a0VlY48UeaUBBknP p{margin:0;}#mermaid-svg-a0VlY48UeaUBBknP .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-a0VlY48UeaUBBknP .cluster-label text{fill:#333;}#mermaid-svg-a0VlY48UeaUBBknP .cluster-label span{color:#333;}#mermaid-svg-a0VlY48UeaUBBknP .cluster-label span p{background-color:transparent;}#mermaid-svg-a0VlY48UeaUBBknP .label text,#mermaid-svg-a0VlY48UeaUBBknP span{fill:#333;color:#333;}#mermaid-svg-a0VlY48UeaUBBknP .node rect,#mermaid-svg-a0VlY48UeaUBBknP .node circle,#mermaid-svg-a0VlY48UeaUBBknP .node ellipse,#mermaid-svg-a0VlY48UeaUBBknP .node polygon,#mermaid-svg-a0VlY48UeaUBBknP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-a0VlY48UeaUBBknP .rough-node .label text,#mermaid-svg-a0VlY48UeaUBBknP .node .label text,#mermaid-svg-a0VlY48UeaUBBknP .image-shape .label,#mermaid-svg-a0VlY48UeaUBBknP .icon-shape .label{text-anchor:middle;}#mermaid-svg-a0VlY48UeaUBBknP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-a0VlY48UeaUBBknP .rough-node .label,#mermaid-svg-a0VlY48UeaUBBknP .node .label,#mermaid-svg-a0VlY48UeaUBBknP .image-shape .label,#mermaid-svg-a0VlY48UeaUBBknP .icon-shape .label{text-align:center;}#mermaid-svg-a0VlY48UeaUBBknP .node.clickable{cursor:pointer;}#mermaid-svg-a0VlY48UeaUBBknP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-a0VlY48UeaUBBknP .arrowheadPath{fill:#333333;}#mermaid-svg-a0VlY48UeaUBBknP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-a0VlY48UeaUBBknP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-a0VlY48UeaUBBknP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-a0VlY48UeaUBBknP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-a0VlY48UeaUBBknP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-a0VlY48UeaUBBknP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-a0VlY48UeaUBBknP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-a0VlY48UeaUBBknP .cluster text{fill:#333;}#mermaid-svg-a0VlY48UeaUBBknP .cluster span{color:#333;}#mermaid-svg-a0VlY48UeaUBBknP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-a0VlY48UeaUBBknP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-a0VlY48UeaUBBknP rect.text{fill:none;stroke-width:0;}#mermaid-svg-a0VlY48UeaUBBknP .icon-shape,#mermaid-svg-a0VlY48UeaUBBknP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-a0VlY48UeaUBBknP .icon-shape p,#mermaid-svg-a0VlY48UeaUBBknP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-a0VlY48UeaUBBknP .icon-shape .label rect,#mermaid-svg-a0VlY48UeaUBBknP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-a0VlY48UeaUBBknP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-a0VlY48UeaUBBknP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-a0VlY48UeaUBBknP :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

需要先获取锁

需要等待事务开启

需要等待业务返回用户id集合

获取用户锁

开启事务

执行业务并计算受影响用户

管理员批量删除需要先进入业务逻辑,才能知道到底影响了哪些用户的存储空间;但是获取锁需要先知道影响了哪些用户;而进入业务逻辑又需要先开启事务。

如果为了拿锁,强行要求业务方法在执行前把用户 id 集合暴露给 AOP,那业务代码就必须反过来配合 AOP 的设计,切面开始反向污染业务签名,这违背了用 AOP 做存储校验的初衷。

所以我最后没有继续把 Java 锁设计成绝对正确性的唯一来源,而是把最后的正确性判断下沉到了 SQL。


UPDATE 的"机制怪":它不是继续用快照读来判断

到这里,如果只看普通 SELECT,事情好像已经有点危险了。线程 B 读到旧值 used=400,业务层判断 400 + 200 <= 666,看起来它也会以为自己可以上传。

真正救场的是下面这条 UPDATE:

<update id="updateStorageById">
update sys_user set
used_storage_bytes = GREATEST(used_storage_bytes + #{updateUser.deltaStorageBytes}, 0),
update_time = now()
where id = #{updateUser.id}
AND max_storage_bytes >= used_storage_bytes + #{updateUser.deltaStorageBytes}
</update>

这条 SQL 的关键点在 WHERE max_storage_bytes >= used_storage_bytes + deltaStorageBytes。

它不是拿 Java 前面 SELECT 出来的旧值做最后判断,而是在 UPDATE 执行时,由 MySQL 对当前最新版本重新判断。

这是因为普通 SELECT 是快照读,但 UPDATE / DELETE / SELECT … FOR UPDATE 属于当前读(Current Read)。它们要修改最新数据,所以会去读取当前最新可修改版本,并对目标行加排他锁。

用 Java 伪代码理解:

public int executeUpdateStorage(long userId, long deltaBytes, long trxId) {
// UPDATE 属于当前读,不继续使用普通 SELECT 的 ReadView
// 它会读取当前最新的可修改版本,并对目标行加排他锁
UserDataLine currentLatest = DataBaseManager.lockAndReadLatest(userId);

try {
// 在最新版本上重新判断 WHERE 条件
// 对应 SQL:WHERE max_storage_bytes >= used_storage_bytes + deltaBytes
if (currentLatest.maxBytes < currentLatest.usedBytes + deltaBytes) {
return 0; // affected rows = 0,更新失败
}

// 条件成立,才真正更新 used_storage_bytes
currentLatest.usedBytes = Math.max(currentLatest.usedBytes + deltaBytes, 0);
currentLatest.dbTrxId = trxId;
currentLatest.rollPointer = DataBaseManager.getPreviousVersion(userId);

DataBaseManager.writeNewVersion(currentLatest);
return 1;
} finally {
DataBaseManager.unlock(userId); // 释放当前行的排他锁
}
}

⚠️ 提示: 此处是作者基于个人对MVCC的理解,写下来为大家便于理解 InnoDB MVCC 可见性判断逻辑的工程简化版伪代码,实际底层实现更为复杂。

所以,事务 B 前面的 SELECT 可能看到 used=400,但它真正 UPDATE 时,InnoDB 会用当前读面对最新版本。如果事务 A 已经把 used 改成 600 并提交,那么事务 B 的 WHERE 条件判断就是:

600 + 200 > 666

最终 affected rows = 0,更新失败,超卖被挡住。

用时序图来看:

Threads

JavaLock

InnoDB

事务B

事务A

LockOperator

InnoDB

事务B

事务A

LockOperator

InnoDB

#mermaid-svg-0XnOHO6VoIpoasGZ{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0XnOHO6VoIpoasGZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0XnOHO6VoIpoasGZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0XnOHO6VoIpoasGZ .error-icon{fill:#552222;}#mermaid-svg-0XnOHO6VoIpoasGZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0XnOHO6VoIpoasGZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0XnOHO6VoIpoasGZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0XnOHO6VoIpoasGZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0XnOHO6VoIpoasGZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0XnOHO6VoIpoasGZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0XnOHO6VoIpoasGZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0XnOHO6VoIpoasGZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0XnOHO6VoIpoasGZ .marker.cross{stroke:#333333;}#mermaid-svg-0XnOHO6VoIpoasGZ svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0XnOHO6VoIpoasGZ p{margin:0;}#mermaid-svg-0XnOHO6VoIpoasGZ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0XnOHO6VoIpoasGZ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-0XnOHO6VoIpoasGZ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-0XnOHO6VoIpoasGZ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-0XnOHO6VoIpoasGZ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-0XnOHO6VoIpoasGZ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-0XnOHO6VoIpoasGZ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-0XnOHO6VoIpoasGZ .sequenceNumber{fill:white;}#mermaid-svg-0XnOHO6VoIpoasGZ #sequencenumber{fill:#333;}#mermaid-svg-0XnOHO6VoIpoasGZ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-0XnOHO6VoIpoasGZ .messageText{fill:#333;stroke:none;}#mermaid-svg-0XnOHO6VoIpoasGZ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0XnOHO6VoIpoasGZ .labelText,#mermaid-svg-0XnOHO6VoIpoasGZ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-0XnOHO6VoIpoasGZ .loopText,#mermaid-svg-0XnOHO6VoIpoasGZ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-0XnOHO6VoIpoasGZ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-0XnOHO6VoIpoasGZ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-0XnOHO6VoIpoasGZ .noteText,#mermaid-svg-0XnOHO6VoIpoasGZ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-0XnOHO6VoIpoasGZ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0XnOHO6VoIpoasGZ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0XnOHO6VoIpoasGZ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0XnOHO6VoIpoasGZ .actorPopupMenu{position:absolute;}#mermaid-svg-0XnOHO6VoIpoasGZ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-0XnOHO6VoIpoasGZ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0XnOHO6VoIpoasGZ .actor-man circle,#mermaid-svg-0XnOHO6VoIpoasGZ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-0XnOHO6VoIpoasGZ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

当前 used=400, max=666

新版本 used=600 尚未提交

v4(600) 已提交

UPDATE 使用当前读,重新看最新版本

获取 storage:user:1

Success

开启事务

SELECT used_storage_bytes

快照读 used=400

UPDATE used_storage_bytes = 600

释放 Java 锁

Success

获取 storage:user:1

Success

开启事务 / 第一次快照读

RR 快照读仍可能看到 used=400

提交事务

UPDATE … WHERE max >= used + delta

max(666) < used(600) + delta(200),条件不满足

affected rows = 0

释放 storage:user:1

Success

最终兜底的是这条 WHERE 条件:WHERE max_storage_bytes >= used_storage_bytes + #{deltaStorageBytes}。

它不依赖 Java 层之前 SELECT 出来的旧值,而是在 UPDATE 执行时由 InnoDB 用当前读重新判断。这就相当于在数据库层做了一次 CAS 操作:只有当前最新版本满足条件,才允许写入。


LockOperator 没白写:有锁和无锁的 DB 压力差异

到这里,LockOperator 的定位发生了变化。

它不是最终保证"不超卖"的那个人,而是应用层的前置削峰器。真正兜底正确性的是数据库层的条件更新;但 Java 锁依旧有价值,因为它可以挡掉大量不必要进入数据库的请求。

下面这两张对比图是我最想保留的:

有锁版本

Threads

JavaLock

InnoDB

事务D

事务C

事务B

事务A

Java层锁

InnoDB

事务D

事务C

事务B

事务A

Java层锁

InnoDB

#mermaid-svg-PIbGct4xFN5U6wLR{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PIbGct4xFN5U6wLR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PIbGct4xFN5U6wLR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PIbGct4xFN5U6wLR .error-icon{fill:#552222;}#mermaid-svg-PIbGct4xFN5U6wLR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PIbGct4xFN5U6wLR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PIbGct4xFN5U6wLR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PIbGct4xFN5U6wLR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PIbGct4xFN5U6wLR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PIbGct4xFN5U6wLR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PIbGct4xFN5U6wLR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PIbGct4xFN5U6wLR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PIbGct4xFN5U6wLR .marker.cross{stroke:#333333;}#mermaid-svg-PIbGct4xFN5U6wLR svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PIbGct4xFN5U6wLR p{margin:0;}#mermaid-svg-PIbGct4xFN5U6wLR .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PIbGct4xFN5U6wLR text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-PIbGct4xFN5U6wLR .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-PIbGct4xFN5U6wLR .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-PIbGct4xFN5U6wLR .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-PIbGct4xFN5U6wLR .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-PIbGct4xFN5U6wLR #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-PIbGct4xFN5U6wLR .sequenceNumber{fill:white;}#mermaid-svg-PIbGct4xFN5U6wLR #sequencenumber{fill:#333;}#mermaid-svg-PIbGct4xFN5U6wLR #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-PIbGct4xFN5U6wLR .messageText{fill:#333;stroke:none;}#mermaid-svg-PIbGct4xFN5U6wLR .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PIbGct4xFN5U6wLR .labelText,#mermaid-svg-PIbGct4xFN5U6wLR .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-PIbGct4xFN5U6wLR .loopText,#mermaid-svg-PIbGct4xFN5U6wLR .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-PIbGct4xFN5U6wLR .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-PIbGct4xFN5U6wLR .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-PIbGct4xFN5U6wLR .noteText,#mermaid-svg-PIbGct4xFN5U6wLR .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-PIbGct4xFN5U6wLR .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PIbGct4xFN5U6wLR .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PIbGct4xFN5U6wLR .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PIbGct4xFN5U6wLR .actorPopupMenu{position:absolute;}#mermaid-svg-PIbGct4xFN5U6wLR .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-PIbGct4xFN5U6wLR .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PIbGct4xFN5U6wLR .actor-man circle,#mermaid-svg-PIbGct4xFN5U6wLR line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-PIbGct4xFN5U6wLR :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

当前 used=400, max=666

获取 storage:user:1

Success

开启事务

SELECT used_storage_bytes

快照读 used=400

获取 storage:user:1

Failure,应用层直接拦截

UPDATE … WHERE max >= used + delta

更新成功 used=600

释放 storage:user:1

Success

获取 storage:user:1

Success

获取 storage:user:1

Failure,应用层直接拦截

SELECT used_storage_bytes

RR 快照读可能仍看到 used=400

提交事务

UPDATE … WHERE max >= used + delta

当前读判断失败,affected rows = 0

释放 storage:user:1

Success

无锁版本

Threads

InnoDB

事务D

事务C

事务B

事务A

InnoDB

事务D

事务C

事务B

事务A

InnoDB

#mermaid-svg-htNTeunfLGV6aGxL{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-htNTeunfLGV6aGxL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-htNTeunfLGV6aGxL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-htNTeunfLGV6aGxL .error-icon{fill:#552222;}#mermaid-svg-htNTeunfLGV6aGxL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-htNTeunfLGV6aGxL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-htNTeunfLGV6aGxL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-htNTeunfLGV6aGxL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-htNTeunfLGV6aGxL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-htNTeunfLGV6aGxL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-htNTeunfLGV6aGxL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-htNTeunfLGV6aGxL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-htNTeunfLGV6aGxL .marker.cross{stroke:#333333;}#mermaid-svg-htNTeunfLGV6aGxL svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-htNTeunfLGV6aGxL p{margin:0;}#mermaid-svg-htNTeunfLGV6aGxL .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-htNTeunfLGV6aGxL text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-htNTeunfLGV6aGxL .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-htNTeunfLGV6aGxL .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-htNTeunfLGV6aGxL .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-htNTeunfLGV6aGxL .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-htNTeunfLGV6aGxL #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-htNTeunfLGV6aGxL .sequenceNumber{fill:white;}#mermaid-svg-htNTeunfLGV6aGxL #sequencenumber{fill:#333;}#mermaid-svg-htNTeunfLGV6aGxL #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-htNTeunfLGV6aGxL .messageText{fill:#333;stroke:none;}#mermaid-svg-htNTeunfLGV6aGxL .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-htNTeunfLGV6aGxL .labelText,#mermaid-svg-htNTeunfLGV6aGxL .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-htNTeunfLGV6aGxL .loopText,#mermaid-svg-htNTeunfLGV6aGxL .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-htNTeunfLGV6aGxL .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-htNTeunfLGV6aGxL .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-htNTeunfLGV6aGxL .noteText,#mermaid-svg-htNTeunfLGV6aGxL .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-htNTeunfLGV6aGxL .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-htNTeunfLGV6aGxL .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-htNTeunfLGV6aGxL .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-htNTeunfLGV6aGxL .actorPopupMenu{position:absolute;}#mermaid-svg-htNTeunfLGV6aGxL .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-htNTeunfLGV6aGxL .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-htNTeunfLGV6aGxL .actor-man circle,#mermaid-svg-htNTeunfLGV6aGxL line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-htNTeunfLGV6aGxL :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

当前 used=400, max=666

事务C需要等待事务A释放行锁

事务A释放行锁,放行事务C

开启事务

SELECT used_storage_bytes

快照读 used=400

开启事务

SELECT used_storage_bytes

快照读 used=400

UPDATE … WHERE max >= used + delta

更新成功 used=600

UPDATE … WHERE max >= used + delta

开启事务

SELECT used_storage_bytes

RR 快照读可能仍看到 used=400

提交事务

当前读判断失败,affected rows = 0

开启事务

SELECT used_storage_bytes

读到 used=600,前置校验失败

UPDATE … WHERE max >= used + delta

当前读判断失败,affected rows = 0

有锁时,C 和 D 被挡在应用层,连 DB 请求都不用发;无锁时,B 和 C 产生的 SELECT 和 UPDATE 都会打进 DB,即使最终 UPDATE 被 InnoDB 拦住了,SELECT 请求、行锁等待、事务开启和提交这些开销也已经产生了。

这个例子里只有 4 个线程,区别看起来还不夸张。如果把它放大到 100 个同用户请求,无锁版几乎等于把所有请求都压到数据库;有锁版则会先在应用层做一轮削峰,只放过去真正必要的竞争。


MVCC 分支总结

所以这个并发业务最后不是靠一把锁解决的,而是两层防护:

第一层:LockOperator,在应用层按用户维度串行化,减少无效 DB 请求,让绝大多数"明显不该过"的并发请求在 JVM 里就被挡住,不要白白给数据库制造行锁竞争压力。

第二层:UPDATE … WHERE max_storage_bytes >= used_storage_bytes + deltaStorageBytes,在数据库层用当前读做最后判断,保证即使应用层出现任何边界对齐问题,也不会真正超卖。

这也是我说"手写内存锁最后败给 MVCC"的原因。不是锁完全失败了,而是我一开始高估了它在一致性链路里的位置。

LockOperator 仍然有用,但它不应该被理解成最终守门人。它更像是站在数据库前面的一层前置过滤:能挡住一部分并发洪峰,但最后那句"这次到底能不能写进去",还是要让 InnoDB 在当前读和条件更新里给出答案。


JMeter 正确性压测:这套组合在实际接口下表现如何?

前面讲了 LockOperator 是应用层削峰,UPDATE … WHERE 是数据库层兜底。最后我还是想用真实接口压一次,看看这套组合在并发请求下会不会把用户存储空间写爆。

这次 JMeter 更像是一次正确性压测,而不是严格的 QPS 极限测试。我要看的不是接口最快能跑多少,而是同一个用户被并发上传轰炸时,最终数据库里会不会超过 5 篇笔记。

测试前先弄了一个测试用户,把它的额度限制到最多上传 5 篇笔记。第一篇先用来确认 JMeter 请求配置能正常打到接口,后面再重置账号做两轮并发测试。

轮次JMeter 配置总请求数测试前状态预期结果最终观察
第一次 20 线程 × 5 次循环 100 测试用户最多 5 篇笔记 最多 5 次成功,其余失败 数据库最终卡在 5 篇
第二次 100 线程 × 1 次循环 100 重置测试账号,仍然最多 5 篇 更瞬时的同用户竞争,仍不能超过 5 篇 数据库最终卡在 5 篇

JMeter 配置文件:100 线程上传压测配置(这里只保留了 100 线程那份,因为本地反复测试时覆盖保存了前一份)

从日志里只能看出两件事:大量请求确实同时进入了上传接口;后面大量请求被"存储空间不足"拦了下来。它不能直接证明数据库最终状态一定正确。

真正让我确认没有超额的,是最后直接去数据库里看:测试用户的笔记数量卡在 5 篇,刚好达到存储上限。也就是说,这套组合在这两轮同用户并发上传里,没有把用户存储空间写爆。

所以这一轮 JMeter 对我的意义不是"证明这个接口性能很强",而是给前面那套推理补一个落地验证:LockOperator 先在应用层挡掉一部分无效竞争,数据库更新语句再用条件判断做最后兜底。两层合在一起,才比较接近我想要的结果。


复盘与展望

这篇文章写到最后,我最大的感受反而不是"我终于写出了一把锁",而是:很多问题并不是一开始坐在那儿就能全部想清楚的。

V1 的时候,我关注的是同一个用户多端上传时,检查和执行之间会不会被插队;V2 的时候,我才意识到管理员批量删除这种后台入口其实是在"走后门";到了 V3,我又开始把锁从 ConcurrentHashMap<Long, ReentrantLock> 抽象成更接近分布式锁语义的 key -> owner 协议。

这条路线看起来像是一步步演进,但真实过程没有那么丝滑。很多地方都是先写出来,跑起来,再回头看它哪里别扭。

比如 volatile 那个问题,其实不是我当时写 V1 时就考虑得很完整,而是后来写博客、回看那段 DCL 代码时才意识到:这里很容易让人联想到经典 DCL 单例里的半初始化问题。但再仔细拆开看,我这段代码和经典 DCL 又不完全一样——锁对象是通过 Hashtable.put() 发布到共享集合中的,而 Hashtable 的核心访问方法本身带有 synchronized,所以 put 和后续 get 之间存在 happens-before 关系。这个细节在补充篇里会展开。

还有一个让我被实验教育到的地方,是 V3 的内存表现。我原本以为 LockOperator 会是一个很漂亮的双赢——结果单条 entry 反而比 ReentrantLock 更重。但这个实验真正有价值的地方,是它把我的结论从"对象更小"修正成了"生命周期更短":V3 真正赢的不是单条记录天然更省内存,也不是峰值一定更低,而是业务结束后的残留更少。

以后项目继续往前走,我大概会分三个阶段处理

第一,当前单机阶段,继续保留 LockOperator + SQL 条件更新 这套组合。LockOperator 负责按用户维度做应用层削峰,减少无效 DB 请求;UPDATE … WHERE max >= used + delta 负责在数据库层做最后判断,保证不会超卖。

第二,如果项目真的进入多实例部署,LockOperator 的底层就应该替换成 Redis 或 Redisson。因为多个 JVM 之间看不到彼此的本地内存,单机锁的"共享公告板"会直接失效。但那时候也不能只是把依赖一加就完事,还要认真理解 owner 校验、过期时间、Lua 释放锁、续期机制、Redisson watchdog 这些东西。

第三,如果并发量继续上来,才需要再考虑更复杂的方案,比如预扣款、异步上传、消息队列,或者针对批量删除做死信补偿。它们不是不能做,只是以现在这个项目阶段来看,提前把系统改成那样,维护成本可能会比收益更早到来。

最后的总结

"败给 MVCC"不是说手写锁完全失败了,而是我后来意识到:一把 Java 锁只能解决它所在边界内的问题。真正的并发正确性,往往要看 JVM、事务、SQL 条件更新和业务入口有没有一起对齐。

如果非要给这次经历做一个总结,那大概就是:我一开始以为自己是在写一把锁,最后才发现自己其实是在学习边界。应用层有应用层的边界,数据库有数据库的边界,单机有单机的边界,分布式又是另一套边界。把这些边界看清楚,比单纯把代码写得更复杂,要重要得多。


思考问题

给认真看完的各位留两个问题:

  • 管理员批量删除存不存在可以优化的空间?
  • 如果单纯是讲这里的存储额防超卖问题,Redis 用手写锁还是 Redisson?

可以认真想一想。


系列导航

  • 上篇:业务背景与锁从 V1 到 LockOperator 的完整演进 – 点击跳转
  • 下篇:本文(LockOperator 不是守门人,MVCC + 当前读才是兜底)
  • 补充篇:五个打断主线但忍不住深挖的设计细节(DCL 为什么不一定需要 volatile、单机锁为什么在多实例下失效、LockSupport vs wait/notify 等)

相关链接

  • 掘金精简版(首发):https://juejin.cn/post/7647372847381200922
  • 全文完整版 / 公开笔记广场同名笔记:https://middleware.jacolp.dpdns.org/guest/notes
赞(0)
未经允许不得转载:171主机测评 » 大二的我手写了一把内存锁(下)
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址