MVCC、隔离级别与锁
余额查询要求历史自洽,扣款要求当前余额非负,医生排班要求至少一人在岗。三者保护的对象不同,不能用一句“RR 有快照”解决。本文从同一行的版本选择走到索引锁,再推演跨行写偏差。
固定源码 mysql/mysql-server · mysql-8.0.39 / InnoDB。2026-10-23已用本机 MySQL Community 8.4.7 运行双会话,8.0.39镜像仍未运行;实验协议提供具体输入、交接点、预期与观察 SQL;Python 推演只验证模型。
先区分历史读取与当前裁决
查询账户余额时,普通 SELECT 希望得到自洽的历史状态;扣款时,系统需要阻止两个请求把同一份余额花两遍。这是两个任务:MVCC 用历史版本降低读写互斥,锁与条件写维护当前不变量。把“读不加锁”理解成“事务不需要锁”,就会在先查后改、预约名额、唯一编号生成中丢失更新。
InnoDB 聚簇记录包含最近修改事务的 DB_TRX_ID 和指向 undo 的 DB_ROLL_PTR;没有可用聚簇键时另有隐藏 row ID。undo 不一定存整行副本,读取历史版本时根据记录的旧字段等信息重建。插入前没有这行,沿版本链走到空表示该快照中行不存在;删除也不是立刻让所有历史读失去此行,而先做删除标记,再由 purge 在安全条件下清理。依据:InnoDB 多版本。
假设同一行依次由 T40、T60、T80 修改,当前页上是 T80 的版本。快照读不是先判断当前页是否脏,也不是只找“最近提交值”;它先问 T80 是否对自己的 ReadView 可见,不可见才重建 T60,再检查 T40。页是否刷盘与版本可见性是两个维度,提交后的记录也可以仍在内存页中。
扣款不能执行“普通读100→应用减80→无条件写20”:两个请求都可能把旧100写成20,实际卖出160却只扣80。使用 UPDATE ds_account SET balance=balance-80,version=version+1 WHERE id=1 AND balance>=80 并立即检查 ROW_COUNT();它将谓词与修改放在同一记录的当前裁决中。跨行转账还需同一事务、统一加锁顺序与流水唯一约束。
ReadView 的建立与可见性
在 read0read.cc 的 ReadView::prepare 中,视图记录创建者事务 ID、当时活跃读写事务集合 m_ids、创建时下一事务 ID 边界 m_low_limit_id,以及最小活跃 ID m_up_limit_id;活跃集合为空时后两者相同。名字容易误导,up 对应数值较小边界,low 对应数值较大边界,口述时最好直接说“最小活跃 ID”和“创建时未来边界”。
read0types.h 的 changes_visible 可精简为:
// 据 mysql-8.0.39 精简改写,省略断言与事务 ID 合法性检查。
if (id < m_up_limit_id || id == m_creator_trx_id) return true;
if (id >= m_low_limit_id) return false;
if (m_ids.empty()) return true;
return !binary_search(m_ids.begin(), m_ids.end(), id);以创建者 70、视图活跃集合 {60, 80}(copy_trx_ids 排除创建者)、未来边界 90 为教学输入:T40 可见;T60 不可见;T70 自己的修改可见;T75 不在活跃集合中,且小于 90,可见;T80 不可见;T95 即使现在已提交,也因视图建立时尚属未来而不可见。该集合只是手算模型,不声称事务 ID 分配与业务请求一一对应。只读事务不一定分配普通读写事务 ID,不能拿连接 ID 代替它。
事务 ID 的顺序近似体现分配顺序,不代表提交顺序。T60 可以比 T75 更晚提交,活跃集合正是为这类交错保留的信息。视图不会因 T60 后来提交就把它从自己的历史集合删掉,否则 RR 的同一快照将不再稳定。
普通 BEGIN 不等于立即创建一致性视图。RR 下首次一致性读通常创建视图并复用;RC 下每次一致性读使用新视图;START TRANSACTION WITH CONSISTENT SNAPSHOT 在适用的 RR 语义下提前建立快照。自己修改过的行则可能与其他行的历史版本共同出现,形成数据库从未整体存在过的组合,因此不能把 RR 宣称为“事务内所有语句始终读同一时刻”。依据:一致性非锁定读。
原码:按创建者、上下界与活跃集合判断可见
原码连续摘录(仅统一公共缩进,截取方法局部):read0types.h,mysql-8.0.39,L163–L183。
[[nodiscard]] bool changes_visible(trx_id_t id,
const table_name_t &name) const {
ut_ad(id > 0);
if (id < m_up_limit_id || id == m_creator_trx_id) {
return (true);
}
check_trx_id_sanity(id, name);
if (id >= m_low_limit_id) {
return (false);
} else if (m_ids.empty()) {
return (true);
}
const ids_t::value_type *p = m_ids.data();
return (!std::binary_search(p, p + m_ids.size(), id));
}id低于m_up_limit_id或等于创建者先判可见;大于等于m_low_limit_id判不可见;中间区间再查活跃事务集合。注意源码变量的up/low命名与很多中文上下界图相反,不能记错比较方向。不可见后还要找undo历史版本,函数本身不负责当前读加锁,也不保证历史一定保留。
原码:视图复制的是当时活跃集合
原码连续摘录,仅截取局部、未改写:mysql/mysql-server · mysql-8.0.39 · storage/innobase/read/read0read.cc · L447–L471。
void ReadView::prepare(trx_id_t id) {
ut_ad(trx_sys_mutex_own());
m_creator_trx_id = id;
m_low_limit_no = trx_get_serialisation_min_trx_no();
m_low_limit_id = trx_sys_get_next_trx_id_or_no();
ut_a(m_low_limit_no <= m_low_limit_id);
if (!trx_sys->rw_trx_ids.empty()) {
copy_trx_ids(trx_sys->rw_trx_ids);
} else {
m_ids.clear();
}
/* The first active transaction has the smallest id. */
m_up_limit_id = !m_ids.empty() ? m_ids.front() : m_low_limit_id;
ut_a(m_up_limit_id <= m_low_limit_id);
ut_d(m_view_low_limit_no = m_low_limit_no);
m_closed = false;
}m_low_limit_id 是建立时的未来边界,m_ids 拷贝当时活跃读写事务,最小者成为 m_up_limit_id。后来事务提交,不会回头改这份历史集合;否则 RR 重复读就会漂移。m_low_limit_no 服务 purge 的序列化边界,不能直接当作另一个行版本事务 ID 判断字段。无活跃集合时两 ID 边界合一;只读连接 ID 仍不等于读写事务 ID。
从 SQL 走到 undo:正常路径与缺失分支
固定 tag 的执行入口是 ha_innodb.cc 中 ha_innobase::index_read / general_fetch,进入 row0sel.cc 的 row_search_mvcc。这里分开锁定读与一致性读;对聚簇记录调用 lock_clust_rec_cons_read_sees 判断是否可直接使用,不能使用就经 row_sel_build_prev_vers_for_mysql 进入 row0vers.cc 的 row_vers_build_for_consistent_read。
这个函数循环调用 trx_undo_prev_version_build,逐版重建记录并用视图判断。两条关键分支决定了 SQL 行为:其一,prev_version == nullptr,说明是视图之后的新插入,没有可返回的旧行;其二,构造历史失败时可能返回 DB_MISSING_HISTORY,不能随意拿当前值冒充快照值。正常活跃视图通过 purge 的安全边界保护所需历史;这个错误分支用于说明实现不能在缺失历史时随意降级,不表示长事务通常都会报这个错误。
二级索引不按每个条目保存完整聚簇行的全部版本信息。读取二级索引时若不能证明可见、遇到删除标记或需要验证历史关系,可能取聚簇记录再沿 undo 判断。所以 SQL 投影覆盖不等于任何并发场景都绝不会访问聚簇索引;“覆盖索引少回表”是常见成本优势,有条件的可见性检查仍要计入。这与 索引页 的执行路径相连。
长事务持有旧 ReadView 会阻止仍有读者需要的 update undo 被 purge;即使它只读不写,也可能扩大 history list 与旧版本重建成本。处理时应查事务年龄、请求用途、撤销历史增长、磁盘与清理进度,再缩短事务或分批导出。直接扩容 undo 只延后容量问题;直接杀长事务也要确认是否正在承担备份一致性。依据:undo、purge。
原码:没有旧行与缺失历史是两件事
原码连续摘录,仅截取局部、未改写:mysql/mysql-server · mysql-8.0.39 · storage/innobase/row/row0vers.cc · L1291–L1309。
/* If purge can't see the record then we can't rely on
the UNDO log record. */
bool purge_sees =
trx_undo_prev_version_build(rec, mtr, version, index, *offsets, heap,
&prev_version, nullptr, vrow, 0, lob_undo);
err = (purge_sees) ? DB_SUCCESS : DB_MISSING_HISTORY;
if (prev_heap != nullptr) {
mem_heap_free(prev_heap);
}
if (prev_version == nullptr) {
/* It was a freshly inserted version */
*old_vers = nullptr;
ut_ad(!vrow || !(*vrow));
break;
}trx_undo_prev_version_build 先尝试重建,失败标记 DB_MISSING_HISTORY;prev_version == nullptr 则说明这条新插入没有更旧版本,当前视图应当看不到该行。不能为了“查出一条数据”回退到不可见当前值。后续循环继续调用 view->changes_visible,找到可见版本后复制返回;此段未展示分配和最终清理,因此不能单独拿来模拟完整内存生命周期。
锁在索引路径上,不在 WHERE 字符串上
意向锁是表级信号,表示事务将在表中持有行级 S/X 锁,便于判断表锁兼容性;它不代替行锁。记录锁锁索引记录;gap lock 保护相邻记录之间的间隙以阻止插入;next-key lock 是记录与其前方间隙的组合。两个普通 gap lock 可以共存,因为主要任务都是禁止向该间隙插入,并非彼此排他修改现有记录。插入意向锁表达准备插入的位置,不同位置的插入不必互相阻塞。
在主键 {10,20,30} 上,RR 的 WHERE id=20 FOR UPDATE 完整唯一等值命中通常只需 20 的记录锁;WHERE id=15 FOR UPDATE 未命中却可能保护 (10,20) 的插入位置;范围扫描可能取得多个 next-key 以及停止扫描边界上的锁。不能把 id>=20 AND id<30 机械翻译为恰好 [20,30),锁范围应从实际扫描和 performance_schema.data_locks 判断。非唯一二级索引还带主键后缀,同一个业务值可能对应一组索引记录。
RC 对普通搜索和扫描通常减少 gap locking,并可能释放不匹配行的记录锁,但外键与重复键检查是例外;它依然会写写冲突。条件 UPDATE 在 RC 下还可能使用半一致性读帮助 MySQL 判断 WHERE,再对需要更新的行重新读取并加锁。因此“RC 没有锁”“RR 不会出现任何业务幻读”都是错误答案。来源:锁、隔离级别。
实现入口在 lock0lock.cc:lock_clust_rec_read_check_and_lock、lock_sec_rec_read_check_and_lock 进入记录锁检查,使用 LOCK_REC_NOT_GAP、LOCK_GAP、LOCK_ORDINARY 等标志表达范围形态。注意页 latch 是短时保护内存页结构的内部同步,事务锁则保护逻辑访问直至相应事务结束;看到 B-tree latch 不能据此推断业务行锁范围。
锁等待只证明某条边,死锁要求等待图成环。等待超时默认通常回滚当前语句,死锁牺牲者则回滚整个事务;应用应显式回滚并按完整事务重试,避免连接里残留前面语句的修改。重试必须有总时限、退避和幂等业务键,不能在事务回调中直接重复发款。详见 错误处理。
原码:记录与间隙是否落入范围分别判断
原码连续摘录,仅截取局部、未改写:mysql/mysql-server · mysql-8.0.39 · storage/innobase/row/row0sel.cc · L5203–L5223。
if (prebuilt->select_lock_type != LOCK_NONE) {
auto row_to_range_relation = row_compare_row_to_range(
set_also_gap_locks, trx, unique_search, index, clust_index, rec, comp,
mode, direction, search_tuple, offsets, moves_up, prebuilt);
ulint lock_type;
if (row_to_range_relation.row_can_be_in_range) {
if (row_to_range_relation.gap_can_intersect_range) {
lock_type = LOCK_ORDINARY;
} else {
lock_type = LOCK_REC_NOT_GAP;
}
} else {
if (row_to_range_relation.gap_can_intersect_range) {
lock_type = LOCK_GAP;
} else {
err = DB_RECORD_NOT_FOUND;
goto normal_return;
}
}
/* in case of semi-consistent read, we use SELECT_SKIP_LOCKED, so we don'trow_compare_row_to_range 返回“该记录是否可能在范围内”和“前方间隙是否可能相交”。两者都要保护则 LOCK_ORDINARY;只记录则 LOCK_REC_NOT_GAP;只间隙则 LOCK_GAP;两者无关才结束。这解释了停止扫描的边界记录也可能承担间隙保护。更改计划、索引、隔离级别后,不可照抄上一条 SQL 的锁范围图。
FOR UPDATE 配合 autocommit 只在本语句持锁,不能替下一条更新保护应用判断;NOWAIT 遇冲突即失败,SKIP LOCKED 跳过锁行返回不完整集合。队列可以设计跳过协议,余额汇总与严格公平排班不能直接套用。
双会话 SQL:把四种读分开观察
先运行实验目录的 setup.sql,得到账户 1 余额 100、版本 1,主键锁表含 10、20、30。A、B 使用独立连接,关闭之前残留的事务。每一组开始前恢复初始数据;不要整文件同时执行而丢失时序。
| 步骤 | 会话 A(RR) | 会话 B | 预期与原因 |
|---|---|---|---|
| 1 | BEGIN;普通读账户 1 | — | 100,建立快照 |
| 2 | — | 更新为 80 / 版本 2,COMMIT | 新提交版本存在 |
| 3 | 再普通读 | — | 仍 100,沿 undo |
| 4 | SELECT … FOR UPDATE | — | 80,锁定当前记录 |
| 5 | UPDATE balance=balance-10 | — | 基于当前值写为 70 |
| 6 | 普通读,ROLLBACK | — | 70,自己的写可见;回滚后库中是 80 |
将 A 改为 RC 并重跑,第 3 步预期是 80。将 A 的首次普通读放到 B 提交之后,RR 也预期读 80,这就检验了“BEGIN 不是快照时刻”。FOR UPDATE 需要处于显式事务才有跨语句保护意义;在 autocommit 单条语句结束即释放,不能覆盖下一条扣款。
第二组:A 在 RR 事务执行 SELECT * FROM ds_lock WHERE id=15 FOR UPDATE,B 在事务插入 15。预期 B 等待 A 结束。观察连接执行实验的 observe.sql,寻找 GAP、请求事务和阻塞事务,不要求锁 ID 或物理页号固定。清理时 A ROLLBACK,B 若插入成功也 ROLLBACK;再用完整唯一命中 id=20 与插入 15 对比,预期后者不被只锁 20 的查询阻塞。若结果不同,先核对隔离级别、表内容和实际索引,不修改观察输出。
观察 SQL 的锁记录由 observe.sql给出,记录 ENGINE_TRANSACTION_ID / INDEX_NAME / LOCK_MODE / LOCK_DATA 和阻塞边,不固定物理页号。双会话脚本在 sessions.sql;按 A/B 提示逐块交接,整文件粘贴到一个连接不能制造并发。
另一个需要真库核查的分支:RC 的条件 UPDATE 等待后应重新评价当前版本。A 先从100扣80未提交,B 同样扣80等待,A提交后B预期影响0行;不能把B较早普通读的100带回作为授权。ROW_COUNT() 必须紧接 UPDATE,不被其他语句覆盖。
本机双会话结果(8.4.7)
run_local.py --mode tx 显式autocommit=True并以BEGIN/COMMIT/ROLLBACK界定事务。RR读取序列实际为100→100→当前80→自己写70,RC为100→80→当前80→70;BEGIN后尚未读而B先提交的首次RR读为80。唯一未命中15时观察到B请求 X,GAP,INSERT_INTENTION、A阻塞 X,GAP,LOCK_DATA=20;完整唯一命中20时B插入15成功。RC条件扣减等待A提交后,B更新0行、余额20。
实际锁边与最终值存于 output/t16-local-tx.json,专用新库已清理;这些是8.4.7实测,不是8.0.39运行输出,也不能覆盖其他计划、外键、复制或所有隔离异常。
写偏差:快照不能保护跨行不变量
第三组是写偏差纸面推演:两个医生行分别 on_call=1,不变量是至少一人在岗。A、B 在各自 RR 快照里都计数为 2,随后分别把自己行设 0,锁的是不同行,因此都可提交。答案是 MVCC 并未保护计数谓词。可用统一守卫行先 FOR UPDATE,所有更改都遵守该协议后再检查总数;也可在适合的查询与隔离条件下锁完整候选集合。不能只为一条路径加锁而让后台批处理绕过。守卫行会成为串行热点,需要按医院/班次分区并证明不变量也按此分区。
守卫行协议的完整顺序是 BEGIN → 锁该班次guard → 当前读取在岗数 → 决定修改 → COMMIT,所有改变该集合的操作都走同一守卫。若先用 RR 普通读建立旧快照,再拿guard,之后仍按旧普通读计数,则守卫只是把错误判断排队;应使用当前锁定读或在取得guard之后才建立所需快照。
可运行 python3 labs/mysql/models.py mvcc:手算输入视图创建者70/活跃{60,80}/未来90,T40/T70/T75可见,T60/T80/T95不可见;另验证BEGIN不建视图的交错、自己的写可见、无守卫两医生可同退和按当前状态串行检查只能退一人。它没有复刻InnoDB调度或gap实现,真实行为仍以双会话协议验证。
本机8.4.7另实测:两个RR事务各读在岗数2后改不同行,提交后在岗数为0;重置专用夹具后,两事务按同一guard、当前锁定读检查顺序执行,只允许一人退出,最终在岗数为1。后一个对照证明该实验协议的最终不变量,不是所有绕过guard路径的保证。
高级面试:可口述答案与三轮追问
主问题:RR 为什么能重复读,又为什么不能把余额读出再写回?答案:一致性读由 ReadView 和 undo 找历史可见版本,RR 复用视图;写入和锁定读面向当前版本。把旧余额在应用里算好后无条件写回可能覆盖其他提交,应该用 UPDATE … SET balance=balance-? WHERE balance>=? 并检查影响行数,或用版本 CAS;涉及多行时在同一事务遵守统一加锁顺序和不变量协议。
第一轮:活跃 T60 后来提交,老视图是否立刻看见?不会,视图保存创建时集合;新 RC 语句的视图才可看见。评分 0–2:只背 RR 得 0,解释冻结集合得 1,区分自己写入与新视图得 2。
第二轮:为什么一个 SELECT … WHERE id=15 FOR UPDATE 没查到行却挡住插入?RR 需要保护搜索间隙,唯一未命中不同于唯一命中;用索引和 data_locks 证明。评分 0–2:说“锁不存在的行”不得分,讲间隙得 1,能设计唯一命中/RC 对照得 2。
第三轮:改为医生值班跨行约束,是否改 RC 就能提升并发且保证正确?不能,RC 不提供跨行序列化;选守卫行、可序列化事务或模型约束并说明重试与热点代价。评分 0–2:识别写偏差得 1,给出所有写路径必须遵守的协议得 2。主问题另计 4 分:视图、undo、当前写、条件更新各 1,共 10 分;8 分以上且不把快照当锁,才算本单元高级通过。
架构师进阶:条件变化后重新选协议
条件一,库存扣减从每秒 500 变为单 SKU 每秒 5 万:单行条件更新仍正确,但排队会主导尾延迟;先测锁等待与提交能力,再考虑按桶预分配库存。桶方案要证明总预算不超售、归还与故障恢复不重复,不能仅以“分库”规避不变量。
条件二,报表需要一致地导出 2 小时:持有长 RR 快照简单却保留 undo;专用只读副本或备份快照隔离在线压力,但要声明水位与复制延迟,按多个短事务分页则不自动得到同一快照。以导出截止水位、允许的历史保留磁盘和业务更新率选方案,验收还要测 purge 能否追上。
条件三,用户要求任何已确认付款都不可回退:主库行锁只保护当前实例并发,不能替代提交耐久性和安全选主。要补 binlog/redo 的提交协议、幂等流水和故障隔离,转到 慢 SQL 与恢复治理。专家可选深化是沿 purge horizon 与二级索引旧版本继续追代码,不作为架构师的统一门槛。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。