大二的我手写了一把内存锁,最后却败给了 MySQL 的 MVCC?
阅读说明:这篇是精简版,主要保留业务背景、锁演进、MVCC 反转、SQL 兜底和最后的工程结论。如果你想看完整代码、实验数据、Redis 对比、JMeter 日志和更细的推导过程,可以看我的网站公开笔记广场里的同名 Markdown 笔记:完整版链接。
我把这版整理成精简版,不是因为完整版里的细节不重要,而是因为这件事本身很容易写散。存储配额、用户锁、批量删除、内存占用、Redis 锁、事务隔离级别、JMeter 压测,每一块都能单独展开。对于第一次读的人来说,如果一上来就被大量代码和实验日志淹没,反而不容易抓到主线。
所以这版会尽量回答一个更核心的问题:为什么一个看起来只是"上传前判断剩余空间够不够"的需求,最后会把我一路带到 Java 锁、MySQL MVCC 和 UPDATE 当前读这里。完整版更像是完整复盘和实验记录,精简版更像是把这条路线重新拉直,让你先看懂问题是怎么一步步变复杂的。
我原本以为,这篇文章会是一篇"我如何手写一把 Java 用户级锁"的记录。
结果写到最后才发现:真正挡住存储空间超卖的,并不是我在 Java 层写出来的那把锁,而是 MySQL 里一条带 WHERE 条件的 UPDATE。
这个结论多少有点反转。因为从业务直觉上看,只要把同一个用户的上传、修改、删除请求都串行化,后一个请求就应该能看到前一个请求留下的最新结果。但继续往事务隔离级别、MVCC、快照读和当前读里挖下去之后,我才意识到:Java 锁只能保证 JVM 层面的进入顺序,不能自动保证事务读到的版本一定是最新提交。
所以这篇文章不是想讲一把多么厉害的锁,而是记录一个存储配额需求,怎么一步步把我推到并发边界面前:先是用户维度锁,再是 LockOperator 抽象,最后是 MySQL UPDATE 当前读 + WHERE 条件做最终兜底。
这次经历对我最大的影响,不是让我多会写一把锁,而是让我开始认真理解"边界"这件事。应用层有应用层的边界,数据库有数据库的边界,事务有事务的边界,单机和分布式也有完全不同的边界。很多时候,代码不是写得越多越安全,而是每一层该负责什么,必须先看清楚。
为什么配额字段会变成并发问题
项目里有一个类似 Obsidian 的笔记系统。用户可以上传 Markdown 笔记,也可以上传图片资源。随着上传、修改、删除这些接口逐渐完善,我又给系统加了一个很现实的限制:每个用户都有自己的存储空间上限。
一开始这个需求看起来很普通。图片表、笔记表里本来就保存了文件大小,用户表里再维护两个字段:
used_storage_bytes:当前已经使用的空间;max_storage_bytes:用户最大可用空间。
上传时先判断剩余空间够不够,操作成功后更新已使用空间;删除时反过来,把释放的空间扣掉。听起来就是一次加减法。
但只要把它放进真实接口,就会发现它不是一个简单字段,而是一份"存储账本"。这份账本一边要给用户展示剩余容量,一边要在上传前做额度判断,还要在删除、修改、批量删除之后保持准确。如果它延迟刷新,用户看到的剩余空间可能是旧的;如果它被并发写坏,资源表和用户表就会开始对不上。
我最开始也想过用定时任务定期扫描图片表和笔记表,重新统计用户占用的空间。这样上传接口里少做一些数据库 IO,看起来性能更好。但这个方案的问题也很明显:上传前配额判断依赖最新值,用户查看剩余空间也依赖最新值。如果靠定时任务延迟修正,那么在两次扫描之间,系统就可能允许用户继续上传已经不该上传的文件。
所以最后我只能选择在强一致要求更高的接口里实时维护用户存储量。文件上传、修改、删除这些操作一旦成功,就同步更新用户表里的 used_storage_bytes。这一步把问题从"定时统计"推到了"并发更新"。
一次上传请求大概可以拆成四步:
- 读取用户当前已使用空间;
- 判断本次文件大小是否还能放得下;
- 执行真正的文件上传和资源记录写入;
- 写回新的用户已使用空间。
单线程下这当然没问题。比如用户当前用了 400MB,最大空间是 666MB,本次上传 200MB。第一个请求上传后变成 600MB,第二个请求再进来时发现 600 + 200 > 666,于是被拦住。
真正的问题出在多端上传。
如果同一个用户同时在两个浏览器窗口上传文件,两个请求都可能先读到 used_storage_bytes = 400。它们各自判断 400 + 200 <= 666,都会认为自己可以上传。等两个业务都执行完,再分别更新用户表,就可能出现"检查时都合法,落库后却超额"的情况。
更麻烦的是,如果两个线程都基于旧值写回 600MB,用户表里看起来甚至没有超额,但图片表、笔记表或者对象存储里实际已经写入了 800MB 的资源。也就是说,问题不只是超卖,还可能造成"用户表缓存值"和"真实资源占用"不一致。
这个问题本质上就是检查-执行窗口:检查发生在前,真正写入发生在后。中间只要允许同一个用户的另一个存储变更请求插进来,前一次读取到的数据就可能变成过期数据。
可以用一个简化时序图理解这个窗口:
这里真正需要保护的不是某个 Java 对象,而是一整段复合流程。只要这段流程允许同一个用户的另一个存储变更请求插进来,就可能产生过期判断、覆盖写入和账本不一致。
为什么需要按 userId 加锁
我需要的不是一把全局锁。
用户 A 上传文件时,不应该阻塞用户 B 上传文件;用户 A 修改自己的头像、昵称,也不应该被存储空间锁影响。真正需要互斥的,是"同一个用户的存储空间变更流程"。
所以锁粒度应该收敛到用户维度。可以把锁资源设计成类似这样的 key:
java
String key = "storage:userId:" + userId;
只要操作会影响这个用户的存储占用,比如上传、修改、删除、批量删除,就必须围绕同一个 storage:userId 竞争同一把锁。这样用户 A 的存储变更会串行执行,但用户 A 和用户 B 之间仍然可以并发。
这个粒度其实是一个工程取舍。锁太粗,会影响整个系统吞吐;锁太细,又保护不住真正的业务不变量。这里的不变量是"同一个用户的存储空间账本不能被并发写坏",所以锁落在 userId 上比较自然。
后面还有一个容易被忽略的入口:管理员批量删除。
如果只有普通上传接口走存储校验和加锁逻辑,而管理员批量删除绕过这套流程,直接去删除资源表、更新用户表,那系统里就出现了两个入口。一个入口遵守锁协议,另一个入口绕过锁协议。只要前台用户上传和后台管理员删除同时发生,就可能把用户存储账本写乱。
举个例子,用户原本占用 500MB,前台同时上传一个 100MB 文件,后台管理员批量删除这个用户的 200MB 资源。正确结果应该是 500 + 100 - 200 = 400MB。但如果上传线程最后写回 600MB,删除的扣减就丢了;如果删除线程最后写回 300MB,上传新增的占用又丢了。
所以后来的设计里,我把上传、修改、删除、批量删除这些会影响用户存储空间的操作,都收敛到同一套处理逻辑里。锁保护的不是某个 Java 对象,而是这一整段跨越"读 DB、执行业务、写 DB"的复合流程的原子性。
为什么我没有一开始就只靠数据库行锁
看到这里,可能会有一个很自然的问题:既然最后还是要靠数据库兜底,那为什么一开始不直接用数据库行锁,比如 SELECT ... FOR UPDATE?
这个问题我当时也想过。数据库行锁当然有它的价值,它直接作用在数据行上,比 Java 内存锁更接近最终数据。但放到这个业务里,我没有一开始就把它作为主线,主要是因为我想保护的并不是单次"读取用户表"这个动作,而是一整段存储变更流程。
上传接口里,真正耗时的不只有查用户表。它还包括文件处理、资源记录写入、笔记或图片数据落库、最后更新用户存储量。删除和批量删除也类似,前面要先处理资源数据,后面才能知道到底释放了多少空间。如果在很早的位置就把数据库行锁拿住,就可能让这把行锁陪着文件 IO、业务计算、批量处理一起等待。
这对数据库的并不友好。
所以我更希望的是:同一个用户的存储变更先在应用层排一排,不要所有请求都冲进数据库抢同一行。应用层用户锁的粒度是 storage:userId,它只对存储相关流程生效,不影响其他用户,也尽量不影响同一个用户的非存储操作。
当然,这并不是说数据库锁不重要。后面我真正认识到的是,Java 锁和数据库条件更新不是二选一。Java 锁负责把一部分竞争挡在应用层,数据库 UPDATE 负责最后的正确性判断。前者更像是削峰,后者才是底线。
如果只靠 Java 锁,不理解事务可见性,就可能被 RR 快照读绕晕;如果只靠数据库兜底,不做任何应用层削峰,高并发下又会把大量无效请求压到数据库。当前这个项目阶段,我最后选择的是两层配合,而不是让其中一层假装自己能解决所有问题。
V1:Hashtable + ReentrantLock
第一版很朴素:用一张 Hashtable<Long, ReentrantLock> 保存"用户 ID -> 用户锁"的映射。每个用户对应一把 ReentrantLock,同一个用户的存储变更请求先拿锁,再进入校验和更新流程。
这个版本的核心想法很简单:不同用户拿不同锁,同一个用户拿同一把锁。只要这个映射关系是正确的,用户 A 的上传不会阻塞用户 B,但用户 A 自己的多个上传请求会排队执行。
但这里有两个问题。
第一,Hashtable 的同步粒度比较粗。我的业务锁粒度虽然已经收敛到了用户 ID,但访问锁表本身仍然有集合级别的同步开销。多个用户明明不应该互相影响,却可能在访问锁表时被同一个同步入口拖住。
第二,创建用户锁这个动作本身也是复合操作。如果两个线程同时发现锁表里没有当前用户的锁,就可能各自创建一把不同的 ReentrantLock。这样同一个用户反而拿到两把锁,串行化直接失效。
所以 V1 需要额外小心"获取锁对象"这个步骤。当时我用了类似 DCL 的思路:先查一次,没有再进总锁,进去之后再查一次,确认还是没有才创建。它能工作,但整体形态已经有点别扭。为了维护用户级锁,我额外引入了一把保护锁表创建过程的总锁。
写完之后再回头看,V1 的问题不是"功能一定不正确",而是它把太多细节暴露出来了:集合本身的同步、锁对象创建、外层总锁、后续清理。业务要的只是"同一个用户串行",实现却开始被锁表生命周期拖着走。
V2:ConcurrentHashMap + computeIfAbsent
第二版最直接的改动,就是把 Hashtable<Long, ReentrantLock> 换成 ConcurrentHashMap<Long, ReentrantLock>,并用 computeIfAbsent 原子地创建用户锁:
java
private final ConcurrentHashMap<Long, ReentrantLock> userStorageLockMap =
new ConcurrentHashMap<>();
private ReentrantLock getLock(Long userId) {
return userStorageLockMap.computeIfAbsent(userId, k -> new ReentrantLock());
}
这一步解决了锁表入口过粗的问题,也不需要再手写 DCL。computeIfAbsent 可以保证同一个 userId 对应的锁只会被创建一次。原来那把保护"创建用户锁"的总锁,也就没有必要继续存在。
但 V2 真正重要的改动,不只是换了一个 Map。
这个版本里,我把所有会影响用户存储空间的入口,都尽量收敛到同一套处理模型里。上传和修改属于增加空间占用,需要前置校验;删除和批量删除属于释放空间,不需要判断剩余容量是否足够,但仍然要保证扣减用户存储量这个动作不能和同一个用户的上传、修改并发写坏。
批量删除尤其麻烦。管理员一次删除的资源可能属于多个用户,所以不能只拿一把锁,而是要先按用户维度汇总出本次释放的空间。比如得到一个 Map<Long, Long>,key 是 userId,value 是这个用户本次被删除文件释放的字节数。然后再按 userId 批量获取锁。
这里我没有选择无限等待。原因也很现实:如果某个用户正在高频上传,管理员批量删除一直等锁,后台接口可能被拖到超时。反过来,如果完全不等锁,又会重新出现"绕过锁更新"的问题。
所以 V2 采用的是限时获取:批量删除最多等待一段时间,如果所有用户锁都能拿到,就继续批量更新用户存储量;如果中途有任何一把锁拿不到,就释放已经拿到的锁,然后抛异常回滚,让前端提示"系统繁忙,请稍后重试"。
这不是一个最极致的高可用方案,但它符合当前项目阶段。不引入消息队列,不做过重的异步补偿,而是在单机项目里先把一致性边界守住。
不过 V2 还有一个让我不太舒服的地方:每个活跃用户都会在 Map 里留下一把真实的 ReentrantLock。
ReentrantLock 不是一个简单的布尔值,背后依赖 AQS,会维护同步状态、等待队列等结构。如果系统里有很多用户访问过存储相关接口,Map 里就会长期驻留大量锁对象。即使用户后面很久不再上传,这些锁也要等定时清理任务去扫描、尝试加锁、移除,再交给 GC 回收。
所以 V2 的问题不是"功能不正确",而是"生命周期太重"。我只是想表达"这个用户当前是否有人正在改存储空间",却给每个来过的用户都留下了一把完整的锁对象,还要靠定时任务做清理。
这个问题把设计推到了第三版:我真的需要为每个用户都保存一把完整的 AQS 锁吗?
V3:LockOperator,把锁从对象变成协议
其实不一定需要一把真实的 ReentrantLock。
锁的本质,是一套大家共同遵守的占用协议。进入临界区之前,先尝试占有某个信号;占有成功才能执行;占有失败就等待或返回;执行结束后释放这个信号。
放回当前业务里,storage:userId:1 就是锁资源,owner 就是当前请求的持有者。只要 LOCK_MAP 里还没有这个 key,就说明当前没有请求正在操作该用户的存储空间。某个请求通过 putIfAbsent(key, owner) 写入成功,就表示拿锁成功。
于是 V3 变成了更接近 Redis 分布式锁语义的 LockOperator。核心加锁和释放逻辑是这样:
java
private static final ConcurrentHashMap<String, String> LOCK_MAP =
new ConcurrentHashMap<>();
public boolean tryLock(String key, String owner) {
return LOCK_MAP.putIfAbsent(key, owner) == null;
}
public boolean releaseLock(String key, String owner) {
return LOCK_MAP.remove(key, owner);
}
putIfAbsent(key, owner) 类似 Redis 里的 SET NX:如果 key 不存在,就写入当前 owner,表示拿锁成功;如果 key 已经存在,就说明锁被其他请求持有。
释放锁时不能直接按 key 删除,而是必须校验 owner。remove(key, owner) 是原子的,只有当 key 当前对应的 owner 正好等于传入 owner 时,删除才会成功。这样可以避免一个请求误删另一个请求刚刚获得的锁。
它的流程大概可以理解成这样:
这个设计还有一个现实好处:业务层不再直接依赖 ReentrantLock。它只需要面对 tryLock、releaseLock、tryLockBatch 这一类统一 API。当前项目还是单机部署,所以底层先用 Java 内存实现;如果以后真的进入多实例部署,再把底层替换成 Redis 或 Redisson,业务代码不用大面积重写。
这里所谓"从对象变成协议",对我来说是一个很重要的变化。
在 V1 和 V2 里,我脑子里想的是"某个用户应该有一把锁"。于是代码自然会变成维护一张用户到锁对象的映射表:用户来过,就给他留一把 ReentrantLock;用户以后可能还会来,这把锁就先留着;什么时候清理,再靠额外任务兜一下。这种写法很好理解,但它把"互斥语义"和"锁对象生命周期"绑得太紧了。
而 LockOperator 换了一个视角:我真正关心的不是这个用户有没有一把常驻对象,而是此刻有没有请求声明自己正在处理这个用户的存储账本。只要大家都遵守同一个 key、同一个 owner 校验、同一个释放规则,那么一条 key -> owner 记录就足够表达"当前被占用"。请求结束后记录删除,锁状态也就消失了。
这个抽象还有一个隐含收益:它让失败语义更明确。拿不到锁时,业务可以选择立即失败,也可以等待一小段时间;批量删除拿多把锁时,可以规定"全部拿到才继续,任意一把失败就释放已拿到的锁并回滚"。这些规则如果散落在各个业务方法里,很容易出现某个入口忘记释放、某个入口没有 owner 校验、某个入口绕过等待策略。收敛成 LockOperator 以后,至少所有存储变更入口都在说同一种语言。
当然,协议化不代表它已经是分布式锁。当前实现的公告板仍然是 JVM 内存,多个实例之间看不到彼此。但因为业务层面对的是协议 API,而不是某个具体 ReentrantLock 对象,后续替换底层时,迁移边界会清楚很多。到时候要替换的不是所有上传、删除、批量删除业务,而是 LockOperator 对"占用、等待、释放、owner 校验"这套协议的实现。
这里我没有直接上 Redis 或 Redisson,原因也很朴素:当前项目还在单机阶段,为这把锁引入 Redis 会多一次网络 IO,也会提前带来过期时间、误删、续期、watchdog 等机制成本。不是 Redis 不合理,而是它更适合系统进入多实例部署之后再作为下一阶段演进。
如果项目真的进入多实例部署,本地 JVM 里的 LockOperator 就不够用了。因为不同实例之间看不到彼此的内存状态。那个时候 Redis 或 Redisson 才是合理的演进方向。但在当前单机阶段,我更愿意先把锁语义抽象干净,而不是提前把系统复杂度拉满。
LockOperator 的优势不是单条记录更轻
我一开始也以为 LockOperator 会在内存上全面优于 ConcurrentHashMap<Long, ReentrantLock>。但实验结果反而修正了这个想法。
只看单条锁记录,LockOperator 并没有更轻。因为它保存的是 String key 和 String owner,还要维护 ConcurrentHashMap<String, String> 这种结构,单条记录甚至可能比原来的 ReentrantLock 映射更重。
真正有价值的是业务生命周期。
ConcurrentHashMap<Long, ReentrantLock> 更像是"给每个来过的用户都留一把专属锁"。只要用户访问过存储接口,这把锁就会留在 Map 里,直到后续定时任务清理。
LockOperator 更像是"请求来了临时登记,业务结束后立即注销"。锁释放时,key -> owner 映射直接被删除。
所以实验最后给我的结论不是"我的锁单条记录更小",而是:它的优势在于生命周期更短、释放后残留更少,长期驻留更可控。不要只问"单个对象有多大",还要问"它会在内存里停留多久"。
这也是一次比较典型的工程认知修正。刚开始我会很自然地想证明"新方案比旧方案更轻"。但实验并没有完全支持这个直觉。最后得到的结论反而更细:新方案不是对象更小,而是对象消失得更快。
对于上传、删除这种锁持有时间通常较短的业务来说,这个差异很重要。长期运行的服务里,残留对象的生命周期有时候比单个对象大小更值得关注。
Java 锁为什么不是最终兜底
写到这里,我一开始以为问题已经解决了。
同一个用户的上传、修改、删除都被 LockOperator 按 storage:userId 串起来了。后一个请求必须等前一个请求释放锁之后才能继续执行。按照这个直觉,线程 B 进来时,线程 A 的修改应该已经完成了,B 应该能读到最新数据。
但后来我发现,这个直觉并不总是成立。
Java 锁控制的是 JVM 层面的进入顺序,事务能看到哪个数据版本,则是 InnoDB 根据隔离级别和 MVCC 决定的。在 MySQL 的 RR 隔离级别下,普通 SELECT 走的是快照读。事务第一次执行普通快照读时会创建 ReadView,后续普通 SELECT 会沿着版本链找一个对当前事务可见的版本。这个版本不一定是物理上的最新行。
所以可能出现一种很别扭的情况:
- 线程 A 拿到 Java 锁;
- A 开启事务,普通
SELECT读到used=400; - A 执行
UPDATE,把 used 改成600,但事务还没提交; - A 先释放 Java 锁;
- 线程 B 拿到 Java 锁;
- B 开启事务,普通
SELECT在 RR 快照读下仍可能看到旧版本used=400; - A 事务提交;
- B 继续基于自己前面读到的
400做业务判断。
这里最关键的是:Java 锁释放和事务提交不是同一件事。Java 锁释放,说明 JVM 层面的临界区让出来了;事务提交,说明 InnoDB 层的新版本对其他事务真正可见了。如果这两个边界没有完全重合,后一个线程即使拿到了 Java 锁,也可能在普通快照读里看到旧版本。
这个地方容易误解,是因为我们平时写 Java 并发时,锁的边界通常就是共享状态的边界。线程 A 在锁里改完一个对象,释放锁;线程 B 再拿到同一把锁,通常就能看见 A 改过的结果。这个直觉在单 JVM 内部是很有用的,因为锁本身就参与了内存可见性保证。
但数据库不是 Java 堆里的一个普通对象。业务线程拿到锁,只代表它获得了进入某段 Java 代码的资格;事务什么时候创建快照、什么时候提交、提交后的版本什么时候对别的事务可见,是 InnoDB 自己的规则。尤其 Spring 事务和 AOP 切面叠在一起时,如果没有特别注意顺序,很容易以为"方法退出了、锁释放了、数据库也一定提交了"。事实上这几个动作可能不是同一个瞬间完成的。
这也是我后来不再把 Java 锁当成最终裁判的原因。它非常适合做入口层面的串行化和削峰,但它不应该独自承担"存储空间绝对不能超卖"这种底线。只要最终事实落在数据库里,最后的条件判断就应该尽量靠近数据库,而不是完全相信业务层前面读到的某个变量。
可以用一个简化时序图表示这个错位:
那为什么不直接让 Java 锁完整包住事务?
理论上可以:先拿锁,再开启事务,执行业务,提交事务,最后释放锁。这样锁边界和事务边界就完全对齐。但我的业务入口不只有普通上传。管理员批量删除这类操作,需要先进入业务逻辑,才能知道本次到底影响了哪些用户。如果为了提前拿锁,强行要求业务方法在执行前把用户 id 集合暴露给 AOP,就会让业务代码反过来配合切面设计。
我原本用 AOP 是为了让业务尽量不感知存储空间校验。如果为了锁边界,又要求业务提前告诉切面"我这次会影响哪些用户",这个抽象就开始反向污染业务签名了。
所以我最后没有继续把 Java 锁设计成绝对正确性的唯一来源,而是把最后的正确性判断下沉到了 SQL。
UPDATE 当前读 + WHERE 条件如何挡住超卖
真正兜底的是这条更新语句:
xml
<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>
关键点在最后这个条件。它不是拿 Java 前面 SELECT 出来的旧值做最后判断,而是在 UPDATE 执行时,由 MySQL 对当前最新可修改版本重新判断。
普通 SELECT 在 RR 下可能是快照读,但 UPDATE 属于当前读。它要修改数据,就必须读取当前最新的可修改版本,并对目标行加锁。也就是说,即使线程 B 前面的普通 SELECT 看到的是 used=400,真正执行 UPDATE 时,InnoDB 仍然会基于当前最新版本判断 WHERE 条件。
如果线程 A 已经把 used 改成 600 并提交,那么线程 B 的 UPDATE 会面对最新的 used=600,判断 600 + 200 > 666。条件不满足,affected rows = 0,更新失败,超卖被挡住。
这就是我后来觉得 UPDATE 很关键的地方。它不是继续拿业务层前面读出来的值做判断,而是在数据库当前读语义下重新判断一次条件。这个判断发生在离数据最近的位置,也更适合作为最后兜底。
这个过程可以继续用时序图表示:
这就是我后来理解的两层防护:
LockOperator在应用层按 userId 做前置削峰,减少同用户请求同时打进数据库;UPDATE ... WHERE max >= used + delta在数据库层用当前读做最后判断,保证不会超卖。
所以 LockOperator 没白写。它不是最终正确性的兜底者,但它能减少无效 DB 请求,把一部分竞争挡在应用层。最终的"空间不能超额"这类底线,仍然应该交给离数据最近的地方来守。
如果完全没有 Java 锁,数据库层的条件更新仍然可以挡住超卖,但更多并发请求会直接打到数据库。它们可能先做普通 SELECT,再排队竞争 UPDATE 行锁,最后被 WHERE 条件拒绝。正确性还在,但压力已经产生了。
有了 Java 层用户锁之后,一部分同用户并发请求可以在应用层被限时阻塞或直接返回"系统繁忙"。数据库只处理真正进入临界区的那部分请求。这个定位比"Java 锁保证绝对正确"更准确:它是前置削峰器,不是最终裁判。
这里可以再换一种说法。
前置校验解决的是"体验"和"压力"问题。比如用户已经快没有空间了,应用层先读一次当前存储信息,可以尽早告诉用户空间不足,也可以避免明显不可能成功的请求继续往后走。按 userId 加锁之后,同一个用户的请求不会一股脑全部冲进数据库,至少能让业务入口有一个更可控的排队点。
但前置校验不能当成最后事实。因为它读到的数据可能会受到事务隔离级别、提交时机、快照可见性的影响。尤其在 RR 隔离级别下,普通 SELECT 看到的是某个 ReadView 下可见的版本,而不是一句"永远最新"的承诺。
最后的落库更新解决的是"正确性"问题。UPDATE 当前读会面对当前最新可修改版本,并在同一条 SQL 里完成条件判断和更新。只要 WHERE 条件不满足,就不会更新成功。这个判断离数据最近,也最不容易被业务层旧变量绕开。
所以我后来给这套逻辑重新定位之后,心里反而更踏实:应用层锁不是为了替代数据库,而是为了让数据库少承受一些没有必要的竞争;数据库条件更新不是为了替代业务判断,而是为了给业务判断加最后一道不可绕开的门。
快照读和当前读的边界
这次让我印象最深的,其实是快照读和当前读这条边界。
我以前很容易把"加锁串行"和"读到最新"放在一起理解。既然线程 B 已经等线程 A 释放锁了,那 B 进入业务时,当然应该看到 A 的结果。这个直觉在普通 Java 对象里往往没问题,但一旦中间隔着数据库事务,就不能这么想。
在 InnoDB 里,普通 SELECT 在 RR 隔离级别下通常是快照读。它看的是当前事务 ReadView 里可见的版本。这个机制的好处是读请求不用总是阻塞写请求,事务也能获得相对稳定的一致性视图。但它也意味着:普通 SELECT 不一定等价于"读取当前物理最新行"。
而 UPDATE 不一样。它要修改数据,就不能只活在旧快照里。它需要读取当前最新的可修改版本,并对目标行加锁,然后再判断 WHERE 条件是否满足。也正是因为这一点,前面普通 SELECT 读到旧值,并不代表最后 UPDATE 一定会按旧值成功写入。
这就是我觉得它"机制怪"的地方,也是这个业务最后没有超卖的关键。线程 B 前面可能基于旧快照以为自己还能上传,但到了 UPDATE ... WHERE max >= used + delta 这一步,MySQL 会基于当前读重新判断。如果最新 used 已经变成 600,本次再加 200 超过 666,那更新就失败。
理解这条边界之后,再回头看 Java 锁,它的位置就清楚多了。它能把同一个 JVM 里的同用户请求排起来,减少并发打库;但它不能代替 InnoDB 决定版本可见性,也不能替代 SQL 条件更新做最终判断。
这也是我这次复盘里最重要的认知变化:并发正确性不是"某一把锁很强"就能解释完的。它往往是很多层机制叠在一起的结果,每一层只能保证自己边界内的事情。
JMeter 压测验证了什么
后面的 JMeter 压测,我没有把它当成一场性能比赛。
它真正验证的是两件事,也只验证这两件事。
第一,LockOperator 的限时阻塞确实能工作。同一个用户并发上传时,一部分请求会在应用层等待锁;如果超出等待时间,就返回"系统繁忙,请稍后"。这说明用户级锁没有把所有压力直接放进数据库。
第二,数据库层的条件更新确实能兜底。当存储空间达到上限后,后续请求会被"存储空间不足"拦下。最后直接看数据库,测试用户的笔记数量卡在上限内,没有把用户存储空间写爆。
但它不能证明"这套实现从此没有并发问题"。JMeter 本地压测更像是一次场景验证:我构造了同一个用户高并发上传、存储空间接近上限的情况,然后观察请求是否会被应用层限流、最终数据库状态是否仍然停在合理范围内。它可以帮我发现明显的竞态,也可以增强我对这条链路的信心,但它不能覆盖所有生产环境变量。
比如它不能证明多实例部署下本地锁仍然有效,因为多个 JVM 之间没有共享 LOCK_MAP;它也不能证明所有批量删除、修改笔记、上传图片的边界都已经被完整覆盖,因为压测脚本只覆盖了我当时构造的上传场景;它更不能直接说明吞吐量已经达到什么线上标准,因为本地机器、网络、数据库配置、文件大小和对象存储条件都会影响结果。
所以这轮压测对我的意义不是证明接口吞吐量有多强,而是验证前面的推理能落地:应用层锁负责限时阻塞和削峰,数据库条件更新负责最后兜底。两层合在一起,才比较接近我想要的结果。
换句话说,JMeter 在这里不是给系统发一张"并发安全毕业证",而是帮我确认一个更朴素的事实:当同一个用户的一堆请求同时冲进来时,系统没有只靠运气维持正确性。请求会先在应用层被排队或拒绝,真正落到数据库时还会再经过一次当前读条件判断。这个结果和前面的机制分析是对得上的,这就已经是一次有价值的验证。
这次复盘给我的工程结论
这次经历最有价值的地方,不是我写出了一个 LockOperator,而是我更清楚地看到了边界。
第一,锁要先想清楚保护的到底是什么。这里保护的不是上传接口本身,也不是某个 Java 对象,而是同一个用户的存储空间变更流程。锁粒度应该落到 storage:userId,不能粗到全局,也不能漏掉管理员批量删除这种后台入口。
第二,所有会改同一份账本的入口,都必须遵守同一套协议。前台上传、前台删除、后台批量删除,本质上都在修改用户存储账本。如果其中一个入口绕过锁和更新逻辑,系统迟早会出现对不上的账。
第三,抽象不是为了显得高级,而是为了隔离变化。LockOperator 的价值不只是把代码包起来,而是把业务层从具体的 ReentrantLock 里解放出来。今天底层是本地内存,明天如果要换成 Redis 或 Redisson,业务层至少不用跟着大改。
第四,实验结论要尊重事实。LockOperator 并不是单条记录更轻,它的优势是生命周期更短、释放后残留更少。这个结论比"我写的锁更省内存"更不漂亮,但也更接近真实工程。
第五,应用层锁不是数据库正确性的替代品。Java 锁能控制 JVM 内的进入顺序,但它不能替 InnoDB 决定事务读到哪个版本。普通 SELECT 的快照读、UPDATE 的当前读、事务提交时机,这些都会影响最终结果。
第六,最终兜底要放在离数据最近的地方。对存储配额来说,最关键的不是前面 Java 判断了多少次"空间够不够",而是最后落库时必须有一条不可绕过的条件更新。WHERE max_storage_bytes >= used_storage_bytes + delta 才是挡住超卖的最后一道门。
所以"败给 MVCC"不是说这把 Java 锁完全失败了。更准确地说,是我后来意识到:一把锁只能解决它所在边界内的问题。真正的并发正确性,往往要看 JVM、事务、SQL 条件和业务入口有没有一起对齐。
如果以后项目继续往前走,我大概会分阶段处理这件事。当前单机阶段,继续保留 LockOperator + SQL 条件更新 这套组合。LockOperator 做应用层削峰,SQL 条件更新做数据库兜底。如果项目进入多实例部署,再把 LockOperator 底层替换成 Redis 或 Redisson。到那时还需要认真处理 owner 校验、过期时间、释放脚本、续期机制这些分布式锁问题,而不是简单加一个依赖就结束。
再往后,如果并发量继续上来,才需要考虑更复杂的方案,比如异步上传、补偿任务、消息队列等。但以当前项目阶段来看,提前把系统改成那样,维护成本可能会比收益更早到来。
我一开始以为自己是在写一把锁,最后才发现自己其实是在学习边界。应用层有应用层的边界,数据库有数据库的边界,单机有单机的边界,分布式又是另一套边界。把这些边界看清楚,比单纯把代码写得更复杂,要重要得多。
我在最后留下一个问题,这个是我写完这篇博客之后想到的:
- 现在到了最后的版本,管理员批量删除接口有没有什么优化空间?
如果你想继续看完整版,可以从这里进:完整版链接。
完整版会跳到我网站的公开笔记广场,里面是同名 Markdown 笔记。那一版保留了更多原始推导:LockOperator 的完整代码、内存实验、Redis 对比、JMeter 日志、以及我当时为什么一步步修正结论的过程。精简版适合先看主线,完整版适合继续追细节。