资讯动态

数据库面试链路:B+树索引、事务隔离与MVCC如何协作

发布时间:2026/10/21 15:24:57 来源:尧图企业网站定制
凌晨一点半Day39的复习终于告一段落。计算机复试走到这个阶段刷题和背八股已经不太提得起新鲜感真正让我觉得有收获的是今天复盘时把两条线拧成了一条索引解决的是怎么读得快事务解决的是怎么写才对而把这两个问题连在一起看的正是MVCC。这篇笔记不打算给标准答案只想还原我今天思考的顺序给你一条可参考的复盘路线。如果你也在准备复试对数据库这块总处于“背了忘、忘了背”的状态那今天的整理思路可能会比多刷五十道题更有用。1. 从一道复试热点题开始的Day39B树索引到底好在哪早上某复试群抛出一个问题听说是模拟面试里被反问过的“多数数据库的默认存储引擎为什么选B树做索引而不是B树、红黑树或哈希表”看起来像基础八股实际能牵出索引设计的一整套逻辑。我一上午都在拆它。1.1 一个我印象深刻的追问B树为什么输给B树先排除两个容易答的选项。哈希表对单行点查确实快时间复杂度接近常数但它解决不了范围查询也搞不定排序一旦出现between、、order by这类需求哈希结构基本报废。红黑树在内存里是一棵优秀平衡树但它一个节点只保存一个键索引量一大树高就拉得很高而数据库的数据都在磁盘上每多一层树高都意味着多一些磁盘访问成本完全不能接受。真正容易卡住的点是B树和B树的区别。B树每个节点既能存索引键也能存数据或指针看起来“每个节点都能命中数据”好像更高效。但磁盘是按页读的页大小固定非叶子节点一旦携带数据或指针单页能容纳的键数量就会变小树随之变高一层多出来的高度可能对应着一次完整的磁盘IO。B树把键和数据彻底分开非叶子节点只存键和子指针叶子节点才存数据叶子之间还做成有序链表。这个结构能带来三个直接优势非叶节点不存数据所以同一页能塞更多键扇出大树高被压得极低。叶子节点有序串联范围扫描可以直接沿着链表走不用反复做中序遍历。所有查询都会落在叶子层访问路径稳定对磁盘预读非常友好。以常见的默认页大小16KB为例假设一个索引键加指针占16字节一个页大约能存1000个键。三层结构保守估计就能覆盖千万级数据量这在实际业务里是一个非常稳的高度。我说这些数字不是为了让你背而是为了第二天被追问“B树三层能存多少数据”时不至于只能挤出一句“反正挺高的”。1.2 聚簇索引与非聚簇索引我最喜欢的“回表”考点索引题另一个高频追问是聚簇索引和非聚簇索引。大多数数据库的主键索引就是聚簇索引它的叶子节点存的是整行数据表里的数据按主键物理有序。二级索引或者说普通索引不一样它的叶子节点只存索引键外加主键值。查询如果用了二级索引但还想取一些不在索引里的列就必须拿着主键再查一次聚簇索引这个过程就是回表。回表的本质是一次随机IO。匹配了几百行就要回表几百次数据量大时性能会很难看。举个例子某课程表里有lesson_id、course_id、teacher_id、status这几个字段执行这个查询select * from lesson where teacher_id 7 and status 1;如果只给teacher_id建了单列索引引擎的第一步是沿着这个二级索引找到所有teacher_id7的记录取出主键然后每一条都回到聚簇索引里取整行再在内存里判断status回表次数等于匹配行数。如果匹配了500行代价就是500次随机读。聚簇索引的另一个坑是主键设计。如果主键用随机字符串或UUID新记录的主键值随机散落插入时经常要把已有页分裂开产生碎片写入性能被明显拖低。而自增整型主键让新记录总是追加到索引右侧顺序写性能要好得多。Day39我特意把“页分裂”这个概念补了进去因为复试时被问到“为什么推荐自增主键”光是说“有序”两个字是不够的。1.3 覆盖索引B树索引的“偷跑”思路回表虽然慢但有一种情况可以完全绕开它就是覆盖索引。如果查询所需的所有列都已经包含在同一个二级索引的键里引擎直接在索引页里就能拿到结果不需要再回聚簇索引取整行。把上面的例子改一下select course_id, teacher_id from lesson where teacher_id 7;如果建一个(teacher_id, course_id)联合索引teacher_id用于定位course_id直接能在索引里读到这条查询的二次回表就省掉了。面试时聊到这里可以再带出一个加分概念索引下推。比如页面上筛选条件是teacher_id 7 and course_id like DB%没有索引下推时引擎先把所有满足teacher_id7的记录全部回表再交给服务层过滤course_id有了下推存储引擎在索引遍历过程中就先把course_id条件过滤掉只对剩余少量记录回表IO次数明显减少。这个点提醒我索引不是单纯让语句变快的魔法而是一种信息复用策略。把哪些字段放进索引本质是在“操作定位效率”和“回表成本”之间做取舍。2. 同一个问题的另一半事务、锁与隔离级别的顺序记忆下午整理索引时我对自己的复习方式产生了怀疑。索引讲的是“读取快”但如果两个事务同时在读写同一行数据索引再快也解决不了“读到什么才算正确”的问题。于是下午我把数据库的另一条主线也重新过了一遍。2.1 先建立一张“并发异常对比表”三个并发异常概念很容易被混在一起我选择用一张表把它们固定下来。并发异常现象描述一页纸类比脏读读到另一个事务尚未提交的数据对方一旦回滚这条数据就是凭空出现又凭空消失的同事改了一份报价但没保存你直接把草稿拿去对账不可重复读同一行数据在同一个事务里两次读到不同的值原因是其他事务中途提交了更新同一本书两次翻到同一页书名没变页码却变了幻读同一个范围条件两次读到的记录集合不一样原因是其他事务插入了新行或删除了行点名时名单突然多了人或少了人光背这三个词没有意义复试更常见的考法是给一个场景让你判断属于哪类异常。我的判断口诀是涉及同一行记录的“新旧值”变化是不可重复读涉及结果集“行数变多或变少”是幻读读到“还没提交就溜出来”的数据是脏读。分清这三个方向场景题基本不会答反。2.2 隔离级别为什么能“递减”解决问题四个隔离级别从宽松到严格分别是读未提交、读已提交、可重复读、串行化。每个级别用不同的锁和版本策略依次堵住上面的并发异常。隔离级别脏读不可重复读幻读读未提交可能可能可能读已提交不可能可能可能可重复读不可能不可能快照读下不可能当前读下仍可能串行化不可能不可能不可能这里有一个很关键的常识不同数据库产品的默认级别不一样。常见开源关系型数据库的默认隔离级别通常设为可重复读而另一些数据库默认是读已提交复试时最好先明确默认值再往下谈否则容易被追问“你说的是哪个产品”。为什么要设置中间级别因为读未提交虽然快但可能读到一份会被撤销的草稿业务上很难接受串行化虽然正确但读写互相阻塞太严重并发性能很差。锁的基本单位是行锁再细分为共享锁和排他锁。共享锁是“可以一起读不能改”排他锁是“我改的时候别人不能读也不能改”。围绕同一行数据读写锁的冲突关系决定了不同隔离级别下的表现差异。2.3 快照读还是当前读这是面试官最爱埋的坑很多同学以为“MVCC就是不用锁”这个理解不完整。普通select确实走快照读通过版本链拿到某个历史版本不加锁但select ... for update、update、delete、insert这类语句走的是当前读永远读最新版本并且要加锁。打个比方。快照读就像复制了一份共享文档的PDF慢慢看别人在云端怎么改都不影响你本地这份PDF当前读则是把文档原件从别人手里拿过来锁在自己桌面上改别人想碰必须等。这个区别太重要了尤其是在“抢名额”这类场景里。假设某个选课系统的余量字段只剩1两个事务同时执行“先查余量再减1”。如果两个都先走普通select都看到余量是1然后各自执行update就会出现超卖。正确写法是直接在update里做原子减操作或者先用select ... for update把这条记录的当前读锁拿到手再更新。面试官一问“超卖怎么防止”本质考的就是“当前读必须加锁”这个点。3. 当索引遇见事务MVCC怎么用版本链解决读写冲突索引让我知道“怎么按条件定位”事务让我知道“怎么保证并发正确”但真正把二者焊在一起的是MVCC。我花了一个晚上想一件事一行记录里面到底藏着什么信息才能让一个读事务和一个写事务同时进行彼此还不用阻塞3.1 从隐藏列到回滚日志一条更新的完整生命周期每个表里的每一行不只是你创建表时定义的那些业务字段存储引擎还会偷偷维护几个隐藏列其中最核心的两个是最后修改该行的事务ID以及回滚指针。一次更新的完整生命周期大概是这样事务T把某行数据的name从alice改成bob引擎不会直接把旧值擦掉而是先把旧版本以记录的形式写进回滚日志再基于当前行生成一个新版本并把新版本的回滚指针指向上一个旧版本。多个事务先后修改同一行就会形成一条从新到旧的版本链。这有点像改论文的时候用红色修正带一层一层往上贴下面旧字没有被撕掉。想看哪个版本就沿着修正带往回翻。为什么要这样做因为当写事务拿着排他锁修改当前行时读事务根本不需要等它释放锁直接顺着版本链往回找找到自己当前可见的那个旧版本读走就行互不干扰。这就是“多版本并发控制”这个名字的真正含义。3.2 ReadView的判断规则我把它记成了四个字落袋为安读事务要决定版本链上哪个版本可见不能无脑取最新。它会生成一个ReadView也就是读视图。这个视图记录了生成瞬间所有还在活跃状态、没有提交的事务ID列表同时记住最小事务ID和最大事务ID。判断规则其实可以浓缩成“落袋为安”四个字只要这个版本的事务已经提交并且在我这个读视图生成之前就已经提交那它就能被读到如果这个版本的事务还没提交或者是在我的读视图生成之后才启动的我就看不见它。具体拆开是四条版本事务ID情况可见性等于当前读事务自己的ID可见自己改的当然能看小于最小活跃事务ID可见说明在当前视图之前已提交大于等于最大活跃事务ID不可见这个事务在我的视图之后才开始位于最小和最大之间不在活跃列表中可见事务已结束并提交位于最小和最大之间且在活跃列表中不可见对方还没提交我的实操心得是不要一字一句背定义复试现场能画出一个“活跃列表区间判断”的流程图比背概念强很多。面试官想看的是你能不能现场推导而不是背诵。3.3 读已提交和可重复读在同一个版本链上的差异这两个隔离级别在MVCC里的差别只有一个点ReadView的生成时机。读已提交模式下每次执行select前都会重新生成一个新的ReadView所以同一个事务里两次查询能看到他人新提交的数据从而出现不可重复读。可重复读模式下事务第一次执行select时生成一个ReadView之后整个事务都复用这一个后续查询不会因为别人提交了新数据而改变结果。用一个例子串联T1开启事务第一次查询课程表中的课程数量返回2T2插入一门新课并提交T1再次查询课程数量。在读已提交下第二次查询会生成新视图看到T2提交的数据返回3在可重复读下T1复用第一次生成的旧视图T2被排除仍然返回2。这也是“为什么可重复读能防止快照读下的不可重复读和幻读”的答案因为整个事务读的是同一张历史快照。但要注意如果T1在可重复读下执行update它不会再看旧视图而是走当前读并加锁。正因如此当前读场景下防幻读要额外靠间隙锁或临键锁。知识链条到这里才真正闭合。4. Day39实战复盘把两天内容搓成一张“回答链路表”白天是知识输入晚上是输出验证。Day39晚上的复盘我没有重新翻书而是按面试官的视角把今天的内容串成一条能口述的回答链路。4.1 我把“索引、事务、MVCC”串成了一条口答话术一个高频问题可能是“介绍一条普通select语句在数据库内部大致经历了什么”我把今天的内容组织成这样一条链路优化器先根据统计信息和查询条件选择索引确定索引后存储引擎从根节点开始沿B树定位到叶子节点普通select是快照读不会锁住当前记录而是在版本链上按照ReadView找到当前事务可见的版本如果是select for update则走当前读读取最新版本并加锁如果走的是二级索引且查询列不在索引内就拿着主键回表到聚簇索引取整行如果要写数据先加锁再更新并把旧版本写进回滚日志生成新版本让并发读事务仍然能顺着版本链读旧数据。把这段话说顺之后我总结成一句好记的话索引保证“能找到那行”MVCC保证“看得到对的那行”锁保证“改的时候只有一个人在改”。这句话最大的价值是面试官追问任何一个中间词我都能顺着链路往下走而不是被拆散的知识点带偏。4.2 模拟追问删除一条记录时索引和事务如何协作为了验证这套链路我给自己加了一个追问“可重复读隔离级别下执行delete from t where id 5会发生什么”这个问题正好把第2章和第3章的内容揉在一起。从事务的角度看delete属于当前读目标行会被加排他锁如果删除条件匹配的是一个范围还要考虑在索引间隙上加锁阻止其他事务往这个范围插入新行否则会出现幻读。从MVCC的角度看删除并不是立刻把数据物理抹掉而是先做逻辑删除旧版本继续留在版本链里让并发读事务仍然能读到自己的历史版本真正物理回收要等后台清理线程判断没有事务再需要它之后。从索引的角度看二级索引和聚簇索引都需要定位到这份记录并在对应的索引项上打删除标记最终由清理线程统一回收。这个追问让我意识到面试官问一条delete就不是考你会不会写SQL而是考你有没有能力把锁、版本链、索引定位三个词组织成一套解释。复试需要的不是零散的知识点是这种组织能力。4.3 今天这个复习法和我之前几天的最大不同前38天我的复习是“模块扫射”今天看SQL优化明天看事务隔离像是两个孤岛。Day39我发现两座岛之间的桥是MVCC隔一天再看发现间隙锁其实是桥上的护栏。这个发现本身比多背三个知识点更重要。具体操作我建议这样做在一张A4纸中间画一条竖线左边写“读”右边写“写”把今天学到的链路画出来。左线上写“快照读-版本链-ReadView”右线上写“当前读-锁-间隙锁-回滚日志”。每画一笔都要能解释“为什么”而不是只写名词。这张纸就是第二天的自测试题不看资料顺着箭头讲一遍讲不流畅的地方就是未来几天的重点。Day39让我明白连接知识比堆砌知识更值钱。5. 给还在冲刺复试的同学Day39这天的三个避坑点如果今天的内容你只能带走三句话我建议就是下面三个坑。它们是我今天复盘时翻车翻出来的比课本目录更有参考价值。5.1 别把“间隙锁”当成锁表它锁的是一段范围一个很常见的错误回答是“可重复读下用间隙锁防幻读所以相当于锁表。”这句话会被复试老师一票否决。间隙锁锁的不是表对象而是索引记录之间的“空隙”区间。它把某个索引值范围的两端之间封住目的是堵住“往这个空隙插入新行”的幻读通道。比如索引列上有1和100两条记录事务A在可重复读下对where teacher_id between 2 and 99做当前读间隙锁锁住的其实是(1, 100)这个范围新插入teacher_id50会被挡住但插入teacher_id200完全不受影响。一个简单判断法问自己“其他事务还能不能插入这条索引区间之外的记录”如果能那就不是锁表。我把间隙锁理解为在队伍中间拉了一根隔离带不是把整栋楼都封了。5.2 手写快照读逻辑前先确认自己用的是哪个隔离级别复试如果有笔试题可能会让描述“如何实现一个快照读的读视图”。很多人的第一反应是“每次读取都重新生成ReadView”这个答案在可重复读语义下是错的。关键点在于ReadView的生成时机不同。可重复读要求整个事务复用同一个ReadView读已提交才要求每次读取重新生成。我建议用这样一段伪代码帮助记忆class SnapshotReader: def __init__(self, engine): self.engine engine self.read_view None # 可重复读下复用 def select(self): if self.engine.isolation() REPEATABLE_READ: if self.read_view is None: self.read_view self.engine.create_read_view() view self.read_view else: # READ_COMMITTED view self.engine.create_read_view() return self.engine.read_with_view(view)这段代码不是某个存储引擎的真实实现只是为了突出“首次生成”和“每次生成”的差别。面试时先说明“这是逻辑示意帮助理解ReadView的生命周期”再讲隔离级别就能避免在生命周期上翻车。5.3 用一页纸记录“说不清”的问题比继续刷题更重要到了Day39我发现真正消耗精力的不是不会的知识而是“以为自己会”的知识。每次被追问到细节就发虚说明这个概念还没有真正长进知识体系里。我准备了一页折页纸只记三个字段问题是什么、我当时卡在哪、计划什么时候回问。今天纸上记了三个待办为什么当前读场景下可重复读还要靠间隙锁防幻读卡点是“快照读和当前读的界线没分清”计划Day42回问。覆盖索引和回表的边界什么时候覆盖会失效卡点是“范围字段会打断最左匹配”计划Day41回问。ReadView复用和写锁之间的关系卡点是“视图旧了但写锁还在持续加”计划Day42回问。这页纸晚上花十分钟就能写满第二天早上花十分钟自测比再刷十道题更能带来确定的进步感。Day39没有惊天动地的技术突破最大的收获是第一次把索引、事务、MVCC、锁这四件事当成一条线来复习。如果你也在准备计算机复试我不建议你直接照抄今天的内容更建议你学这个方法挑两到三个相邻的知识点强行用一条回答链路把它们串起来讲不通的地方就是接下来几天的重点。明天我会按折页上的三个待办继续追Day40先把“当前读下的间隙锁”补到自测通过为止。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑