简介面向数据库系统学习者的CMU-15-445课程配套完整资源覆盖缓冲池管理器、B树索引、并发控制、记录恢复机制等核心模块并包含C11编程实践、数据库理论总结与实验指导建议既适合初学者建立整体框架也适合进阶者对照实现细节深入学习。资源压缩包共121个文件以C/C源码为主55个.h、46个.cpp另有Markdown笔记、txt说明、示例图片及docx附赠文档代码与文档分层存放便于按实验模块检索压缩包整体约2.64MB。目前已有66人进行学习浏览。在内容上源码涉及B树、锁管理器、页表等典型实现可配合课程视频总结与实验指导建议理解缓冲池替换策略、B树操作、锁管理与恢复日志等关键机制。附带的说明文件和使用指南能帮助快速定位源码结构是深入数据库内核实践的宝贵参考资料。1. 15-445 数据库系统课程到底在练什么一份围绕实验代码的自学落地路径15-445 数据库系统课程的实验代码与学习笔记是很多后端开发者补数据库内核底子的首选材料。缓冲池管理器解决数据页在内存与磁盘之间怎么周转B树索引解决数据怎么被高效定位并发控制与记录恢复机制则保证多线程访问下不错乱、不丢数据。这门课对 C11实际是 C11编程能力的要求是实打实的光看课程视频总结过不了实验题目也不靠背答案。适合两类人准备数据库内核方向面试的开发者以及天天写 SQL 但想知道存储层怎么工作的后端工程师。课程自带题库实验代码就是刷题集下面把它拆成能照着走的落地路径。2. C11 与缓冲池管理器先把存储引擎的地基跑通缓冲池管理是数据库存储引擎的第一块拼图。理解它之前先要把 C11 这套工具链对齐否则后面写锁、写恢复机制时会在编译和内存管理上反复翻车。常见做法是直接用课程配套的教学数据库内核框架系统已经搭好了磁盘读写和页面的物理管理你只需要补上替换策略和页表逻辑但正是这部分最容易暴露基本功问题。2.1 先把 C11 环境对齐标准、编译器选项与 RAII 习惯标题里的 C11 指的是 C11 标准。别小看这个语言层面的选择实验框架里大量用到std::mutex、std::condition_variable、std::unordered_map和智能指针这些特性在 C11 里才稳定可用。我一般会把编译标准显式钉在 C11而不是依赖编译器的默认值因为不同机器默认标准不一样代码在同学的电脑上能编到你的环境就报一些莫名其妙的模板错误。cmake_minimum_required(VERSION 3.10) project(database_course CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_FLAGS -Wall -Wextra -Werror -g -O0)这段配置里有几个关键参数。CMAKE_CXX_STANDARD指定语言标准为 C11配合CMAKE_CXX_STANDARD_REQUIRED强制编译器不能偷偷降级。-Wall -Wextra把告警打开课程实验的测试代码往往比较挑剔告警当错误处理更稳妥。-O0关闭优化调试时变量值和调用栈都比较直观等实验跑通了再开-O2验证性能。还有个实践习惯写 RAII 风格的锁用std::lock_guard而不是手动lock()和unlock()成对出现后者一旦中间有异常或提前return锁就永远不释放测试会直接卡死。C11 里还有一个容易被忽略但缓冲池实现里非常关键的容器操作std::list::splice。LRU 替换器通常用双向链表保存淘汰候选访问一个页要把节点从链表中间挪到头部。很多新手用erase加insert两个操作都会让迭代器失效还要处理内存分配用splice一步完成节点转移不触发分配性能好得多。这就是语言特性和数据结构设计结合的地方也是实验代码里最容易看半天才明白的玄学。2.2 缓冲池的核心分工LRU 替换器与页表各管一摊缓冲池管理器这个名字听着大实际职责很清晰它管理一组固定数量的物理帧每个帧里放一个数据页。逻辑页page_id和物理帧frame_id之间靠一张页表映射。你会发现课程实验里通常会单独抽出LRUReplacer它只负责一件事当需要腾出帧时告诉管理器哪一个 frame 可以被淘汰。它不管这个页是不是脏的、要不要写回磁盘那是管理器的事。// lru_replacer.h 的骨架哈希表加双向链表 class LRUReplacer { public: explicit LRUReplacer(size_t num_pages); bool Victim(frame_id_t *frame_id); // 选一个淘汰候选帧 void Pin(frame_id_t frame_id); // 帧被固定不可淘汰 void Unpin(frame_id_t frame_id); // 帧解锁进入候选 size_t Size(); // 当前可淘汰的候选数 private: std::listframe_id_t lru_list_; // 头部最近使用尾部最久未用 std::unordered_mapframe_id_t, std::listframe_id_t::iterator pos_; std::mutex latch_; };// 淘汰候选取链表尾部节点 bool LRUReplacer::Victim(frame_id_t *frame_id) { std::lock_guardstd::mutex guard(latch_); if (lru_list_.empty()) { return false; } *frame_id lru_list_.back(); lru_list_.pop_back(); pos_.erase(*frame_id); return true; }为什么用链表尾部而不是头部因为约定是「头部最近被使用、尾部最久未被使用」每次Unpin用splice把节点挪到头部尾部的自然就是最应该被淘汰的。pos_这个哈希表存的是链表节点的迭代器作用是把frame_id定位到链表节点让Pin和Unpin都能在 O(1) 时间内找到并移动节点而不是遍历链表。frame_id是输出参数函数返回前把候选帧号写进去调用方要判断返回值false说明缓冲池没有可淘汰的页这时候管理器只能返回空指针。这里有个容易误解的点Pin和操作系统里的「钉住」不是一个意思。一个帧被 Pin 说明有线程正在用这个页无论如何都不能淘汰Unpin之后它才进入 LRU 的候选池但不会立刻被淘汰只有新的页请求找不到空闲帧时才会触发Victim。这个模型贯穿后面所有实验B树并发控制里 latch 的获取和释放也沿用了类似的思路。2.3 跑通实验一的最小路径构建、单测与 FetchPage 实现骨架拿到代码后我一般不会先急着写实现而是先让测试框架跑起来。课程实验配有现成的测试用例构建和运行路径通常长这样mkdir -p build cd build cmake -DCMAKE_BUILD_TYPEDebug .. make -j$(nproc) lru_replacer_test ./test/lru_replacer_test-DCMAKE_BUILD_TYPEDebug会加上调试符号-j$(nproc)用所有 CPU 核心并行编译第一次全量构建会快很多。先只编译单个测试目标比make全量编译省时间适合边改边跑的循环。如果测试通过再往里填 BufferPoolManager 的FetchPage和NewPage。下面是一个常见的FetchPage实现骨架注意加锁和脏页判断这两个关键点Page *BufferPoolManager::FetchPage(page_id_t page_id) { std::lock_guardstd::mutex guard(latch_); // 页已经在缓冲池里直接固定并返回 if (page_table_.count(page_id)) { frame_id_t fid page_table_[page_id]; pages_[fid].pin_count_; return pages_[fid]; } // 不在池里需要淘汰一个候选帧 frame_id_t victim static_castframe_id_t(-1); if (!replacer_-Victim(victim)) { return nullptr; // 没有可用帧 } // 如果旧页是脏的先写回磁盘再换入新页 if (pages_[victim].is_dirty_) { disk_manager_-WritePage(pages_[victim].page_id_, pages_[victim].GetData()); } page_table_.erase(pages_[victim].page_id_); disk_manager_-ReadPage(page_id, pages_[victim].GetData()); pages_[victim].page_id_ page_id; pages_[victim].pin_count_ 1; pages_[victim].is_dirty_ false; page_table_[page_id] victim; return pages_[victim]; }逻辑上要卡住三个点。第一pin_count_是计数器FetchPage一次加一对应UnpinPage一次减一只有减到 0 才允许替换器把它当成淘汰候选。第二脏页写回必须发生在页表条目被删除之后、新页读入之前顺序反了会把新页内容覆盖掉。第三page_table_的 key 是page_idvalue 是frame_id这两者很容易搞混写错一次后面查半天。实验里最常见的血泪经验就是UnpinPage忘调或者pin_count_加减不对称最后 LRU 测试的 Size 断言死活过不去。跑通这个最小路径之后缓冲池的地基就算立住了。后面 B树索引实验的页访问、并发控制实验里的事务管理全部建立在这套「固定帧、淘汰帧、脏页写回」的模型上所以这一步值得多花时间把测试跑透而不是调过就往下走。3. B树索引实现与调试的对照检查清单B树索引实验是整套课程里代码量增长最陡的一关。缓冲池那块你还能靠猜和试调过去B树不行索引结构的任何一处指针悬空、分裂遗漏都会在扫描结果里暴露出来。很多同学在这一章放弃不是因为难而是因为不知道从哪里开始定位问题。这里把 B树的考察边界、迭代器语义和调试切入点一起说清楚。3.1 B树的考察边界插入分裂、删除合并与根节点处理课程实验对 B树的实现要求通常包括插入、删除、查找和迭代器四块其中插入和删除是最核心的考察点。插入的主路径是从根节点往下查找到目标叶子节点把键值对插入如果叶子满了就分裂把中间键上提到父节点如果父节点也满了就继续往上分裂最坏情况是分裂根节点并产生新根。删除则是逆过程从叶子节点删除键如果节点太瘦就尝试向右或向左兄弟借键如果兄弟也撑不住就合并并把父节点里的分隔键一并删掉。// 叶子分裂的骨架先把原有键值一分为二 const int split_idx (num_keys_ 1) / 2; KeyType up_key keys_[split_idx]; // 新建右兄弟节点把 [split_idx, num_keys_) 的键值搬过去 LeafNode *right NewLeafNode(); for (int i split_idx; i num_keys_; i) { right-Append(keys_[i], values_[i]); } num_keys_ split_idx; // 更新左右兄弟的 next 指针 right-SetNext(GetNext()); SetNext(right); // 把 up_key 插入父节点 InsertIntoParent(up_key, right);split_idx的选取是 B树实现里最微妙的参数。取(num_keys 1) / 2这种偏左的划分能保证分裂后两个节点都不为空且左节点键数不少于右节点。上提的up_key是右兄弟的第一个键这个键在父节点里既做索引也做分隔叶子节点里它仍然保留着对应的 value——这是 B树和 B 树最大的区别叶子层保留全部键内部层只做导航。常见误区是把上提的键从叶子里删掉那会导致按这个键查找时在叶子层直接找不到。树的自平衡还体现在一个容易被忽略的规则所有叶子节点必须保持在同一层。这也是验证实现正确性的第一步只要出现某个叶子的深度和其他叶子不一致说明分裂时父链没接对。处理根节点分裂时要特别注意根没有父节点分裂后要新建一个根并把高度加一这个逻辑只要写错后续所有查找都会从错误的根开始。3.2 迭代器与叶子链表语义和实现上的两个易错点B树的迭代器用于范围扫描它不像数组迭代器那样只有一个指针因为数据分散在叶子链表的多个节点里。课程实验里迭代器的典型实现是内部持有一个指向当前页面的指针和一个节点内的键值迭代器Next时先移动节点内部迭代器走完当前页就去取叶子链表的下一页。// 迭代器自增先走页内再跨页 IndexIterator IndexIterator::operator() { iter_; // 页内迭代器 if (iter_ page_-End()) { page_id_t next_page page_-GetNext(); if (next_page ! INVALID_PAGE_ID) { // 释放当前页的 latch获取下一页的 latch UnlockPage(); page_ FetchPage(next_page); iter_ page_-Begin(); } } return *this; }易错点之一跨页之后迭代器指向的是新页面的Begin()而不是保持原来的位置因为旧页已经走完了。易错点之二扫描过程中页面不能被 BufferPool 淘汰否则page_指针悬空所以每跨一个页面都要保证页面处于 Pin 状态。课程里迭代器访问的页往往已经被上层代码固定如果迭代器实现里自己又做了一层 Pin 和 Unpin 就很容易乱套建议明确设计成「迭代器不负责 Pin调用方保证页面在扫描期间有效」。另一个实现差异点在叶子节点链表的头尾处理。链表是单向的从最左叶子开始每个节点只保留 next 指针。插入分裂时更新 next 指针的顺序搞反就会出现扫描只遍历一半节点的诡异现象。调试这种问题用断点很难受我一般直接在打印函数里把所有叶子的键序列和 next 指针打出来一目了然。3.3 调试 B树索引的三个切入点如果插入、删除测试不断失败先别急着二分代码按下面三个切入点排查大部分问题能快速定位。第一个切入点是父节点回溯。插入分裂后父节点的孩子指针必须指向正确的子节点。常见的翻车是分裂后只更新了左右兄弟的 next忘了在父节点里插入分隔键导致从根往下查永远找不到新叶子。检查方法很简单写一个递归打印函数从根开始按层输出每个节点的键和子树范围看每一层的区间是否连续覆盖。第二个切入点是删除后的借用与合并方向。删除节点时优先向有富余键的兄弟借而不是直接合并借的时候要把父节点的分隔键同步更新。很多人只写了合并没写借用或者借用的方向固定向右结果测试里「删除后重建」的用例过不去。注意借键时左兄弟的最后一个键放到当前节点的第一个位置右兄弟的第一个键放到当前节点的最后位置方向反了键序就乱了。第三个切入点是并发下的 index 扫描异常。B树实验单线程跑通后老师给的 grader 场景里经常有混合读写压力测试。单线程通过的代码到并发下丢数据十有八九是 latch 获取顺序问题留到下一章的并发控制部分统一讲。# 用一个小数据集反复插入删除并打印索引内容 ./test/b_plus_tree_print_test --gtest_filter*PrintTrees* ./test/b_plus_tree_concurrent_test --gtest_filter*MixedTest*这两个测试目标分别验证结构和并发。前者失败说明树的结构破坏后者失败通常是 latch 问题或迭代器语义错误。把这两类问题分开你就能少走一半弯路。4. 并发控制与记录恢复两个实验的联动理解与最小实现缓冲池和 B树解决了「数据在哪、怎么找」的问题并发控制和恢复机制解决的是「多个事务同时操作时怎么不出错、崩溃后怎么不丢数据」。这两个实验在课程里通常分开写但本质上是一条线先有并发控制保证事务的隔离性再有预写日志保证事务的持久性。很多同学把两个实验割裂开做完锁管理器就忘了日志的存在到做恢复实验时又要回头看一遍。4.1 锁管理器模式、隔离级别与死锁检测的落地写法课程实验的锁管理器通常要支持多种锁模式共享锁 S、排他锁 X以及意图锁 IS、IX、SIX。表级锁用意图锁和行级锁配合事务在访问行之前先要在表上获取对应意图锁。最常考察的是各级隔离级别下加锁规则的差异读未提交不拿 S 锁读已提交在读完立刻释放 S 锁可重复读要持有 S 锁到事务结束可串行化还要加范围锁或防止幻读。// 死锁检测的 DFS 框架维护 wait-for 图检测环 bool HasCycle(txn_id_t t, const std::unordered_maptxn_id_t, std::vectortxn_id_t wait_for, std::unordered_maptxn_id_t, bool visited, std::unordered_maptxn_id_t, bool in_stack) { if (in_stack[t]) return true; // 当前路径上再次遇到自己成环 if (visited[t]) return false; visited[t] true; in_stack[t] true; for (txn_id_t next : wait_for.at(t)) { if (HasCycle(next, wait_for, visited, in_stack)) return true; } in_stack[t] false; return false; }这段代码里两个哈希表的职责要分清。visited记录一个事务是否已经检查过避免重复遍历in_stack记录当前 DFS 路径上有哪些事务只有当路径上出现重复节点才是死锁。这里容易写错的是把两个表当同一个用导致误判死锁或者漏判。检测到环之后通常会选择 abort 一个年轻事务释放它持有的锁打破循环。abort 策略的选择也考过按事务启动时间排序回滚最新的事务比较常见因为它的修改最少补偿成本最低。课程实验在实现锁管理器时会给一张锁表每个锁条目记录持有者列表和等待队列。获取锁的顺序是先加表的 latch 防止并发修改锁表结构再判断锁兼容性不兼容就挂到等待队列里。最容易翻车的地方是等待队列里的事务被 abort 后没有从队列里摘除导致后续事务一直在等待一个永远不可能获锁的事务。4.2 预写日志与 ARIES恢复实验的最小重放模型恢复机制围绕预写日志 WAL 展开核心规则只有一句话日志必须先于数据页落盘。课程实验里通常要求实现日志的写入、刷盘以及崩溃后的恢复过程。恢复算法以 ARIES 为理论框架分为三个阶段分析阶段确定崩溃时有哪些事务未提交、哪些脏页需要恢复重做阶段把已提交事务的改动重新应用回滚阶段撤掉未提交事务的改动。# 恢复重放的三个阶段的简化伪代码 for log in sorted(logs, keylambda l: l.lsn): # 分析阶段扫描日志记录脏页与未提交事务 if log.type BEGIN: active_transactions.add(log.txn_id) if log.type COMMIT: active_transactions.remove(log.txn_id) committed.add(log.txn_id) for log in sorted(logs, keylambda l: l.lsn): # 重做阶段只重放已提交事务且页面 LSN 小于日志 LSN 才做 if log.txn_id in committed and log.page_lsn dirty_page_lsn(log.page_id): redo(log) for txn in active_transactions: # 回滚阶段逆序执行未提交事务的补偿日志 for log in reversed(txn.logs): undo(log) write_abort_log(txn)这里最容易被忽略的是重做阶段的page_lsn判断。如果页面已经在崩溃前被刷到磁盘而且页面上的修改比日志还新再重做一遍会产生重复应用。判断逻辑是比较日志记录的 LSN 和磁盘页面记录的 LSN只有日志 LSN 更大才需要重做。这是 ARIES 的幂等性来源也解释了为什么 WAL 不需要在事务提交时把数据页一起刷盘——只要日志在数据页迟早能恢复到一致状态。实现时还要注意日志序号 LSN 的分配必须是单调递增的常见做法是维护一个全局的原子计数器。日志文件格式里通常要记录事务 ID、页面 ID、旧值和新值旧值用于回滚新值用于重做。很多同学只记录新值漏掉旧值结果回滚阶段无从下手。4.3 并发与恢复的联动为什么两个实验要一起理解两个实验分开做时各有各的难点合起来才是一个完整的事务系统。并发控制管的是运行时恢复机制管的是崩溃后它们的交汇点在日志记录的内容上只有并发控制正确保证了事务的隔离性日志重放才不需要关心其他事务的中间状态。也就是说重做一个已提交事务的日志时可以假设它所读取和修改的数据没有受到未提交事务的污染这是可恢复性的前提。模块核心数据结构关键行为常见验证方式锁管理器锁表、等待队列加锁解锁、锁升级、死锁检测多线程并发读写不丢更新日志管理器WAL 文件、LSN日志追加、刷盘、恢复重放模拟崩溃后重放日志数据一致实践里有个值得做的联动验证开多个线程同时插入和删除 B树索引事务提交后记录数据总量然后手动 kill 进程再用恢复工具重放日志检查总量是否与崩溃前一致。这个过程能同时暴露并发控制的隔离漏洞和恢复逻辑的遗漏点。如果这一步能跑通数据库内核最基础的「事务正确性」就算真正立住了。5. 避坑自查15-445 实验中 5 个最典型的翻车现场现象→原因→解决这一章不按模块讲原理只讲我见过也踩过的具体坑。每一条都按「现象 → 原因 → 解决」的方式写你在实验里遇到对应症状时可以直接对照排查。5.1 缓冲池偶发 segfault锁的粒度没统一现象单线程跑测试全过用并发测试一压缓冲池偶尔崩溃错误栈指向LRUReplacer::Victim里的链表操作。原因BufferPoolManager和LRUReplacer各有一把锁管理器在修改页表和 pin count 时持有自己的锁替换器在维护链表时持有另一把锁两者交错执行时一个线程刚刚从链表里淘汰了帧 X另一个线程却还在用 X 的页面数据。解决确认所有对page_table_、pages_数组和replacer_的访问都在同一把锁下进行。我当时把替换器的方法实现成内部加锁管理器方法外部加锁双重加锁看似没问题但实际上拆成了两个临界区中间插入任何上下文切换都会产生竞态。统一成外部大锁替换器内部不重复加锁问题立刻消失。5.2 B树并发插入后扫描丢数据latch 顺序反了现象并发插入测试不报错但随后的全表扫描总少几个键而且是随机少的。原因插入路径上获取子节点的 latch 之前没有先获取父节点的 latch。两个线程同时走到需要分裂的叶子节点各自认为自己是父节点的唯一孩子同时往上提交分隔键父节点的孩子列表被覆盖。解决严格按「自顶向下」的顺序获取 latch这就是 latch crabbing 协议。访问子节点前先持有父节点的 latch子节点没分裂就把父节点解锁子节点分裂了就带着父节点锁继续往上。代码里每一处FetchPage都要检查当前线程是否已经持有了上一级页面的 latch宁可多锁一层也不要提前释放。5.3 死锁检测把自己堵死检测逻辑与后端线程耦合现象加了死锁检测之后原本正常的并发事务处理变慢了然后整个测试卡住直到超时被判失败。原因死锁检测是在事务线程里同步执行的检测到环之后要 abort 某个事务如果 abort 的正好是当前正在执行的线程它还在等待队列里没出来直接变成死锁。解决把检测线程从后端线程里拆出来单独跑一个周期性的检测任务检测线程只构建 wait-for 图和标记要 abort 的事务 ID真正的 abort 动作由等待中的事务线程在唤醒后自行检查标记。另外注意 abort 标记要加锁保护否则检测线程写标记、事务线程读标记会有竞争。5.4 恢复重放后数据不一致redo 里少了页面 LSN 判断现象崩溃恢复后部分页面的数据比崩溃前多了一部分改动而且是重复的改动。原因恢复重做没有检查页面本身的 LSN。崩溃前某些脏页已经被后台线程刷到磁盘重做阶段又把日志里的改动应用了一遍造成重复应用。解决在重做日志时先读取磁盘页面头部的page_lsn_只有日志记录的 LSN 大于页面 LSN 才执行重做小于等于则跳过。这个判断不能省也是 ARIES 名称里「算法」二字的分量所在。测试时可以用一个小脚本模拟先正常跑一组写入手动把其中一个脏页提前刷盘再走恢复流程对比两种实现的结果差异。5.5 本地全过但 grader 翻车未定义行为与数据竞争现象本地测试跑了很多遍都没问题提交到自动评分环境偶发失败可能这次 90 分、那次 70 分报错还不一样。原因代码里有未定义行为典型的是空指针解引用、整数溢出或者作用域外的引用返回。本地机器和评分机器编译器版本、CPU 架构不同未定义行为的表现自然不同。解决提交前用 Sanitizer 跑一遍测试比人工 review 高效得多。# 用 AddressSanitizer 和 ThreadSanitizer 分别编译测试 cmake -DCMAKE_CXX_FLAGS-fsanitizeaddress,undefined -fno-omit-frame-pointer .. make -j$(nproc) ./test/xxx_test cmake -DCMAKE_CXX_FLAGS-fsanitizethread -fno-omit-frame-pointer ..address检查内存错误undefined检查未定义行为thread检查数据竞争。注意 AddressSanitizer 和 ThreadSanitizer 不能同时开编译起来也会慢不少但比随机崩溃后通宵调试强多了。我见过不少同学本地用了返回值被销毁的临时对象-O0下碰巧没崩-O2下必崩这类问题没有 Sanitizer 基本只能靠猜。6. 课程视频总结的高效复盘法把 20 小时视频压缩成一页知识地图课程视频可以从头看到尾但更高效的做法是按模块看看完立刻在实验代码里找到对应函数。视频的作用不是替代动手而是给动手提供坐标系缓冲池的视频讲清楚「为什么需要 LRU」你回来看代码里lru_list_才不晕B树的视频讲分裂流程你回来才看得懂split_idx为什么那样取。6.1 一页纸知识地图三栏内容强制输出我一般给每个模块做一页笔记单页分三栏。左栏是「概念墙」只写该模块的专有名词和它们的实体对应关系比如 frame 对应pages_数组下标、page table 对应page_table_哈希表。中栏是「实验对照」列出课程视频讲到的主要函数和实验里需要实现的接口比如FetchPage对应「磁盘换页」这一节。右栏是「一句话回答」用一句话回答三个问题这个模块解决了什么问题如果不做会怎样它和下一个模块怎么衔接模块视频重点实验里要找的代码一句话回答缓冲池换页策略与脏页回写FetchPage / UnpinPage用内存缓存磁盘页省下磁盘 IOB树插入分裂与删除合并Insert / Remove / Iterator让有序数据在磁盘上也能高效定位并发控制锁与隔离级别LockManager / 死锁检测防止并发事务互相干扰恢复机制WAL 与 ARIESLogManager / 恢复重放崩溃后不丢已提交数据这个方法的收益在复习阶段体现得最明显。做完一个模块的实验后我隔一周会只看右栏的一句话说自己能不能把中间的实现逻辑串起来说不出来就说明只是抄过了代码不是真的懂。有一次我把 B树笔记的右栏写成「分裂是让树变宽合并是让树变窄」结果复习时发现自己已经答不上来split_idx怎么取回去翻代码才发现自己当时根本没吃透。这个翻车经历让我养成了习惯笔记里不写结论只写「从哪里能推出结论」的线索逼自己每次都重新推一遍。希望帮到你。本文还有配套的精品资源点击获取