数据库内核必修课:缓冲池、B+树与WAL恢复实战解析

发布时间:2026/10/9 23:12:41
数据库内核必修课:缓冲池、B+树与WAL恢复实战解析 简介CMU-15-445 数据库系统课程配套实验资料包适合数据库学习者与从业者深入理解系统实现。内容覆盖缓冲池管理器、B树索引、并发控制、记录恢复机制等核心模块并含C11编程示例、数据库理论实践要点、课程视频总结与实验指导建议。包内共121个文件以 C/C 头文件与源文件55个h、46个cpp为主辅以6个md笔记、5个txt说明、3张png图示及docx补充文档整体压缩包仅2.64MB结构清晰便于查阅。源码对应课程实验项目笔记侧重关键机制拆解与实现细节可帮助降低学习门槛。已有66人学习浏览。通过学习可获得可运行的实验代码、模块解析笔记与常见实验排错思路能帮助读者快速上手缓冲池与B树相关实验并为并发控制与故障恢复等进阶内容打下扎实基础。1. 数据库系统课程15-445到底在练什么一道题讲透内核必修的五个模块很多开发者在聊到数据库时都能把 B 树、事务隔离级别、WAL 这些词挂在嘴边可真到要自己写一版缓冲池替换策略或者日志恢复流程往往连第一步代码都落不下去。编号 15-445 的这门数据库系统课程一直坚持把数据库理论实践绑在一起缓冲池管理器、B 树索引、并发控制、记录恢复机制四个大实验串下来再配合课程视频总结和实验指导建议恰好能补上这块“会说不会做”的缺口。这篇文章不是把课件重新给你翻译一遍而是按我实际做完这些实验的顺序把每个模块的设计思路、关键参数和最容易翻车的地方讲清楚。适合准备认真啃数据库内核、愿意花几周时间泡在实验代码里的人。2. 缓冲池管理器先动手做对的第一步2.1 为什么缓冲池是这节实验的第一道门槛数据库的性能上限往往不是 CPU而是磁盘随机 IO。一个页面在内存里命中和落盘重新读一次代价差两三个数量级所以所有数据库都要有一个页缓存层。缓冲池管理器的职责就是执行引擎要某个 page_id 时先查内存缓存没有就从磁盘读进来内存不够就按替换策略淘汰旧页旧页如果是脏的还要先写回磁盘。看起来就是几行逻辑但它被索引、表扫描、执行器所有模块依赖任何 pin 计数或者脏页标记的疏漏都会在后面的实验里放大成数据错乱。课程一般会提供磁盘读写和 Page 对象骨架由你自己实现 BufferPoolManager 的核心逻辑。动手之前先把两个词分清楚Page 是数据本体Frame 是缓存里的槽位BufferPoolManager 维护 page_id 到 Frame 的映射以及一组可用的 Frame。我实现时选了三个数据结构unordered_map存 page_id 到 Frame 的映射list维护 LRU 顺序free_list维护没装载任何页的空槽。职责划得越单纯后面往上面加并发保护就越容易。2.2 Frame 与 Page先从数据结构上拧清关系下面是一个最小可用的 Frame 设计。注意 Frame 不是 PageFrame 是缓存槽包含页数据、引用计数和脏位。struct Frame { Page page; // 页数据本体包含 data_ 数组和 page_id_ size_t pin_count; // 被多少个执行模块引用0 表示可淘汰 bool is_dirty; // 内存数据比磁盘新淘汰时要写回 std::listFrame *::iterator lru_pos; // 在 LRU 列表中的位置O(1) 摘除 };lru_pos是最容易省掉但最不该省的一个字段。如果命中一个页之后要在std::list里从头找到这个 Frame 再移动复杂度就成了 O(n)高压力下整个实验都会被拖慢。std::list的迭代器在元素被摘下再重新插入后不会失效所以可以安全地把迭代器存在 Frame 里。用 C11 提供的std::list、unordered_map、智能指针来组织这些代码比手动管理原生数组舒服得多也符合这节实验标题里强调的 C 编程要求。pin_count是整个模块里最重要的状态。FetchPage 命中时 pin_count 要加一pin_count 大于 0 的帧即使排到 LRU 尾部也不能淘汰否则正在使用的页会被换出留下一个悬空指针。许多并发测试的偶发崩溃最后追下去都是这里没做保护。2.3 把 LRU 替换在实验代码里跑通Fetch 与 Unpin 的实现要点FetchPage 的完整流程可以分为命中、找 victim、换入三步。Page *BufferPoolManager::FetchPage(page_id_t page_id) { // 1. 查映射表命中则增加 pin 计数并移动到 LRU 最近使用端 auto it page_table_.find(page_id); if (it ! page_table_.end()) { Frame *frame it-second; frame-pin_count; lru_list_.erase(frame-lru_pos); lru_list_.push_front(frame); frame-lru_pos lru_list_.begin(); return frame-page; } // 2. 未命中优先拿空闲槽位否则淘汰一个 pin_count 0 的旧页 Frame *victim nullptr; if (!free_list_.empty()) { victim free_list_.front(); free_list_.pop_front(); } else { auto iter lru_list_.end(); while (iter ! lru_list_.begin()) { --iter; if ((*iter)-pin_count 0) break; } if (iter lru_list_.begin() (*iter)-pin_count ! 0) return nullptr; // 所有帧都被 pin淘汰失败 victim *iter; lru_list_.erase(iter); } // 3. 脏页先写回再清空并装载新页 if (victim-is_dirty) disk_manager_-WritePage(victim-page.page_id_, victim-page.data_); victim-page.page_id_ page_id; victim-page.data_.clear(); disk_manager_-ReadPage(page_id, victim-page.data_); victim-pin_count 1; victim-is_dirty false; page_table_[page_id] victim; lru_list_.push_front(victim); victim-lru_pos lru_list_.begin(); return victim-page; }这段代码里最关键的是“找 victim 时从尾部向前找第一个pin_count 0的帧”而不是直接取lru_list_.back()。单线程小数据量测试里二者差别不大一旦并发测试里出现一个长时间被 pin 的页直接取尾部就会把它提前淘汰错误数据随后会从磁盘被重新读回表现是测试跑着跑着开始报错或读出任意的旧值。另一个细节是 FetchPage 返回后 pin_count 已经从 0 变 1调用方用完必须调 UnpinPage 释放否则这个 Frame 永远无法被替换空闲列表最终耗尽。UnpinPage 的签名通常是bool UnpinPage(page_id_t page_id, bool is_dirty)这里有一个高频误用调用方每次传入“这一次操作是否改了页”而脏位是累积状态。页之前已经是脏的就算这次 unpin 传 false也不应该把脏位清掉。正确写法是frame-is_dirty | is_dirty。漏掉这一步唯一脏页在淘汰时不写回所有修改都会静默丢失。注意FetchPage 命中时移动 LRU 位置要在修改 pin_count 之后再做避免把 pin_count 为 0 的页误放到“最近使用”端头部而影响淘汰顺序。2.4 容易被忽略的边界参数NewPage 与 DeletePage 的约定NewPage 需要分配一个新的 page_id 并返回一个可写的页。常见做法是维护一个从 0 递增的分配器先拿新 id再从 free_list 取一个 Frame 装载。这里最容易犯的错是分配器 id 和实际装载不同步分配器发出 id 后如果 BufferPoolManager 没能成功找到 Frame下次再发就会跳过这个 id页号出现空洞扫描逻辑全部错乱。所以我的习惯是分配器只负责发号页面真正装载成功后才把 page_id 写进 Page 对象。DeletePage 的语义是彻底删除。操作前先查映射表没有这个 id 就直接返回 true有的话再看 pin_count大于 0 说明还有模块在用必须返回 false。删除时要同时从映射表和 LRU 列表摘除把 Frame 放回free_list_。只摘映射表不摘 LRU 列表后面 LRU 遍历会访问到已删除帧的迭代器ASan 立刻报 use-after-free。这里给你一个实验指导建议在写 B 树之前专门花一个晚上把缓冲池这些边界情况用单测打满。B 树的每个节点都是一个页分裂、合并、删除会大量调用 NewPage、DeletePage、UnpinPage。缓冲池漏 pin 或者脏页写回出错到索引阶段都会以“读错 key”的形式暴露到那时再回头排查成本是现在的三倍以上。3. B 树索引从一次插入开始搞懂分裂与合并3.1 为什么 B 树能统治关系型数据库的索引层B 树能长期占据关系型数据库索引的主流位置核心原因是它在磁盘页上的扇出足够大。一棵三层的 B 树可以容纳千万级 key意味着大多数查询只需三次磁盘 IO根一次、内部节点一次、叶子节点一次。相比红黑树或者跳表B 树把相邻 key 都放在同一个叶子页里范围扫描只需要沿叶子链表顺序读页不用反复回溯父节点这个特性在数据库工作负载里极其重要。B 树与普通 B 树的区别也很好记B 树的内部节点也存数据B 树的数据全部在叶子节点内部节点只存 key 和指向子节点的指针。这样做的好处是内部节点可以做得更“扁”树高更稳定叶子节点之间通过链表串起来范围查询就是连续读。索引页的读写完全依赖缓冲池节点本身就是一个 Page所以你在索引代码里看到的每一个“节点指针”本质上都是 page_id。节点大小的参数是理解 B 树代码的钥匙。一个节点通常有 max_size 和 min_size 两个值max_size 表示最多能放多少个 keymin_size 表示删除后不能低于多少 key非根节点一般是半满即 max_size / 2。分裂和合并的触发条件全部基于这两个值写错一位就全盘乱掉。节点类型存的内容子节点指针典型特点内部节点排序好的 keykey 数量 1 个页号只做路由不存数据叶子节点排序好的 key 和 valuenext 指针指向下一个叶子页真正承载数据供扫描3.2 插入路径上的分裂与父指针更新插入操作先要从根一路向下走到正确叶子页然后把 key 写到叶子节点。如果叶子节点满了就要分裂把后半段 key/value 挪到新节点再把新节点的最小 key 提给父节点。父节点如果也满了继续向上分裂直到根。根分裂时新建一个根树高加一。分裂逻辑里最容易写错的点是“哪个 key 上提”。以每个节点最多 4 个 key 为例插入后节点有 5 个 key此时应该把中间位置的 key 上提到父节点而不是把新插入的 key 上提。下面是叶子节点分裂的核心片段。void SplitLeafNode(LeafNode *old_node, LeafNode *new_node) { int total old_node-GetSize(); // 插入后的 key 总数 int mid total / 2; // 后半段的起点 new_node-SetSize(0); // 把第 mid 个到最后一个 key/value 移动到新节点 for (int i mid; i total; i) { new_node-Append(old_node-GetKey(i), old_node-GetValue(i)); } new_node-SetNext(old_node-GetNext()); old_node-SetNext(new_node); old_node-SetSize(mid); // 上提新节点的第一个 key并插入父节点 InsertIntoParent(old_node, new_node, new_node-GetFirstKey()); }InsertIntoParent要在父节点里找到 old_node 对应的指针位置把 new_node 的页号和上提 key 插到后面。这里有个参数陷阱内部节点的 key 数量加一才等于子节点指针数量所以插入到父节点时key 数组和 child 数组的下标要错开处理。很多实现在这里把下标算错后果是树结构里出现两个一样的 key查询时路由到错误的子树。避免这个坑的方法是在实现里维护不变式断言叶子节点 size 永远在 [min_size, max_size] 之间内部节点的 key 数量恒等于 child 指针数减一。每次分裂、合并后都跑一遍断言能把大部分错误拦在测试之前。3.3 删除路径上的合并、重分布与下界删除比插入更麻烦因为删除后节点可能低于 min_size。低于下界时有两种补救先从相邻兄弟借一个元素这叫重分布借不到就与兄弟合并。合并之后父节点少了一个 child要继续向上检查父节点是否也低于下界一路递归到根。根节点如果只有一个 child就把根替换成这个 child树高减一。void CoalesceOrRedistribute(Node *node) { if (node-IsRoot()) { if (node-GetSize() 0) { // 根只有一个子节点让子节点成为新根释放旧根页面 SetRoot(node-GetChild(0)); DeleteNodeFromBufferPool(node-GetPageId()); } return; } Node *parent node-GetParent(); int idx parent-GetChildIndex(node); // 优先尝试从右边兄弟借 Node *sibling parent-GetChild(idx 1); if (sibling sibling-GetSize() sibling-GetMinSize()) { node-Append(sibling-GetFirstKey(), sibling-GetFirstValue()); sibling-RemoveFirst(); parent-UpdateKeyAt(idx 1, sibling-GetFirstKey()); } else { // 借不到就合并到左兄弟或右兄弟然后递归检查父节点 Merge(node, sibling); CoalesceOrRedistribute(parent); } }这里涉及两个参数细节。第一个是 min_size 的算法必须是“临界值一致”如果 min_size 定义为 max_size / 2那么合并后两个节点总数如果小于等于 max_size 才能合并否则应该走重分布。第二个是重分布借元素时父节点里作为分界线的 key 必须同步更新否则后续查找会走进错误分支。删除后不断言、不更新父 key几乎是 B 树实验里最隐蔽的翻车点。我的实验指导建议是先把插入逻辑写到一键跑通 tens of thousands 的随机插入再开始写删除删除逻辑要用“先插入到很大再删除到很小再插回来”的压测专治分裂后合并又分裂的振荡问题。这类问题看起来很像玄学其实都是 min_size 与合并条件不一致导致的。3.4 把索引和缓冲池拼起来先无并发跑逻辑再加锁这一节实验最稳的推进顺序是先不处理并发用单线程把查找、插入、删除全部跑通然后再给每个节点加 latch。很多人一开始就想着读写锁、死锁避免结果索引逻辑本身有 bug并发排查根本查不过来。先把单线程下的 B 树打磨到稳定通过基础测试再考虑并发能让调试难度降低一个量级。在无并发阶段就要把所有页面访问统一走缓冲池接口获取节点页、读数据、修改后标记脏、unpin。绝对不要绕过缓冲池直接对 Page 指针做裸读写。节点页被淘汰后索引代码持有的指针会悬空这类问题在无并发阶段不出现一开多线程就频繁崩溃。4. 并发控制与记录恢复机制让数据库在故障后还能说清楚账4.1 锁管理器从表锁到行锁的粒度选择并发控制的第一个重头戏是锁管理器。课程实验里通常要求实现 LockShared、LockExclusive、Unlock 三个接口并维护每个锁的等待队列。锁粒度先做表锁再做到行锁表锁实现简单但并发度低行锁能提高并发度但要处理更多锁冲突。实现核心是一个从锁目标到等待队列的映射。struct LockRequest { txn_id_t txn_id; LockMode mode; // SHARED / EXCLUSIVE bool granted; // 是否已经获得锁 }; struct LockQueue { std::listLockRequest requests; }; // 表级锁表table_oid_t - 等待队列 std::unordered_maptable_oid_t, LockQueue table_lock_table_; // 行级锁表(table_oid_t, row_oid_t) - 等待队列 std::unordered_mapstd::pairtable_oid_t, row_oid_t, LockQueue row_lock_table_;当一个事务请求锁时先检查同一目标上有没有不兼容的已授权锁。例如已经有事务持有独占锁后来的共享锁请求必须加入等待队列排在前面等待队列中的请求一旦被授权后面的请求即使模式兼容也要按顺序授让否则会插队导致不公平。锁升级时还需要处理一个边界同一个事务已经持有共享锁再请求同一目标的独占锁如果队列中间夹着别的等待者直接升级会绕过它们形成活锁。这里要特别强调封锁协议二阶段锁要求事务先增长后收缩即获得锁的阶段不能释放锁释放锁之后不能再获得新锁。实验里很多死锁测试“偶发超时”本质是事务在释放锁之后又重新请求锁破坏了二阶段性质导致测试框架模拟的调度进入不可预期状态。4.2 死锁检测与隔离级别的实现边界死锁检测是并发实验里最有含金量的部分。常见做法是维护一个 wait-for 图节点是事务有向边表示“事务 A 正在等待事务 B 持有的锁”然后周期性对图做 DFS发现环就中止其中一个事务。下面是一个伪代码骨架展示了检测的基本结构。// 构建 wait-for 图只关心正在等待锁且尚未获得锁的事务 std::unordered_maptxn_id_t, std::unordered_settxn_id_t wait_for; for (auto [lock_target, queue] : all_lock_tables_) { for (auto req : queue.requests) { if (req.granted) continue; // 已拿到锁的不参与等待 for (auto holder : queue.requests) { if (!holder.granted) continue; if (holder.txn_id ! req.txn_id) wait_for[req.txn_id].insert(holder.txn_id); } } } // 对每个事务做 DFS检测环环中找到 victim 事务并 abort for (auto [txn, edges] : wait_for) { if (DfsFindCycle(txn, wait_for, visited, stack, victim)) txn_manager_-Abort(victim); }检测到环后选哪个事务作为牺牲者直接影响测试成绩。最笨但稳定的策略是选 txn_id 最小或者说最早启动的事务更好的策略是先比较持有锁数量再比较启动时间。持有锁多的事务 abort 代价更高所以应该优先中止持有锁少的新事务。这里有一个常见的反直觉点不能每次都 abort 遍历时最先碰到的那个事务否则一个连接如果总在环的起点位置会被反复 abort表现为某个线程永远无法完成。隔离级别的实现边界也需要想清楚。READ_COMMITTED 下事务读完数据后共享锁可以立即释放REPEATABLE_READ 下共享锁要一直持有到事务结束。如果在实现 READ COMMITTED 时错误地把锁保持到事务提交测试框架会检测到过度的锁阻塞并发度大幅下降。相反如果在 REPEATABLE READ 下提前释放共享锁会出现不可重复读测试结果直接不通过。4.3 WAL 与恢复机制日志先落盘数据后写页记录恢复机制依赖的核心理念是 Write-Ahead Logging也就是先写日志再改数据页。这样做的目的是崩溃恢复时有据可依日志里记录了每个事务对页面的修改内容恢复过程可以用日志重放也可以用日志回滚。课程实验里日志记录通常包含这些字段事务 id、LSN日志序号、prev_lsn、日志类型、page_id以及修改前后的数据。struct LogRecord { lsn_t lsn; txn_id_t txn_id; lsn_t prev_lsn; // 同一事务上一条日志的 LSN用于回滚 LogType type; // BEGIN / INSERT / UPDATE / COMMIT / ABORT page_id_t page_id; std::vectorchar old_data; // undo 需要 std::vectorchar new_data; // redo 需要 };恢复流程分两步。redo 阶段从最后一个检查点开始重放所有已提交事务的日志undo 阶段对崩溃时未提交的事务沿着 prev_lsn 链回滚。判断一条日志属于哪个事务需要维护一个已提交事务集合。这里最容易被忽略的是redo 不能无条件重放每条日志。如果一个脏页在崩溃前已经落盘且它的 Page LSN 大于等于日志的 LSN说明这条日志已经生效再重放就会覆盖更新数据。所以恢复代码要先比较日志 LSN 与页面 LSN只有日志更新的才重放。注意提交日志必须真正刷到磁盘后才能向客户端返回提交成功。只要日志还在内存缓冲区崩溃后它就不存在这个事务等于白做。4.4 实验指导建议并发与恢复一起做时怎么安排顺序这门课的并发控制与记录恢复两个实验在顺序上连着做但建议把精力按 6:4 分配。锁管理器完成且稳定后立刻开始日志模块日志模块最难的部分不是 redo/undo 算法而是和缓冲池的配合刷脏页、页 LSN 更新、检查点记录这三个点耦合在一起任何一个先后顺序错位都会带来数据丢失。建议用一个自制的崩溃注入脚本做验证随机在某个日志刷盘点直接终止进程重启后检查数据是否一致。这类脚本能成倍提高对恢复机制的理解而且远比只跑官方测试更能暴露问题。后面第五部分我会把最常见的几个崩溃现场展开讲。5. 常见问题排查缓冲池、B树、并发恢复最容易翻车的五个现场5.1 缓冲池测试偶发崩溃LRU 把带 pin 的页淘汰了现象并发测试跑到几百毫秒后偶发崩溃单线程重跑完全正常ASan 偶尔报出悬空指针访问。原因LRU 替换时直接取列表尾部的 Frame没有检查 pin_count。并发场景下一个事务还在读的页因为排到了尾部被误淘汰另一个线程再访问这个页时拿到的是从磁盘重新读出来的旧副本数据立刻不一致。解决淘汰逻辑必须从 LRU 尾部开始向前扫描找到第一个pin_count 0的帧如果全部被 pin返回失败而不是强行淘汰。同时检查 UnpinPage 是不是真的把所有使用方都释放了。漏一次 unpin表现就是“空闲页越来越少”最终在 unexpected 位置崩溃。5.2 B 树越界写入满页判断与分裂时机差了一位现象插入测试报错 “page size exceeded”或者某个 key 永远查不到。原因分裂条件判断写成了if (node-size max_size)但实际是在插入之后判断此时节点已经能容纳 max_size 1 个 key。插入后直接塞进节点再触发分裂节点数组溢出或者分裂时把中间 key 留在旧节点里导致旧节点超过上限。解决在插入到节点之前判断“如果这个节点再插入一个 key 就会超过 max_size”超过就先分裂再插入。分裂时把中位 key 上提父节点保证旧节点和新节点都在合法范围内。实现后一定要加不变式断言在每次插入、删除结束时检查所有节点的 size 范围否则这种“差一位”的 bug 会非常隐蔽。5.3 并发测试偶发超时死锁检测里等待方向搞反了现象某个并发场景下测试偶发超时手动重跑又通过看起来像玄学。原因wait-for 图建的边方向反了实际是“A 等待 B”图里却记成“B 等待 A”。DFS 找不到环或者找出来的环根本没有语义更常见的是等待队列把已经 granted 的请求也参与了等待关系导致一个事务自己等自己形成假死锁永远 abort 同一个事务。解决构建边时只从“未获得锁的请求”出发指向“持有锁的请求”即等待者指向持有者。DFS 前先输出一张 wait-for 文本图用几个手工场景核对方向。victim 选择先按持有锁数量升序再按 txn_id 升序保证死锁发生时总能稳定中止同一个代价最小的事务。5.4 恢复测试丢数据LSN 分配与刷盘时机没对上现象模拟崩溃后重启某个已提交事务的插入数据消失log 扫描却能看到 COMMIT 记录。原因提交日志没有真正刷盘就返回成功或者页面写盘后 Page LSN 没更新导致 redo 阶段认为日志已失效并跳过。另一个常见来源是日志的 LSN 不是从同一个分配器发出的页面 LSN 用了自己的计数器日志 LSN 用了另一个二者对不上重放时判断逻辑彻底失效。解决所有 LSN 统一由日志管理器分配页面写入时必须从日志记录中拷贝 LSN 到 Page 的字段。提交路径上COMMIT 日志写入后必须调用强制刷盘确认磁盘落定才返回。自己写一个崩溃注入脚本在 COMMIT 刷盘前、刷盘后各打断一次验证两种场景都能恢复出正确数据。5.5 ASan 爆 use-after-freePage 生命周期被两个模块同时管理现象ASan 报 heap-use-after-free调用栈指向 B 树节点访问。原因B 树删除节点时直接 delete 了 Page缓冲池里还有这个 page_id 的映射或者反过来缓冲池淘汰了一个还在被索引使用的页。两套代码对“页归谁管”没有达成一致。解决约定统一由 BufferPoolManager 管理页生命周期。索引侧需要删除页时调用 DeletePage由它负责从映射和 LRU 列表摘除再放回 free_list索引侧永远不要直接 delete Page 对象也不要在 unpin 之后继续持有 Page 指针。想让某个页淘汰正确做法是把 pin_count 减到 0 并交由 LRU 替换策略去处理。6. 把实验台改造成调试工具页面 dump、不变式断言与固定随机种子6.1 给索引加一个页面 dump 函数写 B 树时一个能按层打印所有节点 key 的 dump 函数比任何日志都管用。我的做法是从根节点开始对内部节点递归访问每个子节点对叶子节点输出全部 key/value。打印时带上 page_id 和每个节点的 size方便直接对应到缓冲池状态。void DebugDump(const Page *page) { auto *node reinterpret_castconst BPlusTreePage *(page-GetData()); // 内部节点递归打印叶子节点打印 key 列表 if (!node-IsLeaf()) { for (int i 0; i node-GetSize(); i) DebugDump(node-GetChildPage(i)); } else { for (int i 0; i node-GetSize(); i) std::cerr node-GetKey(i) ; std::cerr std::endl; } }配合不变式断言一起用assert(node-GetSize() node-GetMinSize())在每个 public 操作结束时跑一遍能在测试崩溃前先把结构错误暴露出来。这种断言在 release 模式下会被编译掉只在 debug 模式生效所以不会影响性能测试结果。6.2 用“先退回单线程”的方式定位并发问题并发 bug 最可怕的不是难修而是难复现。我的习惯是先把并发测试改回单线程确定纯逻辑正确然后固定随机种子让插入删除序列完全相同最后再开两个线程并把死锁检测周期调短方便观察等待图。定位等待问题时用 GDB 的thread apply all bt看每个线程卡在哪个锁上比反复打日志高效得多。我自己在这个实验阶段踩过最狠的一次是第四模块里以为 redo 流程顺畅就提交了结果崩溃点正好卡在“日志未刷盘 脏页已写盘”的间隙恢复后整个事务的数据全部丢失。从那以后我给自己定了规矩任何改完核心代码的版本先跑 ASan 加固定随机种子的回归再跑官方测试。这套习惯一直沿用到现在数据库内核这种“错一步就让你查一整天”的黑匣子只有靠复现和断言才能撬开。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询