[{"content":" 本文整理了 2026 年暑期实习面试（偏数据库存储方向）中出现的问题及详细答案，问题来源于多场面试，涵盖 Raft 协议、TinyKV、Bustub、MVCC、LSM-Tree、B+Tree、全文索引、向量数据库以及 C++ 等多个方向。\n一、Raft 协议与分布式一致性 Q1：Follower 给 Candidate 投票需要满足什么条件？ Follower 给 Candidate 投票需要同时满足以下三个条件：\nCandidate 的 term ≥ Follower 的 current term：如果 Candidate 的 term 更小，说明是过期的选举请求，直接拒绝。 Follower 在本 term 内尚未投过票，或已投票给该 Candidate：保证每个节点在同一 term 内最多只投一票。 Candidate 的日志不比 Follower 的日志旧（选举限制，Election Restriction）： 先比较各自最后一条日志的 term，term 更大的更新； 若 term 相同，则 index 更大（日志更长）的更新。 选举限制的意义：确保当选的 Leader 一定拥有集群中最完整的已提交日志，防止已提交日志被覆盖——这正是 Raft Leader Completeness 属性的保证。\nQ2：Follower 变成 Candidate 需要经过什么检查？ Follower 转变为 Candidate 的触发条件是选举超时（electionTimeout）：Follower 维护一个 electionElapsed 计数器，每 tick 加一，当计数器超过随机化的选举超时阈值且该节点没有收到来自 Leader 的心跳时，触发选举。\n转换过程（becomeCandidate()）：\n角色切换为 Candidate，term += 1； 给自己投票（votes[self] = true），重置计时器； 广播 MsgRequestVote 给所有其他节点，携带自己的 term、lastLogIndex、lastLogTerm。 \u0026ldquo;转变之前是否有检查？\u0026rdquo;\n严格来说没有主动检查，转变是被动触发的：Follower 只要在 electionTimeout 内没有收到有效心跳，就会自动触发。这里没有额外的前置条件判断，只要超时就转换。\nQ3：是否所有 Follower 会在同一瞬间全部发起选举？为什么？ 不会。Raft 通过随机化选举超时来避免这一情况：\n每个节点的选举超时时间是在 [ElectionTick, 2×ElectionTick) 范围内随机取值，不同节点的超时时刻不同。在实践中，最先超时的节点率先变成 Candidate 发起选举，其他节点收到该 Candidate 的 RequestVote 消息后，只要 term 和日志条件满足，就会投票，多数情况下该节点能在其他节点超时之前就赢得选举。\n只有在极端情况（如多个节点同时超时导致分票）才会出现多个 Candidate 同时竞选，此时由于各自任期号不同且没有节点能获得多数票，会触发下一轮选举，随机超时机制保证最终一定能选出 Leader。\nQ4：Raft 日志什么时候算 Commit？什么时候算 Apply？顺序关系？ Commit（提交）：Leader 收到来自集群多数节点（含自身）的成功 AppendEntries 响应，且该日志条目属于当前 term（Leader 只能通过当前 term 的日志推进 commitIndex），则将 commitIndex 推进到该日志的 index，此时该日志被认为已 commit。committed 状态由 Leader 通过下一次心跳或 AppendEntries 的 commit 字段告知 Follower，Follower 更新自身 commitIndex。\nApply（应用）：节点将 [lastApplied+1, commitIndex] 范围内的日志逐条送入状态机执行，完成后更新 appliedIndex，此时日志才真正影响系统状态（写入 KV 存储等）。\n顺序关系：严格满足 applied ≤ committed，即日志必须先 commit 才能 apply，且 apply 是幂等的——已 commit 的日志一定最终会被 apply，这是 Raft 的安全性保证之一。\nQ5：快照的作用是什么？快照包含哪些主要信息？ 快照解决的问题：Raft 日志是不断增长的，若不加以处理，日志文件会无限增大，导致：\n磁盘空间耗尽； 节点重启时需要重放所有历史日志才能恢复状态，耗时极长； 新加入的节点需要从头同步大量历史日志，影响集群扩容速度。 快照通过将某个时间点的状态机状态完整存储下来，并截断该时间点之前的所有日志，来解决上述问题。\n快照的主要内容（以 TinyKV 为例）：\nKV 数据：状态机在快照时刻的完整数据（即 kvDB 的内容）； 元数据： snapshot_index：快照对应的最后一条已应用日志的 index； snapshot_term：快照对应的最后一条已应用日志的 term； region_state：Region 的元信息（StartKey、EndKey、RegionEpoch、Peers 等）； 这些元数据是接收快照的节点用来恢复 RaftLog 状态和 Region 配置的关键依据。 Q6：为什么要实现 Multi-Raft？单个 Raft 的问题是什么？ 单个 Raft Group 存在两个根本性瓶颈：\n可扩展性受限：所有数据由同一组节点管理，无法通过增加新节点来线性提升容量，只能垂直扩容。 并发处理能力有限：所有写请求必须经过同一个 Raft Group 串行提交，即使有多台机器，吞吐量也被单个 Raft Group 的处理速度所限制。 Multi-Raft 的解法：将整个 key 空间划分为多个 Region，每个 Region 负责一段连续的 key 范围，由独立的 Raft Group 管理。不同 Region 的请求可以并行处理，同时支持随数据量增长动态分裂 Region 和迁移 Region，实现水平扩展。\nQ7：调度器（Scheduler）根据什么逻辑来做 Region 迁移？ TinyKV 的 Scheduler（类似 TiDB 的 PD）定期收集各 Store 上报的 Region 心跳，维护集群全局视图，主要做以下调度：\nRegion Balance（负载均衡）：统计每个 Store 上的 Region 数量，若 Store 之间 Region 数量差异超过阈值（默认超过 2），则将一个 Region 从 Region 较多的 Store 迁移到较少的 Store。迁移流程是：先对目标 Store AddPeer（ConfChange），等新 Peer 追上日志后，再 TransferLeader（将 Leader 迁移到目标 Store），最后 RemovePeer（移除旧 Store 上的 Peer）。 Leader Balance：尽量让 Leader 均匀分布在各个 Store，避免单一 Store 承担大量读写请求。 Region 分裂（Split）逻辑：当一个 Region 的数据量超过阈值时，触发分裂：将原 [StartKey, EndKey) 的 Region 在中点 key 处一分为二，生成两个新 Region，各自维护独立 Raft Group。分裂是通过 Raft 日志中的 AdminCmdType_Split 指令来完成的，确保分裂动作在 Raft Group 所有副本上一致执行。\nQ8：TinyKV 快照隔离（Snapshot Isolation）是如何实现的？ TinyKV 基于 Percolator 协议实现了快照隔离，核心依赖全局时间戳服务（TSO）：\n事务开始时获取 start_ts：从全局 TSO 获取单调递增的时间戳作为事务的起始时间戳，代表这个事务\u0026quot;看到的快照\u0026quot;版本。 读操作按版本读：所有读都读取 commit_ts ≤ start_ts 的最新已提交版本，不读取 start_ts 之后提交的数据，也不读取任何未提交数据。在 MVCC 存储层，key 以 (user_key, commit_ts) 的复合键存储，读时通过 start_ts 做版本过滤。 写操作（Prewrite）加锁：写操作先在 CfLock 列族写入锁记录，防止并发写冲突（First-Writer-Wins）； 提交时获取 commit_ts：提交时再次申请一个 TSO 作为 commit_ts，写入 CfWrite 作为版本标记。 通过 start_ts 构建的一致性快照，保证了同一事务内多次读取相同 key 的结果一致（可重复读），且不会读到并发事务的中间状态。\nQ9：TinyKV 事务中冲突检测是如何做的？ TinyKV 采用乐观并发控制（OCC） + Percolator 2PC，冲突检测发生在 Prewrite 阶段：\n写写冲突检测：在对 key 加锁（写 CfLock）之前，检查该 key 上是否已存在其他事务的锁（GetLock）。若存在锁且锁的 ts 不是本事务的 start_ts，说明有另一个并发事务正在写这个 key，当前事务的 Prewrite 失败，需要执行重试或等待（等待锁释放后清理）。 读写冲突检测（防止写覆盖已读数据）：检查该 key 在 (start_ts, max) 版本范围内是否有已提交的写记录（MostRecentWrite）。如果有，说明有另一个事务在本事务开始之后提交了对这个 key 的修改，本事务的快照已过期，Prewrite 失败，需要重试（First-Writer-Wins 原则）。 Q10：快照隔离和可串行化的区别？ 维度 快照隔离（SI） 可串行化（Serializable） 并发异常 可防止脏读、不可重复读、大部分幻读 防止所有并发异常 写偏斜（Write Skew） 无法防止 防止 实现方式 MVCC + 时间戳 2PL 或 SSI 性能 高（读不加锁） 低（全局锁或冲突检测开销大） 写偏斜示例：两个事务同时读取\u0026quot;在岗医生 ≥ 1\u0026quot;的条件满足，各自将一名医生设为下班，最终导致在岗医生为零。两者没有写同一行（无写写冲突），但都基于快照做了破坏整体约束的决策。\n可串行化快照隔离（SSI）：PostgreSQL 的解决方案是在读上打标记（读依赖图），提交时检测是否存在\u0026quot;反向读依赖循环\u0026quot;，若有则 abort 其中一个事务，在不降低 SI 并发度的前提下消除写偏斜。\n二、Bustub Buffer Pool / 内存管理 Q11：Buffer Pool（缓冲池）如何设计？ Bustub Buffer Pool Manager（BPM）负责在有限的内存 Frame 和无限的磁盘 Page 之间做透明管理。核心组件如下：\nLRU-K 替换策略：使用两个队列—— history_list：访问次数 \u0026lt; K 的 Frame，按 FIFO 淘汰； lru_list：访问次数 ≥ K 的 Frame，按最早的第 K 次访问时间淘汰（更能适应偶发访问，不像 LRU 那样容易被冷页污染）。 当 Frame 访问次数达到 K 时，从 history_list 移至 lru_list。 Disk Scheduler：将磁盘 IO 操作异步化，放在独立线程池中执行，避免 IO 阻塞 BPM 的其他操作。 Page Guard（RAII）：ReadPageGuard 和 WritePageGuard 包裹页面的读写锁生命周期，防止忘记 unpin 或解锁。 并发设计：对 BPM 的元数据（free_list、page_table 等）加一把全局 latch；对 Frame 本身加 frame 级别的读写锁，使得 IO 操作可以独立于 BPM 元数据操作并发执行，从而利用 IO 并发提高吞吐量。\nQ12：什么场景下需要使用内存管理（Buffer Pool）？ 缓冲池是数据库必需的，原因在于：\n磁盘 IO 代价极高：随机磁盘 IO 与内存访问的速度差距在 3-5 个数量级。对热点数据缓存在内存中可以极大减少磁盘访问。 数据管理的透明性：数据库层通过页表（page_table）维护磁盘 Page 与内存 Frame 的映射，上层访问数据就像访问内存一样，不需要关心数据是否在磁盘上。 预取（Prefetch）与顺序扫描：对大表顺序扫描时，BPM 可以按需预取后续页面，减少 IO 等待。 典型应用场景：OLTP 通常有大量随机点查（热点数据常驻内存收益最大）；OLAP 大表扫描则需要控制 BPM 不被扫描数据污染（使用 SCAN 访问模式的页面不应留在 LRU 中太久）。\nQ13：Bustub 与 OceanBase 在内存管理上的差异？ 对比维度 Bustub BPM OceanBase MemStore/BlockCache 粒度 以固定大小页（4KB/8KB）为单位 OB 分为 MemTable（LSM 写缓存）和 Block Cache（SSTable 读缓存），粒度更灵活 写路径 写操作先修改内存 page，脏页延迟刷盘 OB 写先进 MemTable（内存），达到阈值后 Minor Compaction 落盘 淘汰策略 LRU-K OB Block Cache 基于 LRU 变体，并区分不同数据类型设置不同优先级 多租户 无多租户概念 OB 支持多租户，每个租户有独立的内存配额限制 内存上限 参数化（pool_size） 通过 memory_limit 精确控制，并有内存超限保护机制 Q14：业务场景——亿级门店数据的实时支付流缓存如何设计？ 针对实时支付流补充门店信息的场景（读多写少、数据量亿级），缓存设计思路如下：\n分层缓存：\nL1：进程内 LRU 缓存（如 Guava Cache/Caffeine）：缓存最热的数万条门店信息，访问延迟 \u0026lt; 1ms，无网络开销； L2：分布式缓存（如 Redis Cluster）：缓存更大范围的门店数据，以 store_id 为 key，序列化的门店对象为 value，延迟约 1-5ms； L3：数据库（MySQL/TiDB）：兜底查询，亿级数据建立主键索引，查询 \u0026lt; 20ms。 缓存预热：服务启动时将高频访问的门店（如近 30 天交易量 TopN）预加载到 L1/L2，避免冷启动时大量穿透到数据库。\n缓存更新策略：门店信息更新频率低，可使用 Cache-Aside（旁路缓存）模式：更新数据库后主动删除或更新缓存，设置合理 TTL（如 5 分钟）。\n防穿透/防击穿：对不存在的 store_id 缓存空值（防穿透）；对热点 key 使用分布式锁防止缓存击穿时大量请求同时打到数据库。\nQ15：LRU 与 LRU-K 算法的区别？ 维度 LRU LRU-K 淘汰依据 最近一次访问时间最早 第 K 次历史访问时间最早 冷数据抗污染 弱（一次全表扫描就能把热数据全部淘汰） 强（访问 K 次才能进入主队列，偶发访问不影响热数据） 实现复杂度 简单（双向链表 + 哈希表） 稍复杂（需维护访问历史队列） K 值选择 — 通常 K=2，平衡冷热分离与复杂度 适用场景：数据库缓冲池应使用 LRU-K（如 PostgreSQL 的 Clock-Sweep、InnoDB 的 Young/Old 分区 LRU），防止顺序扫描污染热数据。\n三、B+ 树索引与并发控制 Q16：介绍 B+ 树的原理 B+ 树是一种自平衡多路搜索树，所有数据存储在叶子节点，内部节点仅存储路由键。核心特性：\n内部节点：存储 m 个 key 和 m+1 个子节点指针，只用于路由，不存数据； 叶子节点：存储 m 个 key 和对应 value（或 RID 行指针），叶子节点之间通过单向/双向链表相连，支持范围扫描； 平衡性：所有叶子节点到根的路径长度相同，保证 O(log n) 的查找复杂度； 高扇出：内部节点可以存储大量 key，树高很低（亿级数据只需 3-4 层），缓存命中率高（内部节点常驻内存）。 相比 B 树，B+ 树不在内部节点存数据，使内部节点扇出更大，且叶子链表使范围查询只需找到起始叶子再顺序遍历即可，效率极高。\nQ17：多线程对 B+ 树进行插入、删除、查找时如何保证数据一致性？ 核心技术是 Latch Crabbing（锁螃蟹行走）：\n查找（乐观）：\n从根节点开始，加读锁； 定位到子节点后，先加子节点读锁，再释放父节点读锁； 如此\u0026quot;蟹行\u0026quot;至叶子节点，对叶子持有读锁直到读完后释放。 插入/删除（悲观）：\n从根节点开始，对所有经过的节点加写锁； 如果当前节点被判断为\u0026quot;安全节点\u0026quot;（插入时未满、删除时超过半满，即不会发生分裂或合并），则释放其所有祖先节点的写锁； 继续向叶子推进，在根到叶子路径上只保留可能发生结构变更的节点的写锁。 乐观锁优化：对插入/删除先以乐观方式（只加读锁）走到叶子，检查叶子是否安全；如果安全（不会触发分裂/合并），则直接操作；如果不安全，释放所有锁，回退到悲观方式重来。Bustub 的 24fall 版本要求实现此优化以通过 Contention 测试（加大锁检测）。\nQ18：MySQL 中索引类型有哪些？ 主键索引（Primary Key）：聚簇索引，叶子节点存储整行数据，一张表只能有一个； 唯一索引（Unique Index）：保证索引列值唯一，叶子节点存储主键值（非聚簇）； 普通索引（Index）：无唯一约束，叶子节点存储主键值，查询完需回表； 复合索引（Composite Index）：多列联合建立的索引，需遵循最左前缀原则； 全文索引（Full-Text Index）：基于倒排索引实现，支持 MATCH ... AGAINST 语法； 前缀索引：只对字符串列的前 N 个字节建立索引，节省空间但无法覆盖全值。 Q19：复合索引的基本原则？ 最左前缀原则：复合索引 (a, b, c) 只有查询条件包含最左边的列 a 时才能使用到该索引。WHERE a=1 AND b=2 可走索引，WHERE b=2 AND c=3（跳过 a）则无法走索引； 选择性高的列放左边：基数（Cardinality）高的列放前面，能更快过滤数据； 范围查询列放最后：(a, b, c) 中若 b 有范围查询（b \u0026gt; 10），则 c 上的索引条件无法使用； 覆盖索引：查询的所有列都在索引中，可直接从索引返回结果，无需回表（回表代价高）； 排序/分组列建索引：ORDER BY 或 GROUP BY 的列如果在索引中，可避免额外的排序操作（filesort）。 Q20：遇到慢查询如何排查？建立索引时应注意什么？ 排查步骤：\nEXPLAIN 分析执行计划，关注 type（全表扫描为 ALL，最优为 const 或 ref）、key（走了哪个索引）、rows（扫描行数估计）； 检查是否满足最左前缀，是否发生了索引失效（如对索引列使用函数、隐式类型转换）； 查看是否有大量回表（Extra: Using index condition vs Using index）； 使用慢查询日志（slow_query_log）定位线上慢 SQL。 建立索引注意事项：\n不要建太多索引：每个索引都增加写操作的开销； 索引列应具有高选择性（重复值少），低基数的列（如性别 0/1）建索引意义不大； 优先考虑覆盖索引，让查询在索引层完成，避免回表； 频繁被排序、分组、连接条件用到的列应建索引。 Q21：什么是索引覆盖（Covering Index）？ 索引覆盖是指查询所需的所有数据列都包含在某个索引中，数据库不需要回表（不需要通过主键再去聚簇索引取数据），直接从二级索引的叶子节点就能返回结果。\n-- 假设有索引 (name, age) SELECT name, age FROM users WHERE name = \u0026#39;Alice\u0026#39;; -- 此查询可以覆盖索引，无需回表 SELECT name, age, address FROM users WHERE name = \u0026#39;Alice\u0026#39;; -- address 不在索引中，需要回表 覆盖索引可以极大减少 IO：二级索引通常比聚簇索引小得多，且可以只在内存中扫描，避免了随机 IO 的回表操作。\n四、MVCC 与事务隔离 Q22：Bustub 的 MVCC 机制如何设计？如何解决写冲突？ Bustub（23fall Project4）的 MVCC 基于时间戳实现快照隔离，核心设计如下：\n版本链：每行数据通过 UndoLog 形成版本链，最新版本存储在 table heap 中，历史版本形成链表。每个版本包含：ts（时间戳）、修改前的旧值（用于回滚）。\n读操作：事务以 read_ts 读取数据，沿版本链找到 ts ≤ read_ts 的最新版本——即该事务\u0026quot;开始时\u0026quot;存在的版本，保证快照读。\n写操作与写冲突检测：\n写操作先检查 table 中该行的当前 ts 是否为临时写时间戳（TXN_TEMP_TS，表示有其他事务正在写）； 若存在未提交的写（First-Writer-Wins）：后来的写事务直接 abort； 写操作将该行 ts 标记为 TXN_TEMP_TS，提交后更新为 commit_ts。 与 MySQL Read View 的对比：\nMySQL InnoDB 使用 Read View 结构（m_ids 活跃事务列表 + m_low_limit_id + m_up_limit_id）来判断版本可见性； Bustub 直接用单调递增的时间戳比较 ts ≤ read_ts，更简洁，但依赖全局严格单调的时间戳。 MySQL 的 RC 每次 SELECT 重新创建 Read View（能看到最新提交），RR 只在第一次 SELECT 时创建（整个事务看到相同快照）。 Q23：RC 和 RR 的区别？快照读、当前读的区别？ Read Committed（RC）vs Repeatable Read（RR）：\n隔离级别 Read View 创建时机 可重复读 幻读防护 RC 每次 SELECT 都创建新的 Read View ✗ ✗ RR 事务第一次 SELECT 时创建，之后复用 ✓ InnoDB 通过间隙锁防止 快照读 vs 当前读：\n快照读：普通 SELECT，通过 MVCC 读取历史快照版本，不加锁，不阻塞其他事务； 当前读：SELECT ... FOR UPDATE、SELECT ... IN SHARE MODE、INSERT/UPDATE/DELETE，读取最新版本并加锁（行锁 + 间隙锁），确保操作的是最新数据。 五、可扩展哈希索引（Extendible Hash） Q24：可扩展哈希中每个桶数据很多时如何优化？ 当单个桶数据量过多时，可扩展哈希会触发桶分裂（Split）：将桶一分为二，通过增加 local_depth 用更多位来区分数据。但如果数据分布极度不均匀（如哈希碰撞严重），分裂可能频繁发生而收效甚微。\n几种优化思路：\n桶溢出链（Overflow Chaining）：桶满时不立即分裂，而是追加一个溢出桶，形成链表。类似 HashMap 的 chaining，但读性能略降； 更好的哈希函数：使用分布更均匀的哈希函数（如 MurmurHash、xxHash）减少碰撞； 多级哈希目录：Bustub 的三层设计（Header → Directory → Bucket）通过 Header 将不同前缀的 key 分散到不同 Directory，天然增加并行度，减少单一 Directory 的竞争； 聚类内优化：若桶内数据有序（如排序），可改用跳表或B+树存储桶内数据，将桶内查找从 O(n) 降到 O(log n)。 六、全文索引与存储结构 Q25：全文索引是怎么做的？Doc ID 如何定义？分词流程？ 全文索引基于倒排索引（Inverted Index）：\n倒排索引结构：\n倒排链（Posting List）：token → [(doc_id, frequency), ...]，记录包含该词的所有文档及词频； 正排索引（Forward Index）：doc_id → doc_length，记录每个文档的词汇数，用于 BM25 分数计算。 Doc ID 定义：在 OceanBase seekdb 实现中，Doc ID 是内部自增的整数 ID，通过 doc_id_rowkey 映射表与主表的 rowkey 相互映射。这样设计的好处是内部用小整数 ID 压缩倒排链体积，最终回表时再通过映射获取真实行数据。\n分词（Tokenizer）流程：\n字符过滤：统一转小写、去除 HTML 标签、处理特殊字符； 分词：英文按空格/标点切词，中文使用分词算法（如结巴、IK Analyzer）； 停用词过滤：去掉 \u0026ldquo;the\u0026rdquo;、\u0026ldquo;a\u0026rdquo;、\u0026ldquo;是\u0026rdquo;、\u0026ldquo;的\u0026rdquo; 等无语义词； 词形还原/词干提取：英文可对 \u0026ldquo;running\u0026rdquo; → \u0026ldquo;run\u0026rdquo; 等做词干化处理； N-gram：LIKE 场景下将文本拆成 n 个字符的滑动窗口（N-gram），构建多粒度倒排索引。 Q26：LIKE 语法如何做查询优化？ LIKE 'prefix%' 可以利用普通索引（前缀匹配），但 LIKE '%suffix' 或 LIKE '%substring%' 会触发全表扫描。\n优化途径：\nN-gram 全文索引：将文本按 N-gram 切分后建立倒排索引，对于 substring 匹配，用 N-gram 做粗粒度过滤（先用倒排索引找候选集），再用精确匹配验证，大幅减少扫描量； SkipIndex（Chunk 级跳过）：如 Milvus 的 NgramBF（N-gram Bloom Filter），在 Chunk 粒度上预判整个 Chunk 是否包含目标子串，若不包含则整个 Chunk 跳过，无需加载数据； 前缀倒排：对于前缀 LIKE 场景，建立字符前缀的倒排索引，支持前缀快速定位。 Q27：全文索引的存储结构如何设计？如何支持实时增删改查？ 全文索引维度扩展性极强（一篇文档可能产生成百上千个 token），不适合直接用行列存储。可参考 LSM-Tree 思想：\n内存缓冲层（MemTable）：新写入的文档先在内存中构建增量倒排索引，快速响应写入； 分 Segment 并行构建：数据按时间或大小划分为多个 Segment，每个 Segment 独立构建完整倒排链，并发写入； Compaction 合并：后台将多个小 Segment 的倒排链归并成更大的 Segment，同时处理删除（Tombstone 标记）； 读时多路归并：查询时对所有在用 Segment 的倒排链做多路归并，取合集或交集。 Elasticsearch 和 Lucene 就是采用这种\u0026quot;Segment + Merge\u0026quot;模式，每个 Segment 内部倒排链有序，查询时多路归并。\nQ28：LSM Tree 下全文索引的 Compaction 灾难如何避免？ 当长文档被大幅修改（如从 10000 词删减到 2 词），旧文档产生的大量 token 需要在 Compaction 时被标记为删除，导致涉及极宽 key 范围的 Compaction，甚至触发 Full Compaction。\n优化思路：\nSegment 隔离删除：删除不修改旧 Segment，而是在新 Segment 中写入 Tombstone 记录；在 Compaction 时合并时去除已删除条目，避免单次 Compaction 范围过大； 增量更新（Delta Log）：只写入变更部分（新增/删除的 token），而非重写整个文档的倒排链，大幅减少写放大； 分层限制 Compaction 范围：类似 LevelDB 的分层策略，控制每次 Compaction 只合并相邻层的特定范围 SSTable，避免全局 Compaction； 文档版本标记：在 Compaction 时，只有当该文档的所有旧版本 token 都被新版本覆盖后才真正删除，防止误删。 七、LSM-Tree 与 B+ Tree 对比 Q29：LSM-Tree 和 B+ Tree 的区别？各自适合什么场景？ 维度 B+ Tree LSM-Tree 写性能 随机写，需要维护树结构有序，写放大大 顺序写（WAL + MemTable），写性能极高 读性能 直接 O(log n) 定位，读性能稳定 需要从多层 SSTable 查找，读放大较大 空间放大 低（数据只存一份，但碎片化） 高（同一 key 可能在多层都有版本） 适合场景 读多写少、随机读、需要事务支持 写多读少、日志型数据、时序数据 代表系统 MySQL InnoDB、PostgreSQL、SQLite RocksDB、LevelDB、Cassandra、HBase B+ 树的 index 节点不一定要全在内存：B+ 树的内部节点通过 Buffer Pool 缓存在内存中，但如果内存不足，内部节点也会被淘汰到磁盘，只是树高越低（内部节点越少），越容易全部缓存在内存中（如 InnoDB 的 BP 热点页缓存）。\n分布式 B+ 树：TiDB 的 TiKV 使用 RocksDB（LSM-Tree）作为底层，在其上模拟 B+ 树语义；Google Spanner 使用自研的分布式 B+ 树。\nQ30：如何用 LSM 的思想优化 B+ Tree？ 核心思路：Append-only（追加写） 代替原地修改，仅在读时合并 delta。\n具体设计（类 Bw-Tree 方案）：\nDelta Record（增量记录）：不直接修改 B+ 树节点，而是将更新写成 Delta Record 追加到 Page 的链表头部； Mapping Table：维护逻辑 Page ID → 物理内存地址的映射（CAS 原子操作），读时通过 Mapping Table 定位最新的 Delta 链； Consolidation（合并）：后台线程定期将 Delta 链合并成完整页面（类似 Compaction），控制 Delta 链长度不超过阈值； 无锁并发：由于写操作只追加 Delta，不修改原有页面，多个写线程可以通过 CAS 竞争追加，无需页面写锁。 这种设计将 B+ 树的随机写转为顺序追加，写性能大幅提升；但读时需要遍历 Delta 链，读性能略降。Bw-Tree 是微软 Hekaton 内存数据库引擎中实际使用的数据结构。\n追问：这种小块合并设计会不会影响事务隔离级别？\n不会影响，隔离级别由 MVCC 的版本可见性机制保证，与底层存储是原地写还是追加写无关。Delta 链和 Compaction 只是存储层的实现细节，MVCC 通过 ts 版本号来判断哪个版本可见，该语义在合并前后保持一致。\nQ31：基于 LSM 结构如何实现 Repeatable Read？Tombstone 如何打标记？ 版本号设计： 在 RocksDB 等 LSM 实现中，key 编码为 (user_key, sequence_number)，其中 sequence_number 单调递增，代表写入顺序（也可用时间戳）。读时指定 snapshot = read_seq，只读取 sequence_number ≤ read_seq 的最新版本。\nRepeatable Read 实现：\n事务开始时获取当前 sequence_number 作为 snapshot； 事务内所有读操作都使用此 snapshot，RocksDB 内部会过滤掉所有 sequence_number \u0026gt; snapshot 的版本； 由于 snapshot 是事务级常量，整个事务期间看到相同的数据快照，满足 Repeatable Read。 防幻读： 顺着版本链读不仅能防不可重复读，对于范围查询，只要 snapshot 固定，即使后来的事务在该范围插入了新 key，这些 key 的 sequence_number \u0026gt; snapshot，对当前读操作不可见，自然防止了幻读。\nTombstone（墓碑标记）： LSM-Tree 的删除不是原地删除，而是写入一个特殊的 Delete Marker（Tombstone），内容为空值，sequence_number 为当前写入序号：\nKey: (user_key, seq=100, type=DEL) ← Tombstone Key: (user_key, seq=50, type=PUT) ← 旧值 读时，若找到的最新版本是 Tombstone（type=DEL），则认为该 key 已被删除，返回空。在 Compaction 时，当 Tombstone 已经下推到最底层且没有比它更旧的版本，才能真正从磁盘删除该条记录。\n八、Milvus SkipIndex 与向量数据库 Q32：Milvus 向量数据库是怎么查的？大致流程？ Milvus 的向量查询采用 ANN（Approximate Nearest Neighbor） 搜索，主要流程：\n请求路由：Proxy 接收用户查询（Query Vector + TopK + Filter），路由到对应 Shard 的 QueryNode； Segment 查询：QueryNode 对所有已加载的 Segment（Growing Segment + Sealed Segment）并行执行查询； 向量索引（ANN）：对 Sealed Segment，使用 HNSW/IVF_FLAT/IVF_PQ 等向量索引做近似最近邻搜索，获得候选集；Growing Segment 则暴力全量扫描； 标量过滤（SkipIndex）：在向量搜索之前或同时，对标量过滤条件（如 age \u0026gt; 18）利用 SkipIndex 跳过不满足条件的 Chunk，减少需要精确计算距离的向量数； Reduce 合并：将多个 Segment 的 TopK 候选结果在 QueryCoord/Proxy 层归并，返回最终 TopK。 Q33：SkipIndex 在内存中如何表示？结构是如何的？ SkipIndex 的内存表示以 Segment 下 Field 下 Chunk 三级层次组织：\nSkipIndex └── CacheSlot\u0026lt;FieldChunkMetrics\u0026gt;[] (每个 Field 对应一个数组，每个元素是一个 Chunk 的统计信息) └── FieldChunkMetrics（抽象基类） ├── MinMaxMetrics\u0026lt;T\u0026gt; (最小值/最大值，适用于数值型、字符串) ├── SetMetrics\u0026lt;T\u0026gt; (集合统计，适用于低基数字段，IN 查询优化) ├── BloomFilterMetrics (布隆过滤器，适用于点查) └── NgramBFMetrics (N-gram 布隆过滤器，适用于 LIKE 子串匹配) FieldChunkMetrics 提供统一接口：CanSkipUnaryRange（单值比较）、CanSkipBinaryRange（范围查询）、CanSkipIn（IN 查询），查询时批量判断 Chunk 是否可跳过。\nQ34：如何对不同数据类型分类构建 SkipIndex？ 开源之夏项目中，我扩展了 SkipIndex 统计信息，对不同类型分别构建不同的 Metrics：\n数据类型 构建的 Metrics 支持的查询优化 数值型（int/float/double） MinMaxMetrics \u0026gt;, \u0026lt;, \u0026gt;=, \u0026lt;=, BETWEEN 字符串（低基数） MinMax + SetMetrics =, !=, IN, NOT IN 字符串（高基数/全文） MinMax + NgramBFMetrics LIKE '%substring%'，子串匹配 通用类型 BloomFilterMetrics = 点查（probabilistic 跳过） 构建逻辑在 SkipIndexStatsBuilder 中，通过 std::conditional_t 和模板特化，在编译期根据类型参数选择对应的统计信息构建策略，对 std::string 特殊处理（使用 string_view 避免拷贝）。\nQ35：RAG 在 AI 问答平台中起什么作用？Context 是越多越好吗？ RAG（Retrieval-Augmented Generation）的作用：大模型的知识截止于训练时间，无法感知实时或私有数据。RAG 将用户问题向量化，在外部知识库（向量数据库）中检索相关文档片段，将其拼入 Prompt，为模型提供\u0026quot;实时外挂记忆\u0026quot;。以 Milvus 为后端的 RAG 系统中，用户问题经嵌入模型（Embedding Model）转换为向量，再通过 ANN 检索召回相关文档，最后将文档作为 Context 输入 LLM 生成答案。\n在 Agent 对话场景中，外置记忆库可存储历史对话摘要、用户偏好等，使 Agent 在有限 Context Window 内获取更相关的背景信息。\nContext 并非越多越好：\nLost-in-the-middle 问题：研究表明 LLM 对长上下文中首尾位置的信息关注度高，对中间位置信息的注意力显著下降，关键信息容易被\u0026quot;遗忘\u0026quot;； 增加 Token 消耗和推理延迟； Context 利用率在 30%~50% 时效果最佳，超过后边际收益递减甚至下降。 因此 RAG 的核心优化方向是召回精度（检索到真正相关的片段）和 Re-Ranking（对召回结果重新排序），而非一味增大检索数量。\nQ36：向量索引的落盘与非落盘方法有哪些？各有什么特点？ 类型 代表算法 特点 纯内存（非落盘） HNSW 层次化导航小世界图，查询精度和速度最优，内存占用大 纯内存 ANNOY 随机超平面树，构建快，不支持动态更新 磁盘友好（落盘） DiskANN 图结构 + 磁盘分级存储，支持亿级向量，内存占用小 落盘 IVF_FLAT 聚类中心存内存，原始向量存磁盘，精度高但内存消耗较大 落盘 IVF_PQ Product Quantization 压缩向量，大幅降低存储开销，精度略损 HNSW：通过多层跳表图结构实现快速近似检索，查询时从最高层逐层定位，每层用贪心搜索找最近邻节点，复杂度约 $O(\\log n)$，是目前精度与速度综合最优的内存向量索引。\nDiskANN：通过分层设计（高层节点驻内存，底层节点存磁盘），实现单机十亿级向量检索，适合存储受限但规模超大的场景。Milvus 默认推荐 HNSW 用于内存场景，DiskANN 用于超大规模落盘场景。\n九、C++ 知识 Q37：std::move 底层是如何实现的？ std::move 本质上只是一个类型转换（static_cast），并不做任何数据移动：\n// 标准库实现（简化版） template\u0026lt;typename T\u0026gt; typename std::remove_reference\u0026lt;T\u0026gt;::type\u0026amp;\u0026amp; move(T\u0026amp;\u0026amp; t) noexcept { return static_cast\u0026lt;typename std::remove_reference\u0026lt;T\u0026gt;::type\u0026amp;\u0026amp;\u0026gt;(t); } 它的作用是将任意类型的引用（左值引用或右值引用）无条件转换为右值引用（T\u0026amp;\u0026amp;），从而使编译器选择调用移动构造函数或移动赋值运算符，而非拷贝构造函数。真正的\u0026quot;移动\u0026quot;（资源转移）是由用户定义的移动构造函数完成的，std::move 只是给编译器一个\u0026quot;可以对这个对象执行移动语义\u0026quot;的信号。\nQ38：把移动构造 delete 掉，对 std::move 结果赋值会触发移动构造还是拷贝构造？ 如果一个类只删除了移动构造（MyClass(MyClass\u0026amp;\u0026amp;) = delete）但保留了拷贝构造（MyClass(const MyClass\u0026amp;)），则 std::move 之后赋值会触发拷贝构造。\n原因：std::move 只是产生一个右值引用类型，编译器在重载决议时优先寻找移动构造，但移动构造被 delete 后，编译器会退而求其次，尝试拷贝构造。右值引用可以绑定到 const T\u0026amp;（拷贝构造的参数类型），因此拷贝构造会被调用。\nstruct MyClass { MyClass(const MyClass\u0026amp;) { /* 拷贝构造 */ } MyClass(MyClass\u0026amp;\u0026amp;) = delete; // 移动构造被删除 }; MyClass a; MyClass b = std::move(a); // 触发拷贝构造，因为移动构造不可用 注意区分：如果同时删除了移动构造和拷贝构造，则 std::move 后的赋值会编译报错。\n十、Raft 工程化进阶问题 Q39：Raft 中 Leader 选举如何保证一致性？每个 term 最多几个 Leader？ 每个 term 最多只有一个 Leader。这由 Raft 的投票机制保证：\n每个节点在同一 term 内最多投出一票（持久化 votedFor）； Leader 需要获得多数节点（quorum） 的投票才能当选； 多数派中不可能存在两个互不重叠的子集，因此同一 term 内不可能有两个节点同时获得多数票，即不可能出现两个 Leader。 一致性保证：\n选举安全性（Election Safety）：每个 term 最多一个 Leader； Leader 完整性（Leader Completeness）：当选的 Leader 一定包含所有已提交日志（由选举限制保证）； 日志单调性：Leader 的日志是所有已提交日志的超集，不会回退。 Q40：发生网络分区时 Raft 如何处理？分区恢复后会怎样？ 场景：5 节点集群分区为 A（3 节点，含原 Leader）和 B（2 节点）。\n分区期间：\nA 区（多数派，3 节点）：原 Leader 仍然有效，能获得多数派心跳确认，继续正常提供读写服务； B 区（少数派，2 节点）：因收不到 Leader 心跳，会超时发起选举，但只有 2/5 的票，无法获得多数派支持，选举永远失败，不会产生新 Leader。B 区对外停止服务，term 会不断递增但无实际效果。 分区恢复后：\nB 区节点带着更高的 term 重新连接，A 区原 Leader 收到更高 term 后退化为 Follower； 重新触发选举，由拥有最新日志且 term 满足条件的节点（通常是 A 区的节点之一）当选新 Leader； B 区节点的日志被截断并与新 Leader 同步，B 区期间任何未提交的操作不会对外产生影响； 最终整个集群恢复一致状态。 Q41：什么是脑裂（Split-Brain）？Raft 如何防止脑裂？ 脑裂（Split-Brain） 是指分布式系统发生网络分区后，被隔离的节点无法感知自己处于少数派，仍然以为自己是合法 Leader 并继续对外提供写服务，导致集群中同时出现两套独立演进的数据版本，最终无法协调的严重一致性问题。\nRaft 防止脑裂的三重机制：\n多数派写入（核心保障）：Raft 中一条日志只有被超过半数节点持久化后，Leader 才会 commit 它。设 Leader A 被网络隔离在少数派（如 5 节点集群中仅剩 2 节点），它接受的写请求无法获得多数确认，永远无法提交，客户端不会收到成功响应。由于任意两个多数派集合必有交集，同一 term 内不可能在两个分区同时产生被持久化的提交版本，脑裂从根本上不会发生。\n孤岛检测（Leader 主动退化）：实践中（如 etcd/TiKV），Leader 在每个心跳周期统计收到响应的节点数，若连续若干轮收到的有效响应数未超过半数，Leader 主动退化为 Follower，立即停止对外提供写服务。这样即使 Leader 已经被孤立在了少数派分区中，也能主动让位，彻底杜绝少数派 Leader 继续服务的可能。\nTerm 机制自动收敛：分区期间，少数派因反复选举超时而不断递增 term（虽无法成功 commit）。分区恢复后，少数派携带更高 term 重新接入，多数派原 Leader 收到更高 term 的消息后立即退化为 Follower；新一轮合法选举产生唯一 Leader，少数派期间写入的未提交日志被新 Leader 的日志覆盖，集群自动收敛到一致状态。\n与 Pre-Vote 的关系：Pre-Vote（见 Q42）主要解决少数派 term 虚高造成的\u0026quot;选举风暴\u0026quot;，是对 Q41 第 3 点的补充优化，防止分区恢复时高 term 节点破坏正在运行的合法 Leader。\nQ42：Pre-Vote 机制是什么？解决什么问题？ 问题：标准 Raft 中，少数派节点在分区期间不断递增 term。分区恢复后，高 term 的 B 区节点重新加入，会导致 A 区正常工作的 Leader 立即退化，引发不必要的选举，造成短暂服务中断。\nPre-Vote 机制：节点在正式发起选举（term+1）之前，先以当前 term 发送一轮预投票探测。若能获得多数节点的预投票确认，才真正递增 term 并发起正式选举；否则不递增 term，继续等待。\n效果：分区中的少数派节点无法获得预投票，因此不递增 term，分区恢复后也不会以高 term 冲击已在工作的 Leader，消除了选举风暴问题。\nQ43：Region 分裂时如果 Leader apply 成功但 Follower 失败了怎么办？ Region Split 是通过 Raft 日志项提交的，受 Raft 一致性保护：\nSplit 指令被 propose 为一条 AdminCmdType_Split 的 Raft Entry； 该 Entry 必须被集群多数节点写入日志后，Leader 才推进 commitIndex 标记其为 committed； 一旦 committed，Raft 保证所有节点最终都会 apply 该条日志——Follower 崩溃重启后通过日志重放或快照追进度，必然执行该 Split； 不存在\u0026quot;Leader 已分裂成功但 Follower 永久分裂失败\u0026quot;的情况。 核心原理：已 commit 的日志必须确保所有节点最终 apply，这是 Raft 的基本安全性保证。\nQ44：TinyKV Region 分裂时，底层数据如何处理？ Region 分裂只修改元信息，不移动任何底层数据：\nRaft 提交分裂命令：Leader 将分裂命令封装为 AdminCmdType_Split 的 Raft Entry，多数节点持久化后 commit，所有副本按序 apply。\nApply 阶段更新元信息：根据分裂点 key 将原 Region 的 [StartKey, EndKey) 一分为二：原 Region 缩短 key 范围，新 Region 承接另一半 key 范围；将两者的 Region 元信息（Region ID、Epoch、Peers）写入 raftDB，并向 Scheduler 上报新 Region 的存在。\n数据原地不动：底层 KV 数据全部存储在 badger（LSM-Tree）中，所有行仍按原有 key 存储，物理位置一字未动。分裂后，两个 Region 各自通过 key range 过滤，服务自己范围内的读写请求。\n物理迁移延后执行：数据的实际搬迁（将新 Region 均衡到其他 Store）是在后续 Region Balance 调度中，通过快照机制异步完成的——分裂操作仅负责范围划分，搬迁操作与分裂解耦。\n这种设计使分裂极其轻量，避免了分裂时执行大规模数据复制带来的耗时和 IO 压力。\nQ45：一个完整的事务除了 MVCC 还需要什么？ MVCC 只解决了读写隔离（快照隔离），完整的事务系统还需要：\n原子性（WAL + Undo Log）：WAL 保证崩溃恢复时已提交事务不丢失；Undo Log 保证事务可以完整回滚； 写写冲突检测：MVCC 的快照读不加锁，但写操作之间需要冲突检测防止丢失更新，可用悲观锁（2PL）或乐观验证（如 Percolator 的 Prewrite 阶段冲突检查）； 持久性（WAL 刷盘）：通过 fsync 保证已提交事务的 WAL 在返回客户端前落盘； 全局时间戳服务（分布式场景）：分布式事务需要全局单调递增的 TSO 保证跨节点快照一致性，如 TinyKV 的 TinyScheduler 提供的 TSO； 两阶段提交（分布式场景）：跨 Region/节点的原子提交需要 2PC 协调，如 Percolator 的 Prewrite + Commit 两阶段。 Q46：Raft 日志复制的完整流程是怎样的？ Raft 日志复制是 Leader 将客户端命令安全持久化到集群所有节点的核心流程，共 7 个阶段：\nClient 请求：客户端将写命令发送到 Leader（若发给 Follower，Follower 通过 leader redirect 将请求重定向至 Leader）。\n追加本地日志：Leader 将命令封装为 Log Entry（带当前 term 和递增 index），先追加到自己的 RaftLog 并持久化（WAL 刷盘），再对外广播。\n并行广播 AppendEntries：Leader 向所有 Follower 并发发送 AppendEntries RPC，携带 prevLogIndex/prevLogTerm（一致性校验用）、新日志条目和当前 leaderCommit。\nFollower 校验与追加：Follower 检查 prevLogIndex/prevLogTerm 是否与自己日志末尾匹配：\n匹配：追加新条目，持久化，回复成功； 不匹配：拒绝，返回冲突信息（冲突 term 和该 term 的首条 index），Leader 据此快速回退 nextIndex[i] 并重发。 多数确认后 Commit：Leader 计数确认该条目的节点数，一旦超过半数（含自身），将 commitIndex 推进到该 index；通过随后一次 AppendEntries 的 leaderCommit 字段通知各 Follower 更新其 commitIndex。\nApply 到状态机：Leader 和 Follower 各自将 [lastApplied+1, commitIndex] 范围内的日志按序送入上层状态机（KV 写入、配置变更等），完成后推进 appliedIndex。\n响应 Client：Leader Apply 完成后将执行结果返回给客户端；若 Leader 在 Apply 前崩溃，客户端会超时重试，新 Leader 当选后幂等重放即可。\n核心不变式：appliedIndex ≤ commitIndex ≤ len(log)，apply 严格落后于 commit，committed 日志在任何节点故障下都不会丢失。\n十一、OceanBase 全文索引优化 Q47：TopN 下推与 Bitset 在全文索引优化中如何起作用？ 这两个优化来自 OceanBase2025 决赛的全文索引性能优化工作。\nTopN 下推：\n原始执行路径是全量全文检索后取 TopN。TopN 下推将\u0026quot;只要 Top-10\u0026quot;的信息提前传递给全文索引扫描算子，底层 BMW（Block-Max WAND）合并算法在计算过程中可以提前剪枝——一旦某个候选文档的 BM25 分数上界低于当前 Top-10 的最低分，直接跳过，大幅减少无效计算。TopN 下推将查询性能从约 5 分提升到约 100 分（以比赛评分衡量）。\nBitset 过滤：\n查询带有固定标量条件（base_id IN (...) 且 id \u0026lt; 1000），条件结果集极小。预先计算满足标量过滤条件的 doc_id 集合，构建 Bitset，在倒排链扫描时用 Bitset 快速跳过不满足条件的文档，将有效计算范围缩减到极小，避免大量无效的 BM25 计算和回表操作。\nQ48：查询最近 30 天点赞数最高的 10 条记录，如何快速查找？ 基础方案：建立 (create_time, like_count) 复合索引，利用时间范围过滤 + 排序取 TopN。\n优化方向：\n覆盖索引 + 降序索引：建立 (create_time DESC, like_count DESC) 索引，ORDER BY 可直接利用索引顺序，避免 filesort； 定时缓存：用定时任务每分钟异步计算 Top-10 并存入 Redis，查询时直接读缓存，统计计算离线化； 按天分桶预聚合：将 30 天拆为 30 个日维度桶，每天维护 Top-N 有序集合（Redis SortedSet），查询时合并 30 个桶的结果，复杂度 O(30×K)； 近似统计：超大规模场景下用 Count-Min Sketch + 堆做近似 Top-K，适合对精确性要求不严格的业务。 十二、算法题 算法1：带过期时间的 LRU 缓存 在 LRU 基础上，对缓存中的对象增加淘汰机制：超过 x 时间没有访问就被淘汰。\n设计思路：在 LRU 的双向链表 + 哈希表基础上，为每个节点增加 expire_time = last_access_time + x。\nGet 时：先判断是否过期，若过期则从缓存中删除并返回不存在；否则更新 last_access_time，移到链表头； Put 时：写入时记录当前时间为 last_access_time； 后台清理：可以维护一个按 expire_time 排序的优先队列（或在 LRU 链中，由于每次访问都刷新时间，过期节点自然会沉到链尾），每次 Get/Put 时捎带清理链尾过期节点；也可以用定时后台线程扫描清理。 struct Node { int key, val; long long last_access; // 上次访问时间戳 // 双向链表指针 }; int get(int key) { auto now = current_time_ms(); if (map.count(key)) { auto node = map[key]; if (now - node-\u0026gt;last_access \u0026gt; x) { // 过期检查 remove(node); map.erase(key); return -1; } node-\u0026gt;last_access = now; move_to_front(node); return node-\u0026gt;val; } return -1; } 算法2：滑动窗口最大值 窗口从左往右移，求滑过的每个窗口的最大值。\n经典解法：单调递减双端队列（Deque），时间复杂度 O(n)：\nvector\u0026lt;int\u0026gt; maxSlidingWindow(vector\u0026lt;int\u0026gt;\u0026amp; nums, int k) { deque\u0026lt;int\u0026gt; dq; // 存下标，队头始终是当前窗口最大值的下标 vector\u0026lt;int\u0026gt; result; for (int i = 0; i \u0026lt; nums.size(); i++) { // 弹出窗口外的元素 while (!dq.empty() \u0026amp;\u0026amp; dq.front() \u0026lt; i - k + 1) dq.pop_front(); // 维护单调递减：弹出队尾所有比当前元素小的元素 while (!dq.empty() \u0026amp;\u0026amp; nums[dq.back()] \u0026lt; nums[i]) dq.pop_back(); dq.push_back(i); // 窗口形成后记录结果 if (i \u0026gt;= k - 1) result.push_back(nums[dq.front()]); } return result; } 算法3：回文数 给定整数 x，判断是否是回文整数（不允许转字符串，O(1) 空间）。\nbool isPalindrome(int x) { if (x \u0026lt; 0 || (x % 10 == 0 \u0026amp;\u0026amp; x != 0)) return false; int reversed = 0; while (x \u0026gt; reversed) { reversed = reversed * 10 + x % 10; x /= 10; } // 奇数位数时 reversed/10 去掉中间那位 return x == reversed || x == reversed / 10; } 算法4：找出数组中出现两次的数字（O(n) 时间，O(1) 空间） 数组中数字出现一次或两次，值域 [1, n]，找出所有出现两次的数字。\n利用原数组下标做标记（原地哈希）：遍历数组，将 nums[|nums[i]| - 1] 的位置取负号作为访问标记，若该位置已经是负数，说明对应值已出现过一次，即出现了两次：\nvector\u0026lt;int\u0026gt; findDuplicates(vector\u0026lt;int\u0026gt;\u0026amp; nums) { vector\u0026lt;int\u0026gt; res; for (int i = 0; i \u0026lt; nums.size(); i++) { int idx = abs(nums[i]) - 1; if (nums[idx] \u0026lt; 0) res.push_back(idx + 1); // 已被标记过，出现两次 else nums[idx] = -nums[idx]; // 第一次出现，打标记 } return res; } 时间 O(n)，空间 O(1)（结果数组不算，原地修改输入数组）。\n算法5：搜索旋转排序数组 给定旋转过的有序数组，查找目标值，时间复杂度 O(log n)。\nint search(vector\u0026lt;int\u0026gt;\u0026amp; nums, int target) { int l = 0, r = nums.size() - 1; while (l \u0026lt;= r) { int mid = l + (r - l) / 2; if (nums[mid] == target) return mid; if (nums[l] \u0026lt;= nums[mid]) { // 左半段有序 if (nums[l] \u0026lt;= target \u0026amp;\u0026amp; target \u0026lt; nums[mid]) r = mid - 1; else l = mid + 1; } else { // 右半段有序 if (nums[mid] \u0026lt; target \u0026amp;\u0026amp; target \u0026lt;= nums[r]) l = mid + 1; else r = mid - 1; } } return -1; } 算法6：最长重复子串 给定一个字符串，找出其中最长的重复子串（出现至少两次）。\n最优解：二分答案 + Rolling Hash，时间复杂度 O(n log n)：\nstring longestDupSubstring(string s) { int n = s.size(); long long mod = 1e9 + 7, base = 31; vector\u0026lt;long long\u0026gt; pw(n + 1, 1); for (int i = 1; i \u0026lt;= n; i++) pw[i] = pw[i-1] * base % mod; auto check = [\u0026amp;](int len) -\u0026gt; string { long long h = 0; for (int i = 0; i \u0026lt; len; i++) h = (h * base + s[i]) % mod; unordered_map\u0026lt;long long, vector\u0026lt;int\u0026gt;\u0026gt; seen; seen[h].push_back(0); for (int i = len; i \u0026lt; n; i++) { h = (h * base - s[i-len] * pw[len] % mod + s[i] + mod * 2) % mod; for (int start : seen[h]) if (s.substr(start, len) == s.substr(i-len+1, len)) return s.substr(start, len); seen[h].push_back(i - len + 1); } return \u0026#34;\u0026#34;; }; int lo = 1, hi = n - 1; string res = \u0026#34;\u0026#34;; while (lo \u0026lt;= hi) { int mid = lo + (hi - lo) / 2; string t = check(mid); if (!t.empty()) { res = t; lo = mid + 1; } else hi = mid - 1; } return res; } ","permalink":"https://yinit.github.io/%E6%9A%91%E6%9C%9F%E5%AE%9E%E4%B9%A0%E9%9D%A2%E8%AF%95%E7%BB%8F%E9%AA%8C/","summary":"整理暑期实习面试中的问答，涵盖分布式一致性、存储引擎、并发控制、索引结构、C++ 等方向","title":"暑期实习面试经验"},{"content":"1. 概述与背景 1.1 Project4 整体目标 在 TinyKV 的前三个 Project 中，我们构建了一个具有 Multi-Raft 能力的分布式 KV 存储：每个 Region 内部通过 Raft 协议保证了数据的强一致性与高可用性。然而，这个系统只提供了单 key 的原子性操作，无法保证跨 key、跨 Region 的原子提交。\nProject4 的目标是在 Multi-Raft KV 之上实现分布式事务，让多个 key 的读写操作能够以原子、隔离的方式执行。具体地，TinyKV 采用 Percolator 协议（Google 2010 年提出）实现了一套基于 MVCC（Multi-Version Concurrency Control） 的两阶段提交（2PC）分布式事务。\n1.2 为什么需要分布式事务 考虑一个银行转账场景：\n账户 A: 扣减 100 元 账户 B: 增加 100 元 如果这两个操作不是原子的，可能出现：\nA 扣减成功，B 增加失败 → 钱凭空消失 中间状态被其他事务读到 → 脏读 两个并发转账同时读到旧值 → 丢失更新 在分布式系统中，账户 A 和账户 B 可能分布在不同的 Region（不同的 Raft Group），这使得原子性保证更加复杂。\n1.3 TinyKV 事务架构概览 ┌─────────────────────────────────────────────────────────────┐ │ Client SDK │ │ (获取 TSO, 构造 PrewriteRequest/CommitRequest, 重试逻辑) │ └───────────────────────────┬─────────────────────────────────┘ │ gRPC ┌───────────────────────────▼─────────────────────────────────┐ │ TinyKV Server (server.go) │ │ KvGet | KvPrewrite | KvCommit | KvScan │ │ KvCheckTxnStatus | KvBatchRollback | KvResolveLock │ └───────────────────────────┬─────────────────────────────────┘ │ ┌───────────────────────────▼─────────────────────────────────┐ │ MVCC Layer (transaction.go) │ │ MvccTxn: GetLock/PutLock | GetValue/PutValue │ │ CurrentWrite | MostRecentWrite │ └───────────────────────────┬─────────────────────────────────┘ │ ┌───────────────────────────▼─────────────────────────────────┐ │ RaftStore (Multi-Raft KV Storage) │ │ 三个列族: CfLock | CfWrite | CfDefault │ │ 底层: badger (LSM-Tree) │ └─────────────────────────────────────────────────────────────┘ 1.4 Percolator 协议来源与适用场景 Google Percolator（2010）最初是为了支持 Google 网页索引的增量更新而设计的。传统批处理方式（MapReduce）每次需要重新处理所有网页，效率低下。Percolator 通过在 BigTable 之上实现跨行事务，支持对索引的增量修改。\nPercolator 的核心设计思路：\n利用底层存储（BigTable/RocksDB）已有的单行原子性 通过 Primary Key 充当事务协调者，避免独立协调者的单点问题 基于 MVCC 实现快照隔离，读操作不需要锁 乐观并发控制：先尝试加锁，冲突时回滚重试 Percolator 特别适合：\n读多写少的场景（读不加锁） 跨行、跨节点的原子更新 需要快照隔离的 OLTP 工作负载 2. 单机事务理论基础 2.1 ACID 四要素深度解析 事务是数据库的基本操作单位，ACID 是衡量事务质量的四个属性：\n2.1.1 Atomicity（原子性） 定义：事务中的所有操作要么全部成功，要么全部失败回滚，不存在部分执行的中间状态。\n实现机制：\nUndo Log（回滚日志）：在修改数据之前，先将原始数据写入 undo log。若事务需要回滚，通过 undo log 恢复数据到修改前的状态。\nInnoDB 的 undo log 存储在系统表空间（或独立的 undo 表空间） 每条记录包含：事务 ID、操作类型（INSERT/UPDATE/DELETE）、旧值 MVCC 也依赖 undo log 实现历史版本的读取 WAL（Write-Ahead Log）：所有数据修改在写入磁盘前，必须先将日志写入 redo log。这保证了崩溃恢复时能够重放已提交事务的修改。\nInnoDB 的 redo log 以循环方式写入（ib_logfile0, ib_logfile1） innodb_flush_log_at_trx_commit 控制刷盘时机： 0：每秒刷盘，性能最好但最多丢失 1 秒数据 1（默认）：每次提交都刷盘，最安全 2：写入 OS 缓存但每秒 fsync，折中方案 两段式提交（在单机 InnoDB 中）：\n事务执行阶段: 修改 buffer pool + 写 undo log + 写 redo log buffer 提交阶段: 1. 写 binlog（如果开启） 2. 写 redo log（prepare 状态） 3. 写 binlog（commit 标记） 4. 写 redo log（commit 状态） 注：InnoDB 内部的 2PC 是为了保证 redo log 和 binlog 的一致性，与跨节点的分布式 2PC 概念不同。\n2.1.2 Consistency（一致性） 定义：事务执行前后，数据库从一个合法的状态转变到另一个合法的状态，满足所有预定义的约束和规则。\n关键理解：\n一致性是事务的目的，而 AID 是实现一致性的手段 一致性包含两个层面： 数据库层面：主键唯一性、外键约束、非空约束等 业务层面：账户余额不能为负、库存不能超卖等（由应用程序保证） 某些文献中，C 被认为是 AID 的结果，而非独立属性 注意：CAP 定理中的 Consistency 是指线性一致性（Linearizability），与 ACID 中的 Consistency 含义不同，需要加以区分。\n2.1.3 Isolation（隔离性） 定义：并发执行的多个事务之间相互隔离，一个事务的中间状态对其他事务不可见。\n并发异常类型：\n并发异常 描述 示例 脏读（Dirty Read） 读到未提交的数据 T1 修改 x=10（未提交），T2 读到 x=10，T1 回滚 不可重复读（Non-Repeatable Read） 同一事务内两次读同一行，值不同 T1 读 x=5，T2 提交 x=10，T1 再读 x=10 幻读（Phantom Read） 同一事务内两次范围查询，结果集不同 T1 查询 age\u0026gt;18 得 10 条，T2 插入新行，T1 再查得 11 条 丢失更新（Lost Update） 两事务同时读旧值，先后写入，后者覆盖前者 T1 读 x=5，T2 读 x=5，T1 写 x=6，T2 写 x=7（T1 的更新丢失） 读偏斜（Read Skew） 读到不一致的数据组合 T1 先读 x，T2 修改 x 和 y，T1 再读 y，读到不一致的 x, y 组合 写偏斜（Write Skew） 基于快照做出的决策，破坏了业务约束 见下方详解 写偏斜（Write Skew）详解：\n约束: 值班医生数 \u0026gt;= 1 T1: 查询在岗医生 = 2，决定让 A 下班，修改 A.on_call = false T2: 查询在岗医生 = 2，决定让 B 下班，修改 B.on_call = false 结果: A 和 B 都下班，值班医生 = 0，违反约束 写偏斜在快照隔离（Snapshot Isolation）下也会发生，需要串行化快照隔离（SSI）才能解决。\n2.1.4 Durability（持久性） 定义：已提交的事务对数据的修改是永久性的，即使系统崩溃也不会丢失。\n实现机制：\nWAL（Write-Ahead Log）：已提交事务的 redo log 必须在数据页刷盘之前先持久化到磁盘。崩溃后可通过重放 redo log 恢复未刷盘的数据。\nfsync 系统调用：确保数据真正写入磁盘（而非仅在 OS 缓冲区）。innodb_flush_log_at_trx_commit=1 时每次提交都会调用 fsync。\nCheckpoint 机制：定期将 buffer pool 中的脏页刷盘，减少崩溃恢复时需要重放的 redo log 量。\nDouble Write Buffer：防止\u0026quot;部分写\u0026quot;（Partial Write）问题——当 16KB 的数据页只写了一半时系统崩溃，通过 doublewrite buffer 保证页面完整性。\n2.2 事务隔离级别 SQL 标准定义了四个事务隔离级别，从低到高依次为：\n2.2.1 四种隔离级别定义 READ UNCOMMITTED（读未提交）：\n允许读到其他事务未提交的修改 存在脏读、不可重复读、幻读问题 几乎不使用，无实际价值 READ COMMITTED（读已提交）：\n只能读到已提交的数据，防止脏读 仍存在不可重复读、幻读问题 SQL Server、Oracle 的默认级别 实现：每次读操作都创建新的 Read View（最新快照） REPEATABLE READ（可重复读）：\n同一事务内多次读同一行，结果相同，防止不可重复读 在某些实现中可防止幻读（InnoDB 通过间隙锁） MySQL InnoDB 的默认级别 实现：事务开始时创建 Read View，之后的读操作都使用同一个快照 SERIALIZABLE（可串行化）：\n最强隔离，事务完全串行执行（或等价于串行） 防止所有并发异常，包括幻读和写偏斜 性能最差，通常使用 2PL（两阶段锁）实现 2.2.2 各级别允许/禁止的并发异常 隔离级别 脏读 不可重复读 幻读 写偏斜 READ UNCOMMITTED ✓（允许） ✓ ✓ ✓ READ COMMITTED ✗（禁止） ✓ ✓ ✓ REPEATABLE READ ✗ ✗ ✓（标准）/ ✗（InnoDB） ✓ SERIALIZABLE ✗ ✗ ✗ ✗ 注：\nInnoDB 的 REPEATABLE READ 通过间隙锁（Gap Lock）和临键锁（Next-Key Lock）在大多数场景下防止了幻读，但严格来说并非完全等价于 SQL 标准的 SERIALIZABLE 写偏斜（Write Skew）在快照隔离（SI）下依然存在，需要 SSI 才能防止 2.2.3 MySQL InnoDB 如何防止幻读 InnoDB 在 REPEATABLE READ 级别下通过**间隙锁（Gap Lock）**防止幻读：\n-- 事务 T1 SELECT * FROM orders WHERE amount \u0026gt; 100 FOR UPDATE; -- 对查询范围加 Next-Key Lock（临键锁 = 行锁 + 间隙锁） -- 其他事务无法在此范围内插入新行 -- 事务 T2（被阻塞） INSERT INTO orders (amount) VALUES (150); -- 阻塞！ Next-Key Lock = 行锁 + 该行之前的间隙锁，范围为 (previous_key, current_key]\n注意：\nSELECT ... FOR UPDATE 和 SELECT ... IN SHARE MODE 是当前读，会加锁 普通 SELECT 是快照读，使用 MVCC，不加锁 2.3 MVCC 多版本并发控制 2.3.1 核心思想 MVCC（Multi-Version Concurrency Control，多版本并发控制）的核心思想是：为数据维护多个历史版本，使读操作不需要等待写操作，写操作也不需要等待读操作。\n传统锁机制的问题：\n读写互斥：读需要等写完成，写需要等读完成 高并发下锁竞争严重，吞吐量下降 MVCC 的解决方案：\n写操作不修改原有数据，而是创建新版本 读操作根据事务开始时的快照版本读取对应版本，不受其他并发写的影响 读写并发，互不阻塞 2.3.2 快照读 vs 当前读 快照读（Snapshot Read）：\n读取某个时间点的数据快照，可能不是最新版本 不加锁，不影响其他事务 SQL：SELECT * FROM table WHERE ...（普通 SELECT） InnoDB 通过 undo log 版本链实现历史版本的访问 当前读（Current Read）：\n读取最新版本的数据 加锁（共享锁或排他锁） SQL： SELECT ... FOR UPDATE（加排他锁） SELECT ... IN SHARE MODE（加共享锁） INSERT, UPDATE, DELETE（也是当前读，因为要修改最新数据） 两者的本质区别：快照读通过 MVCC 实现非阻塞读，当前读需要通过锁保证读到最新且一致的数据。\n2.3.3 InnoDB MVCC 实现 InnoDB 的每行数据都有两个隐藏字段：\n字段 说明 DB_TRX_ID 最后修改该行的事务 ID DB_ROLL_PTR 回滚指针，指向 undo log 中的旧版本 DB_ROW_ID 隐藏主键（如果表没有显式主键） Undo Log 版本链：\n当前版本（row）: trx_id=100, name=\u0026#34;Alice\u0026#34;, roll_ptr ──► │ undo log v1: trx_id=80, name=\u0026#34;Bob\u0026#34;, roll_ptr ──────────► │ undo log v2: trx_id=50, name=\u0026#34;Charlie\u0026#34;, roll_ptr ──────► NULL 每次更新都会将旧版本写入 undo log，形成链式结构。\n2.3.4 Read View 与可见性判断 Read View 是事务在执行快照读时创建的一个\u0026quot;视图\u0026quot;，包含：\ntype ReadView struct { m_ids []uint64 // 创建时活跃的事务 ID 列表 m_low_limit_id uint64 // 创建时最大事务 ID + 1（\u0026gt;=此值的事务不可见） m_up_limit_id uint64 // m_ids 中最小的事务 ID（\u0026lt;此值的事务可见） m_creator_trx_id uint64 // 创建此 Read View 的事务 ID } 可见性判断规则（对某行的 trx_id 进行判断）：\n1. trx_id == m_creator_trx_id → 可见（自己修改的，当然能读） 2. trx_id \u0026lt; m_up_limit_id → 可见（创建 Read View 前已提交） 3. trx_id \u0026gt;= m_low_limit_id → 不可见（创建 Read View 后才开始的事务） 4. m_up_limit_id \u0026lt;= trx_id \u0026lt; m_low_limit_id: - trx_id 在 m_ids 中 → 不可见（创建时仍活跃，未提交） - trx_id 不在 m_ids 中 → 可见（已提交） 如果当前版本不可见，沿 roll_ptr 链找到旧版本，重复判断，直到找到可见版本或链尾（返回空）。\n2.3.5 RC 和 RR 的 Read View 创建时机差异 隔离级别 Read View 创建时机 效果 READ COMMITTED 每次执行 SELECT 都创建新的 Read View 能读到已提交的最新数据 REPEATABLE READ 事务内第一次执行 SELECT 时创建，之后复用 整个事务期间读到相同的数据快照 这就是为什么：\nRC 存在不可重复读（每次读视图不同，可以看到新提交的数据） RR 防止了不可重复读（整个事务使用同一快照） 2.4 并发控制机制对比 2.4.1 悲观并发控制（PCC / 2PL） 两阶段锁（Two-Phase Locking, 2PL）：\n加锁阶段：事务可以加锁，不能释放锁 释放阶段：事务开始释放锁后，不能再加新锁 锁的类型：\n共享锁（S Lock）：允许并发读，阻塞写 排他锁（X Lock）：阻塞所有并发读写 锁兼容矩阵：\nS Lock X Lock S Lock 兼容 ✓ 不兼容 ✗ X Lock 不兼容 ✗ 不兼容 ✗ 优缺点：\n优点：实现简单，能防止写冲突 缺点：读写互斥，高并发下性能差；可能死锁（需要死锁检测） 适用场景：写多、冲突率高的场景\n2.4.2 乐观并发控制（OCC） 三个阶段：\n读取（Read）：读取数据，记录版本号 验证（Validate）：提交前检查是否有冲突（版本号是否改变） 写入（Write）：无冲突则提交，有冲突则回滚重试 优缺点：\n优点：读操作不加锁，高并发下吞吐量高 缺点：冲突时需要回滚重试，冲突率高时性能差 适用场景：读多写少、冲突率低的场景（Percolator 就是乐观并发控制）\n2.4.3 时间戳排序（TO） 基于全局时间戳对事务进行排序，保证等价于某个串行执行顺序。\n每个事务分配一个唯一的时间戳 读操作检查：不能读到更新时间戳的写 写操作检查：不能写已被更大时间戳读取的数据 Percolator 中的 MVCC 本质上就是时间戳排序的一种变体，通过 startTS 和 commitTS 确定事务的\u0026quot;位置\u0026quot;。\n2.4.4 选择策略总结 冲突率高（写密集型） → 悲观锁（2PL）减少无效重试 冲突率低（读密集型） → 乐观锁（OCC/MVCC）提高并发 长事务 → 避免持锁太久，考虑乐观锁 短事务/点查询 → 悲观锁开销可接受 3. 分布式事务理论 3.1 分布式系统的挑战 在单机数据库中，ACID 的实现相对简单：undo log、redo log、锁机制已经足够。但在分布式系统中，面临更多挑战：\n3.1.1 网络不可靠 消息丢失：包括请求和响应都可能丢失 消息延迟：网络延迟不确定，超时不意味着失败 消息乱序：路由变化可能导致包乱序到达 网络分区：节点间可能完全断联 一个常见的困境：协调者发送 Commit 消息后网络分区，参与者不知道该提交还是回滚。\n3.1.2 节点故障 崩溃故障：节点宕机后无限期不响应（fail-stop） 拜占庭故障：节点发送错误或恶意消息（更难处理） TinyKV 只考虑崩溃故障（非拜占庭），但崩溃期间的状态恢复仍然复杂。\n3.1.3 时钟不一致 分布式系统中每个节点的时钟可能不同步：\n时钟漂移：石英晶振频率略有差异，随时间累积误差 NTP 同步误差：通常 1-100ms，无法保证毫秒级精确排序 这意味着简单地用本地时钟作为事务 ID 无法保证全局唯一和单调递增，需要特殊的时钟方案（TSO、HLC、TrueTime）。\n3.1.4 CAP 定理与 BASE 理论 CAP 定理（Brewer, 2000）：在分布式系统中，以下三个属性最多只能同时满足两个：\nC（Consistency）：所有节点看到相同的数据（线性一致性） A（Availability）：每个请求都能得到响应（不一定是最新数据） P（Partition Tolerance）：网络分区时系统继续运行 在实际中，网络分区（P）是不可避免的，所以需要在 C 和 A 之间取舍：\nCP 系统（如 ZooKeeper、HBase）：分区时牺牲可用性，保证一致性 AP 系统（如 Cassandra、DynamoDB）：分区时牺牲一致性，保证可用性 BASE 理论（对 CAP 的一种工程实践）：\nBasically Available（基本可用）：允许降级（响应延迟、部分数据不可用） Soft State（软状态）：允许系统处于中间状态（数据同步中） Eventually Consistent（最终一致性）：经过一定时间后，所有副本数据最终一致 BASE 是对强一致性（ACID）的放宽，适合对一致性要求不那么严格的场景。\n3.2 两阶段提交（2PC） 2PC 是实现分布式事务的经典协议，也是 XA 协议和 Percolator 的理论基础。\n3.2.1 角色与流程 角色：\nCoordinator（协调者）：发起事务，协调所有参与者的提交/回滚决策 Participant（参与者）：执行具体的数据修改，接受协调者的指令 Phase 1：Prepare（准备阶段）：\nCoordinator Participant A Participant B │ │ │ │─── Prepare ──────────────────►│ │ │─── Prepare ────────────────────────────────────►│ │ │ │ │ 执行事务操作，写 undo log + redo log，加锁 │ │ │ │ │◄── Vote Yes/No ───────────────│ │ │◄── Vote Yes/No ────────────────────────────────│ 协调者向所有参与者发送 Prepare 消息 参与者执行事务操作（写日志、加锁），但不提交 参与者回复 Yes（准备好了）或 No（无法执行） 此时参与者处于\u0026quot;就绪\u0026quot;状态，等待最终决定 Phase 2：Commit/Abort（提交/回滚阶段）：\n所有 Yes: Coordinator ─── Commit ───► 所有参与者 ─── Ack ────► 协调者 有 No: Coordinator ─── Abort ────► 所有参与者 ──No Ack ────► 协调者 若所有参与者回复 Yes：协调者发送 Commit 消息，参与者提交并释放锁 若有任一 No：协调者发送 Abort 消息，参与者回滚并释放锁 3.2.2 2PC 的问题 问题 1：同步阻塞\nPrepare 阶段后，参与者持锁等待协调者的 Commit/Abort 决定 等待期间，持锁资源无法被其他事务使用，导致吞吐下降 问题 2：协调者单点故障\n情景： 1. 协调者向所有参与者发送 Commit 2. 参与者 A 收到并提交，协调者此时宕机 3. 参与者 B 没收到 Commit，进入无限等待 结果：A 已提交，B 处于未知状态 → 数据不一致 协调者宕机后，参与者处于\u0026quot;悬挂（in-doubt）\u0026ldquo;状态 必须等待协调者恢复才能解决（可能导致长时间阻塞） 问题 3：网络分区\n情景： 1. 协调者发送 Commit 后网络分区 2. 参与者 A 收到 Commit 并提交 3. 参与者 B 超时，不知道是 Commit 还是 Abort 即使参与者有超时机制，也不知道是否该单方面提交 问题 4：\u0026ldquo;最后提交者宕机\u0026rdquo;\n协调者 Prepare 阶段收到所有 Yes 后，向第一个参与者发送 Commit 第一个参与者提交后，协调者宕机 其他参与者不知道整体事务是否提交 3.2.3 2PC 的优化 预提交日志：协调者在发送 Commit 前将决定持久化，崩溃恢复后可继续发送。\n超时机制：参与者等待超时后，可主动询问协调者或其他参与者。但如果无法联系协调者，仍然无法确定（不一致风险）。\n假设提交（Presumed Commit）：默认所有决定未记录的事务都是提交状态，减少日志写入。\n3.3 三阶段提交（3PC） 3PC 是对 2PC 的改进，增加了一个 CanCommit 阶段，并引入超时自动提交机制。\n3.3.1 三个阶段 Phase 1：CanCommit：\n协调者询问各参与者是否可以执行事务（只是询问，不实际执行） 参与者回复 Yes/No（不加锁） Phase 2：PreCommit：\n所有回复 Yes：协调者发送 PreCommit，参与者执行操作并加锁（写日志） 有 No：协调者直接发送 Abort Phase 3：DoCommit：\n所有参与者确认 PreCommit：协调者发送 DoCommit，参与者最终提交 如果参与者超时未收到 DoCommit：自动提交（假设协调者已决定提交） 3.3.2 3PC 的改进与局限 改进：\nCanCommit 阶段降低了无效等待（快速失败） 超时自动提交减少了阻塞时间 协调者宕机时，参与者不会永久阻塞 局限性：\n仍无法解决网络分区下的脑裂问题 假设场景： PreCommit 后网络分区 协调者决定 Abort（因为某个参与者返回 No） 分区的参与者超时后自动提交 结果：部分提交，部分回滚 → 数据不一致 3PC 在实践中并不比 2PC 更受欢迎，主要原因是增加了网络往返次数，且仍无法处理分区+超时的极端情况。\n3.4 Paxos/Raft 与分布式共识 3.4.1 分布式共识与事务的关系 **分布式共识（Consensus）**解决的问题：让多个节点就某个值达成一致（即使有节点故障）。\nRaft/Paxos 解决的是日志复制问题，而不是跨分区的事务协调问题：\n单个 Raft Group 内：保证日志的线性一致性（所有副本有相同的日志序列） 跨 Raft Group：需要额外的协调协议（如 2PC/Percolator） 3.4.2 Raft + 2PC 的协作 在 TiKV/TinyKV 的架构中，两者协作如下：\n分布式事务（跨 Region） │ ▼ 2PC 协调（Percolator） │ │ ▼ ▼ Region A Region B (Raft Group A) (Raft Group B) │ │ ▼ ▼ 持久化 持久化 （Raft 保证每个 Region 内的强一致性） 2PC/Percolator 负责：跨 Region 的原子性协调（Who commits? When?) Raft 负责：每个 Region 内部的日志复制和持久化（确保单节点的 D 和 C） 这两个机制是互补的，缺一不可。\n3.5 分布式事务隔离与一致性 3.5.1 外部一致性（External Consistency） 定义：如果事务 T1 在事务 T2 开始之前提交，则 T2 必须能看到 T1 的所有修改。更正式地：事务的提交顺序必须与实际时钟顺序一致（即线性化）。\n为什么难以实现：\n需要全局精确的时钟 分布式节点之间的时钟不可能完全同步 Spanner 的解决方案（TrueTime）：\n使用 GPS 天线 + 原子钟提供有界不确定性的时间（ε 区间） 提交时等待 2ε 确保时间戳唯一且正确顺序 3.5.2 快照隔离（Snapshot Isolation, SI） 定义：每个事务在开始时读取一个一致的数据快照，事务的写操作在提交前对其他事务不可见。\n快照隔离能防止：脏读、不可重复读、大多数幻读\n快照隔离不能防止：写偏斜（Write Skew）\nPercolator 实现的是快照隔离（通过 startTS 实现快照读，通过 commitTS 的可见性控制）。\n3.5.3 串行化快照隔离（SSI） SSI 在 SI 的基础上，通过检测读写冲突环来识别并阻止写偏斜：\n如果事务 T1 读了某个数据项，T2 写了该数据项，且 T1 也写了 T2 读的数据项 → 形成冲突环 → 中止一个事务 PostgreSQL 的 SERIALIZABLE 级别就是基于 SSI 实现的。\n3.5.4 全局时钟方案对比 方案 原理 精度 实现难度 TSO（中心化时间戳） 专用服务器分发单调递增 ID 取决于网络延迟 低 HLC（混合逻辑时钟） 物理时钟 + 逻辑时钟结合 有界误差 中 TrueTime GPS + 原子钟 ±数毫秒 高（专用硬件） Lamport Clock 纯逻辑时钟 仅保证偏序 低 3.6 分布式事务常见协议 3.6.1 XA 协议 XA 是 Open Group 制定的跨数据库分布式事务标准，基于 2PC：\n-- 应用程序通过 XA 接口控制多个数据库 XA START \u0026#39;xid\u0026#39;; UPDATE accounts SET balance = balance - 100 WHERE id = 1; XA END \u0026#39;xid\u0026#39;; XA PREPARE \u0026#39;xid\u0026#39;; -- Prepare 阶段 XA COMMIT \u0026#39;xid\u0026#39;; -- 或 XA ROLLBACK \u0026#39;xid\u0026#39; 特点：\n标准化接口，支持多种数据库混用 基于 2PC，有协调者单点问题 性能较差（同步阻塞、持锁时间长） 实际应用：JTA（Java Transaction API） 3.6.2 TCC（Try-Confirm-Cancel） TCC 是一种业务层面的补偿事务协议：\n阶段 操作 说明 Try 资源预留 冻结库存/余额，不实际扣减 Confirm 确认执行 正式扣减，释放预留资源 Cancel 取消执行 释放预留资源，回到原始状态 示例（电商下单）：\nTry: 冻结库存 1 件，冻结余额 100 元 Confirm: 扣减库存，扣减余额 Cancel: 解冻库存，解冻余额 特点：\n侵入性强：需要为每个操作实现 Try/Confirm/Cancel 三个方法 没有数据库锁，性能好 适合核心业务（订单、支付） 3.6.3 Saga 长事务 Saga 将一个长事务分解为一系列本地事务，每个本地事务都有对应的补偿事务：\n正向流程: T1 → T2 → T3 → ... → Tn 补偿流程: C1 ← C2 ← C3 ← ... ← Cn 失败处理：\n如果 Ti 失败，执行 Ci-1, Ci-2, \u0026hellip;, C1 进行补偿回滚 最终数据可能经过一段时间才达到一致（最终一致性） 实现方式：\nOrchestration（编排）：有一个 Saga Orchestrator 协调所有步骤 Choreography（编舞）：每个服务在完成本地事务后发布事件，其他服务监听事件执行 特点：\n适合长事务（跨多个微服务、可能持续数分钟） 最终一致性，不提供隔离性 需要业务上接受\u0026quot;暂时不一致\u0026quot;状态 3.6.4 本地消息表 一种基于消息队列的最终一致性方案：\n1. 本地事务：业务操作 + 写消息到本地消息表（原子操作） 2. 异步服务：扫描本地消息表，发送到消息队列 3. 消费者：消费消息，执行目标操作，幂等处理 4. 确认机制：消费成功后删除/标记消息 特点：\n最终一致性，适合异步场景 实现简单，依赖消息队列可靠性 不适合强一致性要求的场景 3.6.5 各协议对比 协议 一致性 协调者 侵入性 性能 适用场景 XA/2PC 强一致 需要 低 差 少量跨库 TCC 强一致 需要 高 中 金融核心业务 Percolator SI 无（Primary Key） 中 中 通用 KV 事务 Saga 最终一致 可选 高 好 微服务长事务 本地消息表 最终一致 无 中 好 异步通知 Spanner 外部一致 TSO（TrueTime） 低 中 全球数据库 4. Percolator 事务模型深度解析 4.1 Percolator 背景与设计哲学 4.1.1 论文背景 Google Percolator（2010 年 OSDI 论文《Large-scale Incremental Processing Using Distributed Transactions and Notifications》）最初是为了解决网页索引的增量更新问题：\n问题：Google 每天有数十亿个网页更新，用 MapReduce 重新处理整个索引效率太低 方案：只处理更新的部分，需要支持跨行原子更新 4.1.2 核心设计哲学 去中心化：传统 2PC 有一个独立的协调者节点，是单点故障风险所在。Percolator 通过将事务状态存储在Primary Key 所在的行，利用底层存储（BigTable/RocksDB）的单行原子性来替代协调者角色。\n乐观并发控制：\nPrewrite 阶段检测冲突（乐观地假设没有冲突） 发现冲突时回滚重试，而非提前加锁等待 MVCC 支持快照读：\n读操作不加锁，使用快照版本读取 写操作通过 lock 防止并发修改 依赖的底层能力：\n单行原子性（对同一 key 的多列读写是原子的） 单调递增时间戳服务（TSO） 4.2 三列族设计 Percolator 使用三个列族（Column Family）来实现分布式事务：\n4.2.1 CfDefault（data 列族） 存储内容：用户数据的实际值\n键格式：EncodeKey(userKey, startTS) 即 (userKey, startTS)\n值格式：用户数据的原始字节\n语义：在 startTS 时刻，某个写事务对 userKey 写入的值。注意是用 startTS 而非 commitTS 作为版本，这样读操作可以通过 Write CF 的 commitTS 找到对应的 startTS，再去 Default CF 中读取。\nCfDefault: Key Value (Alice, startTS=100) ─► \u0026#34;Bob\u0026#34; ← 事务 100 写入的值 (Alice, startTS=80) ─► \u0026#34;Alice\u0026#34; ← 事务 80 写入的值 (Bob, startTS=95) ─► \u0026#34;100\u0026#34; ← 事务 95 写入的值 4.2.2 CfLock（lock 列族） 存储内容：事务在 Prewrite 阶段写入的锁，表示事务正在进行中\n键格式：userKey（不含时间戳，每个 key 同时只能有一个锁）\n值格式（Lock 结构体）：\nmessage Lock { LockType lock_type // PUT / DELETE / ROLLBACK / LOCK bytes primary // Primary Key（用于崩溃恢复时查找事务状态） uint64 ts // startTS（用于区分哪个事务的锁） uint64 ttl // TTL，超时后可强制回滚 uint64 txn_size // 事务大小（决定 TTL） } 语义：key 上有锁 = 有事务正在修改这个 key，还未提交。\nCfLock: Key Value Alice ─► Lock{type=PUT, primary=\u0026#34;Alice\u0026#34;, ts=100, ttl=3000} Bob ─► Lock{type=PUT, primary=\u0026#34;Alice\u0026#34;, ts=100, ttl=3000} 注意：同一事务对 Alice 和 Bob 的锁都指向同一个 primary（\u0026ldquo;Alice\u0026rdquo;），这样通过任何一个锁都能找到事务的决策点。\n4.2.3 CfWrite（write 列族） 存储内容：事务提交记录，记录哪个 commitTS 对应哪个 startTS\n键格式：EncodeKey(userKey, commitTS) 即 (userKey, commitTS)\n值格式（Write 结构体）：\nmessage Write { WriteKind kind // PUT / DELETE / ROLLBACK uint64 start_ts // 对应的 startTS（指向 CfDefault 中的值） } 语义：在 commitTS 时刻，userKey 的值来自 startTS 时刻写入的 CfDefault 数据。读操作通过扫描 CfWrite 找到最新的可见版本，再通过 startTS 去 CfDefault 取值。\nCfWrite: Key Value (Alice, commitTS=105) ─► Write{kind=PUT, startTS=100} (Alice, commitTS=85) ─► Write{kind=PUT, startTS=80} (Bob, commitTS=98) ─► Write{kind=PUT, startTS=95} 4.2.4 三列族时间线图 时间线（数字代表 TS）: 事务 80（已提交）: startTS=80, commitTS=85 事务 100（进行中）: startTS=100, commitTS 待定 CfDefault: Alice: TS=80 → \u0026#34;Alice\u0026#34;(旧值), TS=100 → \u0026#34;Bob\u0026#34;(新值,尚未提交) CfLock: Alice: Lock{ts=100, primary=\u0026#34;Alice\u0026#34;, ...} ← Prewrite 写入，Commit 后删除 CfWrite: Alice: TS=85 → {PUT, startTS=80} ← 事务80 Commit 后写入 读事务（startTS=90）读取 Alice: 1. 查 CfLock[Alice]：有锁 ts=100 \u0026gt; 90，忽略（锁的事务在我的快照之后） 2. 查 CfWrite[Alice]: 找 commitTS \u0026lt;= 90 的最新记录 → commitTS=85 → startTS=80 3. 查 CfDefault[Alice, TS=80] → \u0026#34;Alice\u0026#34; ✓ 读事务（startTS=110）读取 Alice: 1. 查 CfLock[Alice]：有锁 ts=100 \u0026lt;= 110，需要等待或清理 2. 等待或通过 CheckTxnStatus 判断是否可清理 4.3 时间戳服务（TSO） 4.3.1 TSO 的作用 TSO（Timestamp Oracle，时间戳服务） 是 Percolator 的核心组件之一，提供：\n全局单调递增的时间戳：确保每个时间戳唯一且单调递增 因果一致性：如果 T1 在 T2 开始之前提交，则 T1.commitTS \u0026lt; T2.startTS 在 TiDB/TinyKV 中，TSO 由 PD（Placement Driver）提供。\n4.3.2 startTS 与 commitTS 的语义 startTS（事务开始时间戳）：\n在事务开始时从 TSO 获取 定义了事务的读快照：只能看到 commitTS \u0026lt;= startTS 的已提交数据 用作 CfDefault 的键（数据版本号） commitTS（事务提交时间戳）：\n在事务准备提交时从 TSO 获取，commitTS \u0026gt; startTS 定义了事务的提交时序：其他事务的 startTS \u0026gt; commitTS 才能看到此事务的数据 用作 CfWrite 的键（提交版本号） 4.3.3 时间戳保证全局一致快照 假设 T1 提交（commitTS=100），T2 开始（startTS=105）：\nT2 的快照 = 所有 commitTS \u0026lt;= 105 的事务 → T2 能看到 T1 的修改 ✓ 假设 T2 开始（startTS=95），T1 提交（commitTS=100）：\nT2 的快照 = 所有 commitTS \u0026lt;= 95 的事务 → T2 看不到 T1 的修改 ✓（快照隔离） 这个保证依赖于 TSO 的全局单调递增性质：一旦 T2 获取了 startTS=95，之后 T1 获取的 commitTS 必然 \u0026gt; 95（因为时间戳单调递增）。\n4.4 Primary Key 机制 4.4.1 为什么需要 Primary Key 传统 2PC 依赖独立的协调者节点来做 Commit/Abort 决策，协调者是单点故障风险。\nPercolator 的创新：利用事务的某一个写 key（Primary Key）来充当协调者。\n关键观察：\n底层存储（BigTable/RocksDB）支持单行（单 key）的原子操作 对 Primary Key 的 Commit（写 Write CF + 删 Lock CF）是原子的 因此：Primary Key 的 Write CF 有 Commit 记录 = 事务已提交 任何观察者通过检查 Primary Key 的状态，就能确定整个事务的结果 4.4.2 Primary Key 的工作原理 事务状态决策点：\n事务状态判断（通过 PrimaryKey）: - CfWrite[PrimaryKey] 有 WriteKind_Put 记录 → 事务已提交 - CfWrite[PrimaryKey] 有 WriteKind_Rollback 记录 → 事务已回滚 - CfLock[PrimaryKey] 存在 → 事务仍在进行（根据 TTL 决定是否清理） - CfLock[PrimaryKey] 不存在 且 CfWrite 无记录 → 事务已回滚（锁被清理） 崩溃恢复场景：\n场景 1：Prewrite 完成，Commit 前崩溃\nCfLock[Alice] 存在（Primary），CfLock[Bob] 存在（Secondary） → 检查 CfWrite[Alice]：无记录 → 检查 Alice.lock 的 TTL：如果超时 → 清理所有锁，视为回滚 → 如果未超时 → 等待，原事务可能还在执行 场景 2：Primary Key Commit 成功，Secondary Key 未 Commit，崩溃\nCfWrite[Alice] 有 Write{PUT, startTS=100}（Primary 已提交） CfLock[Bob] 存在（Secondary 未提交） → 其他事务遇到 Bob 的锁 → 检查 Primary Key → 已提交 → 替原事务提交 Bob：DeleteLock[Bob] + PutWrite[Bob, TS=105] 4.4.3 Primary Key 的选择 TinyKV/TiKV 中，Primary Key 通常是第一个写入的 key（或随机选择）。\nPrimary Key 的选择会影响热点：如果所有事务都以某个热门 key 作为 Primary，该 key 会成为瓶颈。\n4.5 Prewrite 阶段详解 Prewrite 是事务的第一阶段，负责：\n检测冲突（写冲突 + 锁冲突） 写入数据（CfDefault） 写入锁（CfLock） 4.5.1 完整流程 客户端发起 Prewrite(mutations, primaryKey, startTS, ttl): FOR each mutation in mutations: // 步骤1: 检查写冲突 write, commitTS = MostRecentWrite(mutation.key) IF write != nil AND commitTS \u0026gt;= startTS: RETURN WriteConflict{commitTS, startTS, key} // 步骤2: 检查锁冲突 lock = GetLock(mutation.key) IF lock != nil: RETURN KeyError{locked: lock} // 提示客户端等待或清理 // 步骤3: 写入数据（值） PutValue(mutation.key, mutation.value) // 步骤4: 写入锁 PutLock(mutation.key, Lock{ kind: mutation.kind, // PUT / DELETE primary: primaryKey, ts: startTS, ttl: ttl, }) // 批量提交到存储层（原子性写入） storage.Write(txn.Writes()) 4.5.2 写冲突检测细节 条件：存在 commitTS \u0026gt;= startTS 的写记录\n语义：从 startTS 开始，已经有另一个事务在 startTS 之后提交了（commitTS \u0026gt;= startTS），这意味着如果当前事务也提交，两个事务的修改顺序违反了一致性。\n例： T1 startTS=100, T2 startTS=90 T2 先提交，commitTS=95 T1 尝试 Prewrite：MostRecentWrite 返回 commitTS=95 \u0026gt;= 100? 不，95 \u0026lt; 100 T1 没有冲突，继续 另一种情况： T1 startTS=100, T2 startTS=90 T2 先提交，commitTS=105（发生在 T1 开始后！） T1 尝试 Prewrite：MostRecentWrite 返回 commitTS=105 \u0026gt;= 100？是的 T1 返回 WriteConflict，客户端重试 4.5.3 锁冲突检测细节 条件：key 上存在任意锁（不论 lockTS 大小）\n语义：有另一个事务正在修改这个 key，若同时写入可能导致冲突。\n客户端收到锁冲突后的处理策略：\n等待：短暂等待后重试（TTL 未超时时） 强制回滚：通过 CheckTxnStatus 检查锁的状态，若 TTL 超时则协助回滚 推进提交：若锁的 Primary 已提交，协助完成 Secondary 的提交 4.6 Commit 阶段详解 Commit 是事务的第二阶段，将 Prewrite 的\u0026quot;意图\u0026quot;变为\u0026quot;事实\u0026rdquo;：\n客户端发起 Commit(keys, startTS, commitTS): FOR each key in keys: // 步骤1: 验证锁还在（防止 Prewrite 被回滚） lock = GetLock(key) IF lock == nil OR lock.ts != startTS: RETURN retryable error // 锁已被清理，事务已回滚 // 步骤2: 写提交记录 PutWrite(key, commitTS, Write{ kind: lock.kind, // PUT / DELETE start_ts: startTS, }) // 步骤3: 删除锁（释放） DeleteLock(key) // 批量提交 storage.Write(txn.Writes()) 关键点：Primary Key 的 Commit 是整个事务的\u0026quot;决策点\u0026quot;，Primary Key 的 Write CF 写入成功即代表事务提交，Secondary Keys 的提交可以异步进行。\n幂等性：如果同一 Commit 请求被重试，通过 CurrentWrite 检查发现已有匹配的 Write 记录，直接返回成功。\n4.7 Read 流程 客户端发起 Get(key, version=startTS): // 步骤1: 检查锁冲突 lock = GetLock(key) IF lock != nil AND lock.ts \u0026lt;= startTS: // 有事务（startTS \u0026lt;= version）正在修改这个 key // 需要等待或协助清理 RETURN KeyError{locked: lock} // 步骤2: 在 Write CF 中找最新可见提交 Write = GetValue(key) // 内部实现：Seek CfWrite[key, startTS] IF write == nil: RETURN nil // key 不存在（在此快照下） RETURN value // 通过 write.startTS 从 CfDefault 读取实际值 注意：只有 lock.ts \u0026lt;= startTS 时才阻塞读。如果 lock.ts \u0026gt; startTS，说明锁是在当前读快照之后的事务加的，对当前快照不可见，可以忽略。\n4.8 Rollback 流程 当事务需要回滚时（TTL 超时、手动回滚、崩溃恢复）：\nRollback(key, startTS): // 写 Rollback 标记（重要：即使 CfDefault 无数据也要写） PutWrite(key, startTS, Write{kind: ROLLBACK, start_ts: startTS}) // 删除 CfDefault 中的临时数据 DeleteValue(key) // 删除锁（如果锁存在） lock = GetLock(key) IF lock != nil AND lock.ts == startTS: DeleteLock(key) 为什么必须写 Rollback 标记：\n如果不写，后续可能有另一个并发事务也尝试 Rollback，然后提交一个新事务 如果这时候原事务的 Commit 消息到来（因为网络延迟），会发现 lock 已不存在，导致不确定状态 有了 Rollback 标记：后续 Commit 发现 CurrentWrite 已有 Rollback 记录，直接返回错误，防止\u0026quot;已回滚的事务被错误提交\u0026quot; 4.9 锁冲突与 Stale Lock 处理 4.9.1 TTL 机制 每个锁都有 TTL（Time To Live），用于检测事务是否\u0026quot;卡死\u0026quot;（崩溃、长期无响应）：\nTTL = base_ttl + size_factor * txn_size 大事务（写入数据多）的 TTL 更长，防止误伤 小事务 TTL 较短，崩溃后快速清理 4.9.2 CheckTxnStatus 流程 当读操作遇到 Stale Lock（TTL 超时）时，调用 CheckTxnStatus 检查事务状态：\nCheckTxnStatus(primaryKey, lockTS, currentTS): // 检查事务是否已提交 write, commitTS = CurrentWrite(primaryKey) // 找匹配 startTS=lockTS 的 write IF write != nil AND write.kind != ROLLBACK: RETURN {action: NoAction, commitTs: commitTS} // 已提交 // 检查是否已回滚 IF write != nil AND write.kind == ROLLBACK: RETURN {action: NoAction, commitTs: 0} // 已回滚 // 检查锁是否存在 lock = GetLock(primaryKey) IF lock == nil: // 锁不存在且无 Write 记录 → 写 Rollback 标记 PutWrite(primaryKey, lockTS, Rollback) RETURN {action: LockNotExistRollback, commitTs: 0} // 检查 TTL IF physicalTime(currentTS) \u0026gt;= physicalTime(lockTS) + lock.ttl: // TTL 超时，强制回滚 Rollback(primaryKey, lockTS) RETURN {action: TTLExpireRollback, commitTs: 0} // TTL 未超时，等待 RETURN {action: NoAction, commitTs: 0, lockTtl: lock.ttl} 4.9.3 Resolve Lock 流程 当确定事务结果后（已提交或需要回滚），通过 ResolveLock 批量处理该事务的所有锁：\nResolveLock(startTS, commitTS): // 扫描所有 lockTS == startTS 的锁 locks = AllLocksForTxn(startTS) FOR each lock in locks: IF commitTS == 0: // 回滚 DeleteValue(lock.key) DeleteLock(lock.key) PutWrite(lock.key, startTS, Rollback) ELSE: // 提交 DeleteLock(lock.key) PutWrite(lock.key, commitTS, Write{PUT, startTS}) 5. Project4 代码实现详解 5.1 存储层结构与键编码 5.1.1 EncodeKey 实现 // kv/transaction/mvcc/transaction.go func EncodeKey(key []byte, ts uint64) []byte { encodedKey := codec.EncodeBytes(key) newKey := append(encodedKey, make([]byte, 8)...) binary.BigEndian.PutUint64(newKey[len(encodedKey):], ^ts) // XOR 取反 return newKey } 关键点：时间戳倒序存储（^ts = XOR 取反）\n为什么时间戳要倒序？\n正常存储（升序）: TS=100 \u0026lt; TS=200 \u0026lt; TS=300 倒序存储（降序）: ~TS=100 \u0026gt; ~TS=200 \u0026gt; ~TS=300 (对应字节值: 大 \u0026gt; 中 \u0026gt; 小) 好处：\nSeek 到最新版本更高效：在 CfWrite 中 Seek EncodeKey(key, startTS) 后，第一个扫到的就是 commitTS \u0026lt;= startTS 中最大的（因为倒序，大 commitTS 对应小字节值，排在前面） CfWrite（逻辑上按 key ASC, ts DESC 排序）: Alice/TS=~300 (commitTS=300) ← 排最前 Alice/TS=~200 (commitTS=200) Alice/TS=~100 (commitTS=100) Seek(Alice, startTS=250): → 找到第一个 key=Alice 且 ~TS \u0026lt;= ~250 (即 TS \u0026gt;= 250) 的记录 → 因为倒序，\u0026#34;第一个\u0026#34;就是 commitTS 最大且 \u0026lt;= 250 的记录 → 找到 Alice/TS=~200 (commitTS=200) ✓ 与 MVCC 语义自然契合：读操作总是想要最新的可见版本，倒序存储使 Seek 后第一条就是目标。 5.1.2 DecodeUserKey 和 decodeTimestamp func DecodeUserKey(key []byte) []byte { userKey, _ := codec.DecodeBytes(key) return userKey } func decodeTimestamp(key []byte) uint64 { left, _ := codec.DecodeBytes(key) // 跳过 user key 部分 return ^binary.BigEndian.Uint64(key[len(left):]) // XOR 还原 } 5.1.3 三个 CF 在 badger 中的存储布局 CfDefault: [EncodeKey(\u0026#34;Alice\u0026#34;, 100)] → \u0026#34;Bob\u0026#34; [EncodeKey(\u0026#34;Alice\u0026#34;, 80)] → \u0026#34;Alice\u0026#34; CfLock: [\u0026#34;Alice\u0026#34;] → Lock{ts=100, primary=\u0026#34;Alice\u0026#34;, ...} CfWrite: [EncodeKey(\u0026#34;Alice\u0026#34;, ~105)] → Write{kind=PUT, startTS=100} [EncodeKey(\u0026#34;Alice\u0026#34;, ~85)] → Write{kind=PUT, startTS=80} 5.2 MvccTxn 结构体 // kv/transaction/mvcc/transaction.go type MvccTxn struct { StartTS uint64 // 事务的快照版本（startTS） Reader storage.StorageReader // 存储读接口（不可变） writes []storage.Modify // 写操作缓冲（Commit 时批量提交） } 设计要点：\n写缓冲（writes slice）：所有的 PutLock、PutValue、PutWrite、DeleteLock、DeleteValue 都只是追加到 writes 缓冲中，不立即写存储。只有调用 storage.Write(txn.Writes()) 时才原子地提交所有修改。\n这保证了：\n单个 RPC（如 KvPrewrite）的多个写操作原子性 写失败时不会留下部分状态 Reader 不可变：Reader 是只读的存储快照，通过 Reader.GetCF(cf, key) 和 Reader.IterCF(cf) 读取数据。\n5.3 Part A：核心 MVCC 方法 5.3.1 GetLock 和 PutLock func (txn *MvccTxn) GetLock(key []byte) (*Lock, error) { // 直接读 CfLock，无需时间戳编码 value, err := txn.Reader.GetCF(engine_util.CfLock, key) if err != nil || value == nil { return nil, err } lock, err := ParseLock(value) // 反序列化 Lock 结构体 return lock, err } func (txn *MvccTxn) PutLock(key []byte, lock *Lock) { txn.writes = append(txn.writes, storage.Modify{ Data: storage.Put{ Key: key, Value: lock.ToBytes(), // 序列化 Cf: engine_util.CfLock, }, }) } 注意：CfLock 的键是原始 userKey，不含时间戳。每个 key 同时只能有一个锁。\n5.3.2 GetValue 实现（关键） func (txn *MvccTxn) GetValue(key []byte) ([]byte, error) { // 在 CfWrite 中找 commitTS \u0026lt;= startTS 的最新 Write iter := txn.Reader.IterCF(engine_util.CfWrite) defer iter.Close() // Seek 到 EncodeKey(key, startTS) 处 // 因为时间戳倒序，第一条 \u0026gt;= startTS 的就是我们想要的 iter.Seek(EncodeKey(key, txn.StartTS)) if !iter.Valid() { return nil, nil } item := iter.Item() userKey := DecodeUserKey(item.Key()) // 验证是同一个 user key（防止跨 key） if !bytes.Equal(userKey, key) { return nil, nil } // 反序列化 Write 记录 value, err := item.Value() write, err := ParseWrite(value) // 如果是 DELETE 或 ROLLBACK，说明此版本不是有效值 if write.Kind != WriteKindPut { return nil, nil } // 通过 write.StartTS 从 CfDefault 取实际值 return txn.Reader.GetCF(engine_util.CfDefault, EncodeKey(key, write.StartTs)) } 关键实现细节：\nSeek 后需要验证 userKey == key，防止 Seek 越过了目标 key（扫到了下一个 key） 必须检查 write.Kind != WriteKindPut：若是 DELETE 或 ROLLBACK，说明该版本数据已被删除，应返回 nil 5.3.3 CurrentWrite 实现（关键） func (txn *MvccTxn) CurrentWrite(key []byte) (*Write, uint64, error) { // 从最大时间戳向小扫描，找匹配 startTS 的 Write iter := txn.Reader.IterCF(engine_util.CfWrite) defer iter.Close() // 从 TsMax 开始扫描（相当于最大 commitTS） for iter.Seek(EncodeKey(key, TsMax)); iter.Valid(); iter.Next() { item := iter.Item() userKey := DecodeUserKey(item.Key()) if !bytes.Equal(userKey, key) { break // 已扫过此 key 的所有版本 } value, _ := item.Value() write, _ := ParseWrite(value) if write.StartTs == txn.StartTS { commitTS := decodeTimestamp(item.Key()) return write, commitTS, nil } } return nil, 0, nil } 作用：\n在 KvCommit 中检查幂等性（已提交则直接成功） 在 KvBatchRollback 中检查是否已处理（已 Rollback 则跳过） 在 KvCheckTxnStatus 中确认事务状态 实现细节：从 TsMax 向小扫描，这样能遍历所有 commitTS，找到 startTS 匹配的记录。\n5.3.4 MostRecentWrite 实现 func (txn *MvccTxn) MostRecentWrite(key []byte) (*Write, uint64, error) { iter := txn.Reader.IterCF(engine_util.CfWrite) defer iter.Close() iter.Seek(EncodeKey(key, TsMax)) if !iter.Valid() { return nil, 0, nil } item := iter.Item() userKey := DecodeUserKey(item.Key()) if !bytes.Equal(userKey, key) { return nil, 0, nil } value, _ := item.Value() write, _ := ParseWrite(value) commitTS := decodeTimestamp(item.Key()) return write, commitTS, nil } 作用：在 KvPrewrite 中检测写冲突（若 commitTS \u0026gt;= startTS 则冲突）。\n5.4 Scanner 实现 // kv/transaction/mvcc/scanner.go type Scanner struct { iter engine_util.DBIterator // CfWrite 的迭代器 txn *MvccTxn key []byte // 上一次返回的 user key（用于跳过同 key 的旧版本） } func NewScanner(startKey []byte, txn *MvccTxn) *Scanner { scanner := \u0026amp;Scanner{ iter: txn.Reader.IterCF(engine_util.CfWrite), txn: txn, } // Seek 到 startKey 的最新版本 scanner.iter.Seek(EncodeKey(startKey, txn.StartTS)) return scanner } func (scan *Scanner) Next() ([]byte, []byte, error) { for { if !scan.iter.Valid() { return nil, nil, nil // 已遍历完 } item := scan.iter.Item() currentKey := DecodeUserKey(item.Key()) // 跳过与上次相同的 user key（同一 key 的旧版本） if bytes.Equal(currentKey, scan.key) { scan.iter.Next() continue } // 检查此版本是否在快照范围内（commitTS \u0026lt;= startTS） commitTS := decodeTimestamp(item.Key()) if commitTS \u0026gt; scan.txn.StartTS { scan.iter.Next() continue } scan.key = currentKey // 获取值（内部处理 DELETE/ROLLBACK 返回 nil） value, err := scan.txn.GetValue(currentKey) scan.iter.Next() if value == nil { continue // 此 key 已被删除或不可见，继续扫下一个 } return currentKey, value, err } } 设计要点：\nScanner 维护 key 字段：记录上一次返回的 user key，用于跳过同一 key 的多个版本（只取最新可见版本） 每次 Next() 后移动迭代器，确保不会重复返回同一个 key GetValue 内部已处理 DELETE/ROLLBACK 情况，返回 nil 时 Scanner 继续跳过 5.5 Part B：核心 RPC 处理器 5.5.1 KvGet 实现 // kv/server/server.go func (server *Server) KvGet(_ context.Context, req *kvrpcpb.GetRequest) (*kvrpcpb.GetResponse, error) { reader, _ := server.storage.Reader(req.Context) defer reader.Close() txn := mvcc.NewMvccTxn(reader, req.Version) // 检查锁冲突 lock, _ := txn.GetLock(req.Key) if lock != nil \u0026amp;\u0026amp; lock.Ts \u0026lt;= req.Version { // 有事务（startTS \u0026lt;= version）持有此 key 的锁，需要等待 return \u0026amp;kvrpcpb.GetResponse{ Error: \u0026amp;kvrpcpb.KeyError{ Locked: lock.Info(req.Key), }, }, nil } // 读取值 value, _ := txn.GetValue(req.Key) if value == nil { return \u0026amp;kvrpcpb.GetResponse{NotFound: true}, nil } return \u0026amp;kvrpcpb.GetResponse{Value: value}, nil } 注意：只有 lock.Ts \u0026lt;= req.Version 时才阻塞读。如果锁的 startTS \u0026gt; 版本，说明锁是在快照之后加的，读操作可以忽略。\n5.5.2 KvPrewrite 实现 func (server *Server) KvPrewrite(_ context.Context, req *kvrpcpb.PrewriteRequest) (*kvrpcpb.PrewriteResponse, error) { reader, _ := server.storage.Reader(req.Context) defer reader.Close() txn := mvcc.NewMvccTxn(reader, req.StartVersion) var keyErrors []*kvrpcpb.KeyError for _, mutation := range req.Mutations { // 1. 写冲突检测 write, commitTS, _ := txn.MostRecentWrite(mutation.Key) if write != nil \u0026amp;\u0026amp; commitTS \u0026gt;= req.StartVersion { keyErrors = append(keyErrors, \u0026amp;kvrpcpb.KeyError{ Conflict: \u0026amp;kvrpcpb.WriteConflict{ StartTs: req.StartVersion, ConflictTs: commitTS, Key: mutation.Key, Primary: req.PrimaryLock, }, }) continue } // 2. 锁冲突检测 lock, _ := txn.GetLock(mutation.Key) if lock != nil { keyErrors = append(keyErrors, \u0026amp;kvrpcpb.KeyError{ Locked: lock.Info(mutation.Key), }) continue } // 3. 写入数据和锁 switch mutation.Op { case kvrpcpb.Op_Put: txn.PutValue(mutation.Key, mutation.Value) txn.PutLock(mutation.Key, \u0026amp;mvcc.Lock{ Primary: req.PrimaryLock, Ts: req.StartVersion, Ttl: req.LockTtl, Kind: mvcc.WriteKindPut, }) case kvrpcpb.Op_Del: txn.DeleteValue(mutation.Key) txn.PutLock(mutation.Key, \u0026amp;mvcc.Lock{ Primary: req.PrimaryLock, Ts: req.StartVersion, Ttl: req.LockTtl, Kind: mvcc.WriteKindDelete, }) } } if len(keyErrors) \u0026gt; 0 { return \u0026amp;kvrpcpb.PrewriteResponse{Errors: keyErrors}, nil } // 批量原子提交 server.storage.Write(req.Context, txn.Writes()) return \u0026amp;kvrpcpb.PrewriteResponse{}, nil } 5.5.3 KvCommit 实现 func (server *Server) KvCommit(_ context.Context, req *kvrpcpb.CommitRequest) (*kvrpcpb.CommitResponse, error) { reader, _ := server.storage.Reader(req.Context) defer reader.Close() txn := mvcc.NewMvccTxn(reader, req.StartVersion) // Latch 防止并发修改同一 key server.Latches.WaitForLatches(req.Keys) defer server.Latches.ReleaseLatches(req.Keys) for _, key := range req.Keys { // 检查幂等性：是否已提交 write, _, _ := txn.CurrentWrite(key) if write != nil { if write.Kind == mvcc.WriteKindRollback { // 已回滚，不能再提交 return \u0026amp;kvrpcpb.CommitResponse{ Error: \u0026amp;kvrpcpb.KeyError{Retryable: \u0026#34;already rolled back\u0026#34;}, }, nil } // 已提交（幂等），继续下一个 key continue } // 验证锁 lock, _ := txn.GetLock(key) if lock == nil || lock.Ts != req.StartVersion { // 锁不存在或 ts 不匹配（可能被其他事务抢走了） return \u0026amp;kvrpcpb.CommitResponse{ Error: \u0026amp;kvrpcpb.KeyError{Retryable: \u0026#34;lock not found\u0026#34;}, }, nil } // 写提交记录 txn.PutWrite(key, req.CommitVersion, \u0026amp;mvcc.Write{ StartTs: req.StartVersion, Kind: lock.Kind, }) // 删除锁 txn.DeleteLock(key) } server.storage.Write(req.Context, txn.Writes()) return \u0026amp;kvrpcpb.CommitResponse{}, nil } 5.6 Part C：辅助 RPC 处理器 5.6.1 KvScan 实现 func (server *Server) KvScan(_ context.Context, req *kvrpcpb.ScanRequest) (*kvrpcpb.ScanResponse, error) { reader, _ := server.storage.Reader(req.Context) defer reader.Close() txn := mvcc.NewMvccTxn(reader, req.Version) scanner := mvcc.NewScanner(req.StartKey, txn) defer scanner.Close() var pairs []*kvrpcpb.KvPair for i := uint32(0); i \u0026lt; req.Limit; i++ { key, value, err := scanner.Next() if key == nil { break } if err != nil { // 处理 lock 错误：记录到 pairs，而非终止 if keyErr, ok := err.(*mvcc.KeyError); ok { pairs = append(pairs, \u0026amp;kvrpcpb.KvPair{ Error: keyErr.Err, }) continue } return nil, err } pairs = append(pairs, \u0026amp;kvrpcpb.KvPair{ Key: key, Value: value, }) } return \u0026amp;kvrpcpb.ScanResponse{Pairs: pairs}, nil } 注意：KvScan 遇到 lock 错误时，应将错误记录到 pairs 中继续扫描，而不是直接返回错误中止整个扫描。这让客户端知道哪些 key 有锁冲突，可以选择性地处理。\n5.6.2 KvCheckTxnStatus 实现 func (server *Server) KvCheckTxnStatus(_ context.Context, req *kvrpcpb.CheckTxnStatusRequest) ( *kvrpcpb.CheckTxnStatusResponse, error) { reader, _ := server.storage.Reader(req.Context) defer reader.Close() txn := mvcc.NewMvccTxn(reader, req.LockTs) // 1. 检查是否已提交 write, commitTS, _ := txn.CurrentWrite(req.PrimaryKey) if write != nil \u0026amp;\u0026amp; write.Kind != mvcc.WriteKindRollback { return \u0026amp;kvrpcpb.CheckTxnStatusResponse{ CommitVersion: commitTS, Action: kvrpcpb.Action_NoAction, }, nil } // 2. 检查是否已回滚 if write != nil \u0026amp;\u0026amp; write.Kind == mvcc.WriteKindRollback { return \u0026amp;kvrpcpb.CheckTxnStatusResponse{ Action: kvrpcpb.Action_NoAction, }, nil } // 3. 检查锁是否存在 lock, _ := txn.GetLock(req.PrimaryKey) if lock == nil { // 锁不存在，写 Rollback 标记 txn.DeleteValue(req.PrimaryKey) txn.PutWrite(req.PrimaryKey, req.LockTs, \u0026amp;mvcc.Write{ StartTs: req.LockTs, Kind: mvcc.WriteKindRollback, }) server.storage.Write(req.Context, txn.Writes()) return \u0026amp;kvrpcpb.CheckTxnStatusResponse{ Action: kvrpcpb.Action_LockNotExistRollback, }, nil } // 4. 检查 TTL currentPhysical := mvcc.PhysicalTime(req.CurrentTs) lockPhysical := mvcc.PhysicalTime(req.LockTs) if currentPhysical \u0026gt;= lockPhysical+lock.Ttl { // TTL 超时，强制回滚 txn.DeleteValue(req.PrimaryKey) txn.DeleteLock(req.PrimaryKey) txn.PutWrite(req.PrimaryKey, req.LockTs, \u0026amp;mvcc.Write{ StartTs: req.LockTs, Kind: mvcc.WriteKindRollback, }) server.storage.Write(req.Context, txn.Writes()) return \u0026amp;kvrpcpb.CheckTxnStatusResponse{ Action: kvrpcpb.Action_TTLExpireRollback, }, nil } // 5. TTL 未超时，返回锁信息 return \u0026amp;kvrpcpb.CheckTxnStatusResponse{ LockTtl: lock.Ttl, Action: kvrpcpb.Action_NoAction, }, nil } 5.6.3 KvBatchRollback 实现 func (server *Server) KvBatchRollback(_ context.Context, req *kvrpcpb.BatchRollbackRequest) ( *kvrpcpb.BatchRollbackResponse, error) { reader, _ := server.storage.Reader(req.Context) defer reader.Close() txn := mvcc.NewMvccTxn(reader, req.StartVersion) for _, key := range req.Keys { // 检查是否已处理 write, _, _ := txn.CurrentWrite(key) if write != nil { if write.Kind == mvcc.WriteKindRollback { continue // 已回滚，幂等跳过 } // 已提交，回滚失败 return \u0026amp;kvrpcpb.BatchRollbackResponse{ Error: \u0026amp;kvrpcpb.KeyError{Abort: \u0026#34;already committed\u0026#34;}, }, nil } // 删除锁（如果存在且属于此事务） lock, _ := txn.GetLock(key) if lock != nil \u0026amp;\u0026amp; lock.Ts == req.StartVersion { txn.DeleteLock(key) } // 删除数据，写 Rollback 标记 txn.DeleteValue(key) txn.PutWrite(key, req.StartVersion, \u0026amp;mvcc.Write{ StartTs: req.StartVersion, Kind: mvcc.WriteKindRollback, }) } server.storage.Write(req.Context, txn.Writes()) return \u0026amp;kvrpcpb.BatchRollbackResponse{}, nil } 5.6.4 KvResolveLock 实现 func (server *Server) KvResolveLock(_ context.Context, req *kvrpcpb.ResolveLockRequest) ( *kvrpcpb.ResolveLockResponse, error) { reader, _ := server.storage.Reader(req.Context) defer reader.Close() txn := mvcc.NewMvccTxn(reader, req.StartVersion) // 获取所有属于此事务的锁 locks, _ := mvcc.AllLocksForTxn(txn) for _, lockInfo := range locks { txn2 := mvcc.NewMvccTxn(reader, req.StartVersion) if req.CommitVersion == 0 { // 回滚 txn2.DeleteValue(lockInfo.Key) txn2.DeleteLock(lockInfo.Key) txn2.PutWrite(lockInfo.Key, req.StartVersion, \u0026amp;mvcc.Write{ StartTs: req.StartVersion, Kind: mvcc.WriteKindRollback, }) } else { // 提交 txn2.DeleteLock(lockInfo.Key) txn2.PutWrite(lockInfo.Key, req.CommitVersion, \u0026amp;mvcc.Write{ StartTs: req.StartVersion, Kind: lockInfo.Lock.Kind, }) } server.storage.Write(req.Context, txn2.Writes()) } return \u0026amp;kvrpcpb.ResolveLockResponse{}, nil } 5.7 Latches 并发控制 // 在 KvCommit 中使用 Latches server.Latches.WaitForLatches(req.Keys) defer server.Latches.ReleaseLatches(req.Keys) Latches 的作用：\nLatches 是一个内存级别的行锁（不是分布式锁） 作用范围：同一个 TinyKV 节点上的并发请求 防止问题：同一节点上两个并发 Commit 请求操作同一个 key 时，可能产生竞争条件 与 MVCC 的关系：\nMVCC 解决的是多版本读写并发问题（通过时间戳实现快照隔离） Latches 解决的是同一节点上的写-写并发问题（防止内存状态竞争） 两者互补：MVCC 是逻辑层面的并发控制，Latches 是物理层面的并发保护 5.8 实现注意事项与坑 5.8.1 GetValue 的 WriteKind 检查 // 错误做法：不检查 WriteKind if write != nil { return txn.Reader.GetCF(CfDefault, EncodeKey(key, write.StartTs)) } // 正确做法：必须检查 WriteKind if write.Kind != WriteKindPut { return nil, nil // DELETE 或 ROLLBACK 不返回值 } 原因：DELETE 操作在 Write CF 中写的是 WriteKindDelete，如果不检查，会尝试从 Default CF 读值，但 Default CF 中可能没有（或读到旧版本的值），导致数据语义错误。\n5.8.2 KvCommit 的 Retryable 错误 锁不存在（lock == nil）： 可能原因：锁已被 Rollback 清理（TTL 超时被其他事务清理） 正确行为：返回 Retryable Error，客户端应该重新执行整个事务 锁 ts 不匹配（lock.ts != startVersion）： 可能原因：有另一个事务在同一 key 上加了新锁（说明当前事务的锁已被清理） 正确行为：返回 Retryable Error Retryable vs Abort：\nRetryable：客户端可以重试（如重新获取 startTS，重新执行事务） Abort：事务无法继续（如尝试提交已回滚的事务） 5.8.3 KvScan 中 lock 错误的处理 // 错误做法：遇到 lock 错误直接返回 if err != nil { return nil, err } // 正确做法：将 lock 错误加入 pairs 继续扫描 pairs = append(pairs, \u0026amp;kvrpcpb.KvPair{Error: keyErr.Err}) continue 原因：客户端期望获取尽可能多的结果，对于有锁冲突的 key，记录错误信息让客户端决定如何处理，而不是直接终止整个扫描。\n5.8.4 KvCheckTxnStatus 无锁时的处理 // 无锁时，必须写 Rollback 标记，即使 Default CF 中没有数据 txn.DeleteValue(req.PrimaryKey) // 即使不存在也无害 txn.PutWrite(req.PrimaryKey, req.LockTs, Rollback) 原因：不写 Rollback 标记的风险 ——\n时序：T1 的锁因网络问题消失 → CheckTxnStatus 认为已回滚，但未写标记 后来 T1 的 Prewrite 消息迟到 → 锁又被加上 或 T1 的 Commit 消息迟到 → 没有 Rollback 标记，Commit 成功！ 数据不一致 写了 Rollback 标记：Commit 时 CurrentWrite 发现 Rollback 记录，拒绝提交 ✓\n5.8.5 Scanner 跳过旧版本 // 维护 scan.key，跳过相同 user key 的多个版本 if bytes.Equal(currentKey, scan.key) { scan.iter.Next() continue } scan.key = currentKey 如果不跳过：同一个 key 的多个历史版本都会被返回，客户端会看到重复 key，违反 Scan 语义。\n6. 工业界分布式事务方案对比 6.1 TiDB/TiKV 事务演进 TiKV 是 TinyKV 的工业原型，其事务方案也基于 Percolator，但在工程实践中进行了多项优化。\n6.1.1 基础方案：Percolator + TSO TiKV 的基础事务方案与 TinyKV 实现基本一致：\nTSO 由 PD（Placement Driver）提供全局单调递增时间戳 两阶段提交：Prewrite 阶段写锁和数据，Commit 阶段写提交记录并删锁 Primary Key 作为事务的状态决策点 快照隔离（SI）：通过 startTS 实现一致性快照读 每次事务需要两次 TSO 请求（获取 startTS 和 commitTS）以及两次存储写入（Prewrite 和 Commit），这在高并发场景下成为性能瓶颈。\n6.1.2 Async Commit（异步提交） 动机：标准 2PC 的 Commit 阶段需要客户端等待所有 key 的写操作完成，延迟较高。\n核心思想：Prewrite 完成后立即返回给客户端（不等待 Commit），Commit 阶段在后台异步完成。\n实现关键：\nPrewrite 时记录所有 Secondary Key 的位置（secondaries 列表） 计算 minCommitTS：所有 Secondary Key 所在 Region 的 maxTS 中取最大值，确保 commitTS \u0026gt; 所有可能的读事务的 startTS Primary Key 的 lock 中记录 secondaries 和 minCommitTS 后台异步提交所有 key 好处：客户端在 Prewrite 完成后即可继续，减少了一个网络往返延迟。\n代价：\nPrewrite 阶段需要额外收集 minCommitTS（一次额外的读操作） 崩溃恢复更复杂：需要检查所有 Secondary Key 状态来计算 commitTS 不适合大事务（Secondary Key 列表很大） 6.1.3 1PC（One-Phase Commit） 适用条件：事务的所有写操作都在同一个 Region 内（单分片事务）。\n原理：单 Region 内的多个写操作可以通过 Raft 一次提交（Raft 本身保证原子性），无需两阶段提交。\n实现：\n检测到单 Region 事务后，跳过 Prewrite 阶段 直接写 Write CF 和 Data CF，不写 Lock CF 一次 Raft 提交完成所有操作 好处：消除 2PC 开销，延迟降低约 50%，吞吐量显著提升。\n局限：仅适用于单 Region 事务，跨 Region 仍需 2PC。\n6.1.4 Pipelined Locking（流水线加锁） 动机：大事务（写入数千个 key）的 Prewrite 阶段需要串行等待所有 key 的锁写入确认，延迟随事务规模线性增长。\n原理：不等待前一个 key 的锁写入确认，立即开始下一个 key 的锁写入（流水线化）。\n好处：减少大事务的端到端延迟。\n代价：更复杂的错误处理（部分锁写入失败时回滚更复杂）。\n6.2 Google Spanner 6.2.1 TrueTime API Spanner 的核心创新是 TrueTime，一套基于 GPS 天线和原子钟的时钟服务：\nTrueTime 返回的不是单一时间点，而是一个区间： TT.now() = [earliest, latest] 其中 |latest - earliest| ≤ 2ε（ε 通常为 1-7 毫秒） 时间戳保证：\n如果事件 A 在事件 B 开始之前结束，则 TT.after(B.start) \u0026gt; TT.before(A.end) 即 B 的时间戳严格大于 A 的时间戳 6.2.2 Commit-Wait 机制 Spanner 的提交等待机制确保外部一致性：\n提交流程： 1. 获取提交时间戳 s = TT.now().latest 2. 执行 Prepare（等待所有参与者就绪） 3. 等待（commit-wait）：等到 TT.now().earliest \u0026gt; s（即确保 s 已过去） 4. 执行 Commit 意义：等待 2ε 时间后，整个地球上所有节点的时钟一定都已经超过了 s，保证了外部一致性（后续任何事务的 startTS \u0026gt; s）。\n6.2.3 Spanner vs Percolator 对比项 Spanner Percolator/TiKV 时钟方案 TrueTime（GPS+原子钟） TSO（中心化时间戳服务） 一致性级别 外部一致性 快照隔离（SI） 协调者 有（基于 Paxos Group） 无（Primary Key） 硬件依赖 专用 GPS/原子钟 无特殊硬件 跨地域延迟 commit-wait 增加延迟 TSO 往返延迟 适用场景 全球分布式数据库 通用分布式数据库 6.3 CockroachDB 6.3.1 混合逻辑时钟（HLC） 问题：CockroachDB 希望无需 GPS/原子钟，也不依赖中心化 TSO，但仍要保证全局时序。\nHLC（Hybrid Logical Clock）：将物理时钟（NTP 同步）与逻辑时钟结合：\nHLC 时钟 = (physical, logical) 规则： 发送消息时：HLC = max(本地HLC, 消息HLC) + 1 本地事件时：HLC = max(本地HLC, NTP时钟) + 1 特性：\n物理分量接近 NTP 时间（有界误差） 逻辑分量处理同一物理时刻的多个事件 不需要专用硬件，不需要中心化 TSO 6.3.2 事务不确定区间 由于时钟不确定性，CockroachDB 在读操作中使用不确定区间（uncertainty window）：\n如果读事务的 maxTS = readTS + uncertaintyInterval 遇到 commitTS ∈ (readTS, maxTS) 的写操作 → 不确定，需要重启事务 这保证了即使时钟略有偏差，也不会读到\u0026quot;未来\u0026quot;写的数据。\n6.3.3 Transaction Record CockroachDB 中的 Transaction Record 类似 Percolator 的 Primary Key：\n存储事务的最终状态（PENDING / COMMITTED / ABORTED） 崩溃恢复时通过 Transaction Record 确定事务结果 与数据存储在同一节点（减少网络往返） 6.3.4 Contention Resolution（冲突解决） CockroachDB 遇到锁冲突时，不是简单等待，而是尝试**推进（push）**持锁事务：\n如果高优先级事务 T2 遇到低优先级事务 T1 的锁： T2 可以强制推进 T1： - 如果 T1 的优先级较低 → T1 的 commitTS 被推高（晚于 T2） - T2 继续执行，T1 在提交时需要重新校验 这避免了长时间锁等待，提高了高优先级事务的响应性。\n6.4 MySQL XA 6.4.1 XA 标准接口 XA 是 The Open Group 制定的分布式事务标准，MySQL InnoDB 实现了 XA 接口：\n-- 开始 XA 事务 XA START \u0026#39;transaction_id\u0026#39;; UPDATE accounts SET balance = balance - 100 WHERE id = 1; XA END \u0026#39;transaction_id\u0026#39;; -- Prepare 阶段 XA PREPARE \u0026#39;transaction_id\u0026#39;; -- Commit 或 Rollback XA COMMIT \u0026#39;transaction_id\u0026#39;; -- 或 XA ROLLBACK \u0026#39;transaction_id\u0026#39;; 查询未完成的 XA 事务：\nXA RECOVER; -- 返回所有 PREPARED 状态的事务 6.4.2 XA 的限制 性能差：每个 XA 事务需要两次磁盘 fsync（prepare + commit），持锁时间长 协调者单点：应用程序作为协调者，崩溃后 PREPARED 的事务需要手动处理 实现不完整：早期 MySQL XA 实现存在 binlog 一致性问题（已在 5.7 修复） 适用场景：跨数据库实例的少量事务，不适合高吞吐场景 6.5 Saga 长事务方案 6.5.1 Saga 的设计动机 在微服务架构中，一个业务流程可能涉及多个服务（订单服务、库存服务、支付服务），且每个服务拥有独立的数据库。传统 2PC 在此场景下的问题：\n需要跨服务加锁，锁持有时间可能很长（分钟级） 服务不同技术栈，XA 支持困难 2PC 的阻塞问题在网络较慢的微服务场景下更加严重 Saga 的思路：接受最终一致性，通过补偿事务实现回滚。\n6.5.2 Saga 实现示例 电商下单 Saga：\n正向事务序列： T1: 创建订单（订单服务） T2: 扣减库存（库存服务） T3: 扣款（支付服务） T4: 更新积分（积分服务） 补偿事务序列（逆序执行）： C4: 撤销积分更新 C3: 退款 C2: 恢复库存 C1: 取消订单 失败场景（T3 失败）： T1 → T2 → T3（失败）→ C2 → C1 6.5.3 Orchestration vs Choreography Orchestration（编排）：\n有一个 Saga Orchestrator（如 workflow engine） Orchestrator 按顺序调用各服务，处理失败和补偿 优点：流程集中，易于监控和调试 缺点：Orchestrator 是单点，流程耦合 Choreography（编舞）：\n每个服务完成本地事务后发布领域事件 其他服务监听事件并响应 优点：服务解耦，高可用 缺点：流程分散，难以追踪和调试 6.5.4 Saga 的局限 无隔离性：中间状态对外可见，可能导致\u0026quot;脏读\u0026quot;（其他事务看到未完成的 Saga） 补偿逻辑复杂：每个操作都需要实现补偿，业务代码量倍增 补偿不总能实现：如果操作不可逆（已发货），补偿只能是近似恢复 6.6 综合对比表 方案 一致性 隔离性 协调者 侵入性 性能 适用场景 XA/2PC 强一致 可串行化 有（单点） 低 差 少量跨库操作 Percolator SI（快照隔离） SI 无（Primary Key） 中 中 通用 KV 事务 Spanner 外部一致 可串行化 有（Paxos） 低 中（受 commit-wait 影响） 全球数据库 CockroachDB 可串行化 可串行化 无（HLC） 低 中 多云/无 GPS 环境 TCC 强一致 最终一致（中间状态存在） 有 极高 好 金融核心交易 Saga 最终一致 无 可选 高 好 微服务长事务 本地消息表 最终一致 无 无 中 好 异步通知场景 7. 现有实现的问题与优化空间 7.1 TinyKV 实现的性能局限 7.1.1 两次 TSO 请求的延迟 标准 Percolator 需要两次从 TSO 获取时间戳：\nPrewrite 前获取 startTS Commit 前获取 commitTS 每次 TSO 请求都是一次网络往返（取决于客户端到 PD 的延迟），在高频小事务场景下，这两次额外的网络延迟显著影响吞吐量。\n优化方向：\nAsync Commit：减少一次 TSO 请求（commitTS 由 minCommitTS 计算） TSO 批处理：客户端批量预取时间戳，摊薄网络开销 7.1.2 两次写入放大 每个 key 的写入涉及：\nPrewrite：写 CfDefault + 写 CfLock（2 次写） Commit：写 CfWrite + 删 CfLock（1写1删） 总计：每个 key 4 次存储操作（相比单机事务的 1 次写 + 1 次 WAL）。\n优化方向：\n1PC：对单 Region 事务，跳过 CfLock，直接写 CfWrite + CfDefault（减少 2 次操作） 合并小写入：在存储层批量合并写操作 7.1.3 锁等待与重试开销 Percolator 是乐观并发控制，遇到冲突时需要回滚整个事务并重试：\n重试包含重新获取 startTS、重新 Prewrite 所有 key 在高冲突场景下，重试次数可能很多（称为\u0026quot;活锁\u0026quot;问题） 优化方向：\n死锁检测：主动检测死锁并强制中止优先级低的事务 优先级调度：高优先级事务优先获取锁（类似 CockroachDB 的 Contention Resolution） 7.2 TinyKV 功能局限 7.2.1 无 Async Commit 支持 TinyKV 实现中，所有事务必须完整执行两阶段提交：\n客户端等待 Prewrite 完成 → 等待 Commit 完成 → 返回结果 工业级 TiKV 中，Async Commit 允许：\n客户端等待 Prewrite 完成 → 立即返回（Commit 在后台异步进行） 这减少了客户端感知的延迟，但 TinyKV 为简化实现未包含此功能。\n7.2.2 无 1PC 支持 TinyKV 所有写事务，即使只涉及单个 key，也执行完整 2PC：\n单 key 写：PrewriteRequest → CommitRequest（2 次 RPC） 1PC 优化将此简化为：\n单 key 写：PrewriteRequest（含 commit 信息）（1 次 RPC） TiKV 在检测到单 Region 事务时自动使用 1PC。\n7.2.3 无写入管道化 TinyKV 的 KvPrewrite 串行处理每个 mutation：\n检测冲突 → 写 Default CF → 写 Lock CF 等待第 N 个 key 完成后，才处理第 N+1 个 key 工业级实现通常并行处理多个 key 的加锁操作（Pipelined Locking）。\n7.2.4 Scanner 不处理锁等待 TinyKV 的 Scanner 在遇到锁冲突时直接将错误记录到结果中返回：\n// 现有实现：锁冲突直接返回错误 if err, ok := err.(*mvcc.KeyError); ok { pairs = append(pairs, \u0026amp;kvrpcpb.KvPair{Error: err.Err}) } 工业级实现应该等待锁释放或协助清理 Stale Lock，然后继续扫描，对客户端透明。\n7.3 隔离级别问题 7.3.1 TinyKV 的隔离级别 TinyKV 实现达到的隔离级别是快照隔离（Snapshot Isolation, SI）：\n能防止：\n脏读 ✓（通过 CfWrite 只读已提交数据） 不可重复读 ✓（通过 startTS 固定快照） 大多数幻读 ✓（快照读不受并发插入影响） 不能防止：\n写偏斜（Write Skew） ✗ 7.3.2 写偏斜示例 场景：医院值班系统，约束：至少 1 名医生在岗 当前状态：医生 A（on_call=true），医生 B（on_call=true） T1（医生 A 申请下班）: startTS=100 Read: SELECT count(*) FROM doctors WHERE on_call=true → 2 决策: 2 \u0026gt; 1，可以下班 Write: UPDATE doctors SET on_call=false WHERE id=\u0026#39;A\u0026#39; T2（医生 B 申请下班）: startTS=100（与 T1 相同快照） Read: SELECT count(*) FROM doctors WHERE on_call=true → 2 决策: 2 \u0026gt; 1，可以下班 Write: UPDATE doctors SET on_call=false WHERE id=\u0026#39;B\u0026#39; 两个事务都提交成功（无冲突，因为写的是不同 key） 最终结果：A.on_call=false, B.on_call=false → 0 名医生在岗！违反约束 原因：T1 和 T2 都读了对方的写（on_call 字段），但写的是不同的 key（A 和 B），MVCC 无法检测到这种读-写依赖。\n解决方案：\n悲观锁：T1 和 T2 各自对读到的所有行加 SELECT FOR UPDATE，T2 会阻塞等待 T1 SSI（串行化快照隔离）：检测读写冲突环，自动中止一个事务 应用层控制：在应用层显式加锁（如 Redis 分布式锁） 7.3.3 MVCC GC 问题 随着时间推移，MVCC 数据不断积累历史版本，会占用大量存储空间并降低读性能（需要扫描更多历史版本）。\nTinyKV 现状：没有实现 MVCC GC，历史版本会无限积累。\n工业级 TiKV 的 GC 机制：\n定期从 PD 获取 GC Safe Point（所有活跃事务的最小 startTS） 对 GC Safe Point 之前的所有版本进行清理 只保留每个 key 在 GC Safe Point 时刻可见的最新版本 Safe Point = 100 CfDefault 中 startTS \u0026lt; 100 的旧版本 → 可以删除 CfWrite 中 commitTS \u0026lt; 100 的旧提交记录（保留每个 key 的最新一条）→ 部分删除 8. 关键设计思考与深度分析 8.1 为什么 Percolator 能去掉独立的协调者 传统 2PC 依赖独立协调者的原因：需要一个中立的节点来做全局决策（Commit or Abort），且该决策必须持久化。\nPercolator 的洞察：\n单行原子性：底层存储（BigTable/RocksDB）支持对单个 key 的原子读写 Primary Key 作为决策点：对 Primary Key 的 Write CF 写入是原子的，写入成功即代表\u0026quot;事务已提交\u0026quot;这一事实被持久化 无需集中协调：所有参与者只需查看 Primary Key 的状态，就能确定事务结果 本质上，Percolator 将\u0026quot;协调者的决策\u0026quot;从一个独立进程变成了\u0026quot;Primary Key 行上的一次原子写操作\u0026quot;，避免了独立协调者的单点故障。\n代价：\n读操作可能遇到进行中的事务的锁，需要额外逻辑（CheckTxnStatus）来处理 崩溃恢复需要检查 Primary Key 状态，比独立协调者的 WAL 恢复更复杂 8.2 为什么时间戳要倒序存储 核心原因：MVCC 的读操作需要找到\u0026quot;最新的、commitTS \u0026lt;= startTS 的版本\u0026quot;。\n如果时间戳正序存储：\nCfWrite（正序）: Alice/TS=85 （commitTS=85） Alice/TS=100 （commitTS=100） Alice/TS=200 （commitTS=200） 读操作（startTS=150）： Seek(Alice, TS=150) → 找到第一个 \u0026gt;= TS=150 的记录：Alice/TS=200 → 但 200 \u0026gt; 150，不可见！ → 需要向后扫，找上一个记录 Alice/TS=100 ✓ 正序存储需要 Seek + 向前回退，实现复杂（LSM-Tree 的迭代器通常只支持向后扫描）。\n如果时间戳倒序存储：\nCfWrite（倒序，大 TS 排前面）: Alice/~TS=85 （对应 commitTS=85，字节值最大） Alice/~TS=100 （对应 commitTS=100） Alice/~TS=200 （对应 commitTS=200，字节值最小） 实际存储顺序（按字节值升序）: Alice/~TS=200 （字节值最小，排最前） Alice/~TS=100 Alice/~TS=85 读操作（startTS=150）： Seek(EncodeKey(\u0026#34;Alice\u0026#34;, 150)) = Seek(Alice/~TS=150) → 第一条 \u0026gt;= Alice/~150 的记录：Alice/~100（因为 ~100 \u0026gt; ~150） → 对应 commitTS=100 ≤ 150，直接可用！✓ 倒序存储使 Seek 直接定位到目标版本，向后遍历即可，实现简洁高效。\n8.3 MVCC GC 的必要性与安全性 8.3.1 为什么必须 GC 存储膨胀：每次写操作都产生新版本，删除操作也只是标记（WriteKindDelete），历史版本持续积累。\n读性能下降：GetValue 中的 Seek 虽然能直接定位，但如果历史版本很多，会增加 LSM-Tree 的文件数量，降低扫描性能。\n8.3.2 Safe Point 的确定 Safe Point = 所有活跃长事务的最小 startTS。Safe Point 之前的所有版本，已经没有任何活跃事务会读取。\n活跃事务: T1(startTS=90), T2(startTS=95), T3(startTS=110) Safe Point = min(90, 95, 110) = 90 Safe Point = 90 意味着： - commitTS \u0026lt; 90 的历史版本：没有任何活跃事务的 startTS \u0026lt;= 90 能读到这些版本 - 可以安全删除（保留每个 key 在 TS=90 时刻可见的最新版本） 8.3.3 GC 的安全性保证 GC 需要保证：执行 GC 后，所有活跃事务（startTS \u0026gt;= Safe Point）的读操作结果不变。\n证明：\n对于 startTS \u0026gt;= Safe Point 的读事务，其快照版本 \u0026gt;= Safe Point GC 只删除 commitTS \u0026lt; Safe Point 的旧版本，保留最新可见版本 最新可见版本 = commitTS 最接近 Safe Point 的版本，保留后仍能被这些读事务读到 8.4 锁的 TTL 设计权衡 8.4.1 TTL 太短的风险 场景： 大事务（写入 10000 个 key，需要 5 秒） TTL = 3 秒 时序： t=0: 开始 Prewrite t=3: TTL 到期，其他事务清理了部分锁 t=5: 事务继续 Commit → 发现锁已被清理 → 事务失败，需要重试 结果：大事务被误杀，需要重试，浪费资源 8.4.2 TTL 太长的风险 场景： 事务崩溃（客户端宕机），遗留了锁 TTL = 10 分钟 影响： 读取该 key 的其他事务等待长达 10 分钟（或需要主动发起 CheckTxnStatus） 系统吞吐量大幅下降 8.4.3 自适应 TTL TiKV 实现中，TTL 根据事务大小动态计算：\nbase_ttl = 3000ms（3 秒） size_factor = 1ms per KB TTL = base_ttl + size_factor × txn_size_kb = 3000 + 1 × 10000 （10MB 事务） = 13000ms（13 秒） 这样大事务有更长的 TTL，小事务快速清理，平衡了两种风险。\n8.5 Primary Key 的性能影响 8.5.1 热点问题 如果业务逻辑导致大量事务的 Primary Key 集中在同一个 key（如全局计数器、自增序列），该 key 会成为热点：\n所有事务的 CheckTxnStatus 都需要读写这个 key 并发 Commit 时会产生锁竞争 缓解方案：\n避免使用单一热点 key 作为 Primary 使用分布式序列（如 TiDB 的 AUTO_RANDOM）代替自增主键 8.5.2 Primary Key 选择策略 实践中，Primary Key 通常选择事务写入的第一个 key，原因：\n简单，不需要特殊处理 对于有序写入，第一个 key 是最早写入的，崩溃恢复时最先被检查 TiKV 的实现也采用此策略 8.6 Read-Your-Own-Writes 一致性 问题：在分布式系统中，写入某个节点后立即读取，可能读不到（因为副本同步有延迟）。\nPercolator 的保证：\n同一事务内的写入，通过写缓冲（MvccTxn.writes）在客户端内存中维护 事务内读取自己写的 key 时，先查 write buffer，再查存储 事务提交后，其他客户端可能因副本延迟暂时读不到（但最终一致） 注意：TinyKV 的实现中，读请求创建新的 MvccTxn，不共享写缓冲，同一\u0026quot;会话\u0026quot;的读写实际上是独立的事务。真正的 Read-Your-Own-Writes 需要客户端维护更多状态。\n9. 常见问题与注意事项（代码层面） 9.1 GetValue 的 Seek 越界问题 问题：GetValue 中 Seek 后可能扫到另一个 key 的版本。\n// 错误写法（未验证 user key） iter.Seek(EncodeKey(key, txn.StartTS)) if iter.Valid() { item := iter.Item() value, _ := item.Value() write, _ := ParseWrite(value) return txn.Reader.GetCF(CfDefault, EncodeKey(key, write.StartTs)) // ↑ 如果 Seek 到了下一个 key 的版本，write.StartTs 是另一个 key 的 TS！ } // 正确写法（必须验证 user key） iter.Seek(EncodeKey(key, txn.StartTS)) if iter.Valid() { item := iter.Item() userKey := DecodeUserKey(item.Key()) if !bytes.Equal(userKey, key) { return nil, nil // 已超出此 key 的范围 } // ... 继续处理 } 原因：CfWrite 按 (key ASC, ts DESC) 排序，当某个 key 没有 commitTS \u0026lt;= startTS 的版本时，Seek 可能跳到下一个 key 的版本。\n9.2 CurrentWrite 的扫描终止条件 问题：CurrentWrite 从 TsMax 向小扫描，需要正确判断何时停止。\nfor iter.Seek(EncodeKey(key, TsMax)); iter.Valid(); iter.Next() { item := iter.Item() userKey := DecodeUserKey(item.Key()) if !bytes.Equal(userKey, key) { break // 已扫完此 key 的所有版本，进入下一个 key } // 处理... } 如果不检查 !bytes.Equal(userKey, key)，扫描会继续到其他 key 的版本，导致错误结果或无限循环。\n9.3 KvCommit 的 Retryable 与 Abort 区分 情况1：lock.ts != startVersion（锁属于另一个事务） → Retryable: 当前事务的锁已被清理，客户端应重新开始事务 情况2：write.kind == WriteKindRollback（已回滚） → Abort: 不应该再提交（返回 Retryable: \u0026#34;already rolled back\u0026#34;） → 注意：有些实现在这里返回非 Retryable 错误，需根据测试要求调整 情况3：lock == nil（锁不存在） → Retryable: 同情况1，事务可能已超时被回滚 关键：区分 Retryable 和非 Retryable 对客户端行为影响很大：\nRetryable：客户端重试整个事务（重新获取 startTS） 非 Retryable（Abort）：客户端报错给用户，不重试 9.4 KvBatchRollback 的幂等性 // 已 Rollback 的 key 直接跳过（幂等） if write != nil \u0026amp;\u0026amp; write.Kind == WriteKindRollback { continue // 不重复写 Rollback } // 已提交的 key 报错 if write != nil \u0026amp;\u0026amp; write.Kind != WriteKindRollback { return AbortError(\u0026#34;already committed\u0026#34;) } 为什么需要幂等：客户端可能因为网络重试多次发送 BatchRollback 请求，对已处理的 key 再次 Rollback 应该静默成功，而不是报错。\n9.5 KvCheckTxnStatus 必须在无锁时写 Rollback 错误做法：\nlock = GetLock(primaryKey) if lock == nil { return {Action: LockNotExistRollback} // 只返回，不写 Rollback 标记 } 问题时序：\nt=1: T1 Prewrite（获得 startTS=100） t=2: T1 的 Prewrite 网络延迟，锁还未写入 t=3: T2 读取 primaryKey，发现无锁 → CheckTxnStatus 返回 LockNotExistRollback（但未写标记） t=4: T1 的 Prewrite 到达 → 锁成功写入！ t=5: T1 尝试 Commit → 发现锁存在 → 成功提交！ t=6: 数据不一致（T2 已经认为 T1 不存在，但 T1 实际提交了） 正确做法：写 Rollback 标记，使后续 Commit 失败：\nif lock == nil { txn.PutWrite(primaryKey, lockTS, WriteKindRollback) server.storage.Write(...) return {Action: LockNotExistRollback} } 这样 T1 的 Commit 在 CurrentWrite 时发现 Rollback 记录，拒绝提交 ✓\n9.6 AllLocksForTxn 的性能考量 // 扫描所有 CfLock，找属于 startTS 的锁 func AllLocksForTxn(txn *MvccTxn) ([]KlPair, error) { iter := txn.Reader.IterCF(engine_util.CfLock) defer iter.Close() var locks []KlPair for iter.Seek(nil); iter.Valid(); iter.Next() { item := iter.Item() value, _ := item.Value() lock, _ := ParseLock(value) if lock.Ts == txn.StartTS { locks = append(locks, KlPair{Key: item.Key(), Lock: lock}) } } return locks, nil } 性能问题：需要扫描所有 CfLock，如果系统中有大量锁（大量并发事务），这个操作的时间复杂度为 O(n)，n 是总锁数量。\n工业级优化：\n记录事务涉及的所有 key（在 Primary Key 的 lock 中存储 secondary keys 列表） ResolveLock 时直接读取 secondary keys 列表，不需要全表扫描 这也是 Async Commit 中 secondaries 字段的设计动机 9.7 PutWrite 的参数顺序 // TinyKV 中 PutWrite 参数：key, ts（commitTS）, write txn.PutWrite(key, req.CommitVersion, \u0026amp;mvcc.Write{StartTs: req.StartVersion, Kind: lock.Kind}) 注意：\nts 参数是 commitTS（用作 CfWrite 的键的时间戳部分） write.StartTs 是 startTS（存储在值中，用于从 CfDefault 读取实际数据） 两者含义不同，混淆会导致读操作找不到正确版本 10. 代码索引与附录 10.1 核心代码文件 文件 内容 关键函数 kv/transaction/mvcc/transaction.go MvccTxn 核心实现 GetLock, PutLock, GetValue, PutValue, CurrentWrite, MostRecentWrite, EncodeKey kv/transaction/mvcc/scanner.go 范围扫描实现 NewScanner, Next, Close kv/server/server.go RPC 处理器 KvGet, KvPrewrite, KvCommit, KvScan, KvCheckTxnStatus, KvBatchRollback, KvResolveLock kv/transaction/mvcc/lock.go Lock 结构体 ParseLock, Lock.ToBytes, Lock.Info kv/transaction/mvcc/write.go Write 结构体 ParseWrite, Write.ToBytes 10.2 关键常量与类型 // Column Families engine_util.CfDefault = \u0026#34;default\u0026#34; // 存储实际值 engine_util.CfLock = \u0026#34;lock\u0026#34; // 存储锁 engine_util.CfWrite = \u0026#34;write\u0026#34; // 存储提交记录 // Write Kinds mvcc.WriteKindPut // 写入操作 mvcc.WriteKindDelete // 删除操作 mvcc.WriteKindRollback // 回滚标记 // Lock Kinds（与 Write 相同类型） mvcc.LockKindPut mvcc.LockKindDelete // 特殊时间戳 mvcc.TsMax = math.MaxUint64 // 最大时间戳，用于 Seek 最新版本 10.3 Percolator 事务流程速查 事务生命周期： 1. 客户端获取 startTS（TSO） 2. Prewrite 阶段（FOR 每个 mutation）: a. MostRecentWrite(key) → commitTS \u0026gt;= startTS? → WriteConflict b. GetLock(key) → lock != nil? → LockConflict c. PutValue(key, value) + PutLock(key, Lock{primary, startTS, TTL}) d. storage.Write（批量原子提交） 3. 客户端获取 commitTS（TSO） 4. Commit 阶段（FOR 每个 key）: a. CurrentWrite(key) → 已有记录? → 幂等成功/已回滚报错 b. GetLock(key) → 锁不存在或 ts 不匹配? → Retryable Error c. PutWrite(key, commitTS, Write{startTS, kind}) + DeleteLock(key) d. storage.Write（批量原子提交） 5. 崩溃恢复（遇到 Stale Lock）: a. CheckTxnStatus(primaryKey, lockTS) → 确认事务状态 b. ResolveLock(startTS, commitTS) → 批量解决所有锁 10.4 隔离级别与 Percolator 对应关系 隔离级别 单机 InnoDB 实现 Percolator 对应 READ UNCOMMITTED 无 Read View 不适用（Percolator 始终读已提交） READ COMMITTED 每次读创建 Read View 理论上可通过每次读获取新 startTS 实现 REPEATABLE READ 事务开始时创建 Read View TinyKV 实现（startTS 固定快照） SERIALIZABLE 2PL + Next-Key Lock 需要 SSI 或显式锁（Percolator 本身不支持） TinyKV 的实现达到快照隔离（SI），近似于可重复读（RR），但不等同于 SQL 标准的 SERIALIZABLE。\n10.5 参考资料 Percolator 论文：Google, \u0026ldquo;Large-scale Incremental Processing Using Distributed Transactions and Notifications\u0026rdquo;, OSDI 2010 Spanner 论文：Google, \u0026ldquo;Spanner: Google\u0026rsquo;s Globally Distributed Database\u0026rdquo;, TOCS 2013 CockroachDB HLC：Cockroach Labs, \u0026ldquo;Living Without Atomic Clocks\u0026rdquo;, 2016 TiKV 事务文档：https://tikv.org/deep-dive/distributed-transaction/introduction/ Async Commit 设计：TiKV, \u0026ldquo;Async Commit, the Accelerator for Transaction Commit in TiKV 5.0\u0026rdquo;, 2021 ","permalink":"https://yinit.github.io/tinykv-%E5%88%86%E5%B8%83%E5%BC%8F%E4%BA%8B%E5%8A%A1%E5%AE%9E%E7%8E%B0%E4%B8%8E%E5%A4%8D%E4%B9%A0/","summary":"深入解析 TinyKV 基于 Percolator 协议的分布式事务实现，涵盖 MVCC 存储模型、2PC 流程与异常恢复机制","title":"TinyKV 分布式事务实现与复习"},{"content":"1. 概述与背景 1.1 为什么需要 Multi-Raft 在 Project2 中，我们实现了基于单个 Raft Group 的 KV 存储服务。这个架构存在两个根本性的瓶颈：\n瓶颈一：可扩展性受限\n单个 Raft Group 意味着所有数据都由同一组节点管理，无法横向扩展。当数据量增长时，只能垂直扩容（更大的机器），无法通过增加节点来线性提升容量。\n瓶颈二：并发处理能力有限\n所有写请求都必须经过同一个 Raft Group 串行提交，即使拥有多台机器，也无法并行处理不同 key 范围的请求，吞吐量受限于单个 Raft Group 的处理速度。\nMulti-Raft 的解法\nProject3 引入了 Multi-Raft 架构：将整个 key 空间划分为多个 Region，每个 Region 负责一段连续的 key 范围，由独立的 Raft Group 管理。不同 Region 的请求可以并行处理，从而突破单 Raft Group 的性能瓶颈。\nProject2（单 Raft）： [key: 0 ～ 100] → 一个 Raft Group → 串行处理所有请求 Project3（Multi-Raft）： [key: 0 ～ 50] → Raft Group A → 并行处理 [key: 50 ～ 100] → Raft Group B → 并行处理 1.2 三个子部分 子部分 内容 难度 Project3A 在 Raft 层实现 Leader Transfer 和 ConfChange ★★☆ Project3B 在 RaftStore 层实现 ConfChange 应用和 Region Split ★★★★★ Project3C 实现调度器（Scheduler）：心跳收集 + Region Balance ★★★ Project3A 是 3B 的底层支撑，3C 相对独立，是调度系统的实现。整个 Project3 中最难的是 3B，涉及大量并发、网络分区、异常恢复场景的处理。\n1.3 Project2 → Project3 的演进 Project2 建立了以下基础：\nRaft 状态机（log.go / raft.go / rawnode.go） RaftStore 消息处理（peer_msg_handler.go） 持久化存储（peer_storage.go） 日志压缩与快照（Project2C） Project3 在此基础上新增：\nLeader Transfer：主动将 Leader 职责转移给指定节点 ConfChange：动态增删集群成员（AddNode / RemoveNode） Region Split：将一个 Region 一分为二，支持数据分片 Scheduler：全局调度器，监控集群状态并指挥 Region 迁移 2. TinyKV 整体架构深度解析 2.1 宏观架构 ┌─────────────┐ │ Client │ (读写请求) └──────┬──────┘ │ gRPC ┌──────▼──────────────────────────────────────────┐ │ TinyKV Server │ │ ┌──────────────────────────────────────────┐ │ │ │ RaftStore │ │ │ │ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │ │ │ │ Region 1 │ │ Region 2 │ │ ... │ │ │ │ │ │ RaftGroup│ │ RaftGroup│ │ │ │ │ │ │ └──────────┘ └──────────┘ └────────┘ │ │ │ └──────────────────────────────────────────┘ │ │ ┌─────────────────┐ ┌──────────────────────┐ │ │ │ raftDB │ │ kvDB │ │ │ │ (Raft 元数据) │ │ (KV 业务数据) │ │ │ └─────────────────┘ └──────────────────────┘ │ └──────────┬──────────────────────────────────────┘ │ 心跳/调度指令 ┌──────────▼──────┐ │ TinyScheduler │ (对应 TiDB PD，全局调度) │ (Project3C) │ └─────────────────┘ 关键组件说明：\nRaftStore：管理本物理节点（Store）上所有 Region 的 Raft Group Region：一个 key 范围的逻辑分片，由独立 Raft Group 管理 TinyScheduler：类似 TiDB 的 Placement Driver（PD），负责全局调度 raftDB：存储 Raft 元数据（HardState、RaftApplyState、RegionLocalState 等） kvDB：存储业务 KV 数据（由各 Region apply 写入） 2.2 Store / Peer / Region 三层概念 理解这三个概念是理解 Multi-Raft 的基础：\n物理层： Store（物理机器/进程） ├── 运行多个 Peer（每个 Peer 属于不同 Region 的副本） ├── 共享 raftDB 和 kvDB └── 运行 storeWorker、raftWorker 等 逻辑层： Region（逻辑分片） ├── 管理一段 key 范围 [StartKey, EndKey) ├── 由 3 个（或更多）Peer 组成（分布在不同 Store 上） └── 每个 Region 有独立的 Raft Group 对应关系： Region 1: Peer{StoreId=1, PeerId=1}, Peer{StoreId=2, PeerId=2}, Peer{StoreId=3, PeerId=3} Region 2: Peer{StoreId=1, PeerId=4}, Peer{StoreId=2, PeerId=5}, Peer{StoreId=3, PeerId=6} 同一 Store 上可以有多个 Region 的 Peer： Store 1: [Peer 1 (Region 1 副本), Peer 4 (Region 2 副本), ...] RegionEpoch\n每个 Region 维护一个版本信息 RegionEpoch：\nmessage RegionEpoch { uint64 conf_ver = 1; // ConfChange 版本：AddNode/RemoveNode 时递增 uint64 version = 2; // Split/Merge 版本：Region 分裂或合并时递增 } RegionEpoch 用于判断消息的新旧：\n网络分区后，两个 Leader 可能同时存在，RegionEpoch 较旧的消息应被丢弃 每次 ConfChange 后 ConfVer++，每次 Split 后 Version++ 2.3 Router 消息路由机制 TinyKV 通过 router 结构（kv/raftstore/router.go）将消息分发到对应的 Region Peer：\n// router routes a message to a peer. type router struct { peers sync.Map // regionID -\u0026gt; peerState peerSender chan message.Msg // 发给 peer 的消息队列（容量 40960） storeSender chan\u0026lt;- message.Msg // 发给 store 的消息队列 } 关键方法：\n方法 说明 register(peer) 注册新的 Peer（Split 创建新 Region 时调用） close(regionID) 关闭 Region（destroyPeer 时调用） send(regionID, msg) 向指定 Region 发送消息 sendStore(msg) 向 Store 级别发送消息 消息的分发路径：\n外部请求（Client、其他节点的 Raft 消息）→ peerSender 队列 raftWorker 从 peerSender 取出消息 → 找到对应 Region 的 peerMsgHandler → 处理 2.4 raftWorker 驱动循环 raftWorker（kv/raftstore/raft_worker.go）是 TinyKV 的核心驱动组件：\nraftWorker.run() 循环： ├── 从 router.peerSender 批量取消息 ├── 对每个消息，找到对应 peer 的 peerMsgHandler ├── 调用 handler.HandleMsg() 处理消息 └── 调用 handler.HandleRaftReady() 处理 Ready 关键设计：批量处理\nraftWorker 采用批量消息处理策略：每次循环尽量多取消息（最多 4096 条），减少 HandleRaftReady 的调用次数，提升整体吞吐量。\n// 批量取消息 var msgs []message.Msg for { msgs = append(msgs, msg) if len(msgs) \u0026gt;= RaftWorkerMaxRecvMsgCnt { break } // 尝试继续取，取不到就停止 select { case msg = \u0026lt;-rw.receiver: default: goto DONE } } 2.5 storeWorker 职责 storeWorker（kv/raftstore/store_worker.go）负责 Store 级别的操作：\n主要职责：\n处理 Raft 消息路由（onRaftMessage）：收到其他节点发来的 Raft 消息，找到对应 peer 转发，如果 peer 不存在则尝试创建（maybeCreatePeer） 定时 Store 心跳（onSchedulerStoreHeartbeatTick）：向 Scheduler 汇报本 Store 的状态（Region 数量、存储容量等） 快照 GC（onSnapMgrGC）：清理过期快照文件 maybeCreatePeer 的重要性：\nfunc (d *storeWorker) onRaftMessage(msg *rspb.RaftMessage) error { // ...各种检查... created, err := d.maybeCreatePeer(regionID, msg) // ... } 当一个新节点被 AddNode 后，Leader 会向其发送心跳。此心跳会经过 storeWorker 的 onRaftMessage，如果目标 Peer 不存在，则调用 maybeCreatePeer 创建。创建条件由 IsInitialMsg 判断：\nfunc IsInitialMsg(msg *eraftpb.Message) bool { return msg.MsgType == eraftpb.MessageType_MsgRequestVote || // MsgHeartbeat 且 Commit == 0（RaftInvalidIndex）才触发创建 (msg.MsgType == eraftpb.MessageType_MsgHeartbeat \u0026amp;\u0026amp; msg.Commit == RaftInvalidIndex) } 关键细节：Leader 发送给新节点的第一条心跳，其 msg.Commit 字段必须是 RaftInvalidIndex（即 0），才能触发 maybeCreatePeer。如果实现时直接复用普通心跳（commit 字段有值），新节点就永远无法被创建！\n2.6 GlobalContext 与 StoreMeta GlobalContext 包含整个 Store 共享的全局状态：\ntype GlobalContext struct { cfg *config.Config engine *engine_util.Engines store *metapb.Store storeMeta *storeMeta // 全局 Region 元数据 snapManager *snap.SnapManager router *router trans Transport schedulerTaskSender chan\u0026lt;- worker.Task regionTaskSender chan\u0026lt;- worker.Task // ... } storeMeta 维护了 Store 级别的 Region 信息：\ntype storeMeta struct { sync.RWMutex regions map[uint64]*metapb.Region // regionID -\u0026gt; Region 元数据 regionRanges *btree.BTree // 按 key 范围索引的 Region（用于路由） } regionRanges 是一个 B-tree，支持按 key 快速查找对应的 Region（用于请求路由）。修改 storeMeta 必须加锁，这是 3B 中经常遗忘的细节。\n2.7 心跳双重机制 TinyKV 中存在两种完全不同的心跳：\n心跳类型 发送方 接收方 目的 Raft 心跳 Leader Peer Follower Peer 维持 Raft Leader 地位，防止 Follower 发起选举 Scheduler 心跳 Leader Peer TinyScheduler 汇报 Region 状态（大小、Peers、Leader 信息），供调度决策 Scheduler 心跳触发时机：\npeer_msg_handler.go 的 onHeartbeatSchedulerTick() 定期调用 ConfChange apply 后主动调用（notifyHeartbeatScheduler） Split apply 后主动调用 2.8 调度器工作原理 TinyScheduler 收到 Region 心跳后，检查是否需要下发调度指令：\nRegion 发送 RegionHeartbeatRequest └── TinyScheduler 接收 ├── processRegionHeartbeat：更新本地 Region 信息 └── 检查是否有 pending operator ├── 有：通过 RegionHeartbeatResponse 下发（ChangePeer / TransferLeader） └── 无：检查是否需要 balance，生成新 operator 调度指令通过心跳响应（RegionHeartbeatResponse）下发，最终通过 sendAdminRequest 封装成 RaftCmdRequest 发给 Leader。\n3. Raft 层扩展实现（Project3A） 3.1 Leader Transfer 总体设计 Leader Transfer 允许当前 Leader 主动将 Leader 权限转移给指定节点，常用于：\nRegion Balance 调度（先 Transfer，再 RemovePeer 原 Leader） 计划内维护（graceful shutdown 前先迁走 Leader） 引入的两种消息类型：\n消息类型 语义 MsgTransferLeader 请求 Leader 将权限转移给指定节点（msg.From 为转移目标）。本地消息，不通过网络传播 MsgTimeoutNow Leader 发给转移目标，命令其立即发起选举，不等待选举超时 关键字段：\ntype Raft struct { // ... leadTransferee uint64 // 当前 Leader Transfer 的目标节点 ID（0 表示没有进行中的 Transfer） transferElapsed int // Transfer 已经持续的 tick 数（超过 electionTimeout 则放弃） } 3.2 Leader Transfer 完整流程 Step 1：上层调用 RawNode.TransferLeader()\nfunc (rn *RawNode) TransferLeader(transferee uint64) { _ = rn.Raft.Step(pb.Message{ MsgType: pb.MessageType_MsgTransferLeader, From: transferee, // From 字段存储转移目标 }) } 注意：MsgTransferLeader 中 From 是转移目标（不是发送方），这与其他消息的语义不同。\nStep 2：Leader 处理 MsgTransferLeader\nfunc (r *Raft) handleTransferLeader(m pb.Message) { // 1. 检查转移目标是否在集群中 if _, ok := r.Prs[m.From]; !ok { return } // 2. 如果转移目标就是自己，忽略 if m.From == r.id { return } // 3. 处理正在进行中的 Transfer if r.leadTransferee != None { if r.leadTransferee == m.From { return // 相同目标，忽略重复请求 } r.leadTransferee = None // 不同目标，强制覆盖 } // 4. 设置转移目标，开始计时 r.leadTransferee = m.From r.transferElapsed = 0 // 5. 判断目标日志是否足够新 if r.Prs[m.From].Match == r.RaftLog.LastIndex() { // 日志已同步，直接发 TimeoutNow r.sendTimeoutNow(m.From) } else { // 日志落后，先帮目标同步日志 r.sendAppend(m.From) } } Step 3：等待日志同步（如需）\n当 Leader 收到转移目标的 MsgAppendResponse 时，检查是否同步完成：\n// 在处理 MsgAppendResponse 时 if r.leadTransferee != None \u0026amp;\u0026amp; r.leadTransferee == m.From { if r.Prs[m.From].Match == r.RaftLog.LastIndex() { r.sendTimeoutNow(m.From) // 同步完成，发 TimeoutNow } } Step 4：转移目标收到 MsgTimeoutNow\nfunc (r *Raft) handleTimeoutNow(m pb.Message) { // 检查自己是否在集群中（可能已被 RemoveNode） if _, ok := r.Prs[r.id]; !ok { return } // 立即发起选举，不等待 electionTimeout r.Step(pb.Message{MsgType: pb.MessageType_MsgHup}) } 目标节点收到 TimeoutNow 后立即调用 MsgHup 开始选举，由于其日志至少和原 Leader 一样新，在 term+1 的新一轮选举中必定能赢得多数票，成为新 Leader。\nStep 5：超时放弃\n// 在 tickHeartbeat() 中 if r.leadTransferee != None { r.transferElapsed++ if r.transferElapsed \u0026gt;= r.electionTimeout { // 超过一个选举超时，放弃 Transfer r.leadTransferee = None } } Step 6：Propose 阻塞\nTransfer 进行中，Leader 拒绝新的 propose：\nfunc (r *Raft) handlePropose(m pb.Message) { if r.leadTransferee != None { return // 正在 Transfer，拒绝新 propose } // ...正常处理 } Step 7：非 Leader 收到 MsgTransferLeader 转发\n// Follower 收到 MsgTransferLeader case pb.MessageType_MsgTransferLeader: if r.Lead != None { m.To = r.Lead r.msgs = append(r.msgs, m) // 转发给当前 Leader } 虽然文档说 MsgTransferLeader 是本地消息，但测试中会将其发给 Follower，Follower 需要将其转发给 Leader。\n3.3 Leader Transfer 成功的根本原因 LeaderTransfer 之所以可靠，原因在于：\n转移前确保目标节点日志与 Leader 完全一致（Match == LastIndex） 目标节点收到 TimeoutNow 后以 term+1（比当前 Leader 的 term 大）发起选举 其他节点收到更高 term 的 RequestVote，且目标节点日志最新，会投票支持 原 Leader 收到更高 term 的消息后退化为 Follower 整个过程在一次选举周期内完成（通常很快），对外几乎无感知。\n3.4 成员变更（ConfChange）总体设计 ConfChange 允许动态增删集群成员。TinyKV 实现的是单步变更（每次只增加或删除一个节点），而非 Raft 论文中的 Joint Consensus 算法。\n为什么用单步变更？\nJoint Consensus 允许一次性变更多个节点，但实现复杂。单步变更足够简单安全：每次只变更一个节点，不会产生两个独立的多数派，避免脑裂。\nPendingConfIndex 字段：\ntype Raft struct { // ... PendingConfIndex uint64 // 上一个 ConfChange Entry 的 Index（未 apply 前不允许新 ConfChange） } PendingConfIndex 保证同一时刻最多只有一个未 apply 的 ConfChange，防止并发成员变更导致不一致。\n3.5 ConfChange 在 Raft 层的实现 ProposeConfChange（通过 RawNode 调用）：\nfunc (rn *RawNode) ProposeConfChange(cc pb.ConfChange) error { data, err := cc.Marshal() if err != nil { return err } ent := pb.Entry{ EntryType: pb.EntryType_EntryConfChange, // 特殊 Entry 类型 Data: data, } return rn.Raft.Step(pb.Message{ MsgType: pb.MessageType_MsgPropose, Entries: []*pb.Entry{\u0026amp;ent}, }) } ConfChange Entry 与普通 Entry 的区别在于 EntryType 为 EntryType_EntryConfChange，上层 apply 时需要特殊处理。\n在 Propose 阶段更新 PendingConfIndex：\n// 在 handlePropose 中，当 entry.EntryType == EntryConfChange 时 r.PendingConfIndex = r.RaftLog.LastIndex() + uint64(i) + 1 addNode 实现：\nfunc (r *Raft) addNode(id uint64) { if _, ok := r.Prs[id]; !ok { r.Prs[id] = \u0026amp;Progress{ Next: r.RaftLog.LastIndex() + 1, Match: 0, } } r.PendingConfIndex = None // 清除 PendingConfIndex } removeNode 实现：\nfunc (r *Raft) removeNode(id uint64) { delete(r.Prs, id) r.PendingConfIndex = None // 清除 PendingConfIndex // 关键：删除节点后 quorum 变小，可能有新的 entry 可以 commit if r.State == StateLeader { r.maybeCommit() // 需要广播 append，通知其他节点更新 commit r.bcastAppend() } // 如果被删的正是 leadTransferee，终止 Transfer if r.leadTransferee == id { r.leadTransferee = None } } 为什么 removeNode 后必须重新计算 commit？\n考虑以下场景（TestCommitAfterRemoveNode3A）：\n初始状态：3 节点集群 [1, 2, 3]，节点 1 是 Leader 1. Leader 发送 entry3 给节点 2 和 3 2. 节点 2 返回了 AppendResponse（Match 更新为 3） 3. 节点 3 在返回 AppendResponse 之前被 removeNode 了 4. 此时集群只剩 [1, 2]，quorum = 2 5. entry3 已经在节点 1 和 2 上，满足多数派条件，应该 commit 6. 但 Leader 还没有收到节点 3 的 AppendResponse，不会主动计算 commit 7. 需要 removeNode 后立即重新计算 commit，推进 committedIndex 4. RaftStore 层实现（Project3B） 4.1 代码改动范围 Project3B 的主要改动集中在两个文件：\nkv/raftstore/peer_msg_handler.go\n函数 改动内容 proposeRaftCommand 新增对 TransferLeader、ChangePeer、Split 的处理 HandleRaftReady 新增 processCommittedEntry 对 ConfChange 和 Split 的 apply processConfChange（新增） 处理 ConfChange Entry 的 apply processSplit（新增） 处理 Split Entry 的 apply 整体请求处理框架：\nfunc (d *peerMsgHandler) proposeRaftCommand(msg *raft_cmdpb.RaftCmdRequest, cb *message.Callback) { if msg.AdminRequest != nil { switch msg.AdminRequest.CmdType { case raft_cmdpb.AdminCmdType_CompactLog: // 2C 已实现，propose 到 Raft case raft_cmdpb.AdminCmdType_TransferLeader: // 3B 新增，不 propose，直接执行 case raft_cmdpb.AdminCmdType_ChangePeer: // 3B 新增，ProposeConfChange case raft_cmdpb.AdminCmdType_Split: // 3B 新增，普通 Propose } } else { // 普通 KV 请求，直接 Propose } } 4.2 LeaderTransfer 上层处理 TransferLeader 是四种 AdminCmd 中最简单的，因为它不需要经过 Raft 日志同步：\ncase raft_cmdpb.AdminCmdType_TransferLeader: // 直接调用 TransferLeader，不走 Propose d.RaftGroup.TransferLeader(msg.AdminRequest.TransferLeader.Peer.Id) // 立即返回 Response adminResp := \u0026amp;raft_cmdpb.AdminResponse{ CmdType: raft_cmdpb.AdminCmdType_TransferLeader, TransferLeader: \u0026amp;raft_cmdpb.TransferLeaderResponse{}, } cb.Done(\u0026amp;raft_cmdpb.RaftCmdResponse{ Header: \u0026amp;raft_cmdpb.RaftResponseHeader{}, AdminResponse: adminResp, }) 为什么不需要 Propose？\nLeader Transfer 不修改数据或集群成员，是一个单边操作（Leader 单方面决定转移），不需要其他节点确认，因此无需走 Raft 日志同步。\n4.3 ConfChange Propose 阶段 case raft_cmdpb.AdminCmdType_ChangePeer: // 1. 单步变更保证：只有上一个 ConfChange 已 apply 才能发起新的 if d.peerStorage.AppliedIndex() \u0026gt;= d.RaftGroup.Raft.PendingConfIndex { // 2. 特殊情况：两节点且要 Remove Leader，先 Transfer if len(d.Region().Peers) == 2 \u0026amp;\u0026amp; msg.AdminRequest.ChangePeer.ChangeType == pb.ConfChangeType_RemoveNode \u0026amp;\u0026amp; msg.AdminRequest.ChangePeer.Peer.Id == d.PeerId() { for _, p := range d.Region().Peers { if p.Id != d.PeerId() { d.RaftGroup.TransferLeader(p.Id) break } } // 注意：这里不 return，让 client 重试（或者 return 也可以） } // 3. 创建 proposal d.proposals = append(d.proposals, \u0026amp;proposal{ index: d.nextProposalIndex(), term: d.Term(), cb: cb, }) // 4. 将 RaftCmdRequest 序列化后放入 ConfChange.Context context, _ := msg.Marshal() d.RaftGroup.ProposeConfChange(pb.ConfChange{ ChangeType: msg.AdminRequest.ChangePeer.ChangeType, NodeId: msg.AdminRequest.ChangePeer.Peer.Id, Context: context, }) } 关键点：\nAppliedIndex \u0026gt;= PendingConfIndex 才允许新的 ConfChange（单步保证） 两节点 Remove Leader 的特殊处理（否则会导致集群陷入死循环，详见第8节） ProposeConfChange 而非 Propose（这样 Entry 的 EntryType 会被设为 EntryConfChange） Context 字段存原始 RaftCmdRequest（apply 时反序列化获取 Peer 信息） ProposeConfChange vs Propose 的区别：\n// Propose 产生普通 Entry func (rn *RawNode) Propose(data []byte) error { ent := pb.Entry{EntryType: pb.EntryType_EntryNormal, Data: data} // ... } // ProposeConfChange 产生 ConfChange Entry func (rn *RawNode) ProposeConfChange(cc pb.ConfChange) error { data, _ := cc.Marshal() ent := pb.Entry{EntryType: pb.EntryType_EntryConfChange, Data: data} // ... } HandleRaftReady 在 apply 时通过 entry.EntryType 区分两者的处理路径。\n4.4 ConfChange Apply 阶段 ConfChange Entry commit 后，在 HandleRaftReady 的 apply 阶段处理：\nfunc (d *peerMsgHandler) processCommittedEntry(entry *pb.Entry, kvWB *engine_util.WriteBatch) *engine_util.WriteBatch { if entry.EntryType == pb.EntryType_EntryConfChange { var cc pb.ConfChange cc.Unmarshal(entry.Data) return d.processConfChange(entry, \u0026amp;cc, kvWB) } // ...普通 Entry 处理 } processConfChange 完整流程：\nfunc (d *peerMsgHandler) processConfChange(entry *pb.Entry, cc *pb.ConfChange, kvWB *engine_util.WriteBatch) *engine_util.WriteBatch { // 1. 反序列化 Context 获取原始 RaftCmdRequest msg := \u0026amp;raft_cmdpb.RaftCmdRequest{} msg.Unmarshal(cc.Context) region := d.Region() // 2. 检查 RegionEpoch 是否过期（防止重复执行同一 ConfChange） // 测试会多次提交同一 ConfChange 直到 apply，RegionEpoch 检查能过滤重复 if err := util.CheckRegionEpoch(msg, region, true); err != nil { if errEpochNotMatch, ok := err.(*util.ErrEpochNotMatch); ok { d.handleProposal(entry, ErrResp(errEpochNotMatch)) return kvWB } } switch cc.ChangeType { case pb.ConfChangeType_AddNode: // 3a. 检查待添加节点是否已存在 if d.searchPeerWithId(cc.NodeId) == len(region.Peers) { // 不存在，执行添加 region.Peers = append(region.Peers, msg.AdminRequest.ChangePeer.Peer) region.RegionEpoch.ConfVer++ // 写入 kvDB meta.WriteRegionState(kvWB, region, rspb.PeerState_Normal) // 更新 storeMeta（需加锁） d.updateStoreMeta(region) // 更新 peerCache（消息发送时需要知道对方的 StoreId） d.insertPeerCache(msg.AdminRequest.ChangePeer.Peer) } case pb.ConfChangeType_RemoveNode: // 3b. 如果删除目标是自己，直接销毁 if cc.NodeId == d.PeerId() { d.destroyPeer() return kvWB // 必须 return，后面不能再执行任何操作 } // 不是自己，检查待删节点是否存在 n := d.searchPeerWithId(cc.NodeId) if n != len(region.Peers) { region.Peers = append(region.Peers[:n], region.Peers[n+1:]...) region.RegionEpoch.ConfVer++ meta.WriteRegionState(kvWB, region, rspb.PeerState_Normal) d.updateStoreMeta(region) d.removePeerCache(cc.NodeId) } } // 4. 更新 Raft 层的成员配置（调用 3A 实现的 addNode/removeNode） d.RaftGroup.ApplyConfChange(*cc) // 5. 处理 proposal 回调 d.handleProposal(entry, \u0026amp;raft_cmdpb.RaftCmdResponse{ Header: \u0026amp;raft_cmdpb.RaftResponseHeader{}, AdminResponse: \u0026amp;raft_cmdpb.AdminResponse{ CmdType: raft_cmdpb.AdminCmdType_ChangePeer, ChangePeer: \u0026amp;raft_cmdpb.ChangePeerResponse{Region: region}, }, }) // 6. 如果是 Leader，通知 Scheduler 更新 Region 缓存 if d.IsLeader() { d.notifyHeartbeatScheduler(region, d.peer) } return kvWB } notifyHeartbeatScheduler 的必要性：\nfunc (d *peerMsgHandler) notifyHeartbeatScheduler(region *metapb.Region, peer *peer) { clonedRegion := new(metapb.Region) util.CloneMsg(region, clonedRegion) d.ctx.schedulerTaskSender \u0026lt;- \u0026amp;runner.SchedulerRegionHeartbeatTask{ Region: clonedRegion, Peer: peer.Meta, PendingPeers: peer.CollectPendingPeers(), ApproximateSize: peer.ApproximateSize, } } ConfChange 修改了 Region 的成员，需要立即通知 Scheduler 更新其缓存。否则 Scheduler 可能基于旧信息下发错误的调度指令，或者 Client 请求时 Scheduler 返回\u0026quot;no region\u0026quot;错误。\n4.5 节点增删完整流程梳理 理解完整的增删节点链路，对面试至关重要。\nRemoveNode 完整链路：\n1. 测试/调度器 调用 schedulerClient.RemovePeer(regionID, peer) └── 设置 operators[regionID] = RemovePeerOperator 2. 定期心跳：某 Region 的 Leader 发送 RegionHeartbeatRequest └── MockSchedulerClient.RegionHeartbeat() 处理 ├── 发现 operators[regionID] 有待执行的 RemoveNode └── 调用 makeRegionHeartbeatResponse 生成响应（ChangePeer: RemoveNode） 3. heartbeatResponseHandler 处理响应 └── onRegionHeartbeatResponse() 调用 sendAdminRequest └── 封装 AdminCmdType_ChangePeer 请求发给 Leader 4. Leader 的 peerMsgHandler 收到 HandleMsg └── proposeRaftCommand → ProposeConfChange └── Raft 层写入 EntryConfChange Entry 5. Entry 复制到多数节点后 commit └── HandleRaftReady → processCommittedEntry → processConfChange ├── 所有节点执行：更新 region.Peers，ConfVer++，持久化 └── 被删节点：调用 destroyPeer()，从集群中移除自己 AddNode 完整链路：\n1. 调度器调用 AddPeer → 通过心跳响应下发 AdminCmdType_ChangePeer(AddNode) 2. Leader proposeRaftCommand → ProposeConfChange └── 所有节点 apply：region.Peers 中添加新节点 F，ConfVer++，持久化 3. 此时 F 还不存在，Leader 的 Prs 中有 F，会给 F 发心跳 └── 心跳进入 storeWorker.onRaftMessage() └── F 不存在 → maybeCreatePeer() ├── 条件：IsInitialMsg（心跳 + Commit == RaftInvalidIndex） └── 成立 → replicatePeer() 创建空的 Peer F └── 注册到 router，发送 MsgTypeStart 启动 4. F 启动后，Leader 发现其日志落后（LastIndex=0） └── 直接发 Snapshot 给 F 5. F 收到 Snapshot，通过 HandleRaftReady → ApplySnapshot └── 更新 F.Region() 信息，同步 storeMeta └── F 正式加入集群，可以服务请求 4.6 Region Split 详解 Region Split 是 Multi-Raft 的核心功能，将一个大 Region 分裂成两个小 Region，使系统可以用两个 Raft Group 并行处理请求。\n分裂不迁移数据的原理：\n分裂后两个 Region 仍然共用同一个 Store 的 badger 实例（kvDB），数据在 kvDB 中的 key 本身没有变化。分裂只是修改了元数据（StartKey/EndKey），告知系统哪个 Region 负责哪段 key。\n分裂前：Region A [0, 100) 在 Store 1 上 kvDB: {key1: v1, key2: v2, ..., key100: v100}（全在一个 badger 中） 分裂后： Region A [0, 50) → 负责 key1~key49 Region B [50, 100) → 负责 key50~key100 共用同一 kvDB，没有数据搬移 数据真正的迁移发生在 Region Balance 时：Scheduler 通过 MovePeer 操作，将某个 Region 的 Peer 从一个 Store 移到另一个 Store，这才会通过 Snapshot 同步数据。\n4.6.1 Split 触发流程 Step 1: peer_msg_handler.go onTick() └── onSplitRegionCheckTick() └── 生成 SplitCheckTask，发给 split_checker.go Step 2: split_checker.go Handle() └── 遍历 kvDB 中该 Region 的 key，按字典序找中位 key（SplitKey） └── 如果 Region 大小超过阈值（cfg.RegionMaxSize），发送 MsgTypeSplitRegion Step 3: peer_msg_handler.go HandleMsg() └── 收到 MsgTypeSplitRegion └── onPrepareSplitRegion() └── 发送 SchedulerAskSplitTask 给 scheduler_task.go └── 向 Scheduler 申请新的 RegionId 和 PeerIds（保证全局唯一） Step 4: scheduler_task.go onAskSplit() └── 收到 Scheduler 分配的 NewRegionId 和 NewPeerIds └── 发起 AdminCmdType_Split 的 RaftCmdRequest 给 Leader Step 5: 正常 propose → Raft 同步 → apply 流程 SplitKey 的选取：\nsplit_checker.go 遍历 Region 内所有 key，找到能将数据等分的中位 key。遍历是按照 badger 的字典序（不是 key 的数值顺序），所以 SplitKey 按字典序将 Region 一分为二。\n4.6.2 Split 应用流程 Split Entry commit 后，在 apply 阶段执行：\ncase raft_cmdpb.AdminCmdType_Split: // Step 1: 检查请求合法性 if requests.Header.RegionId != d.regionId { // ErrRegionNotFound d.handleProposal(entry, ErrResp(\u0026amp;util.ErrRegionNotFound{...})) return kvWB } if err := util.CheckRegionEpoch(requests, d.Region(), true); err != nil { // RegionEpoch 不匹配（Region 已经分裂过了） d.handleProposal(entry, ErrResp(err)) return kvWB } if err := util.CheckKeyInRegion(adminReq.Split.SplitKey, d.Region()); err != nil { // SplitKey 不在当前 Region 范围内 d.handleProposal(entry, ErrResp(err)) return kvWB } if len(d.Region().Peers) != len(adminReq.Split.NewPeerIds) { // NewPeerIds 数量不一致，拒绝（Scheduler 信息过时） d.handleProposal(entry, ErrRespStaleCommand(d.Term())) return kvWB } // Step 2: 创建新 Region oldRegion := d.Region() split := adminReq.Split // 为新 Region 创建 Peers（复制 oldRegion 的 Peers，修改 PeerId） newPeers := make([]*metapb.Peer, len(oldRegion.Peers)) for i, peer := range oldRegion.Peers { newPeers[i] = \u0026amp;metapb.Peer{ Id: split.NewPeerIds[i], StoreId: peer.StoreId, } } newRegion := \u0026amp;metapb.Region{ Id: split.NewRegionId, StartKey: split.SplitKey, // 新 Region 从 SplitKey 开始 EndKey: oldRegion.EndKey, // 到原 Region 结束 RegionEpoch: \u0026amp;metapb.RegionEpoch{ ConfVer: 1, Version: 1, }, Peers: newPeers, } // Step 3: 更新 RegionEpoch oldRegion.RegionEpoch.Version++ newRegion.RegionEpoch.Version = oldRegion.RegionEpoch.Version // 同步版本号 // Step 4: 更新 storeMeta（必须加锁！） storeMeta := d.ctx.storeMeta storeMeta.Lock() storeMeta.regionRanges.Delete(\u0026amp;regionItem{region: oldRegion}) // 先删旧范围 oldRegion.EndKey = split.SplitKey // 修改 oldRegion 范围 storeMeta.regionRanges.ReplaceOrInsert(\u0026amp;regionItem{region: oldRegion}) // 重新插入 storeMeta.regionRanges.ReplaceOrInsert(\u0026amp;regionItem{region: newRegion}) // 插入新 Region storeMeta.regions[newRegion.Id] = newRegion storeMeta.Unlock() // Step 5: 持久化 meta.WriteRegionState(kvWB, oldRegion, rspb.PeerState_Normal) meta.WriteRegionState(kvWB, newRegion, rspb.PeerState_Normal) // Step 6: 创建新 Region 的 Peer，注册到 router 并启动 newPeer, err := createPeer(d.storeID(), d.ctx.cfg, d.ctx.schedulerTaskSender, d.ctx.engine, newRegion) d.ctx.router.register(newPeer) d.ctx.router.send(newRegion.Id, message.Msg{Type: message.MsgTypeStart}) // Step 7: 处理 proposal 回调 d.handleProposal(entry, \u0026amp;raft_cmdpb.RaftCmdResponse{ Header: \u0026amp;raft_cmdpb.RaftResponseHeader{}, AdminResponse: \u0026amp;raft_cmdpb.AdminResponse{ CmdType: raft_cmdpb.AdminCmdType_Split, Split: \u0026amp;raft_cmdpb.SplitResponse{ Regions: []*metapb.Region{newRegion, oldRegion}, }, }, }) // Step 8: 通知 Scheduler 更新缓存（防止 no region 问题） if d.IsLeader() { d.notifyHeartbeatScheduler(oldRegion, d.peer) d.notifyHeartbeatScheduler(newRegion, newPeer) // 也要通知新 Region！ } 关键细节：Split Key 顺序问题\n注意更新 storeMeta 时的顺序：\n先 Delete 旧的 regionRange（删除 oldRegion 的旧范围） 更新 oldRegion.EndKey = SplitKey ReplaceOrInsert 更新 oldRegion 的新范围 ReplaceOrInsert 插入 newRegion 的范围 如果先修改 oldRegion.EndKey 再 Delete，因为 B-tree 的 key 是按 region 的 EndKey 排序的，会导致找不到原来的节点而删除失败，产生 meta corruption detected 错误。\n4.7 Apply 阶段关键注意事项 4.7.1 每个 Entry 执行前检查 RegionEpoch 在 apply 每个 Entry 之前，都应检查 RegionEpoch 是否与当前 Region 匹配：\n// 在处理 entry 之前 if err := util.CheckRegionEpoch(req, d.Region(), true); err != nil { d.handleProposal(entry, ErrResp(err)) return kvWB } 为什么需要这个检查？\nentry 从 propose 到 apply 之间，Region 可能已经发生了 split 或 confchange，导致这个 entry 中的 key 不再属于当前 Region，或者 Region 成员已经变化。尤其是 Snapshot 类型的请求，如果在 Snap entry 执行前 Region 已经 split，那么这个 Snap 覆盖的 key 范围可能已经超出当前 Region 范围，强行执行会导致数据不一致。\n4.7.2 SizeDiffHint 更新 case raft_cmdpb.CmdType_Put: // Put 时增加 SizeDiffHint d.SizeDiffHint += uint64(len(req.Put.Key) + len(req.Put.Value)) // 执行写入... case raft_cmdpb.CmdType_Delete: // Delete 时减少 SizeDiffHint if len(req.Delete.Key) \u0026gt; d.SizeDiffHint { d.SizeDiffHint = 0 } else { d.SizeDiffHint -= uint64(len(req.Delete.Key)) } // 执行删除... SizeDiffHint 用于触发 split 检查（onSplitRegionCheckTick）。如果不更新，Region 大小估算会不准确，导致 split 不被触发，在高并发大量写入的测试中会出现卡死。\n4.7.3 processConfChange 后检查 d.stopped // 处理完 ConfChange Entry 后 d.processConfChange(entry, cc, kvWB) // 关键：检查是否因为 RemoveNode 自己而停止 if d.stopped { return } // 继续处理下一个 entry... 如果 ConfChange 是 RemoveNode 自己，destroyPeer() 会设置 d.stopped = true。此时必须立即停止处理后续 entry，否则会在一个已销毁的节点上继续执行操作，产生各种错误。\n5. Scheduler 实现（Project3C） 5.1 Scheduler 整体架构 TinyScheduler 对应 TiDB 生态中的 PD（Placement Driver），负责：\n全局元数据管理：维护所有 Region 的位置、大小、Leader 等信息 负载均衡调度：当 Store 间 Region 数量不均衡时，通过 MovePeer 操作均衡负载 健康状态监控：通过 Store 心跳监控节点存活状态 Scheduler 通过心跳获取集群信息：\nRegion 的 Leader 定期发送 RegionHeartbeatRequest 各 Store 定期发送 StoreHeartbeatRequest Scheduler 通过心跳响应下发调度指令：\nRegionHeartbeatResponse 中携带 ChangePeer（AddNode/RemoveNode）或 TransferLeader 指令 5.2 processRegionHeartbeat 实现 函数签名与 RegionInfo 结构：\n// RegionInfo 包含心跳中的所有信息 type RegionInfo struct { meta *metapb.Region // Region 元数据（ID, 范围, Peers, Epoch） learners []*metapb.Peer voters []*metapb.Peer leader *metapb.Peer // 当前 Leader pendingPeers []*metapb.Peer // 待同步的 Peer approximateSize int64 // Region 大小估算 } func (c *RaftCluster) processRegionHeartbeat(region *core.RegionInfo) error { // ... } 过期判断逻辑（两种场景）：\n场景一：Scheduler 中已有相同 ID 的 Region\norigin := c.core.GetRegion(region.GetID()) if origin != nil { // 比较 RegionEpoch：新 epoch 的 Version 或 ConfVer 必须 \u0026gt;= 旧的 if util.IsEpochStale(region.GetRegionEpoch(), origin.GetRegionEpoch()) { return ErrRegionIsStale(region.GetMeta(), origin.GetMeta()) } } 场景二：Scheduler 中没有相同 ID 的 Region\nif origin == nil { // 扫描所有与该 Region key 范围有 overlap 的 Region overlaps := c.core.ScanRegions(region.GetStartKey(), region.GetEndKey(), -1) for _, r := range overlaps { // 新 Region 必须比所有 overlap Region 都要新 if util.IsEpochStale(region.GetRegionEpoch(), r.GetRegionEpoch()) { return ErrRegionIsStale(region.GetMeta(), r.GetMeta()) } } } 判断是否需要更新（可跳过条件）：\n以下任何一个条件成立，则不能跳过更新：\n1. Version 或 ConfVer 大于原来的（Region 发生了 split 或 confchange） 2. Leader 发生了变化 3. 新的或原来的有 pending peers（说明有成员正在追赶日志） 4. ApproximateSize 发生了变化（大小变了，可能触发新的调度） 执行更新：\n// 通过检查后，执行更新 c.core.PutRegion(region) // 更新 Region 树 c.core.UpdateStoreStatus(region.GetLeaderStoreId()) // 更新 Store 的负载状态 5.3 Schedule 实现（balance_region） 平衡调度的目标： 将 regionSize 最大的 Store 中的某个 Region，迁移到 regionSize 最小的 Store，使集群负载趋于均衡。\n完整实现流程：\nfunc (s *balanceRegionScheduler) Schedule(cluster opt.Cluster) *operator.Operator { // Step 1: 筛选并排序 suitableStore stores := cluster.GetStores() var suitableStores []*core.StoreInfo for _, store := range stores { if store.IsUp() \u0026amp;\u0026amp; store.DownTime() \u0026lt; cluster.GetMaxStoreDownTime() { suitableStores = append(suitableStores, store) } } if len(suitableStores) \u0026lt; 2 { return nil } // 按 regionSize 降序排列 sort.Slice(suitableStores, func(i, j int) bool { return suitableStores[i].GetRegionSize() \u0026gt; suitableStores[j].GetRegionSize() }) // Step 2: 从 regionSize 最大的 Store 中找待迁移 Region var sourceRegion *core.RegionInfo var sourceStore *core.StoreInfo for _, store := range suitableStores { // 按优先级依次查找：pending \u0026gt; follower \u0026gt; leader cluster.GetPendingRegionsWithLock(store.GetID(), func(container core.RegionsContainer) { sourceRegion = container.RandomRegion(nil, nil) }) if sourceRegion != nil { sourceStore = store break } cluster.GetFollowersWithLock(store.GetID(), func(container core.RegionsContainer) { sourceRegion = container.RandomRegion(nil, nil) }) if sourceRegion != nil { sourceStore = store break } cluster.GetLeadersWithLock(store.GetID(), func(container core.RegionsContainer) { sourceRegion = container.RandomRegion(nil, nil) }) if sourceRegion != nil { sourceStore = store break } } if sourceRegion == nil { return nil } // Step 3: 检查 Region 副本数是否满足最小要求 if len(sourceRegion.GetPeers()) \u0026lt; cluster.GetMaxReplicas() { return nil // 副本数不足，不能再减少 } // Step 4: 找 regionSize 最小的目标 Store（不能在源 Region 中） var targetStore *core.StoreInfo sourceRegionStoreIDs := sourceRegion.GetStoreIds() for i := len(suitableStores) - 1; i \u0026gt;= 0; i-- { store := suitableStores[i] if _, ok := sourceRegionStoreIDs[store.GetID()]; !ok { targetStore = store break } } if targetStore == nil { return nil } // Step 5: 判断迁移是否有价值 // 差值必须大于 Region 大小的两倍，避免来回迁移 if sourceStore.GetRegionSize()-targetStore.GetRegionSize() \u0026lt;= 2*sourceRegion.GetApproximateSize() { return nil } // Step 6: 创建 MovePeer Operator newPeer, err := cluster.AllocPeer(targetStore.GetID()) if err != nil { return nil } return operator.CreateMovePeerOperator( \u0026#34;balance-region\u0026#34;, cluster, sourceRegion, operator.OpBalance, sourceStore.GetID(), targetStore.GetID(), newPeer.GetId(), ) } MovePeer Operator 的执行步骤：\nCreateMovePeerOperator 生成一个包含三步的 Operator：\nAddPeer(targetStore, newPeer)：在目标 Store 添加新 Peer TransferLeader（如果源 Store 是 Leader）：迁移 Leadership RemovePeer(sourceStore)：删除源 Store 上的 Peer 这三步由 Scheduler 逐步通过心跳响应下发，不是同时执行。\n6. Multi-Raft 关键操作流分析 6.1 正常读写请求流 Client 发起 Put(key, value) 请求 ↓ TinyKV Server 收到 gRPC 请求 ↓ 根据 key 路由到对应 Region（查 storeMeta.regionRanges 或询问 Scheduler） ↓ 将请求发给该 Region 的 Leader（通过 router.send） ↓ Leader 的 peerMsgHandler.HandleMsg(MsgTypeRaftCmd) 处理 ↓ proposeRaftCommand → RaftGroup.Propose(data) ↓ Raft 层：Leader 将 Entry 写入 RaftLog，广播 MsgAppend 给 Followers ↓ Followers 收到 MsgAppend，持久化 Entry，回复 MsgAppendResponse ↓ Leader 收到多数 AppendResponse，推进 committedIndex ↓ raftWorker 触发 HandleRaftReady ↓ apply CommittedEntries：执行 kvWB.SetCF(key, value)，写入 kvDB ↓ 处理 proposal 回调：cb.Done(response) ↓ 响应 Client 6.2 Region Split 操作全流程 [触发阶段] 1. onTick() → onSplitRegionCheckTick() └── 检查 Region 大小（通过 SizeDiffHint 估算） └── 超过阈值 → 发送 SplitCheckTask 2. split_checker.go Handle(SplitCheckTask) └── 扫描 kvDB，按字典序找 SplitKey（中位 key） └── 发送 MsgTypeSplitRegion(splitKey) 3. HandleMsg(MsgTypeSplitRegion) └── onPrepareSplitRegion() └── 发送 SchedulerAskSplitTask（申请新 RegionId 和 PeerIds） 4. scheduler_task.go onAskSplit() └── Scheduler 分配 NewRegionId 和 NewPeerIds └── 发起 AdminCmdType_Split 的 RaftCmdRequest [Propose 阶段] 5. proposeRaftCommand(AdminCmdType_Split) └── 检查 RegionEpoch 和 SplitKey 合法性 └── RaftGroup.Propose(splitRequest) [Raft 同步阶段] 6. Leader 广播 Entry，多数节点 commit [Apply 阶段] 7. HandleRaftReady → processCommittedEntry → processSplit ├── 创建 newRegion（StartKey=SplitKey, EndKey=oldRegion.EndKey） ├── 更新 oldRegion.EndKey = SplitKey ├── 两个 Region 的 Version++ ├── 持久化（WriteRegionState） ├── 更新 storeMeta.regionRanges（加锁） ├── createPeer(newRegion) → router.register → MsgTypeStart └── notifyHeartbeatScheduler（两次，分别通知新旧 Region） [新 Region 选 Leader] 8. 新 Region 的 Peer 启动后，开始 Raft 选举 └── 选出 Leader 后，开始正常服务请求 6.3 ConfChange Add 完整流程 [调度器下发] 1. Schedule() 决定向某 Store 添加一个 Region 的 Peer └── 生成 AddPeer Operator └── 通过下一次 Region 心跳响应下发 ChangePeer(AddNode) [Propose 到 Apply] 2. Leader 收到 AdminCmdType_ChangePeer(AddNode) └── 检查 AppliedIndex \u0026gt;= PendingConfIndex └── ProposeConfChange(AddNode, newPeerID) 3. Raft 同步后 commit，所有节点 Apply └── 每个节点：region.Peers 中添加新 Peer，ConfVer++，持久化 └── ApplyConfChange → addNode（更新 Prs） [新节点创建] 4. Leader 向新 PeerID 发送心跳（msg.Commit = RaftInvalidIndex） └── storeWorker.onRaftMessage → maybeCreatePeer └── IsInitialMsg 判断通过 → replicatePeer() 创建空 Peer └── router.register → MsgTypeStart 启动 [数据同步] 5. Leader 发现新节点 Match=0，LastIndex 落后 └── 直接发 Snapshot 给新节点（跳过逐条 AppendEntry） 6. 新节点 HandleRaftReady 处理 Snapshot └── ApplySnapshot → 更新 Region 信息、storeMeta └── 新节点正式加入集群，开始参与 Raft 投票 6.4 ConfChange Remove 完整流程 1. Schedule() 决定移除某 Store 上的 Region Peer └── 生成 RemovePeer Operator → 心跳响应下发 ChangePeer(RemoveNode) 2. Leader proposeRaftCommand(ChangePeer, RemoveNode) └── 特殊检查：若 2 节点且 Remove 的是 Leader └── 先 TransferLeader，等 Leader 迁移后再 Remove 3. Entry commit，所有节点 Apply processConfChange ├── 非目标节点：region.Peers 中删除该 Peer，ConfVer++，removePeerCache └── 目标节点（被删除的）：destroyPeer() ├── 清理 raftDB 中的 Region 信息 ├── 清理 storeMeta（regions, regionRanges） ├── 关闭 router 中的 Peer └── d.stopped = true 4. destroyPeer 后不再处理任何 entry（检查 d.stopped） 6.5 Leader Transfer 操作流 1. 调度器通过心跳响应下发 TransferLeader 指令 2. Leader 收到 AdminCmdType_TransferLeader └── 直接调用 RaftGroup.TransferLeader(targetPeerID) └── Step(MsgTransferLeader{From: targetPeerID}) 3. Raft 层处理 MsgTransferLeader ├── 若目标日志已同步：直接发 MsgTimeoutNow └── 若目标日志落后：发 MsgAppend，等 AppendResponse 后再发 TimeoutNow 4. 目标节点收到 MsgTimeoutNow └── 立即 Step(MsgHup)，发起选举 5. 新的 Leader 选举成功（term+1，日志最新） └── 原 Leader 收到更高 term 的消息，退化为 Follower 6. 新 Leader 就位，上层收到 AdminCmdType_TransferLeader 的 resp 6.6 Region Balance 操作流 [调度决策] 1. Scheduler 定期调用 balanceRegionScheduler.Schedule() └── 选出负载最重的 Store（sourceStore）中的一个 Region └── 选出负载最轻的 Store（targetStore） 2. 生成 MovePeer Operator：[AddPeer, TransferLeader?, RemovePeer] [执行 AddPeer] 3. 通过心跳响应下发 ChangePeer(AddNode) 给源 Region 的 Leader └── ... 执行 ConfChange AddNode 流程（见 6.3） └── 新 Peer 在 targetStore 上创建并同步数据 [执行 TransferLeader（如需）] 4. 若源 Store 是 Leader，下发 TransferLeader 到 targetStore 上的新 Peer └── ... 执行 Leader Transfer 流程（见 6.5） [执行 RemovePeer] 5. 下发 ChangePeer(RemoveNode) 删除 sourceStore 上的 Peer └── ... 执行 ConfChange RemoveNode 流程（见 6.4） [完成] 6. Region 成功从 sourceStore 迁移到 targetStore └── 数据通过 Snapshot 完成同步 7. 设计分析与关键决策 7.1 为何使用单步变更而非 Joint Consensus Raft 论文提出了 Joint Consensus 算法，支持一次性变更多个节点，但 TinyKV 选择了更简单的单步变更（一次只增删一个节点）。\nJoint Consensus 的问题：\n需要两阶段提交（C_old,new 阶段 → C_new 阶段） 实现复杂，边界条件多，容易出 Bug 对于一般场景（增删单个节点）没有必要 单步变更的安全性保证：\n考虑 3 节点集群 [A, B, C]，AddNode D 后变成 4 节点：\n3 节点时 quorum = 2，4 节点时 quorum = 3 单步变更保证：在 [A, B, C] 阶段的 quorum（2 票）和 [A, B, C, D] 阶段的 quorum（3 票）之间，任意一个多数派都包含至少一个公共节点 因此不会出现两个独立的多数派同时认可不同的 Leader 为什么需要 PendingConfIndex？\n如果允许多个 ConfChange 并发（比如先 AddNode D，还没 apply 就又 RemoveNode B），可能出现一系列 ConfChange 相互干扰。PendingConfIndex 确保：前一个 ConfChange 完全 apply 后（已经修改了集群成员），才允许发起下一个 ConfChange。\n7.2 RegionEpoch 双版本的设计意图 RegionEpoch 有两个独立的版本号：\nConfVer：成员变更版本（AddNode/RemoveNode 时递增） Version：分片版本（Split/Merge 时递增） 为什么分开？\n网络分区场景下，可能有两个旧 Leader 分别处理了不同类型的操作：\n分区 1 的旧 Leader 执行了 ConfChange（ConfVer 更大） 分区 2 的旧 Leader 执行了 Split（Version 更大） 如果只用一个版本号，这两种情况的\u0026quot;谁更新\u0026quot;就难以判断。分开后，Scheduler 可以分别比较：\nVersion 相同但 ConfVer 更大 → 成员变更更新 ConfVer 相同但 Version 更大 → 分片更新 两者都更大才是整体上更新的 判断标准（IsEpochStale）：\nfunc IsEpochStale(epoch *metapb.RegionEpoch, checkEpoch *metapb.RegionEpoch) bool { return epoch.GetVersion() \u0026lt; checkEpoch.GetVersion() || epoch.GetConfVer() \u0026lt; checkEpoch.GetConfVer() } 只要任一维度落后，就认为是过时的。\n7.3 心跳双重机制的必要性 Raft 心跳（周期约 150ms）：\n目的：维持 Leader 地位，防止 Follower 超时发起选举 只在 Region 内部的 Peer 之间传递 不经过 Scheduler Scheduler 心跳（周期约 10s，比 Raft 心跳慢得多）：\n目的：让 Scheduler 感知集群状态 由 Region Leader 定期发给 Scheduler 携带 Region 的完整元数据（大小、Peers 列表、Leader 等） 两者解决不同问题，缺一不可：\n没有 Raft 心跳 → Follower 频繁发起选举，集群不稳定 没有 Scheduler 心跳 → Scheduler 无法感知集群状态，无法做负载均衡 7.4 Split 不迁移数据的设计 Split 操作只修改 Region 的元数据（StartKey/EndKey/RegionEpoch），不移动 kvDB 中的任何 KV 数据。\n优点：\nSplit 操作极快（毫秒级），只需修改元数据 不会因大量数据拷贝而产生 IO 压力 可以频繁 Split，精细化管理 Region 粒度 代价：\n同一 Store 上的多个 Region 共用 kvDB 存储，数据并非物理隔离 如果某个 key 范围的访问热点在一个 Store 上，即使 Split 了也无法减少该 Store 的 IO 压力（需要配合 balance 操作将 Region 迁移到其他 Store） 数据真正迁移的时机：\n当 Scheduler 决定将某 Region 的 Peer 从 Store A 迁移到 Store B 时，通过 AddPeer 操作，会触发 Snapshot 传输，将该 Region 的所有数据从 Leader 发给 Store B 上的新 Peer。这才是真正的数据迁移。\n7.5 storeMeta 的一致性保证 storeMeta 是全局共享的 Region 元数据，多个 goroutine（raftWorker、storeWorker 等）会并发读写：\ntype storeMeta struct { sync.RWMutex regions map[uint64]*metapb.Region regionRanges *btree.BTree } 必须加锁的场景：\n修改 regions map（Split 时插入新 Region） 修改 regionRanges B-tree（Split 时更新 key 范围） 读取 regions 或 regionRanges（路由请求时） 如果 Split 时忘记加锁，可能导致并发读写 B-tree 产生数据竞争，产生 panic 或数据损坏。\n7.6 调度器 Operator 模式设计 Scheduler 不直接向 Peer 发送命令，而是生成 Operator（操作序列），通过心跳响应下发：\nOperator = [Step1: AddPeer, Step2: TransferLeader, Step3: RemovePeer] 每次 Region 发来心跳时，Scheduler 检查是否有未完成的 Operator，如果有，下发当前步骤。当前步骤完成后（Scheduler 通过下一次心跳确认），再下发下一步。\n这种设计的优点：\n幂等性：每步操作可以安全重试（心跳可能超时） 可观测性：每个步骤的完成状态可以通过心跳确认 解耦：Scheduler 不需要维持与 Peer 的长连接 8. 常见问题与注意事项 8.1 Project3A 常见问题 问题一：Follower 收到 MsgTransferLeader 不处理\n现象：TestTransferLeaderToUpToDateNode3A 测试超时 原因：文档说 MsgTransferLeader 是本地消息，不通过网络传播，但测试中会将其发给 Follower 解决：Follower 收到 MsgTransferLeader 时，将其转发给自己的 Leader case pb.MessageType_MsgTransferLeader: if r.State != StateLeader { if r.Lead != None { m.To = r.Lead r.msgs = append(r.msgs, m) } return nil } // Leader 处理 Transfer... 问题二：addNode 重复节点抛异常\n现象：TestRawNodeProposeAddDuplicateNode3A 测试 panic 原因：添加已存在节点时抛异常 解决：如果节点已存在，直接返回，不报错 func (r *Raft) addNode(id uint64) { if _, ok := r.Prs[id]; ok { return // 已存在，忽略 } r.Prs[id] = \u0026amp;Progress{Next: r.RaftLog.LastIndex() + 1} r.PendingConfIndex = None } 问题三：removeNode 后没有重新计算 commit\n现象：TestCommitAfterRemoveNode3A 测试失败，log entry 无法 commit 原因：removeNode 后 quorum 变小，之前因为节点不够而无法 commit 的 entry 现在可以 commit 了 解决：removeNode 后调用 maybeCommit，如果 commit 推进了，需要 bcastAppend 广播 func (r *Raft) removeNode(id uint64) { delete(r.Prs, id) r.PendingConfIndex = None if r.State == StateLeader { if r.maybeCommit() { r.bcastAppend() } } } 8.2 Project3B 常见问题 问题一：meta corruption detected\n现象：panic: [region X] meta corruption detected 原因一：Split 时更新 storeMeta 顺序错误——先修改了 oldRegion.EndKey 再从 B-tree 删除，导致 B-tree 找不到对应节点 解决一：必须先 Delete 旧节点（基于旧 EndKey），然后再修改 EndKey，再 Insert storeMeta.regionRanges.Delete(\u0026amp;regionItem{region: oldRegion}) // 先删（此时 EndKey 还是旧值） oldRegion.EndKey = split.SplitKey // 再修改 storeMeta.regionRanges.ReplaceOrInsert(\u0026amp;regionItem{region: oldRegion}) // 再插入 原因二：AddNode 时没有更新 regionRanges 解决二：AddNode apply 后需要在 storeMeta 中插入新 Region（如果是新建的话） // 在 maybeCreatePeer 中，或者 addNode apply 后 meta.Lock() meta.regionRanges.ReplaceOrInsert(\u0026amp;regionItem{region: peer.Region()}) meta.Unlock() 问题二：Remove Leader 两节点死循环\n现象：两节点集群，要 Remove 的正好是 Leader，unreliable 网络下，另一个节点永远无法完成选举\n根因：\nLeader apply Remove 自己后调用 destroyPeer，不再存在 另一个节点因为没收到最后的心跳（commit 没推进），不知道 ConfChange 已经 commit 另一个节点发起选举，但需要 2 票（quorum），自己只有 1 票，因为它还认为集群有两个节点 死循环：永远无法获得多数票 解决一（Propose 阶段）：在 Propose 时检测到这种情况，拒绝并先 TransferLeader\nif len(d.Region().Peers) == 2 \u0026amp;\u0026amp; cc.ChangeType == pb.ConfChangeType_RemoveNode \u0026amp;\u0026amp; cc.Peer.Id == d.PeerId() { // 先转移 Leader，让 client 重试 d.RaftGroup.TransferLeader(otherPeerID) return // 不 propose，让 client 重试 } 解决二（Apply 阶段）：在 destroyPeer 前重复发送多次心跳给对方，尽量确保对方 commit func (d *peerMsgHandler) startToDestroyPeer() { if len(d.Region().Peers) == 2 \u0026amp;\u0026amp; d.IsLeader() { // 找到另一个节点 for _, peer := range d.Region().Peers { if peer.Id != d.PeerId() { heartbeat := pb.Message{ To: peer.Id, MsgType: pb.MessageType_MsgHeartbeat, Commit: d.peerStorage.raftState.HardState.Commit, } for i := 0; i \u0026lt; 10; i++ { d.Send(d.ctx.trans, []pb.Message{heartbeat}) time.Sleep(100 * time.Millisecond) } break } } } d.destroyPeer() } 问题三：d.stopped 判断缺失\n现象：RemoveNode 自己后，继续处理后续 Entry，产生各种空指针或状态错误 解决：processConfChange 执行后立即检查 d.stopped for _, entry := range committedEntries { kvWB = d.processCommittedEntry(\u0026amp;entry, kvWB) if d.stopped { return // 已销毁，不再处理 } } 问题四：no region 问题\n现象：Client 请求某个 key，Scheduler 返回\u0026quot;no region\u0026quot; 原因：Split 或 ConfChange 后，Scheduler 缓存没有及时更新 解决：在 Split 和 ConfChange apply 后，主动调用 notifyHeartbeatScheduler // ConfChange apply 后 if d.IsLeader() { d.notifyHeartbeatScheduler(region, d.peer) } // Split apply 后 if d.IsLeader() { d.notifyHeartbeatScheduler(oldRegion, d.peer) d.notifyHeartbeatScheduler(newRegion, newPeer) // 新 Region 也要通知！ } 问题五：NewPeerIds 数量与 Region Peers 数量不一致\n现象：Split apply 时，len(split.NewPeerIds) != len(region.Peers) 根因：可能 Scheduler 在 Region 成员变更途中（还没完成），就收到了旧 Leader 的 Split 请求，导致 NewPeerIds 的数量基于旧的成员信息 解决：检查数量，不一致则拒绝 if len(d.Region().Peers) != len(adminReq.Split.NewPeerIds) { d.handleProposal(entry, ErrRespStaleCommand(d.Term())) return kvWB } 问题六：重复 ConfChange 命令\n现象：panic: should only one conf change 原因：测试会多次提交同一 ConfChange 直到被 apply，如果重复执行会导致节点被重复添加/删除 解决：apply 时检查 RegionEpoch 是否过期（已 apply 过的 ConfChange 会更新 ConfVer，重复提交的请求携带旧 ConfVer） if err := util.CheckRegionEpoch(msg, region, true); err != nil { d.handleProposal(entry, ErrResp(err)) return kvWB } 问题七：心跳 msg.Commit 必须为 RaftInvalidIndex\n现象：AddNode 后，新节点永远无法被创建 原因：IsInitialMsg 要求心跳的 Commit 字段为 RaftInvalidIndex（0），才会触发 maybeCreatePeer 解决：Leader 向不存在的节点发心跳时，Commit 字段设为 0 // 在 sendHeartbeat 中 if pr.Match == 0 { // 对方还没有数据，Commit 设为 RaftInvalidIndex m.Commit = 0 } 问题八：storeMeta 加锁缺失\n现象：偶现数据竞争 panic 或数据不一致 解决：所有修改 storeMeta 的操作（regionRanges、regions map）都必须加锁 问题九：Snap entry 执行前 RegionEpoch 检查\n现象：Scan 操作返回 key 不在 region 的错误 原因：Region Split 后，旧的 Snap entry 中的 key 范围可能已超出当前 Region 解决：执行任何 entry 前都检查 RegionEpoch 问题十：SizeDiffHint 未更新\n现象：高并发大量写入测试（nclient\u0026gt;=8 \u0026amp;\u0026amp; crash=true \u0026amp;\u0026amp; split=true）卡死 原因：Put/Delete 时没更新 SizeDiffHint，导致 Region 大小估算错误，split 不被触发 解决：Put 时 SizeDiffHint += len(key) + len(value)，Delete 时 SizeDiffHint -= len(key) 8.3 Project3C 常见问题 问题一：region store 数量未检查\n现象：调度器 panic 或 operator 执行异常 原因：如果 Region 的副本数已经小于 maxReplicas，不能再 RemovePeer 解决： if len(sourceRegion.GetPeers()) \u0026lt; cluster.GetMaxReplicas() { return nil } 问题二：遍历方向错误\n现象：Schedule 没有效果，或者在 regionSize 差距大时不触发 balance 原因：Source store 应从大到小遍历，target store 应从小到大遍历 解决：确认排序方向正确（降序排列后，前面是大的，后面是小的） 9. 性能与一致性分析 9.1 Multi-Raft 并发带来的性能提升 单 Raft 的瓶颈：\n所有写请求串行提交，吞吐量上限约等于单 Raft Group 的处理速度 写操作必须等 entry commit，commit 需要等多数节点确认（涉及网络往返） Multi-Raft 的提升：\n不同 Region 的写请求并行处理，互不阻塞 假设 N 个 Region 均匀分布，理论吞吐量提升 N 倍 不同 Region 的 Leader 可以在不同 Store 上，充分利用多机 CPU 和 IO 资源 限制：\n跨 Region 的事务需要 2PC（TiKV 通过 Percolator 模型实现），有额外开销 Region 数量过多会增加 Scheduler 的心跳处理压力 9.2 Region Balance 的负载均衡效果 不平衡的来源：\n初始只有一个 Region，所有写入都由一个 Raft Group 处理 Split 后，新 Region 仍在同一 Store 上，没有物理分散 需要 balance 操作将 Region 均匀分散到各 Store Balance 的判断标准：\ndiff = sourceStore.regionSize - targetStore.regionSize 如果 diff \u0026gt; 2 * region.ApproximateSize，才执行迁移 这个 2x 的系数是为了防止迁移后又触发反向迁移（乒乓效应）：\n迁移 Region R（大小 S）之后： sourceStore 减少 S，targetStore 增加 S 新的 diff = (originalDiff - 2S) 如果 originalDiff \u0026gt; 2S，则新 diff \u0026gt; 0（source 仍然大于 target），不会反向迁移 9.3 线性一致性保证 Multi-Raft 下，TinyKV 如何保证线性一致性（Linearizability）？\n写请求：所有写请求必须经过 Raft 日志提交，保证全局有序。同一 Region 内的写请求严格按照 commit 顺序执行，天然线性一致。\n读请求：如果允许 Follower 直接读，可能读到过期数据（Follower 可能落后）。TinyKV 将读请求也走 Raft 日志（Log Read），确保线性一致，但牺牲了读性能。\n优化方案（TiKV 生产实现）：\nReadIndex：Leader 收到读请求后，不走日志，而是记录当前 commitIndex，等待 apply 到该 Index 后再响应。需要向多数节点确认自己还是 Leader（防止脑裂）。 LeaseRead：Leader 在租约期内无需确认就可以响应读请求（基于时钟假设：租约内不会有新 Leader 产生）。延迟最低，但依赖时钟精度。 TinyKV 实现中未实现 ReadIndex/LeaseRead 优化，读请求和写请求一样走完整的 Raft 日志提交。\n10. 代码索引附录 关键文件与函数 文件 关键函数 功能 raft/raft.go handleTransferLeader 处理 Leader Transfer 请求 raft/raft.go handleTimeoutNow 目标节点收到 TimeoutNow 立即选举 raft/raft.go addNode 添加节点到 Prs raft/raft.go removeNode 删除节点，重算 commit raft/rawnode.go TransferLeader 发起 Leader Transfer raft/rawnode.go ProposeConfChange 提交 ConfChange Entry raft/rawnode.go ApplyConfChange 应用 ConfChange，更新 Prs kv/raftstore/peer_msg_handler.go proposeRaftCommand 处理 4 种 AdminCmd 的 Propose kv/raftstore/peer_msg_handler.go processConfChange Apply ConfChange Entry kv/raftstore/peer_msg_handler.go processSplit Apply Split Entry kv/raftstore/peer_msg_handler.go notifyHeartbeatScheduler 通知 Scheduler 更新 Region 缓存 kv/raftstore/store_worker.go onRaftMessage 处理来自其他节点的 Raft 消息 kv/raftstore/store_worker.go maybeCreatePeer 自动创建不存在的 Peer kv/raftstore/router.go register / send 注册/发消息给 Region Peer kv/raftstore/runner/split_checker.go Handle 扫描 Region 数据找 SplitKey kv/raftstore/runner/scheduler_task.go onAskSplit 向 Scheduler 申请 Split ID scheduler/server/cluster.go processRegionHeartbeat 处理 Region 心跳，更新调度器缓存 scheduler/server/schedulers/balance_region.go Schedule Region Balance 调度算法 关键常量与阈值 常量 值 作用 RaftInvalidIndex 0 标识无效 Index，心跳触发 maybeCreatePeer 的条件 cfg.RegionMaxSize 配置 Region 大小超过此值触发 Split cfg.RaftLogGcCountLimit 配置 Raft 日志条数超过此值触发 CompactLog MaxStoreDownTime 配置 Store 不可用超过此时间不参与调度 2 × ApproximateSize 动态 Region balance 有价值的最小 diff 阈值 关键 Proto 结构 // Region 元信息 message Region { uint64 id = 1; bytes start_key = 2; bytes end_key = 3; RegionEpoch region_epoch = 4; repeated Peer peers = 5; } // Region 版本信息 message RegionEpoch { uint64 conf_ver = 1; // ConfChange 版本 uint64 version = 2; // Split 版本 } // 成员变更 message ConfChange { ConfChangeType change_type = 1; // AddNode / RemoveNode uint64 node_id = 2; bytes context = 3; // 序列化的 RaftCmdRequest } ","permalink":"https://yinit.github.io/tinykv-multi-raft-%E5%AE%9E%E7%8E%B0%E4%B8%8E%E5%A4%8D%E4%B9%A0/","summary":"深入解析 TinyKV Multi-Raft 架构，涵盖 Leader Transfer、成员变更、Region 分裂与全局调度器实现","title":"TinyKV Multi-Raft 实现与复习"},{"content":"1. 背景与核心问题 MVCC（Multi-Version Concurrency Control，多版本并发控制）的核心目标是：\n让不同事务在并发访问同一条逻辑记录时，能够看到对自己正确且一致的版本。\n从存储引擎实现角度看，MVCC 最关键的是回答四个问题：\n当前版本存放在哪里； 旧版本存放在哪里； 查询时如何找到对当前事务可见的版本； 旧版本如何被清理与回收。 不同的数据结构与存储引擎，本质上就是对这四个问题给出了不同答案。\n2. B+Tree 与 MVCC 2.1 总体思路 在传统基于 B+Tree 的事务型存储引擎中，最典型的设计是：\nB+Tree 负责定位记录，MVCC 负责决定当前事务应该看到哪个版本。\n其核心特点是：\nB+Tree 中主要维护当前版本 旧版本不直接存放在 B+Tree 中 旧版本通过 undo/version chain 保存 查询时先找到最新版本，不可见时再向历史版本回退 这是一种典型的“当前态主存 + 历史版本外置”方案。\n2.2 数据组织方式 这种方案通常可以抽象为如下结构：\nB+Tree 叶子节点 -\u0026gt; 当前记录 -\u0026gt; 版本元数据（如事务 ID、删除标记等） -\u0026gt; 指向旧版本的指针 -\u0026gt; undo record 1 -\u0026gt; undo record 2 -\u0026gt; undo record 3 在这种结构下：\nB+Tree 叶子页中存放的是当前记录； 当前记录上带有一些隐藏的版本信息； 当前记录通过一个指针与旧版本链连接； 历史版本通常位于独立的 undo / version store 区域中。 2.3 查询流程 B+Tree 与 MVCC 的查询配合过程大致如下：\n第一步：通过 B+Tree 找到当前版本 对于主键点查、范围扫描、二级索引检索等，B+Tree 先定位出当前记录。\n第二步：检查当前版本是否可见 系统根据当前记录携带的版本信息，与事务的快照规则进行比较，判断该版本是否对当前事务可见。\n第三步：必要时沿版本链向后回退 如果当前版本不可见，则根据记录中的指针找到 undo 中的上一个版本，在内存中恢复旧版本，再继续做可见性判断。\n这个过程会持续进行，直到：\n找到第一个可见版本； 或者版本链结束。 2.4 这种设计的优点 1. 当前版本访问快 由于 B+Tree 中主要只维护当前版本，因此：\n索引结构更紧凑； 页利用率更高； 缓存命中率更好； 范围扫描性能更稳定。 2. 多版本成本按需支付 并不是所有查询都要访问历史版本。只有在快照读或旧事务读取时，才需要沿版本链回退。\n3. 很适合传统 OLTP 大多数 OLTP 系统读的都是最新已提交值，因此把主索引优化为“面向当前态”非常合理。\n2.5 这种设计的代价 1. 历史版本读取可能较慢 如果某条记录被频繁更新，而又存在较老的事务快照，那么读取旧版本时可能需要回退很多步。\n2. 需要额外的 purge / GC 历史版本不能永久存在，需要在确认不再被任何活跃事务访问后才能清理。\n3. 长事务会阻碍版本清理 只要旧快照仍然存活，对应的旧版本就不能回收，容易导致 undo 膨胀。\n2.6 适用场景 B+Tree + undo/version chain 的方案更适合：\n传统关系型 OLTP； 大量点查和范围查询； 二级索引访问频繁； 读请求多数面向当前值； 希望主索引保持紧凑的系统。 2.7 小结 B+Tree 与 MVCC 的典型结合方式是：主索引维护当前版本，历史版本外置到 undo/version chain，查询时先查当前版本，不可见再回退。\n3. LSM-Tree 与 MVCC 3.1 总体思路 对于基于 LSM-Tree 的存储系统，MVCC 的常见实现方式与 B+Tree 明显不同。\n这类系统通常不是：\n主存只保存最新版本； 历史版本放在单独的 undo 链中； 而更常见的是：\n把多个版本直接存入底层 LSM/KV 存储中，版本本身就是存储结构的一部分。\n也就是说，LSM 路线更接近：\n同一个逻辑 key 对应多个物理版本； 不同版本通过时间戳或版本号区分； 查询时直接从多个版本中选择一个可见版本。 3.2 为什么 LSM 天然适合多版本 LSM-Tree 的基本特点是：\n写入偏追加； 不强调原地更新； 倾向于顺序写与后台合并； 通过 compaction 整理数据。 而 MVCC 恰好需要“保留旧版本、写入新版本”。 因此对于 LSM 来说，一个非常自然的做法就是：\n不覆盖旧值； 直接写入一个新版本； 旧版本先保留； 后续由 GC / compaction 清理。 所以在 LSM 中，“多版本直接进入主存储层”是非常顺手的设计。\n3.3 数据组织方式 可以将 LSM 中的多版本结构抽象为：\nk@12 -\u0026gt; value_v12 k@9 -\u0026gt; value_v9 k@5 -\u0026gt; value_v5 其中：\nk 表示逻辑 key； @12、@9、@5 表示不同版本时间戳。 这样，同一个 key 的多个版本会在排序上彼此相邻或便于遍历。\n3.4 查询流程 LSM 中的 MVCC 查询流程通常可以概括为：\n第一步：定位到该 key 的版本集合 通过 LSM 的查找与迭代机制，找到同一个 user key 的多个版本。\n第二步：根据读时间戳选择可见版本 从这些版本中选出满足：\n版本时间戳不大于当前读时间； 且满足事务快照规则； 的那个版本。\n第三步：返回该版本 因此，LSM 中读取历史版本通常不是“回退构造”，而是“从已有多个版本中选一个”。\n3.5 这种设计的优点 1. 版本管理天然适配分布式事务 如果系统本身就是基于时间戳推进事务，那么多版本直接做进 KV 存储层非常自然。\n2. 历史读与时间旅行查询更方便 由于历史版本本来就存在主存储中，因此：\nstale read； 历史快照读； CDC； time-travel query； 都会更自然。\n3. 更新路径简单 更新不需要像页式引擎那样维护外部 undo 回退链，而是直接写新版本即可。\n3.6 这种设计的代价 1. 多版本扫描会带来读放大 如果某个 key 版本很多，读取时可能需要检查多个历史版本。\n2. 更依赖 GC 与 compaction 旧版本长期积累会带来明显的空间与查询代价，因此垃圾回收与压缩合并非常关键。\n3. 热点 key 更容易产生 MVCC amplification 高频更新的单行或单 key 会积累大量历史版本，影响读取效率。\n3.7 适用场景 LSM + 多版本的方案更适合：\n分布式 KV / NewSQL； 基于时间戳的事务系统； stale read / snapshot read 使用频繁； 追加写多、更新频繁； 系统能接受依赖 compaction 与版本 GC 的架构。 3.8 小结 LSM-Tree 与 MVCC 的典型结合方式是：多个版本直接存入 LSM/KV，查询时按时间戳选择可见版本，而不是依赖外部 undo 链回退。\n4. Bw-Tree 与 MVCC 4.1 一个容易混淆的前提 Bw-Tree 中有一个非常重要的概念：delta chain。\n但必须明确：\nBw-Tree 的 delta chain 通常不是 MVCC 版本链。\nBw-Tree 中的 delta chain 主要用于描述索引节点本身的增量更新，例如：\ninsert； delete； split； merge； 节点状态变更。 它体现的是：\n索引节点结构的演化过程 而不是：\n记录内容版本的演化过程 因此，Bw-Tree 中“节点 delta 链”和“事务版本链”是两个不同层面的概念。\n4.2 Bw-Tree 与 MVCC 的自然分工 Bw-Tree 更适合作为一种：\n高并发； latch-free； 节点不原地更新； 基于 mapping table 与 CAS 的索引结构。 而 MVCC 更关注的是：\n记录级别的多版本； 事务时间戳可见性； 旧版本回收。 所以 Bw-Tree 与 MVCC 最自然的组合方式通常是：\nBw-Tree 负责索引，记录层负责多版本。\n换句话说：\nBw-Tree 叶子节点中通常存放的是记录指针； 记录本身是多版本对象； 查询时先通过 Bw-Tree 找到记录，再根据版本时间戳选择可见版本。 4.3 数据组织方式 可以抽象为如下结构：\nBw-Tree -\u0026gt; leaf item -\u0026gt; pointer to versioned record -\u0026gt; version 1 [begin, end) -\u0026gt; version 2 [begin, end) -\u0026gt; version 3 [begin, end) 其中：\nbegin 和 end 表示该版本的有效时间区间； 一个读事务只需检查其读时间戳是否落入该区间。 判断规则通常可以抽象为：\nbegin \u0026lt;= read_ts \u0026lt; end 4.4 查询流程 Bw-Tree 与 MVCC 结合后的查询过程通常为：\n第一步：通过 Bw-Tree 定位到候选记录 Bw-Tree 提供有序索引能力与高并发更新能力。\n第二步：根据记录版本元数据判断可见性 记录层维护多个版本，每个版本带有 begin/end timestamp 或等价的版本信息。\n第三步：返回对当前事务可见的版本 查询不一定需要像 undo 链那样逐层“回退恢复”，而是直接在记录层的多版本中判断可见性。\n4.5 这种设计的优点 1. 索引层与事务层职责清晰 Bw-Tree 负责高并发索引管理； 记录层负责版本管理与可见性控制。 2. 适合内存优化设计 Bw-Tree 非常强调 latch-free / lock-free 风格，与内存优化引擎结合自然。\n3. 避免将 MVCC 强行塞入索引节点更新链 索引的节点增量更新与记录版本历史分层管理，结构更加清晰。\n4.6 这种设计的代价 1. 系统实现复杂度较高 需要同时管理：\n索引层的 delta chain； 记录层的版本对象； 版本 GC； 节点 consolidation。 2. 更适合特定体系结构 Bw-Tree 并不是传统磁盘页式 OLTP 的主流选择，它更多出现在特定的高并发内存优化场景中。\n4.7 工业界代表 Bw-Tree 与 MVCC 结合的典型工业实现是：\nMicrosoft SQL Server In-Memory OLTP（Hekaton） 其设计特点可以总结为：\nBw-Tree 用作范围索引； 记录层维护多版本； 更新创建新版本； 可见性通过 begin/end timestamp 判断； 旧版本由后台 GC 清理。 因此，Hekaton 更接近：\nBw-Tree + row-version MVCC\n而不是：\nBw-Tree + undo 链\n4.8 小结 Bw-Tree 与 MVCC 的典型结合方式是：Bw-Tree 只做高并发索引，记录层负责多版本，查询时先通过索引找到记录，再根据版本时间戳判断可见性。\n5. 三种路线的本质对比 5.1 从“旧版本存放位置”看 B+Tree 当前版本在主索引 / 主记录中； 旧版本在 undo / version chain 中。 LSM-Tree 当前版本与旧版本都直接存在 LSM/KV 主存储中； 版本通过时间戳区分。 Bw-Tree Bw-Tree 主要负责索引； 旧版本保存在记录层的多版本对象中。 5.2 从“读取旧版本方式”看 B+Tree 先定位当前版本； 当前版本不可见时，沿 undo 链回退。 LSM-Tree 直接在多个已存储版本中，按时间戳选一个可见版本。 Bw-Tree 先通过索引找到记录； 再在记录层依据 begin/end timestamp 选择版本。 5.3 从“设计哲学”看 B+Tree 更像是：\n当前态主存 + 历史外置\nLSM-Tree 更像是：\n历史版本本来就是存储层的一部分\nBw-Tree 更像是：\n索引层和版本层分离，索引负责定位，版本层负责 MVCC\n6. 应用场景对比 6.1 B+Tree + undo/version chain 更适合：\n传统关系型 OLTP； 高效点查、范围扫； 二级索引密集使用； 大量查询读取当前值。 6.2 LSM + 多版本 key 更适合：\n分布式 KV / NewSQL； 时间戳驱动的事务模型； 历史读、stale read、CDC 需求较多； 高写入吞吐与追加写友好场景。 6.3 Bw-Tree + row-version 更适合：\n内存优化存储引擎； 高并发索引更新； latch-free / lock-free 风格实现； 索引与事务版本管理分层的体系。 7. 最终总结 从本质上看，B+Tree、LSM-Tree、Bw-Tree 与 MVCC 的结合方式可以概括为：\nB+Tree 主索引维护当前版本，旧版本外置，查询时先查当前版本，不可见再回退。\nLSM-Tree 多个版本直接进入存储层，查询时按时间戳选择一个可见版本。\nBw-Tree Bw-Tree 负责高并发索引，记录层维护多版本，查询时通过索引定位后再做版本可见性判断。\n如果只记一个总判断，可以记为：\nB+Tree 强调“当前态索引 + 历史外置”，LSM 强调“多版本就是主存储的一部分”，Bw-Tree 强调“索引层与版本层分离”。\n8. 延伸思考 理解各类索引结构与 MVCC 的结合方式，不仅仅是为了回答\u0026quot;旧版本存在哪里\u0026quot;这一个问题，而是帮助我们建立对存储引擎整体设计的直觉：\n写入路径决定了版本的存放形式——原地更新的 B+Tree 需要外置 undo，追加写的 LSM 可以直接将多版本落入主存储； 读取路径决定了可见性判断的代价——undo 链的逐步回退 vs. 多版本的一次定位选择； GC 策略决定了旧版本何时能被安全清理——长事务与 GC 之间的张力在三种方案中都必须面对。 不同的工程取舍背后，其实都是对同一组核心问题的不同回答。在设计或评估一个存储引擎时，从这四个维度出发往往能很快抓住其 MVCC 实现的本质。\n","permalink":"https://yinit.github.io/%E7%B4%A2%E5%BC%95%E7%BB%93%E6%9E%84%E4%B8%8E-mvccb-treelsm-tree-%E4%B8%8E-bw-tree-%E7%9A%84%E8%AE%BE%E8%AE%A1%E5%AF%B9%E6%AF%94/","summary":"从版本存储位置、读取方式与设计哲学三个维度，对比 B+Tree、LSM-Tree 与 Bw-Tree 在 MVCC 实现中的异同","title":"索引结构与 MVCC：B+Tree、LSM-Tree 与 Bw-Tree 的设计对比"},{"content":" 适用场景：异地远程连接完全隔离的内网服务器及 Docker 容器进行开发。\n1. 方案核心概念 1.1 什么是 SSH 反向隧道？ 通常，外网无法直接访问内网设备（因为内网设备没有公网 IP）。反向隧道的原理是：让内网设备（主动方）去连接公网服务器（被动方），并在两者之间建立一条加密通道。公网服务器就像一个中转站，我们将请求发送给公网服务器，它通过这条现成的通道将请求“反向”转发给内网设备。\n1.2 为什么选择这个架构？ 低成本：无需购买昂贵的商业内网穿透服务，仅需一台普通的云服务器。 高安全 (ProxyJump)：本方案采用 ProxyJump 模式。公网服务器不再对外开放任何业务端口（如旧方案的 8022），端口仅监听在本地回环地址 (127.0.0.1)。黑客扫描公网 IP 无法发现任何入口，只有通过 SSH 验证后的用户才能“跳”进隧道。 稳定性 (Autossh)：原生 SSH 连接容易因网络波动断开，autossh 是一个监控程序，能自动检测连接状态并在断线时毫秒级重连，实现无人值守。 2. 公网服务器配置 目标：创建一个权限受限的专用账户，用于接收内网的连接请求，防止 Root 权限泄露。\n2.1 创建隧道专用用户 不要使用 root 建立隧道。登录公网服务器（root），执行以下命令：\n# 1. 创建用户 tunnel_user，并创建家目录 useradd -m -s /bin/bash tunnel_user # 2. 切换到该用户，创建 ssh 目录 su - tunnel_user mkdir ~/.ssh chmod 700 ~/.ssh touch ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys 2.2 安全配置 SSHD 编辑公网服务器的 SSH 配置文件，启用网关功能（虽然我们绑定 127.0.0.1，但此选项仍需开启以允许转发）。\n# 切回 root 权限 exit sudo vim /etc/ssh/sshd_config 确保以下配置存在：\nGatewayPorts yes AllowTcpForwarding yes 重启 SSH 服务：\nsudo systemctl restart sshd 3. 内网服务器配置 (部署 Autossh) 目标：配置内网服务器主动连接公网，并注册为系统服务实现开机自启。\n3.1 准备工作：免密登录配置 在内网服务器上，将用户的公钥复制到公网服务器的 tunnel_user 中。\n# 在内网服务器上执行 (假设当前用户是 tkk) # 如果没有密钥，先生成：ssh-keygen -t rsa # 将公钥发送到公网服务器 (需要输入公网 tunnel_user 的密码，或者手动复制内容) # ssh-copy-id tunnel_user@\u0026lt;公网IP\u0026gt; 验证：尝试 ssh tunnel_user@\u0026lt;公网IP\u0026gt;，如果不需要密码直接登录，即成功。\n3.2 安装 Autossh sudo apt update sudo apt install autossh 3.3 编写 Systemd 服务文件 (宿主机) 创建服务文件，使隧道常驻后台。\nsudo vim /etc/systemd/system/autossh-host.service 配置内容 (核心修改点：绑定 127.0.0.1)：\n[Unit] Description=AutoSSH Tunnel for Host (Secure Loopback) After=network-online.target [Service] # 内网服务器的实际操作用户 User=tkk # 启动命令核心参数解析： # -M 0: 关闭 autossh 自带的监控端口(使用 SSH 原生心跳) # -N: 不执行远程命令 # -R 127.0.0.1:50022:localhost:22 # -\u0026gt; 127.0.0.1: 强制公网服务器只在本地监听，禁止外网直接连 # -\u0026gt; 50022: 公网服务器上的映射端口 (宿主机入口) # -\u0026gt; localhost:22: 流量转发到内网本机的 22 端口 ExecStart=/usr/bin/autossh -M 0 -o \u0026#34;ServerAliveInterval 30\u0026#34; -o \u0026#34;ServerAliveCountMax 3\u0026#34; -N -R 127.0.0.1:50022:localhost:22 tunnel_user@\u0026lt;公网IP\u0026gt; -i /home/tkk/.ssh/id_rsa # 失败重启策略 Restart=always RestartSec=10 [Install] WantedBy=multi-user.target 3.4 启动并验证服务 # 重载配置 sudo systemctl daemon-reload # 启动服务 sudo systemctl start autossh-host # 设置开机自启 sudo systemctl enable autossh-host # 查看状态 sudo systemctl status autossh-host 若状态为 active (running)，则隧道建立成功。\n4. Docker 容器的远程开发配置 目标：让 Docker 容器也能像独立服务器一样被远程连接。\n4.1 容器内准备 (Dockerfile 或 手动) 确保你的开发容器安装了 openssh-server 并运行中。\n# 进入容器安装 apt-get update \u0026amp;\u0026amp; apt-get install -y openssh-server mkdir /var/run/sshd # 设置 root 密码或者配置密钥 (推荐密钥) 4.2 为 Docker 配置独立的 Autossh 服务 无需在容器内跑 Autossh，直接在内网宿主机上跑第二个 Autossh 服务，指向容器映射到宿主机的端口。\n假设：Docker 容器的 22 端口映射到了宿主机的 22334 端口。\n创建新文件 /etc/systemd/system/autossh-docker-dev.service：\n[Unit] Description=AutoSSH Tunnel for Docker Dev After=network-online.target [Service] User=tkk # 核心区别：将流量转发到 localhost:22334 (容器映射端口) # 公网映射端口改为 50023 (避免冲突) ExecStart=/usr/bin/autossh -M 0 -o \u0026#34;ServerAliveInterval 30\u0026#34; -o \u0026#34;ServerAliveCountMax 3\u0026#34; -N -R 127.0.0.1:50023:localhost:22334 tunnel_user@\u0026lt;公网IP\u0026gt; -i /home/tkk/.ssh/id_rsa Restart=always RestartSec=10 [Install] WantedBy=multi-user.target 启动该服务：sudo systemctl enable --now autossh-docker-dev\n5. 本地开发环境配置 (VS Code) 目标：配置 VS Code 实现一键无感连接，就像连接本地虚拟机一样。\n5.1 客户端 SSH 配置 在你的笔记本（Windows/Mac）上，打开 SSH 配置文件 ~/.ssh/config。\n# ------------------------------ # 1. 跳板机配置 (公网服务器) # ------------------------------ Host aliyun-jump HostName \u0026lt;公网IP\u0026gt; User root # 你平时管理云服务器的账号 Port 22 # 云服务器 SSH 端口 IdentityFile ~/.ssh/id_rsa_laptop # 笔记本连云服务器的私钥 # ------------------------------ # 2. 内网宿主机 (目标 1) # ------------------------------ Host dev-host HostName 127.0.0.1 # 必须填 127.0.0.1 User tkk # 内网服务器用户名 Port 50022 # 对应 autossh-host 服务映射的端口 ProxyJump aliyun-jump # 核心：通过跳板机跳转 IdentityFile ~/.ssh/id_rsa_laptop # ------------------------------ # 3. Docker 开发环境 (目标 2) # ------------------------------ Host dev-docker HostName 127.0.0.1 User root # 容器内的用户名 Port 50023 # 对应 autossh-docker 服务映射的端口 ProxyJump aliyun-jump IdentityFile ~/.ssh/id_rsa_laptop 5.2 连接使用 打开 VS Code。 安装插件 Remote - SSH。 点击左侧“远程资源管理器”。 右键点击 dev-host 或 dev-docker -\u0026gt; \u0026ldquo;Connect in Current Window\u0026rdquo;。 VS Code 会自动穿过公网，跳进隧道，连接到你的内网环境。 6. 安全性检查清单 为了保证这套系统坚不可摧，请务必检查以下安全项：\n公网防火墙 (Security Group)： 阿里云安全组仅需放行 22 端口（公网 SSH）。 严禁放行 50022、50023 等映射端口（因为它们绑定的是 127.0.0.1，放行了也没用，反而混淆视听）。 公网用户权限： 确认 /home/tunnel_user/.ssh 目录权限为 700。 确认 /home/tunnel_user/.ssh/authorized_keys 权限为 600。 确认所有者为 tunnel_user。 内网 SSH 安全： 建议在内网服务器 /etc/ssh/sshd_config 中设置 PasswordAuthentication no（禁止密码登录），仅允许密钥登录，防止隧道被攻破后遭到爆破。 7. 故障排查速查表 现象 可能原因 检查命令 autossh 服务启动失败 密钥路径错误或权限不足 journalctl -u autossh-host -f VS Code 连接超时 公网服务器 GatewayPorts 未开 公网执行 cat /etc/ssh/sshd_config 提示 Permission denied tunnel_user 目录权限错误 公网执行 ls -la /home/tunnel_user/.ssh 连接时提示 Host key verification failed 主机指纹变更 本地执行 ssh-keygen -R [127.0.0.1]:50022 隧道频繁断开 阿里云防火墙切断了空闲连接 检查 autossh -o ServerAliveInterval 参数 ","permalink":"https://yinit.github.io/%E8%BF%9C%E7%A8%8B%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83%E9%85%8D%E7%BD%AE/","summary":"异地远程连接完全隔离的内网服务器及 Docker 容器进行开发","title":"远程开发环境配置"},{"content":"1. 概述与背景 1.1 Project2 整体目标 Project2 要求在一个分布式 KV 存储系统中实现基于 Raft 协议的强一致性复制。整个 Project 分为三个递进的子部分：\n子部分 目标 核心文件 Project2A 实现基础 Raft 算法（选举、日志复制） raft/raft.go, raft/log.go, raft/rawnode.go Project2B 在 Raft 之上构建容错 KV 服务 kv/raftstore/peer_msg_handler.go, kv/raftstore/peer_storage.go Project2C 添加日志 GC 和快照支持 同上，扩展快照处理 1.2 整体架构分层 ┌─────────────────────────────────────────────┐ │ Client │ └─────────────────┬───────────────────────────┘ │ RaftCmdRequest (gRPC) ┌─────────────────▼───────────────────────────┐ │ RaftStore / PeerMsgHandler │ ← peer 层（Project2B） │ proposeRaftCommand() HandleRaftReady() │ └─────────────────┬───────────────────────────┘ │ Propose / Ready ┌─────────────────▼───────────────────────────┐ │ RawNode │ ← raft 层接口（Project2A） │ Tick() Step() Ready() Advance() │ └─────────────────┬───────────────────────────┘ │ ┌─────────────────▼───────────────────────────┐ │ Raft 状态机 │ ← raft 核心（Project2A） │ 选举 日志复制 角色转换 消息处理 │ └─────────────────┬───────────────────────────┘ │ ┌─────────────────▼───────────────────────────┐ │ RaftLog │ ← 日志内存缓存（Project2A） │ entries[] committed applied stabled │ └─────────────────┬───────────────────────────┘ │ ┌─────────────────▼───────────────────────────┐ │ PeerStorage（Storage 接口实现） │ ← 持久化层（Project2B） │ raftDB（badger） │ │ kvDB（badger） │ └─────────────────────────────────────────────┘ 1.3 关键设计思想 TinyKV 的 Raft 实现深度参考了 etcd 的 Raft 库，核心设计思想如下：\n状态机化：整个 Raft 被设计为一个纯函数状态机，从一端输入消息（Step），从另一端输出结果（Ready），全程线性无并发。 关注点分离：Raft 模块只负责逻辑判断，持久化、网络收发全部交给上层（RawNode + PeerStorage）处理。 Pull 模式输出：Raft 不主动推送结果给上层，而是由上层定期轮询 HasReady() 拉取 Ready。 2. Raft 算法核心知识点 2.1 基础概念 2.1.1 节点角色 Raft 集群中每个节点处于以下三种角色之一：\n角色 说明 Leader 集群唯一领导者，负责接收客户端请求、复制日志 Follower 被动响应，不发起请求，只处理 Leader/Candidate 的 RPC Candidate 候选人，Leader 不可用时发起选举 状态转换规则（对应 raft.go 中的 becomeFollower/becomeCandidate/becomeLeader）：\n启动时默认 Follower Follower 选举超时 → Candidate Candidate 获得多数票 → Leader Candidate/Leader 发现更高 term → Follower 2.1.2 任期（Term） 任期是严格单调递增的整数，逻辑时钟意义 每次选举任期加 1 节点收到更高 term 的消息时立即更新自己的 term 并转为 Follower Term 用于识别过期信息：任何携带旧 term 的消息都会被拒绝 // raft.go - becomeFollower func (r *Raft) becomeFollower(term uint64, lead uint64) { r.State = StateFollower r.Term = term r.Vote = lead r.Lead = lead r.electionElapsed = 0 r.heartbeatElapsed = 0 r.leadTransferee = None } 注意：becomeFollower 中 r.Vote = lead，这是 TinyKV 的实现细节。将 Vote 设置为 lead，意味着在新 term 中如果该 Leader 发来 RequestVote（在 Leader Transfer 场景中），可以直接投票。\n2.1.3 日志结构 每条日志条目（Log Entry）包含三个字段：\nmessage Entry { EntryType entry_type = 1; uint64 term = 2; // 生成该日志时 Leader 的任期 uint64 index = 3; // 日志在序列中的位置（全局单调递增） bytes data = 4; // 实际数据（序列化的命令） } 关键约束：\n如果两个节点日志中某位置 \u0026lt;index, term\u0026gt; 相同，则该位置及之前的所有日志完全相同（Log Matching 属性） 这个约束由 AppendEntries 中的 prevLogIndex/prevLogTerm 校验来保证 2.1.4 重要的日志索引指针 所有节点维护：\ncommitted：已被集群多数确认的最高日志 index applied：已经应用到状态机的最高日志 index 不变式：applied \u0026lt;= committed Leader 额外维护（Prs map[uint64]*Progress）：\nnextIndex[i]：下次发给节点 i 的日志起始 index，初始化为 leader 最后日志 index + 1 matchIndex[i]：已确认节点 i 已复制的最高 index，初始化为 0 2.2 Leader 选举 2.2.1 选举触发 Follower 维护一个 electionElapsed 计数器，每次 tick() 加 1。当 electionElapsed \u0026gt;= electionTimeout 时（且未收到 Leader 心跳），触发选举。\n随机化选举超时：为避免多个节点同时选举导致分票，每次重置时 electionTimeout 是随机的：\n// util.go func randElectionTimeout(timeout int) int { n, _ := rand.Int(rand.Reader, big.NewInt(int64(timeout))) return int(n.Int64()) + timeout } 即超时范围是 [ElectionTick, 2*ElectionTick)，有效降低分票概率。\n2.2.2 选举流程 Follower → Candidate：becomeCandidate() 将 term +1，给自己投票，重置计时 广播 MsgRequestVote 给所有其他节点 收集投票结果，存储于 r.votes map[uint64]bool 获得多数票 → becomeLeader()；被多数拒绝 → becomeFollower() // raft.go - becomeCandidate func (r *Raft) becomeCandidate() { r.State = StateCandidate r.Term += 1 r.Vote = r.id // 给自己投票 r.Lead = None r.votes = make(map[uint64]bool) r.votes[r.id] = true if len(r.Prs) == 1 { r.becomeLeader() // 单节点直接成为 Leader return } } 2.2.3 投票规则 节点 B 给 Candidate A 投票的条件（同时满足）：\nA 的 term \u0026gt;= B 的 term B 在当前 term 还没有投过票（或已投给 A） A 的日志不比 B 的日志旧（选举限制） 日志新旧比较规则：\n先比较最后一条日志的 term，term 更大的更新 term 相同时，index 更大（日志更长）的更新 // raft.go - handleRequestVote } else if r.Vote == None { lastIndex := r.RaftLog.LastIndex() lastTerm, _ := r.RaftLog.Term(lastIndex) if m.LogTerm \u0026gt; lastTerm || m.LogTerm == lastTerm \u0026amp;\u0026amp; m.Index \u0026gt;= lastIndex { reject = false r.Vote = m.From } } 选举限制的意义：确保每个当选的 Leader 都拥有当前集群中最完整的已提交日志，防止已提交的日志被覆盖。\n2.2.4 成为 Leader 的初始化 // raft.go - becomeLeader func (r *Raft) becomeLeader() { r.State = StateLeader r.Lead = r.id // 初始化所有节点的进度 for id := range r.Prs { if id == r.id { r.Prs[id].Match = r.RaftLog.LastIndex() } else { r.Prs[id].Match = 0 } r.Prs[id].Next = r.RaftLog.LastIndex() + 1 } r.startPropose() // 提交一条空日志（noop entry） } 重要：Leader 在成为 Leader 后必须立即提交一条 noop entry（空日志）。原因：\nRaft 论文要求：Leader 只能通过当前任期的日志来推进 commit 通过提交一条当前任期的空日志，可以间接提交之前任期的所有未提交日志 这是 Leader Completeness 属性的工程化实现 2.3 日志复制 2.3.1 正常流程 客户端将请求发给 Leader（通过 MsgPropose） Leader 将其追加到本地日志 Leader 广播 MsgAppend 给所有 Follower Follower 检查 prevLogIndex/prevLogTerm，成功则追加日志，返回 MsgAppendResponse（Reject=false） Leader 收到多数成功响应后推进 commitIndex Leader 在下次心跳或 AppendEntries 中携带新的 commitIndex 告知 Follower Follower 更新自己的 commitIndex 2.3.2 AppendEntries 一致性校验 MsgAppend 字段： - Index: prevLogIndex（要追加日志的前一条日志的 index） - LogTerm: prevLogTerm（要追加日志的前一条日志的 term） - Entries: 要追加的日志条目 - Commit: Leader 的 commitIndex Follower 收到 AppendEntries 时的校验逻辑：\n如果 prevLogIndex \u0026gt; lastIndex：reject，表示缺少日志 如果 term(prevLogIndex) != prevLogTerm：reject，表示日志冲突 // log.go - Append func (l *RaftLog) Append(preIndex, preTerm uint64, entries []*pb.Entry) bool { if term, err := l.Term(preIndex); err != nil || term != preTerm { return false } // 去除重复项（跳过已经一致的条目） offset := l.entries[0].Index for len(entries) \u0026gt; 0 { firstIndex := entries[0].Index if firstIndex \u0026gt; l.LastIndex() || l.entries[firstIndex-offset].Term != entries[0].Term { break } entries = entries[1:] } // 截断冲突条目并追加 if len(entries) \u0026gt; 0 { l.entries = l.entries[:entries[0].Index-offset] l.stabled = min(l.stabled, l.LastIndex()) for len(entries) \u0026gt; 0 { l.entries = append(l.entries, *entries[0]) entries = entries[1:] } } return true } 2.3.3 日志回退优化 当 Follower 拒绝后，Leader 需要找到与 Follower 日志一致的最后位置（nextIndex 回退）。\n朴素方法：每次 reject 后 nextIndex\u0026ndash;，逐步回退（O(n) 次 RPC）\n优化方法（TinyKV 实现）：Follower 在 AppendResponse 中携带 Commit 字段（当前 committedIndex），Leader 直接将 nextIndex 设置为 Commit + 1：\n// raft.go - handleAppendResponse if m.Reject { r.Prs[m.From].Next = m.Commit + 1 r.Prs[m.From].Match = m.Commit r.sendAppend(m.From) } 这里使用 committed 作为回退点，因为已提交的日志一定是一致的，这样可以快速找到一致点。\n2.3.4 Commit 推进算法 Leader 收到多数成功 AppendResponse 后需要推进 commitIndex。\n条件：存在 N \u0026gt; commitIndex，使得多数节点的 matchIndex[i] \u0026gt;= N，且 log[N].term == currentTerm\nTinyKV 中使用差分数组加速：\n// raft.go - checkCommit func (r *Raft) checkCommit() { if _, exist := r.Prs[r.id]; !exist { return } arr := make([]uint64, r.Prs[r.id].Next-r.RaftLog.committed) for _, p := range r.Prs { if p.Match \u0026gt;= r.RaftLog.committed { arr[p.Match-r.RaftLog.committed]++ } } for i, n := len(arr)-1, 0; i \u0026gt; 0; i-- { n += int(arr[i]) if n \u0026gt; len(r.Prs)/2 { r.RaftLog.committed = r.RaftLog.committed + uint64(i) r.sendAllAppend() break } } } 算法思路：\narr[i] 表示 matchIndex 恰好为 committed + i 的节点数量 从右往左累加，找到第一个累计数超过半数的位置 这个位置就是新的 commitIndex（前提是该位置的日志 term 必须等于 currentTerm） 注意：必须检查 log[N].term == currentTerm。这是安全性要求，防止提交\u0026quot;前任期日志\u0026quot;带来的问题（详见 2.4.2）。\n2.4 安全性 2.4.1 选举安全性 保证：每个任期内最多只有一个 Leader。\n实现机制：\n每个节点在一个 term 内只能投一票（r.Vote 记录） 需要获得多数票（\u0026gt; len(Prs)/2）才能成为 Leader 由于多数集合有交集，两个不同的 Candidate 不可能同时获得多数票 2.4.2 为何不能直接提交前任期日志 考虑如下场景（论文 Figure 8）：\n时刻 a: S1 是 Leader，index=2 写入 term=2 的日志，复制到 S2 时刻 b: S1 崩溃，S5 成为 Leader(term=3)，在 index=2 写入 term=3 的日志 时刻 c: S5 崩溃，S1 重新成为 Leader(term=4)，将 term=2 的日志复制到多数节点 时刻 d1: S1 崩溃，S5 成为 Leader，覆盖了多数节点的 index=2 日志 时刻 d2: S1 不崩溃，通过 term=4 的日志间接提交了 index=2 的日志 结论：即使 term=2 的日志已经复制到多数节点（时刻 c），仍然可能被覆盖（时刻 d1）。因此 不能仅凭\u0026quot;复制到多数节点\u0026quot;就提交前任期的日志。\n正确做法：只通过当前任期的日志来推进 commit。一旦当前任期的日志提交了，其之前的所有日志（包括前任期）也随之提交。\n这正是 becomeLeader 中提交 noop entry 的原因。\n2.4.3 Leader 完备性（Leader Completeness） 保证：如果一条日志在某任期被提交，则所有更高任期的 Leader 都包含这条日志。\n实现：通过选举限制（投票时检查日志新旧）来保证。只有日志不比多数节点旧的 Candidate 才能当选，而已提交的日志必然存在于多数节点中，因此新 Leader 一定包含所有已提交的日志。\n2.5 集群成员变更 TinyKV 采用论文推荐的单步成员变更（每次只添加或删除一个节点），确保任何时刻不存在两个独立的多数集合。\n在 TinyKV Project3A 中通过 ConfChange 消息实现：\nConfChangeType_AddNode：调用 r.addNode(id) ConfChangeType_RemoveNode：调用 r.removeNode(id) 删除节点后，如果是当前节点自身被删除，触发 destroyPeer()。\n2.6 日志压缩与快照 随着时间增长，日志会无限增大。快照（Snapshot）将状态机的某个时刻状态压缩存储，并丢弃该时刻之前的日志。\n快照包含：\n最后一条被压缩的日志的 \u0026lt;index, term\u0026gt;（用于 AppendEntries 的前缀检查） 状态机在该时刻的完整状态（KV 数据） 集群配置信息（ConfState） 触发时机：当 Leader 发现 Follower 所需的日志已经被压缩时（nextIndex \u0026lt; firstIndex），发送快照而非日志。\n3. etcd Raft 工程化设计 3.1 核心设计：与应用层的解耦 etcd Raft 库（TinyKV 的参考实现）将 Raft 协议逻辑与应用层完全解耦。Raft 库本身不进行：\n网络通信（不发送 RPC） 磁盘写入（不持久化） 状态机执行（不应用日志） 所有这些操作都通过 Ready 结构体 交给应用层处理。\n3.2 Ready 结构体设计 // rawnode.go type Ready struct { *SoftState // 易失性状态（Lead, RaftState），仅用于通知上层状态变化 pb.HardState // 持久性状态（Term, Vote, Commit），需要持久化 Entries []pb.Entry // 需要持久化的日志条目（unstable entries） Snapshot pb.Snapshot // 需要应用的快照 CommittedEntries []pb.Entry // 可以应用到状态机的日志 Messages []pb.Message // 需要发送给其他节点的消息 } 设计哲学：\nHardState 和 Entries 必须在发送 Messages 之前持久化，否则崩溃重启后可能不一致 CommittedEntries 是已经被持久化过的日志，可以安全应用 SoftState 不需要持久化，只是通知上层有状态变化 上层处理 Ready 的标准顺序（非常重要）：\n如果有 Snapshot，先应用快照 将 Entries 写入持久化存储（WAL） 将 HardState 写入持久化存储 发送 Messages 给其他节点 应用 CommittedEntries 到状态机 调用 Advance() 通知 Raft 库处理完毕 3.3 Storage 接口抽象 // storage.go - Storage 接口 type Storage interface { InitialState() (pb.HardState, pb.ConfState, error) Entries(lo, hi uint64) ([]pb.Entry, error) Term(i uint64) (uint64, error) LastIndex() (uint64, error) FirstIndex() (uint64, error) Snapshot() (pb.Snapshot, error) } Storage 代表已经持久化到磁盘的数据视图。Raft 库通过 Storage 接口读取已持久化的日志，但不通过 Storage 写入（写入由上层负责）。\n在 TinyKV 中：\n测试时使用 MemoryStorage（内存实现） 生产中由 PeerStorage 实现（基于 badger） 3.4 三层数据管理 etcd Raft 的数据分三层管理：\n┌─────────────────────────────────────┐ │ unstable（raft 库内存中） │ │ - 尚未持久化的日志 │ │ - 待应用的快照 │ └───────────────┬─────────────────────┘ │ 持久化后 ┌───────────────▼─────────────────────┐ │ Storage（应用层管理的持久化数据） │ │ - 已持久化但未压缩的日志 │ │ - 最新的快照 │ └─────────────────────────────────────┘ TinyKV 简化了这个设计，将 unstable 和 stable 的日志都存在 RaftLog.entries[] 中，用 stabled 指针区分。\n3.5 Pipeline 异步设计 etcd Raft 支持 Pipeline：不等待上一批日志提交就继续发送下一批。这是通过 nextIndex 的乐观更新实现的：Leader 发送日志后立即更新 nextIndex，不等待响应就发送下一批。\nTinyKV 实现中也有类似思路：sendAllAppend() 在 commit 推进后立即广播新的 commitIndex，无需等待。\n3.6 与 TinyKV 实现的差异 特性 etcd Raft TinyKV Heartbeat 与 AppendEntries 合并 独立的 MsgHeartbeat unstable/stable 日志 分开存储 混合存储（用 stabled 指针区分） Progress 状态 Probe/Replicate/Snapshot 三态 简化，只有 Match/Next ReadIndex 完整实现 未实现（可选优化） PreVote 支持 未实现（2A 测试不允许） 4. TinyKV Raft 层实现分析（Project2A） 4.1 RaftLog（log.go） RaftLog 是 Raft 模块内部的日志内存缓存，负责管理所有未被 compact 的日志条目。\n4.1.1 数据结构 // log.go type RaftLog struct { storage Storage // 已持久化数据的视图（只读） committed uint64 // 已被多数节点确认的最高 index applied uint64 // 已应用到状态机的最高 index stabled uint64 // 已持久化到 storage 的最高 index entries []pb.Entry // 所有未被 compact 的日志（含持久化和非持久化） pendingSnapshot *pb.Snapshot // 待处理的快照（2C 使用） } 核心不变式：\nsnapshot.Index \u0026lt;= firstIndex - 1 \u0026lt;= applied \u0026lt;= committed \u0026lt;= stabled \u0026lt;= lastIndex 实际上在 TinyKV 中，stabled 和 committed 的关系比较微妙：stabled 可能大于或小于 committed，因为持久化和提交是独立操作。\n4.1.2 哨兵节点设计 entries[0] 是一个虚拟哨兵节点（dummy entry），其 Index 和 Term 来自最后一次快照（或初始值 0）。\n为何需要哨兵节点：\nAppendEntries 校验需要检查 prevLog（entries[prevIndex]），当 prevIndex 是最早可用的日志时，需要有一个\u0026quot;前一条\u0026quot;日志 哨兵节点的 Index 表示 \u0026ldquo;compact 截止位置\u0026rdquo;，firstIndex = entries[0].Index + 1 通过哨兵，Term(i) 方法可以均匀处理所有有效 index，包括 truncatedIndex // log.go - newLog func newLog(storage Storage) *RaftLog { firstIndex, _ := storage.FirstIndex() lastIndex, _ := storage.LastIndex() term, _ := storage.Term(firstIndex - 1) // 获取 truncatedTerm entries := make([]pb.Entry, 1) entries[0].Index = firstIndex - 1 // 哨兵节点的 Index = truncatedIndex entries[0].Term = term // 哨兵节点的 Term = truncatedTerm ents, _ := storage.Entries(firstIndex, lastIndex+1) entries = append(entries, ents...) return \u0026amp;RaftLog{ storage: storage, stabled: lastIndex, entries: entries, committed: firstIndex - 1, applied: firstIndex - 1, } } 4.1.3 关键方法实现 LastIndex()：返回最后一条日志的 index\nfunc (l *RaftLog) LastIndex() uint64 { return l.entries[0].Index + uint64(len(l.entries)) - 1 // 哨兵的 Index + 总条数 - 1（哨兵本身） } FirstIndex()：返回第一条有效日志的 index\nfunc (l *RaftLog) FirstIndex() uint64 { return l.entries[0].Index + 1 // 哨兵 Index + 1 } Term(i)：返回 index i 的日志 term\nfunc (l *RaftLog) Term(i uint64) (uint64, error) { offset := l.entries[0].Index if i \u0026lt; offset { return 0, ErrCompacted } if int(i-offset) \u0026gt;= len(l.entries) { return 0, ErrUnavailable } return l.entries[i-offset].Term, nil } unstableEntries()：返回需要持久化的日志\nfunc (l *RaftLog) unstableEntries() []pb.Entry { offset := l.entries[0].Index return l.entries[l.stabled+1-offset:] // stabled+1 之后的都是未持久化的 } nextEnts()：返回已提交但未应用的日志\nfunc (l *RaftLog) nextEnts() (ents []pb.Entry) { offset := l.entries[0].Index lo := l.applied + 1 - offset hi := l.committed + 1 - offset return l.entries[lo:hi] } 4.1.4 maybeCompact：日志 GC 当上层（PeerStorage）完成 snapshot 后，storage.FirstIndex() 会增大，RaftLog 中旧的条目可以删除：\n// log.go - maybeCompact func (l *RaftLog) maybeCompact() { if FirstIndex, _ := l.storage.FirstIndex(); l.FirstIndex() \u0026lt; FirstIndex { l.entries = l.entries[FirstIndex-l.FirstIndex():] l.pendingSnapshot = nil } } 调用时机：在 tick() 中每次调用，确保内存中的 entries 不会无限增长。\n4.1.5 ApplySnapshot：应用快照 // log.go - ApplySnapshot func (l *RaftLog) ApplySnapshot(snap *pb.Snapshot) { l.pendingSnapshot = snap l.entries = make([]pb.Entry, 1) l.entries[0].Index = snap.Metadata.Index // 新的哨兵 l.entries[0].Term = snap.Metadata.Term l.applied = snap.Metadata.Index l.committed = snap.Metadata.Index l.stabled = snap.Metadata.Index } 应用快照后，所有旧的日志条目被清空，以快照的 index 作为新的起点，三个指针都推进到快照的 index。\n4.2 Raft 核心模块（raft.go） 4.2.1 Raft 结构体 type Raft struct { id uint64 Term uint64 // 当前任期（需持久化） Vote uint64 // 当前任期投票给谁（需持久化） RaftLog *RaftLog // 日志模块 Prs map[uint64]*Progress // 每个节点的复制进度（仅 Leader 使用） State StateType // 当前角色 votes map[uint64]bool // 选票记录（Candidate 使用） msgs []pb.Message // 待发送的消息 Lead uint64 // 当前 Leader ID heartbeatTimeout int // 心跳超时（固定，来自 Config.HeartbeatTick） electionTimeout int // 选举超时（随机化） heartbeatElapsed int // 心跳计时 electionElapsed int // 选举计时 leadTransferee uint64 // Leader 迁移目标（3A） PendingConfIndex uint64 // 待应用的配置变更（3A） active map[uint64]bool // 存活节点集合（用于孤岛检测） electionTick int // ElectionTick 基准值（用于随机化） } type Progress struct { Match, Next uint64 } 4.2.2 tick() 逻辑时钟 tick() 是 Raft 的逻辑时钟推进函数，上层每隔固定时间调用一次（通过 RawNode.Tick()）。\n// raft.go - tick func (r *Raft) tick() { r.electionElapsed++ r.RaftLog.maybeCompact() // 顺便检查日志压缩 switch r.State { case StateFollower: if r.electionElapsed \u0026gt;= r.electionTimeout { r.startElection() } case StateCandidate: if r.electionElapsed \u0026gt;= r.electionTimeout { r.startElection() // 超时后重新选举 } case StateLeader: if r.electionElapsed \u0026gt;= r.electionTimeout { r.electionElapsed = 0 r.leadTransferee = None // 检查是否收到足够心跳响应（孤岛检测） activeNum := len(r.active) if activeNum*2 \u0026lt;= len(r.Prs) { r.becomeFollower(r.Term, None) r.RaftLog.pendingSnapshot = nil return } r.active = make(map[uint64]bool) r.active[r.id] = true } r.heartbeatElapsed++ if r.heartbeatElapsed \u0026gt;= r.heartbeatTimeout { r.startBeat() } } } Leader 孤岛检测（TinyKV 特有设计）：\nLeader 维护一个 active map，记录在一个 electionTimeout 周期内收到响应（心跳响应或日志响应）的节点 每个 electionTimeout 周期检查：如果 active 节点数 \u0026lt;= 半数，说明可能被网络隔离（孤岛 Leader） 孤岛 Leader 应立即 becomeFollower，避免在网络分区中继续接受写请求 这解决了网络分区后旧 Leader 持续占用的问题。\n4.2.3 Step() 消息分发 // raft.go - Step func (r *Raft) Step(m pb.Message) error { switch r.State { case StateFollower: r.FollowerStep(m) case StateCandidate: r.CandidateStep(m) case StateLeader: r.LeaderStep(m) } return nil } 注意：TinyKV 的 Step 没有做 term 的统一预处理（etcd 原版会先统一处理 term），而是在每个具体 handler 中分别处理。这需要在每个 handler 中仔细处理 term 比较。\n4.2.4 12 种 MessageType 详解 ① MsgHup（Local Msg，触发选举）\n发送方 接收方 触发条件 上层/tick 自身 选举超时 case pb.MessageType_MsgHup: r.becomeCandidate() r.sendRequestVote() // 广播投票请求 特殊处理：Follower 收到 MsgHup 才发起选举；Leader 收到 MsgHup 忽略（已经是 Leader）。\n② MsgBeat（Local Msg，触发心跳）\n发送方 接收方 触发条件 tick 自身（Leader） 心跳超时 case pb.MessageType_MsgBeat: r.sendHeartbeat() // 向所有 Follower 发送心跳 只有 Leader 处理 MsgBeat；Follower/Candidate 忽略。\n③ MsgPropose（Local Msg，提交新日志）\n发送方 接收方 触发条件 上层（客户端请求） Leader 客户端写请求 // raft.go - handlePropose func (r *Raft) handlePropose(m pb.Message) error { if r.leadTransferee != None { return ErrProposalDropped // 正在迁移 Leader，拒绝 } // 设置 entry 的 index 和 term NextIndex := r.Prs[r.id].Next for i, entry := range m.Entries { entry.Index = NextIndex + uint64(i) entry.Term = r.Term } // 追加到本地日志 lastIndex := r.RaftLog.LastIndex() term, _ := r.RaftLog.Term(lastIndex) r.RaftLog.Append(lastIndex, term, m.Entries) // 更新自身进度 r.Prs[r.id].Next = r.RaftLog.LastIndex() + 1 r.Prs[r.id].Match = r.RaftLog.LastIndex() // 单节点直接提交 if len(r.Prs) == 1 { r.RaftLog.committed = r.RaftLog.LastIndex() } // 广播 AppendEntries r.sendAllAppend() return nil } 非 Leader 节点收到 MsgPropose 时直接返回 ErrProposalDropped（或转发给 Leader）。\n④ MsgAppend（Common Msg，日志复制）\n字段说明：\nIndex: prevLogIndex LogTerm: prevLogTerm Entries: 待追加的日志 Commit: Leader 的 commitIndex 发送：\n// raft.go - sendAppend func (r *Raft) sendAppend(to uint64) bool { index := r.Prs[to].Next - 1 term, err := r.RaftLog.Term(index) if err == ErrCompacted { r.sendSnapshot(to) // 需要的日志已被压缩，发快照 } else { entries, _ := r.RaftLog.Entries(r.Prs[to].Next, r.RaftLog.LastIndex()+1) r.msgs = append(r.msgs, pb.Message{ MsgType: pb.MessageType_MsgAppend, Term: r.Term, From: r.id, To: to, Commit: r.RaftLog.committed, Index: index, LogTerm: term, Entries: entries, }) } return true } 接收处理：\n// raft.go - handleAppendEntries func (r *Raft) handleAppendEntries(m pb.Message) { // 旧 Leader 的消息，拒绝 if m.Term \u0026lt; r.Term || r.State == StateLeader \u0026amp;\u0026amp; m.Term == r.Term { r.sendAppendResponse(m.From, true) return } r.becomeFollower(m.Term, m.From) // 更新 term，确认 Leader reject := !r.RaftLog.Append(m.Index, m.LogTerm, m.Entries) if !reject { // 更新 committed if len(m.Entries) \u0026gt; 0 { r.RaftLog.committed = max(r.RaftLog.committed, min(m.Commit, m.Entries[len(m.Entries)-1].Index)) } else { r.RaftLog.committed = max(r.RaftLog.committed, min(m.Commit, m.Index)) } } r.sendAppendResponse(m.From, reject) } committed 更新逻辑：取 min(m.Commit, 最新追加的日志 Index)，防止 committed 超过自己实际拥有的日志范围。\n⑤ MsgAppendResponse（Common Msg，日志复制响应）\n字段说明：\nReject: 是否拒绝 Index: 成功时为最后追加的 index；拒绝时辅助 Leader 快速定位 Commit: 节点当前 committedIndex（用于 reject 时回退） 处理：\n// raft.go - handleAppendResponse func (r *Raft) handleAppendResponse(m pb.Message) { if m.Term \u0026gt; r.Term { r.becomeFollower(m.Term, None) return } r.active[m.From] = true // 记录该节点活跃 if m.Reject { r.Prs[m.From].Next = m.Commit + 1 // 回退到对方 committed 之后 r.Prs[m.From].Match = m.Commit r.sendAppend(m.From) // 重新发送 } else { r.Prs[m.From].Next = m.Index + 1 r.Prs[m.From].Match = r.Prs[m.From].Next - 1 // 检查是否可以推进 commit if term, _ := r.RaftLog.Term(m.Index); term == r.Term \u0026amp;\u0026amp; m.Index \u0026gt; r.RaftLog.committed { r.checkCommit() } // Leader Transfer 完成检查 if r.leadTransferee == m.From \u0026amp;\u0026amp; r.Prs[m.From].Match == r.RaftLog.LastIndex() { r.sendTimeoutNow(m.From) } } } ⑥ MsgRequestVote（Common Msg，请求投票）\n字段：\nTerm: Candidate 的 term Index: Candidate 最后一条日志的 index LogTerm: Candidate 最后一条日志的 term 处理：\n// raft.go - handleRequestVote func (r *Raft) handleRequestVote(m pb.Message) { if m.Term \u0026gt; r.Term { r.becomeFollower(m.Term, None) } reject := true if m.Term \u0026lt; r.Term || r.State != StateFollower \u0026amp;\u0026amp; m.Term == r.Term { reject = true // term 小或非 Follower 状态 } else if r.Vote == m.From { reject = false // 已经投给他了 } else if r.Vote == None { // 检查日志新旧 lastIndex := r.RaftLog.LastIndex() lastTerm, _ := r.RaftLog.Term(lastIndex) if m.LogTerm \u0026gt; lastTerm || m.LogTerm == lastTerm \u0026amp;\u0026amp; m.Index \u0026gt;= lastIndex { reject = false r.Vote = m.From } } r.sendRequestVoteResponse(m.From, reject) } ⑦ MsgRequestVoteResponse（Common Msg，投票结果）\n// raft.go - handleRequestVoteResponse func (r *Raft) handleRequestVoteResponse(m pb.Message) { if m.Term \u0026gt; r.Term { r.becomeFollower(m.Term, None) return } if _, exists := r.votes[m.From]; exists { return // 已处理过该节点的响应 } r.votes[m.From] = !m.Reject if len(r.votes)*2 \u0026lt;= len(r.Prs) { return // 票数还不够，继续等待 } // 统计票数 argNum, denNum := 0, 0 for _, v := range r.votes { if v { argNum++ } else { denNum++ } } if argNum*2 \u0026gt; len(r.Prs) { r.becomeLeader() } if denNum*2 \u0026gt; len(r.Prs) { r.becomeFollower(m.Term, None) } } 早退出优化：只有当 votes 数量 \u0026gt; 一半时才进行统计，避免频繁统计。\n⑧ MsgHeartbeat（Common Msg，心跳）\n字段：\nTerm: Leader 的 term Commit：在 TinyKV 实现中，心跳携带 min(matchIndex, committed)，让 Follower 推进 committed 处理：\n// raft.go - handleHeartbeat func (r *Raft) handleHeartbeat(m pb.Message) { if m.Term \u0026lt; r.Term || r.State == StateLeader \u0026amp;\u0026amp; m.Term == r.Term { r.sendHeartbeatResponse(m.From) return } r.becomeFollower(m.Term, m.From) r.sendHeartbeatResponse(m.From) } Follower 收到心跳后：重置 electionElapsed（隐含在 becomeFollower 中），回复心跳响应。\n⑨ MsgHeartbeatResponse（Common Msg，心跳响应）\n// raft.go - handleHeartbeatResponse func (r *Raft) handleHeartbeatResponse(m pb.Message) { if m.Term \u0026gt; r.Term { r.becomeFollower(m.Term, None) return } r.active[m.From] = true // 记录活跃（用于孤岛检测） // 如果 Follower 落后，触发日志同步 if r.RaftLog.committed \u0026gt; m.Commit { r.sendAppend(m.From) } } Leader 通过检查 r.RaftLog.committed \u0026gt; m.Commit 来判断 Follower 是否落后，若落后则主动触发 AppendEntries。这解决了网络恢复后 Follower 长时间落后的问题。\n⑩ MsgSnapshot（Common Msg，快照）\n只有 Leader 会发送快照（当 Follower 需要的日志已被压缩时）。\n// raft.go - sendSnapshot func (r *Raft) sendSnapshot(to uint64) { if r.RaftLog.pendingSnapshot == nil { snapshot, err := r.RaftLog.storage.Snapshot() if err != nil { return // 快照尚未生成好，等待下次 } r.RaftLog.pendingSnapshot = \u0026amp;snapshot } r.msgs = append(r.msgs, pb.Message{ MsgType: pb.MessageType_MsgSnapshot, Term: r.Term, From: r.id, To: to, Snapshot: r.RaftLog.pendingSnapshot, }) } Follower 接收快照：\n// raft.go - handleSnapshot func (r *Raft) handleSnapshot(m pb.Message) { if m.Term \u0026lt; r.Term || r.State == StateLeader \u0026amp;\u0026amp; m.Term == r.Term { r.sendAppendResponse(m.From, true) return } r.becomeFollower(m.Term, m.From) reject := false if m.Snapshot == nil || m.Snapshot.Metadata.Index \u0026lt;= r.RaftLog.committed { reject = true // 快照比自己旧 } else { // 更新 Prs（集群配置可能变化） if m.Snapshot.Metadata.ConfState != nil { r.Prs = make(map[uint64]*Progress, len(m.Snapshot.Metadata.ConfState.Nodes)) for _, id := range m.Snapshot.Metadata.ConfState.Nodes { r.Prs[id] = \u0026amp;Progress{} } } r.RaftLog.ApplySnapshot(m.Snapshot) } r.sendAppendResponse(m.From, reject) } ⑪ MsgTransferLeader（Local Msg，Leader 迁移，Project3）\nLeader 收到后：\n设置 leadTransferee = m.From 检查目标节点日志是否最新，若不是则先同步 同步完成后发送 MsgTimeoutNow ⑫ MsgTimeoutNow（Local Msg，强制立即选举，Project3）\n目标节点收到后立即发起选举（忽略 electionElapsed）：\nfunc (r *Raft) handleTimeoutNow() { if _, ok := r.Prs[r.id]; !ok { return // 自己已被移出集群 } r.startElection() } 4.2.5 Progress 与 sendAllAppend sendAllAppend() 在 Leader 写入新日志或推进 commit 后调用，向所有 Follower 广播：\nfunc (r *Raft) sendAllAppend() { r.heartbeatElapsed = 0 // 重置心跳计时（AppendEntries 兼有心跳作用） for id := range r.Prs { if id == r.id { continue } r.sendAppend(id) } } 4.3 RawNode（rawnode.go） RawNode 是 Raft 模块暴露给上层的接口层，负责：\n向 Raft 传入外部消息（Step）和逻辑时钟推进（Tick） 从 Raft 收集处理结果并打包成 Ready 供上层消费 4.3.1 状态记录 type RawNode struct { Raft *Raft softState SoftState // 上次生成 Ready 时的 SoftState 快照 hardState pb.HardState // 上次生成 Ready 时的 HardState 快照 } RawNode 需要缓存上一次的状态，用于 HasReady() 检测是否有变化。\n4.3.2 HasReady() func (rn *RawNode) HasReady() bool { return len(rn.Raft.msgs) \u0026gt; 0 || rn.Raft.RaftLog.stabled != rn.Raft.RaftLog.LastIndex() || rn.Raft.RaftLog.applied != rn.Raft.RaftLog.committed || rn.softState != rn.Raft.getSoftState() || !isHardStateEqual(rn.hardState, rn.Raft.GetHardState()) || rn.Raft.State != StateLeader \u0026amp;\u0026amp; rn.Raft.RaftLog.pendingSnapshot != nil } 判断条件：\n有待发送的消息 有未持久化的日志（stabled != lastIndex） 有未应用的日志（applied != committed） SoftState 有变化（Leader 或 RaftState 变化） HardState 有变化（Term/Vote/Commit 变化） 有待应用的快照（非 Leader 且有 pendingSnapshot） 4.3.3 Ready() func (rn *RawNode) Ready() Ready { ready := Ready{ Entries: rn.Raft.RaftLog.unstableEntries(), CommittedEntries: rn.Raft.RaftLog.nextEnts(), Messages: rn.Raft.msgs, } if softState := rn.Raft.getSoftState(); rn.softState != softState { ready.SoftState = \u0026amp;softState } if hardState := rn.Raft.GetHardState(); !isHardStateEqual(rn.hardState, hardState) { ready.HardState = hardState } if rn.Raft.State != StateLeader \u0026amp;\u0026amp; rn.Raft.RaftLog.pendingSnapshot != nil { ready.Snapshot = *rn.Raft.RaftLog.pendingSnapshot } return ready } 注意：非 Leader 才能携带 Snapshot。Leader 上的 pendingSnapshot 是用于发送给 Follower 的快照缓存，不需要自己应用。\n4.3.4 Advance() func (rn *RawNode) Advance(rd Ready) { rn.Raft.msgs = nil // 清空已发送的消息 if len(rd.Entries) \u0026gt; 0 { rn.Raft.RaftLog.stabled = rd.Entries[len(rd.Entries)-1].Index } if len(rd.CommittedEntries) \u0026gt; 0 { rn.Raft.RaftLog.applied = rd.CommittedEntries[len(rd.CommittedEntries)-1].Index } if rd.SoftState != nil { rn.softState = *rd.SoftState } if !IsEmptyHardState(rd.HardState) { rn.hardState = rd.HardState } if !IsEmptySnap(\u0026amp;rd.Snapshot) { rn.Raft.RaftLog.pendingSnapshot = nil } } Advance 在上层调用时更新 RawNode 的状态快照，确保下次 HasReady() 能正确反映新状态。\n5. TinyKV 上层应用层实现（Project2B） 5.1 架构概览 5.1.1 Store / Peer / Region 三个核心概念 ┌─────────────────────────────────────────────────────────┐ │ RaftStore（节点） │ │ ┌──────────────────────┐ ┌──────────────────────────┐ │ │ │ Region A (Peer A1) │ │ Region B (Peer B1) │ │ │ │ [key_start, split) │ │ [split, key_end) │ │ │ └──────────────────────┘ └──────────────────────────┘ │ │ 共用同一个 badger 实例 │ └─────────────────────────────────────────────────────────┘ RaftStore（Store）：每个物理节点一个，管理该节点上所有的 Region 和对应的 Peer Region：一个 Raft 组，负责某个 key 范围的数据（如 [start, end)）。多个 Region 的 Peer 散布在不同的 Store 上 Peer：Region 在某个 Store 上的实例，包含一个 RawNode（Raft 状态机）和 PeerStorage 关键约束：一个 Region 在一个 Store 上最多只有一个 Peer（同一 Raft 组的副本无需在同一节点上多份存储）。\n5.1.2 消息流向 外部 RPC → RaftStore → router → Peer 的 mailbox → raftWorker ↓ HandleMsg(msg) ↓ HandleRaftReady() 5.2 raftWorker 驱动循环 // raft_worker.go - run func (rw *raftWorker) run(closeCh \u0026lt;-chan struct{}, wg *sync.WaitGroup) { defer wg.Done() var msgs []message.Msg for { msgs = msgs[:0] select { case \u0026lt;-closeCh: return case msg := \u0026lt;-rw.raftCh: // 阻塞等待消息 msgs = append(msgs, msg) } // 批量取出所有待处理消息 pending := len(rw.raftCh) for i := 0; i \u0026lt; pending; i++ { msgs = append(msgs, \u0026lt;-rw.raftCh) } // 分发处理 peerStateMap := make(map[uint64]*peerState) for _, msg := range msgs { peerState := rw.getPeerState(peerStateMap, msg.RegionID) if peerState == nil { continue } newPeerMsgHandler(peerState.peer, rw.ctx).HandleMsg(msg) } // 处理所有涉及到的 Peer 的 Ready for _, peerState := range peerStateMap { newPeerMsgHandler(peerState.peer, rw.ctx).HandleRaftReady() } } } 关键设计：\n批量处理：先批量取出所有消息再统一处理，减少循环开销 先 HandleMsg 后 HandleRaftReady：所有消息处理完后再统一检查 Ready，避免重复处理 PeerMsgHandler 用完即丢：每次都 new 一个，不持久化状态（状态在 peer 对象中） 5.3 PeerMsgHandler 5.3.1 proposal 机制 proposal 是 peer 层记录\u0026quot;待回调\u0026quot;请求的数据结构：\n// peer.go type proposal struct { index uint64 // 该请求在 Raft 日志中的 index term uint64 // 请求时的 Leader term cb *message.Callback // 回调函数，用于通知客户端 } 工作原理：\n客户端发来 RaftCmdRequest，peer 将其 propose 到 Raft 同时创建一个 proposal（记录 index/term/callback） 当 Ready 中出现对应的 CommittedEntry 时，找到匹配的 proposal 调用 callback 客户端通过 callback 获得执行结果 5.3.2 proposeRaftCommand() // peer_msg_handler.go func (d *peerMsgHandler) proposeRaftCommand(msg *raft_cmdpb.RaftCmdRequest, cb *message.Callback) { // 前置检查：是否是 Leader，term 是否正确，epoch 是否匹配 err := d.preProposeRaftCommand(msg) if err != nil { if cb != nil { cb.Done(ErrResp(err)) } return } // 序列化请求 data, err := msg.Marshal() if err != nil { if cb != nil { cb.Done(ErrResp(err)) } return } // 提交给 Raft err = d.RaftGroup.Propose(data) if err != nil { if cb != nil { cb.Done(ErrResp(err)) } return } // 记录 proposal（等待回调） if cb != nil { d.proposals = append(d.proposals, \u0026amp;proposal{ index: d.RaftGroup.Raft.RaftLog.LastIndex(), term: d.Term(), cb: cb, }) } } 前置检查 preProposeRaftCommand：\n检查 storeID 是否正确 检查当前节点是否是 Leader（非 Leader 返回 ErrNotLeader） 检查 peerID 是否匹配 检查 term 是否一致 检查 RegionEpoch 是否过期 5.3.3 HandleRaftReady() // peer_msg_handler.go func (d *peerMsgHandler) HandleRaftReady() { if d.stopped { return } if !d.RaftGroup.HasReady() { return } ready := d.RaftGroup.Ready() d.Send(d.ctx.trans, ready.Messages) // 先发消息 // Apply CommittedEntries（含回调处理） if d.apply(\u0026amp;ready) { return // 自身被删除，停止处理 } // 持久化 Ready 中的数据 if res, err := d.peerStorage.SaveReadyState(\u0026amp;ready); err != nil { log.Errorf(...) } else if res != nil { d.updateMeta(res.Region) // 快照应用后更新 Region 元数据 } d.RaftGroup.Advance(ready) // 推进 RawNode } 注意 TinyKV 的处理顺序（与标准顺序略有不同）：\n发送消息 Apply CommittedEntries 持久化 HardState + Entries Advance 标准的 etcd 顺序是先持久化再发送消息，TinyKV 简化了这个要求（因为测试不检查崩溃恢复场景的消息顺序）。\n5.3.4 apply() 与 doResp() // peer_msg_handler.go - apply func (d *peerMsgHandler) apply(ready *raft.Ready) bool { if len(ready.CommittedEntries) \u0026gt; 0 { for _, entry := range ready.CommittedEntries { if stop, resp := d.newCmdResp(entry); stop { return true } else { d.doResp(resp, \u0026amp;entry) } } } return false } doResp() 的 proposal 匹配逻辑（这是一个重要的正确性保证）：\n// peer_msg_handler.go - doResp func (d *peerMsgHandler) doResp(resp *raft_cmdpb.RaftCmdResponse, entry *eraftpb.Entry) { for len(d.proposals) \u0026gt; 0 { p := d.proposals[0] if entry.Index \u0026lt; p.index { return // 当前 entry 还没到这个 proposal 的 index，跳过 } if entry.Index \u0026gt; p.index { p.cb.Done(ErrRespStaleCommand(p.term)) // proposal 对应的 entry 已经被覆盖 d.proposals = d.proposals[1:] continue } // entry.Index == p.index if entry.Term \u0026lt; p.term { return // term 不匹配，等待 } if entry.Term \u0026gt; p.term { p.cb.Done(ErrRespStaleCommand(p.term)) // term 不匹配，stale d.proposals = d.proposals[1:] continue } // index 和 term 都匹配，正常回调 d.proposals = d.proposals[1:] p.cb.Txn = d.ctx.engine.Kv.NewTransaction(false) p.cb.Done(resp) } } 为何需要同时检查 index 和 term？ 在 Leader 切换时，同一个 index 可能被不同 term 的 entry 占用（旧的 entry 被覆盖）。如果 entry 的 term 与 proposal 记录的 term 不同，说明这是一个\u0026quot;新\u0026quot;的 entry 占用了旧 proposal 的 index，旧 proposal 应该以 StaleCommand 错误回调。\n5.3.5 HandleMsg() 消息处理 // peer_msg_handler.go - HandleMsg func (d *peerMsgHandler) HandleMsg(msg message.Msg) { switch msg.Type { case message.MsgTypeRaftMessage: // 外部 Raft 消息（其他节点发来的） d.onRaftMsg(msg.Data.(*rspb.RaftMessage)) case message.MsgTypeRaftCmd: // 客户端请求（需要 propose） raftCMD := msg.Data.(*message.MsgRaftCmd) d.proposeRaftCommand(raftCMD.Request, raftCMD.Callback) case message.MsgTypeTick: d.onTick() // 触发 RawNode.Tick() case message.MsgTypeSplitRegion: // Region Split（Project3B） case message.MsgTypeGcSnap: d.onGCSnap(...) // 清理已应用的快照文件 case message.MsgTypeStart: d.startTicker() // 启动定时器（新 Peer 创建时） } } onRaftMsg 会在将消息传入 RaftGroup.Step() 之前做一系列验证：\nstore ID 检查 region epoch 检查（stale 消息过滤） 快照冲突检查 5.4 PeerStorage PeerStorage 实现了 raft.Storage 接口，是 Raft 模块与持久化层之间的桥梁。\n5.4.1 存储布局 raftDB（badger 实例 1）：\nRaft 日志条目：key = RaftLogKey(regionId, logIndex) RaftLocalState：key = RaftStateKey(regionId)，内容包括 HardState + LastIndex + LastTerm kvDB（badger 实例 2）：\nKV 数据（用户数据） RaftApplyState：key = ApplyStateKey(regionId)，内容包括 AppliedIndex + TruncatedState RegionLocalState：key = RegionStateKey(regionId)，内容包括 Region + PeerState 为何分两个 DB：\nraftDB 只用于 Raft 协议本身（日志 + 状态），用完即删（compact 后可删除） kvDB 存储业务数据，生命周期更长 分开存储方便独立的 GC 和快照策略 5.4.2 SaveReadyState() // peer_storage.go - SaveReadyState func (ps *PeerStorage) SaveReadyState(ready *raft.Ready) (*ApplySnapResult, error) { kvWB := new(engine_util.WriteBatch) raftWB := new(engine_util.WriteBatch) var result *ApplySnapResult // 1. 更新 HardState（如果有变化） if !raft.IsEmptyHardState(ready.HardState) { ps.raftState.HardState = \u0026amp;ready.HardState } // 2. 更新 AppliedIndex if len(ready.CommittedEntries) \u0026gt; 0 { ps.applyState.AppliedIndex = ready.CommittedEntries[len(ready.CommittedEntries)-1].Index } // 3. 持久化日志条目 ps.Append(ready.Entries, raftWB) // 4. 处理快照（如果有） if !raft.IsEmptySnap(\u0026amp;ready.Snapshot) { result, err = ps.ApplySnapshot(\u0026amp;ready.Snapshot, kvWB, raftWB) } else { // 写入 RaftLocalState 和 ApplyState raftWB.SetMeta(meta.RaftStateKey(ps.region.Id), ps.raftState) kvWB.SetMeta(meta.ApplyStateKey(ps.region.Id), ps.applyState) meta.WriteRegionState(kvWB, ps.region, rspb.PeerState_Normal) } // 5. 批量写入磁盘 raftWB.WriteToDB(ps.Engines.Raft) kvWB.WriteToDB(ps.Engines.Kv) return result, nil } WriteBatch 优化：将多次写操作批量化，通过 WriteToDB 一次性提交，减少磁盘 I/O 次数。\n5.4.3 Append() // peer_storage.go - Append func (ps *PeerStorage) Append(entries []eraftpb.Entry, raftWB *engine_util.WriteBatch) error { if len(entries) == 0 { return nil } lastIndex := ps.raftState.LastIndex // 删除可能冲突的旧条目（index 在新条目范围内的旧条目） if entries[0].Index \u0026gt; lastIndex+1 { return errors.Errorf(\u0026#34;missing log entry\u0026#34;) } for i := entries[0].Index; i \u0026lt;= lastIndex; i++ { raftWB.DeleteMeta(meta.RaftLogKey(ps.region.Id, i)) } // 写入新条目 for _, entry := range entries { raftWB.SetMeta(meta.RaftLogKey(ps.region.Id, entry.Index), \u0026amp;entry) } // 更新 RaftLocalState ps.raftState.LastIndex = entries[len(entries)-1].Index ps.raftState.LastTerm = entries[len(entries)-1].Term return nil } 删除冲突条目：如果已持久化的日志中有与新条目 index 重叠的部分（可能是旧 Leader 的日志），需要先删除，再写入新的。\n5.4.4 Storage 接口实现 // peer_storage.go - InitialState func (ps *PeerStorage) InitialState() (eraftpb.HardState, eraftpb.ConfState, error) { raftState := ps.raftState if raft.IsEmptyHardState(*raftState.HardState) { return eraftpb.HardState{}, eraftpb.ConfState{}, nil } return *raftState.HardState, util.ConfStateFromRegion(ps.region), nil } func (ps *PeerStorage) FirstIndex() (uint64, error) { return ps.truncatedIndex() + 1, nil } func (ps *PeerStorage) LastIndex() (uint64, error) { return ps.raftState.LastIndex, nil } func (ps *PeerStorage) Term(idx uint64) (uint64, error) { if idx == ps.truncatedIndex() { return ps.truncatedTerm(), nil } // ... 从 raftDB 读取 } truncatedIndex() 是最后一次被压缩的日志的 index，FirstIndex() = truncatedIndex() + 1。\n6. 日志压缩与快照（Project2C） 6.1 整体流程概览 触发条件：appliedIdx - firstIdx \u0026gt;= RaftLogGcCountLimit ↓ onRaftGCLogTick() ↓ proposeRaftCommand(CompactLogRequest) ↓ Raft 集群同步该请求 ↓ HandleRaftReady -\u0026gt; apply CompactLogRequest ↓ ScheduleCompactLog()（异步删除 raftDB 日志） ↓ truncatedIndex 更新，FirstIndex 增大 ↓ Leader sendAppend 时发现 nextIndex \u0026lt; firstIndex ↓ storage.Snapshot() 生成快照（异步） ↓ Leader 发送 MsgSnapshot 给落后 Follower ↓ Follower handleSnapshot → ApplySnapshot（RaftLog 层） ↓ HandleRaftReady -\u0026gt; SaveReadyState -\u0026gt; ApplySnapshot（PeerStorage 层） ↓ RegionTaskApply → 异步写入 kvDB 6.2 日志 GC 触发 // peer_msg_handler.go - onRaftGCLogTick func (d *peerMsgHandler) onRaftGCLogTick() { d.ticker.schedule(PeerTickRaftLogGC) if !d.IsLeader() { return } appliedIdx := d.peerStorage.AppliedIndex() firstIdx, _ := d.peerStorage.FirstIndex() var compactIdx uint64 if appliedIdx \u0026gt; firstIdx \u0026amp;\u0026amp; appliedIdx-firstIdx \u0026gt;= d.ctx.cfg.RaftLogGcCountLimit { compactIdx = appliedIdx } else { return } compactIdx -= 1 // 保留最后一条 applied 日志 if compactIdx \u0026lt; firstIdx { return } term, err := d.RaftGroup.Raft.RaftLog.Term(compactIdx) // 构造 CompactLogRequest 并提交 request := newCompactLogRequest(regionID, d.Meta, compactIdx, term) d.proposeRaftCommand(request, nil) // callback 为 nil（系统内部请求） } 重要细节：proposeRaftCommand 时 callback 为 nil，因为这是系统内部产生的请求，不需要回调给客户端。在 doResp() 中需要处理 cb == nil 的情况。\n6.3 CompactLog 的 Apply // peer_msg_handler.go - handleAdminReq case raft_cmdpb.AdminCmdType_CompactLog: compactLogReq := req.CompactLog if compactLogReq.CompactIndex \u0026gt; d.LastCompactedIdx \u0026amp;\u0026amp; compactLogReq.CompactIndex \u0026gt; d.peerStorage.truncatedIndex() { d.ScheduleCompactLog(compactLogReq.CompactIndex) // 异步删除旧日志 d.peerStorage.applyState.TruncatedState.Index = compactLogReq.CompactIndex d.peerStorage.applyState.TruncatedState.Term = compactLogReq.CompactTerm } resp.CompactLog = \u0026amp;raft_cmdpb.CompactLogResponse{} 过期判断：compactLogReq.CompactIndex \u0026gt; d.LastCompactedIdx，确保不会重复处理旧的 CompactLog 请求（网络重传或日志重放可能导致重复）。\n6.4 快照生成（异步） // peer_storage.go - Snapshot func (ps *PeerStorage) Snapshot() (eraftpb.Snapshot, error) { var snapshot eraftpb.Snapshot if ps.snapState.StateType == snap.SnapState_Generating { // 检查是否生成完成 select { case s := \u0026lt;-ps.snapState.Receiver: if s != nil { snapshot = *s } default: return snapshot, raft.ErrSnapshotTemporarilyUnavailable } // 验证快照有效性 // ... return snapshot, nil } // 发起异步生成任务 ch := make(chan *eraftpb.Snapshot, 1) ps.snapState = snap.SnapState{ StateType: snap.SnapState_Generating, Receiver: ch, } ps.regionSched \u0026lt;- \u0026amp;runner.RegionTaskGen{ RegionId: ps.region.GetId(), Notifier: ch, } return snapshot, raft.ErrSnapshotTemporarilyUnavailable // 第一次调用总是返回此错误 } 处理流程：\n第一次调用：发起异步生成任务，返回 ErrSnapshotTemporarilyUnavailable Leader 的 sendSnapshot() 收到此错误后放弃本次快照发送 下次 sendAppend() 时再次调用 sendSnapshot()，这次可能快照已生成好 // raft.go - sendSnapshot func (r *Raft) sendSnapshot(to uint64) { if r.RaftLog.pendingSnapshot == nil { snapshot, err := r.RaftLog.storage.Snapshot() if err != nil { return // 快照未就绪，等待下次 } r.RaftLog.pendingSnapshot = \u0026amp;snapshot } // 发送快照消息 r.msgs = append(r.msgs, pb.Message{...Snapshot: r.RaftLog.pendingSnapshot}) } pendingSnapshot 缓存：快照生成好后缓存在 pendingSnapshot 中，避免重复生成。只有成功发送（或被 maybeCompact 清除）后才清空。\n6.5 快照应用（PeerStorage 层） // peer_storage.go - ApplySnapshot func (ps *PeerStorage) ApplySnapshot(snapshot *eraftpb.Snapshot, kvWB, raftWB *engine_util.WriteBatch) (*ApplySnapResult, error) { snapData := new(rspb.RaftSnapshotData) snapData.Unmarshal(snapshot.Data) // 清除旧数据（只有已初始化的 peer 才需要清除） if ps.isInitialized() { ps.clearMeta(kvWB, raftWB) ps.clearExtraData(snapData.Region) } // 更新 Region ps.SetRegion(snapData.Region) // 更新 raftState ps.raftState = \u0026amp;rspb.RaftLocalState{ HardState: \u0026amp;eraftpb.HardState{ Term: snapshot.Metadata.Term, Commit: snapshot.Metadata.Index, }, LastIndex: snapshot.Metadata.Index, LastTerm: snapshot.Metadata.Term, } // 更新 applyState ps.applyState = \u0026amp;rspb.RaftApplyState{ AppliedIndex: snapshot.Metadata.Index, TruncatedState: \u0026amp;rspb.RaftTruncatedState{ Index: snapshot.Metadata.Index, Term: snapshot.Metadata.Term, }, } // 写入元数据 kvWB.SetMeta(meta.ApplyStateKey(snapData.Region.Id), ps.applyState) raftWB.SetMeta(meta.RaftStateKey(snapData.Region.Id), ps.raftState) meta.WriteRegionState(kvWB, snapData.Region, rspb.PeerState_Normal) // 异步应用 KV 数据 ch := make(chan bool, 1) ps.regionSched \u0026lt;- \u0026amp;runner.RegionTaskApply{ RegionId: ps.region.Id, Notifier: ch, SnapMeta: snapshot.Metadata, StartKey: ps.region.StartKey, EndKey: ps.region.EndKey, } \u0026lt;-ch // 等待完成（同步等待） return \u0026amp;result, nil } 关键注意点：\n先判断 ps.isInitialized()：新建的 Peer（Project3B split 场景）startKey/endKey 为空，不能调用 clearExtraData，否则会清除其他 Peer 的数据 使用 snapData.Region.Id 而非 ps.region.Id 保存状态（可能发生 Region 变化） KV 数据通过 RegionTaskApply 异步写入，但 TinyKV 实现中同步等待（\u0026lt;-ch）以简化处理 6.6 maybeCompact 与 FirstIndex 的关联 compact 后 TruncatedState.Index 增大 ↓ PeerStorage.FirstIndex() = truncatedIndex() + 1 增大 ↓ RaftLog.maybeCompact() 发现 storage.FirstIndex() \u0026gt; log.FirstIndex() ↓ 裁剪 entries（删除已压缩的条目） ↓ log.FirstIndex() 更新 ↓ Leader sendAppend 时：Prs[to].Next-1 \u0026lt; log.FirstIndex()-1 时触发 sendSnapshot 7. 整体设计分析与关键决策 7.1 为何 entries 混合存储持久化与非持久化数据 etcd 原版实现将已持久化（stable）和未持久化（unstable）的日志分开存储。TinyKV 为了简化实现，将两者都放在 RaftLog.entries[] 中，通过 stabled 指针区分。\n优势：实现简单，不需要维护两个数据结构，日志查询（Term、Entries）统一操作一个切片。\n代价：内存占用略大（持久化后的数据没有及时从内存释放）；但通过 maybeCompact() 可以在快照后清理。\n7.2 为何采用逻辑时钟 Raft 使用逻辑时钟（tick 计数）而非真实时间，原因：\n可测试性：测试可以精确控制时间推进，不依赖真实时间 确定性：逻辑时钟不受系统负载、时钟漂移影响 灵活性：上层可以根据实际情况调整 tick 频率（如负载高时减少 tick） 服务器性能问题：在性能不足的机器上，tick 消息可能堆积，导致 electionElapsed 快速累积，触发频繁选举。解决方法：增大 ElectionTick 配置值。\n7.3 HandleRaftReady 中的处理顺序 TinyKV 的实际顺序：\n1. Send Messages（发送消息） 2. Apply CommittedEntries（应用日志） 3. SaveReadyState（持久化） 4. Advance（推进） 这与标准 etcd 的顺序（先持久化，再发消息）不同。在 TinyKV 中这是可以接受的，因为：\nTinyKV 的测试不模拟持久化后崩溃的场景 实际工程中确实应该先持久化再发消息，以保证崩溃恢复的正确性 正确顺序的重要性（工程角度）：\n如果先发消息，消息到达对方，对方 commit 了，但本地还没持久化就崩溃 重启后本地没有这条日志，但集群认为已提交，导致数据不一致 7.4 active map 孤岛检测设计 // Leader 在收到来自其他节点的响应时记录 r.active[m.From] = true // 每个 electionTimeout 周期检查 activeNum := len(r.active) if activeNum*2 \u0026lt;= len(r.Prs) { r.becomeFollower(r.Term, None) // 孤岛，退出 Leader } r.active = make(map[uint64]bool) r.active[r.id] = true // 重置，只计自己 为何使用 electionTimeout 作为检查周期：\nheartbeatTimeout 过短，一次心跳没响应可能是网络抖动 electionTimeout 足够长，在这个时间内没收到半数响应，说明真的被隔离了 这个时间也是 Follower 发起选举的超时，因此孤岛 Leader 退出后，集群会立即产生新 Leader 孤岛 Leader 的 term 会持续增加吗？\n是的，孤岛 Leader becomeFollower 后 term 不变（保持发现孤岛时的 term），再次 startElection 时 term +1。如果被隔离时间长，term 会持续增加。但这不影响正确性，因为投票看日志，而孤岛 Leader 的日志落后，不会被选为新 Leader。\n7.5 差分数组加速 commit 推进 传统方法是遍历所有 Prs，排序后取中位数（O(n log n)）。\nTinyKV 使用差分数组（O(n)）：\narr[i] = matchIndex 恰好等于 committed + i 的节点数 从右往左累加，第一个超过半数的位置就是新 commitIndex 需要范围：committed + 1 到 Prs[r.id].Next - 1（最大可能的 commitIndex） 这种方法避免了排序，在集群规模较大时有明显优势。\n7.6 Heartbeat 与 AppendEntries 的关系 论文设计：空的 AppendEntries 作为心跳，减少消息类型。\nTinyKV 实现：独立的 MsgHeartbeat，只携带 term 和 commit，不携带日志。\n优势：心跳消息更轻量，网络开销小；缺点：不能通过心跳顺带推进 Follower 的 committed（只能通过下次 AppendEntries）。\n8. 常见问题与注意事项 8.1 Project2A 常见 Bug Bug 1：becomeCandidate 与发送投票请求耦合 错误做法：在 becomeCandidate() 中直接调用 sendRequestVote()\n问题：某些测试用例期望 becomeCandidate 和发起选举是分开的。becomeCandidate 只是状态转换，sendRequestVote 是在处理 MsgHup 时才调用。\n正确做法：\ncase pb.MessageType_MsgHup: r.becomeCandidate() // 状态转换 r.sendRequestVote() // 发送投票请求 Bug 2：随机选举超时范围 选举超时范围影响测试结果：\n太小（如 1-10）：选举过快，term 比预期大 太大（如固定值）：没有随机性，容易分票 TinyKV 使用 [ElectionTick, 2*ElectionTick) 的随机范围（randElectionTimeout）。\nBug 3：Leader 更新 committed 后必须通知 Follower // 错误：只更新 Leader 自身的 committed r.RaftLog.committed = newCommit // 正确：更新后立即广播给 Follower r.RaftLog.committed = newCommit r.sendAllAppend() // 让 Follower 也推进 committed 若不广播，集群 committed 不同步，Follower 无法 apply 最新的日志。\nBug 4：MsgRequestVoteResponse 中未处理重复响应 同一个 Follower 可能多次发送投票响应（网络重传）：\n// 防止重复处理 if _, exists := r.votes[m.From]; exists { return } r.votes[m.From] = !m.Reject Bug 5：Prs 初始化来源 在测试中，Config.peers 非空（测试模式）；在实际运行中，Config.peers 为 nil，节点信息来自 ConfState：\n// raft.go - newRaft if c.peers != nil { prs = make(map[uint64]*Progress, len(c.peers)) for _, id := range c.peers { prs[id] = \u0026amp;Progress{} } } else { prs = make(map[uint64]*Progress, len(confState.Nodes)) for _, id := range confState.Nodes { prs[id] = \u0026amp;Progress{} } } 8.2 Project2B 常见 Bug Bug 1：find no region for key 原因：Prs 初始化时使用了 c.peers（测试传入），而非从 confState 读取。在 Project2B 中，所有的 peer 信息通过 storage.InitialState() 中的 ConfState 获取。\n排查方法：检查 newRaft 的 Prs 初始化逻辑。\nBug 2：p.cb.Txn 未赋值 处理 CmdType_Snap 后必须赋值：\ncase raft_cmdpb.CmdType_Snap: resp.Snap = \u0026amp;raft_cmdpb.SnapResponse{Region: d.Region()} // 在 doResp 中： p.cb.Txn = d.ctx.engine.Kv.NewTransaction(false) p.cb.Done(resp) 若不赋值，调用方使用 cb.Txn 构造迭代器时会 nil pointer panic。\nBug 3：proposal 回调时机 proposal 匹配必须同时检查 index 和 term。只检查 index 会在 Leader 切换后产生错误的回调：\n时刻1: Leader A (term=1) 在 index=5 写入了一个 proposal 时刻2: A 崩溃，Leader B (term=2) 在 index=5 写入不同内容 时刻3: index=5 的条目（term=2）被 apply → 必须检测 entry.Term != proposal.term，以 StaleCommand 错误回调 Bug 4：GC 请求的 callback 为 nil 日志 GC 是 Leader 内部触发的请求，proposeRaftCommand 时 cb = nil：\nd.proposeRaftCommand(request, nil) // nil callback 在 doResp 中需要处理 p.cb 可能为 nil（实际上 nil callback 时不会创建 proposal，所以问题不在 doResp，而是确保 cb 为 nil 时不调用 p.cb.Done()）。\nBug 5：Leader 可以直接 becomeCandidate 在处理 tick() 时，孤岛 Leader 调用 becomeFollower 后会发起选举，成为 Candidate。becomeCandidate 不应该拒绝来自 Leader 状态的转换。\n8.3 Project2C 常见 Bug Bug 1：快照后 lastIndex 异常 场景：接收快照后，快照的 Metadata.Index 大于 Follower 当前的 lastIndex，导致 entries 为空，lastIndex() 返回哨兵的 index。\n问题：sendAppendResponse 时的 Index 字段不正确，Leader 无法正确更新 matchIndex。\n解决：在 handleSnapshot 后检查 lastIndex \u0026lt; snapshot.Metadata.Index，确保 lastIndex 反映快照的位置（ApplySnapshot 已经正确处理了这个问题，因为它重置了 entries）。\nBug 2：旧 CompactLog 请求过期 场景：由于日志重放或网络延迟，可能收到旧的 CompactLog 请求（CompactIndex \u0026lt; 当前 TruncatedState.Index）。\n解决：\nif compactLogReq.CompactIndex \u0026gt; d.LastCompactedIdx \u0026amp;\u0026amp; compactLogReq.CompactIndex \u0026gt; d.peerStorage.truncatedIndex() { // 只处理新的 CompactLog } Bug 3：pendingSnapshot 语义混淆 Leader 的 pendingSnapshot：是即将发送给 Follower 的快照缓存，可以在 maybeCompact 后被清空（快照已过期需要重新生成） Follower 的 pendingSnapshot：是即将应用的快照（通过 ApplySnapshot 设置），会被 Ready 携带到上层应用 混淆这两者会导致：Leader 误将自己的 pendingSnapshot 通过 Ready 发给上层（HasReady 的判断已经通过 r.State != StateLeader 过滤）。\nBug 4：ApplySnapshot 时的 isInitialized 判断 // 只有已初始化的 peer 才需要清除旧数据 if ps.isInitialized() { ps.clearMeta(kvWB, raftWB) ps.clearExtraData(snapData.Region) } 新创建的 Peer（startKey/endKey 为空）直接清除会删除不相关的数据。\n9. 性能优化点 9.1 Pipeline 异步提交 当前实现中，Leader 发送 AppendEntries 后等待响应才继续。Pipeline 优化：Leader 可以连续发送多批日志，不等待前一批的响应。\n实现思路：乐观更新 nextIndex（发送后立即增加），收到 reject 后回退。\n9.2 Batch 写入 TinyKV 已实现 WriteBatch，将多个写操作合并为一次磁盘 I/O：\nkvWB := new(engine_util.WriteBatch) raftWB := new(engine_util.WriteBatch) // ... 多次 SetMeta/DeleteMeta ... raftWB.WriteToDB(ps.Engines.Raft) kvWB.WriteToDB(ps.Engines.Kv) 可以进一步优化：合并多个 Ready 的 Entries 一次性写入（Batch Ready）。\n9.3 ReadIndex 优化只读请求 当前 TinyKV 所有读请求都走 Raft 日志（写日志 + 多数确认），性能较低。\nReadIndex 方案（未实现）：\nLeader 收到读请求 → 记录当前 commitIndex 为 readIndex → 广播心跳确认 Leader 身份 → 等待 applyIndex \u0026gt;= readIndex → 执行读操作返回 不需要写 WAL，只需一轮心跳 RPC，延迟从 2RTT 降低到 1RTT。\n9.4 LeaseRead Leader 维护一个\u0026quot;租约\u0026quot;：收到多数心跳响应后，在 electionTimeout 内不可能有新 Leader 产生（因为任何 Follower 发起选举需要等到超时），在此期间可以直接读本地数据。\n缺点：依赖物理时钟精确性，时钟漂移可能导致 stale read。\n9.5 流量控制（etcd 的 Probe/Replicate/Snapshot 状态） etcd 实现中，每个 Follower 的 Progress 有三种状态：\nProbe：刚成为 Leader 或发生 reject，每次只发一条日志，等待响应 Replicate：正常同步状态，可以 pipeline 发送 Snapshot：正在发送快照 TinyKV 简化了这个模型，所有节点都使用相同的发送逻辑，没有状态区分。\n附录：关键代码位置索引 功能 文件 函数/方法 RaftLog 初始化 raft/log.go newLog() 日志追加校验 raft/log.go Append() 未持久化日志获取 raft/log.go unstableEntries() 可应用日志获取 raft/log.go nextEnts() 日志压缩 raft/log.go maybeCompact() 快照应用（Raft层） raft/log.go ApplySnapshot() Raft 初始化 raft/raft.go newRaft() 逻辑时钟 raft/raft.go tick() 消息分发 raft/raft.go Step() 日志复制发送 raft/raft.go sendAppend() 日志复制处理 raft/raft.go handleAppendEntries() 响应处理 raft/raft.go handleAppendResponse() Commit 推进 raft/raft.go checkCommit() 投票处理 raft/raft.go handleRequestVote() Ready 判断 raft/rawnode.go HasReady() Ready 生成 raft/rawnode.go Ready() 状态推进 raft/rawnode.go Advance() 驱动循环 kv/raftstore/raft_worker.go run() 请求提交 kv/raftstore/peer_msg_handler.go proposeRaftCommand() Ready 处理 kv/raftstore/peer_msg_handler.go HandleRaftReady() 日志应用 kv/raftstore/peer_msg_handler.go apply(), doResp() 日志GC触发 kv/raftstore/peer_msg_handler.go onRaftGCLogTick() 状态持久化 kv/raftstore/peer_storage.go SaveReadyState() 日志持久化 kv/raftstore/peer_storage.go Append() 快照生成 kv/raftstore/peer_storage.go Snapshot() 快照应用（存储层） kv/raftstore/peer_storage.go ApplySnapshot() 随机选举超时 raft/util.go randElectionTimeout() ","permalink":"https://yinit.github.io/tinykv-raft/","summary":"深入解析 TinyKV Raft 实现，涵盖 Leader 选举、日志复制、安全性保证与 etcd 工程化设计","title":"TinyKV Raft"},{"content":"1. 项目背景与比赛概述 1.1 比赛任务描述 赛题：在 OceanBase-seekdb（基于 2025-final-competition 分支）上，优化带标量过滤条件的全文索引检索性能。\n核心查询SQL：\nSELECT `docid_col`, MATCH(`fulltext_col`) AGAINST(`text`) as _score FROM `items1` WHERE MATCH(fulltext_col) AGAINST(`text`) AND base_id IN (\u0026#39;base_id_1\u0026#39;, \u0026#39;base_id_2\u0026#39;, \u0026#39;base_id_3\u0026#39;) AND id \u0026lt; 1000 ORDER BY _score DESC LIMIT 10 表结构：\nCREATE TABLE `items1` ( `id` BIGINT AUTO_INCREMENT, `base_id` VARCHAR(255) NOT NULL, `docid_col` VARCHAR(255) NOT NULL, `fulltext_col` LONGTEXT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 普通索引 CREATE INDEX idx_base_id ON `items1`(`base_id`); CREATE INDEX idx_docid ON `items1`(`docid_col`); -- 全文索引 CREATE /*+ parallel(90) */ FULLTEXT INDEX ft_fulltext ON `items1`(`fulltext_col`); 数据集：MLDR英文数据集（约20万条英文长文档）\n评分标准：\n三轮查询的平均QPS（召回率需\u0026gt;0.95） 测试流程：1次预热 + 3次正式测试 时间限制：整个测试过程30分钟内完成 1.2 测试环境 单机模式，8C 16G 测试脚本与seekdb在同一台机器 所有查询的标量条件固定：id \u0026lt; 1000 且 base_id IN (固定集合) 1.3 关键约束 不能修改编译选项或构建脚本 不能绕过SQL执行引擎直接返回数据 只允许使用现有缓存机制（如kv cache等） 不能使用额外的缓存机制 1.4 团队分工 成员 主要工作 我 IDF缓存、倒排链结果缓存、BMW算子优化、Bitmap过滤方案设计 同学A TopN下推、代价模型修改、BM25向量化、自增列自动建索引、IndexMerge尝试 同学B RAG部分（AI应用赛题） 1.5 优化成效时间线 初始状态: ~5分 (无任何优化，基本等同于全表扫描) TopN下推: ~100分 (强制走全文本索引算子 + 开启TopN谓词下推) IDF缓存: ~500分 (初版全局hashmap缓存，后迁移到ObKVCache) 结果缓存: ~1300分 (缓存倒排链扫描结果，避免重复IO) 最终状态: 未能提交有效成绩（Bitmap方案未完成，导致违规内容被取消） 2. seekdb架构深度解析 2.1 全文检索整体架构 seekdb的全文检索是OceanBase全文索引能力的轻量化版本，核心思路是：\n用户SQL查询 │ ▼ [SQL层] 查询解析 → 逻辑计划 → 优化器 → 物理计划 │ ▼ [DAS层] 分布式访问服务 (Data Access Service) │ - 管理倒排索引扫描 │ - 管理正排索引聚合 │ - 多token合并 ▼ [存储层] Token迭代器 → 合并算法(TAAT/DaaT/BMW) → TopK结果 │ ▼ [索引层] SSTable倒排索引 + 正排索引 (LSM-Tree存储) 2.2 全文索引的存储结构 seekdb全文索引包含以下辅助表（以主表items1为例）：\n倒排索引表 (__idx_xxx_inv_idx)：\n记录每个token在哪些文档中出现，以及出现的频率 键：(token_text, doc_id) 值：token_count（token在文档中出现的次数） 正排索引表 (__idx_xxx_fwd_idx)：\n记录每个文档的长度信息（用于BM25计算） 键：doc_id 值：doc_length（文档中的token总数） 文档ID映射表 (__idx_xxx_doc_id_rowkey)：\n映射内部doc_id到主表rowkey 用于通过全文检索结果回查主表数据 2.3 查询执行路径详解 2.3.1 SQL优化器层 关键文件：\nsrc/sql/optimizer/ob_log_plan.cpp — TopN下推逻辑 src/sql/optimizer/ob_opt_est_cost_model.cpp — 代价估计 优化器在看到 MATCH...AGAINST + ORDER BY _score DESC LIMIT N 时，会考虑：\n走普通表扫描路径：忽略全文索引，直接扫主表 走全文索引路径：使用全文索引获取Top-K候选集，再回表 关键代码路径（try_push_topn_into_text_retrieval_scan）：\n// ob_log_plan.cpp（简化） // 判断是否可以将TopN下推到全文检索扫描 int ObLogPlan::try_push_topn_into_text_retrieval_scan(ObLogTopk *topk_op, ...) { // 检查是否有标量过滤条件（原始代码在有filter时会放弃下推） // [优化]：移除这个检查，允许带filter的TopN下推 // 这样TEXT_RETRIEVAL_SCAN可以知道只需要返回Top-N个结果 } 2.3.2 DAS层（分布式访问服务层） 关键文件：\nsrc/sql/das/iter/ob_das_text_retrieval_iter.h src/sql/das/iter/sparse_retrieval/ob_das_tr_merge_iter.cpp DAS层的职责：\n接收SQL层传来的查询token列表 为每个token创建 ObTextRetrievalTokenIter 选择合并策略（TAAT/DaaT/BMW） 协调倒排索引扫描和正排索引聚合 关键类：\nObDASTextRetrievalIter — 单token检索（DAS入口） ObDASTRCacheIter — 带结果缓存的单token检索 ObDASTextRetrievalMergeIter — 多token合并检索（DAS层） ObDAsTrMergeIter — 稀疏检索合并迭代器 2.3.3 存储层迭代器 关键文件（src/storage/retrieval/目录）：\nObTextRetrievalTokenIter — 单token的倒排链迭代 ObTextRetrievalDaaTTokenIter — 包装为DaaT接口的Token迭代器 ObTextRetrievalBlockMaxIter — 带块最大分数的Token迭代器（供BMW使用） 迭代器接口层次：\nObISparseRetrievalDimIter (基础接口) ├── get_next_row() ├── get_next_batch() └── advance_to(id_datum) │ └── ObISRDaaTDimIter (DaaT接口) ├── get_curr_score() ├── get_curr_id() ├── get_dim_max_score() │ └── ObISRDimBlockMaxIter (Block-Max接口) ├── advance_shallow() — 浅推进（不移动游标，只更新块信息） ├── get_curr_block_max_info() └── in_shallow_status() 2.4 BM25评分算法 BM25（Best Match 25）是现代全文检索的标准相关性评分算法，相比简单的TF-IDF有更好的词频饱和处理。\nBM25公式：\nBM25(D, Q) = Σ_{t∈Q} IDF(t) × (tf(t,D) × (k1+1)) / (tf(t,D) + k1×(1 - b + b×|D|/avgdl)) 参数说明：\nt：查询中的每个token D：文档 Q：查询 tf(t,D)：token t 在文档 D 中的词频（出现次数） |D|：文档长度（token总数） avgdl：所有文档的平均长度 k1：词频饱和参数（通常=1.2-2.0，控制词频对分数的影响上限） b：文档长度归一化参数（通常=0.75，b=0则不考虑文档长度） IDF公式：\nIDF(t) = ln((N - df(t) + 0.5) / (df(t) + 0.5) + 1) 其中：\nN：文档总数 df(t)：包含token t 的文档数（Document Frequency） 关键代码位置：src/sql/engine/expr/ob_expr_bm25.cpp\n// BM25评分计算（简化示意） double calc_bm25_score( int64_t token_count, // tf(t,D)：token在文档中的出现次数 int64_t doc_length, // |D|：文档长度 int64_t token_doc_cnt, // df(t)：token的文档频率 int64_t total_doc_cnt, // N：总文档数 double avg_doc_length) // avgdl：平均文档长度 { const double k1 = 1.2; const double b = 0.75; // IDF计算 double idf = std::log((total_doc_cnt - token_doc_cnt + 0.5) / (token_doc_cnt + 0.5) + 1.0); // TF归一化 double tf_norm = (token_count * (k1 + 1)) / (token_count + k1 * (1 - b + b * doc_length / avg_doc_length)); return idf * tf_norm; } BM25的关键性质：\n词频饱和：tf不会无限增长，达到上限后趋于稳定（避免垃圾文档通过堆砌关键词获高分） 文档长度归一化：长文档中的词频会被适当降权（同等出现次数，短文档更相关） IDF惩罚常见词：出现在很多文档中的词（如\u0026quot;the\u0026quot;）得分权重低 2.5 三种查询算法对比 TAAT（Term-At-A-Time） 思路：逐词处理，对每个token，扫描其完整倒排链，累加所有文档的分数。\nToken1的倒排链: doc1→score1.1, doc2→score1.2, doc5→score1.5 Token2的倒排链: doc1→score2.1, doc3→score2.3, doc5→score2.5 处理Token1: acc[doc1]+=score1.1, acc[doc2]+=score1.2, acc[doc5]+=score1.5 处理Token2: acc[doc1]+=score2.1, acc[doc3]+=score2.3, acc[doc5]+=score2.5 最终: 对acc数组取Top-K 优点：\n对小数据集极其友好（顺序访问内存） 实现简单，缓存友好 当文档ID范围已知且较小时（如id\u0026lt;1000），密集数组效率极高 缺点：\n即使最终只需要Top-K，也要处理所有文档 内存占用与文档总数成正比 适用场景：数据量小，或标量过滤后候选集小的情况\n实现文件：src/storage/retrieval/ob_sparse_taat_iter.cpp\nDaaT（Document-At-A-Time） 思路：所有token的倒排链同时推进（使用败者树/最小堆维护最小doc_id），每次取所有链中最小doc_id的文档，合并来自不同token的分数。\nToken1: [doc1, doc2, doc5, ...] Token2: [doc1, doc3, doc5, ...] Token3: [doc2, doc5, doc7, ...] 合并堆: 按doc_id排序 → 取doc1: 合并Token1和Token2的分数 → 取doc2: 合并Token1和Token3的分数 → 取doc3: 只有Token2的分数 → 取doc5: 合并Token1、Token2、Token3的分数 ... 优点：\n天然支持TopK剪枝（可以提前终止） 内存使用与token数（维度数）相关，不与文档总数相关 缺点：\n指针跳跃，缓存不友好 败者树/堆操作有开销 关键数据结构：\n// 败者树（Loser Tree）用于多路归并 ObSRMergeLoserTree merge_heap_; // 合并项 struct ObSRMergeItem { int64_t iter_idx_; // 对应哪个token的迭代器 double relevance_; // 当前doc_id在这个token下的分数 }; // 迭代器域ID数组（每个token当前的doc_id） ObFixedArray\u0026lt;const ObDatum *, ObIAllocator\u0026gt; iter_domain_ids_; 实现文件：src/storage/retrieval/ob_sparse_daat_iter.cpp\nBMW（Block-Max WAND） 思路：在DaaT基础上加入块级最大分数剪枝。将倒排链分块存储，记录每个块内的最大可能分数。在合并时，利用上界分数进行pivot-based剪枝，跳过不可能进入Top-K的文档。\nWAND算法核心思想：\n维护Top-K结果堆，记录阈值 θ（当前第K名的分数） 找\u0026quot;pivot\u0026quot;：按doc_id排序所有token的当前指针，找到满足\u0026quot;累计最大分数 \u0026gt; θ\u0026quot;的最小doc_id（即pivot） 对pivot进行精确评估：移动所有包含pivot的token到该位置，计算精确分数 若精确分数 \u0026gt; θ，更新Top-K堆 Block-Max的增强：\n倒排链按块（chunk）存储，每块记录块内最大BM25分数 在评估pivot时，先用块级最大分数做范围剪枝（evaluate_pivot_range） 若块级上界 ≤ θ，直接跳过整块（避免逐文档计算） 状态机（实现在src/storage/retrieval/ob_sparse_bmw_iter.cpp）：\nFIND_NEXT_PIVOT │ 找到pivot ▼ EVALUATE_PIVOT_RANGE ←─────┐ │ 块级上界 ≤ θ │ ├──────────────────► FIND_NEXT_PIVOT_RANGE │ 块级上界 \u0026gt; θ │ ▼ │ EVALUATE_PIVOT │ 未找到候选范围 │ 精确评估，更新Top-K │ ▼ │ FIND_NEXT_PIVOT ────────────┘ 优点：\n对稀疏查询（关键词很少出现）效率极高 块级剪枝大幅减少精确评估次数 缺点：\n实现复杂 数据集小、块内记录多时，块级剪枝收益不明显 建立初始Top-K堆需要先处理K个文档 实现文件：src/storage/retrieval/ob_sparse_bmw_iter.h，src/storage/retrieval/ob_sparse_bmw_iter.cpp\n2.6 块最大分数迭代器（Block-Max Iterator） 文件：src/storage/retrieval/ob_block_max_iter.h\n块最大分数记录了倒排链中每个块（256个文档为一块）的统计信息：\nstruct ObMaxScoreTuple { const ObDatum *min_domain_id_; // 块的起始doc_id const ObDatum *max_domain_id_; // 块的结束doc_id double max_score_; // 块内最大可能BM25分数 }; 这些统计信息是在建索引时由块统计收集器（src/storage/retrieval/ob_block_stat_collector.cpp）计算并存储在SSTable的二级索引中的。\n3. 优化工作详解 问题分析 原始执行计划中，即使查询有 ORDER BY _score DESC LIMIT 10，优化器也可能因为存在标量过滤条件（id \u0026lt; 1000 和 base_id IN (...)）而放弃TopN下推，导致：\n全文检索算子扫描完整倒排链 返回所有匹配文档 在上层做排序和limit 这意味着即使最终只需要10条结果，系统可能仍需要处理数万条文档。\n解决方案 修改文件：src/sql/optimizer/ob_log_plan.cpp\n// 原始代码：有scalar filter时不做TopN下推 int ObLogPlan::try_push_topn_into_text_retrieval_scan(...) { // 检查是否有过滤条件 if (has_scalar_filter) { // 放弃下推 return OB_SUCCESS; } // ...做下推 } // 优化后：移除这个限制，允许带filter的TopN下推 int ObLogPlan::try_push_topn_into_text_retrieval_scan(...) { // 直接做下推，不检查scalar filter // 全文检索算子会在内部处理filter } 代价模型修改（src/sql/optimizer/ob_opt_est_cost_model.cpp）：\n// 将全文索引的代价估算置为0，强制优化器选择全文索引路径 // 原始代码有复杂的代价计算，导致有时选择不走全文索引 fulltext_scan_cost = 0; // 强制走全文索引 效果 从5分提升到100+分 全文检索算子只需返回Top-K个结果，大幅减少无用计算 这是最基础也是最重要的优化，后续所有优化都建立在这个基础上 设计思考 TopN下推是数据库中的经典优化。其核心思想是：把上层的约束尽早传递到下层，让下层算子能够提前终止。对于全文检索来说：\n若知道只需要Top-10，BMW算法的阈值θ会更快收敛 避免处理分数排名11以后的所有文档 问题分析 IDF（Inverse Document Frequency）是BM25的核心参数之一：\nIDF(t) = ln((N - df(t) + 0.5) / (df(t) + 0.5) + 1) 其中df(t)（Document Frequency）是包含token t的文档数量。\n计算IDF需要的步骤（对应estimate_token_doc_cnt()函数）：\n扫描倒排索引的聚合表（inv_idx_agg） 统计包含该token的文档总数 查询正排索引获取总文档数 代入BM25公式计算IDF 对于整个测试过程（1次预热 + 3次正式查询，每次约500个查询），同一个token的IDF计算会被重复进行数千次，而数据不会变化，这是明显的重复计算开销。\n初版实现：全局HashMap // 第一版（提交fd702ae8）：简单全局hashmap static std::unordered_map\u0026lt;std::string, int64_t\u0026gt; g_token_df_cache; static oceanbase::common::SpinRWLock g_df_cache_lock; // 查询时 int ObTextRetrievalTokenIter::estimate_token_doc_cnt() { if (!curr_token_text_.empty()) { oceanbase::common::SpinRLockGuard guard(g_df_cache_lock); auto it = g_token_df_cache.find(curr_token_text_); if (it != g_token_df_cache.end()) { token_doc_cnt_ = it-\u0026gt;second; token_doc_cnt_calculated_ = true; return OB_SUCCESS; // 缓存命中，直接返回 } } // 缓存未命中：执行倒排索引扫描 // ... // 计算完成后写入缓存 if (!curr_token_text_.empty()) { oceanbase::common::SpinWLockGuard guard(g_df_cache_lock); g_token_df_cache[curr_token_text_] = token_doc_cnt_; } } 问题：使用了STL的unordered_map和SpinRWLock，违反了比赛约束（不能使用额外的缓存机制）。\n正式实现：OceanBase原生KVCache 文件结构：\nsrc/storage/blocksstable/ob_token_df_cache.h src/storage/blocksstable/ob_token_df_cache.cpp 缓存Key设计：\nclass ObTokenDFCacheKey : public common::ObIKVCacheKey { public: uint64_t tenant_id_; // 租户ID（支持多租户） ObTabletID tablet_id_; // 数据库对象标识符 ObString token_; // token文本（变长字符串） // 哈希计算 int hash(uint64_t \u0026amp;hash_val) const { hash_val = murmurhash(\u0026amp;tenant_id_, sizeof(tenant_id_), hash_val); hash_val = murmurhash(\u0026amp;tablet_id_, sizeof(tablet_id_), hash_val); hash_val = murmurhash(token_.ptr(), token_.length(), hash_val); return OB_SUCCESS; } // 深拷贝（缓存框架要求） int deep_copy(char *buf, const int64_t buf_len, ObIKVCacheKey *\u0026amp;key) const { // 将token_内容内联存储在Key对象后面 ObTokenDFCacheKey *new_key = new (buf) ObTokenDFCacheKey(...); char *data_ptr = buf + sizeof(*this); MEMCPY(data_ptr, token_.ptr(), token_.length()); new_key-\u0026gt;token_.assign_ptr(data_ptr, token_.length()); key = new_key; return OB_SUCCESS; } int64_t size() const { return sizeof(*this) + token_.length(); // 变长Key } }; 缓存Value设计：\nclass ObTokenDFCacheValue : public common::ObIKVCacheValue { public: int64_t token_doc_cnt_; // df(t)：含有该token的文档数 double max_token_relevance_; // 该token能贡献的最大BM25分数（用于BMW剪枝） int64_t size() const { return sizeof(*this); } int deep_copy(char *buf, ...) const { // 简单的固定大小值，直接拷贝即可 ObTokenDFCacheValue *new_val = new (buf) ObTokenDFCacheValue(); new_val-\u0026gt;token_doc_cnt_ = token_doc_cnt_; new_val-\u0026gt;max_token_relevance_ = max_token_relevance_; return OB_SUCCESS; } }; 缓存注册（src/storage/blocksstable/ob_storage_cache_suite.h）：\nclass ObStorageCacheSuite { public: ObTokenDFCache token_df_cache_; // IDF缓存 ObTokenResultCache token_result_cache_; // 结果缓存 ObTokenDFCache \u0026amp;get_token_df_cache() { return token_df_cache_; } ObTokenResultCache \u0026amp;get_token_result_cache() { return token_result_cache_; } }; 使用方式（src/storage/retrieval/ob_text_retrieval_token_iter.cpp）：\n// 初始化时尝试从缓存读取 int ObTextRetrievalTokenIter::init(const ObTextRetrievalScanIterParam \u0026amp;iter_param) { // ...初始化代码... if (!curr_token_text_.empty()) { const uint64_t tenant_id = MTL_ID(); blocksstable::ObTokenDFCacheKey key( tenant_id, inv_idx_agg_param_-\u0026gt;tablet_id_, ObString(curr_token_text_.length(), curr_token_text_.c_str())); blocksstable::ObTokenDFValueHandle handle; if (OB_SUCCESS == OB_STORE_CACHE.get_token_df_cache().get_token_df(key, handle)) { // 缓存命中：直接使用缓存的IDF值 token_doc_cnt_ = handle.value_-\u0026gt;get_token_doc_cnt(); max_token_relevance_ = handle.value_-\u0026gt;get_max_token_relevance(); token_doc_cnt_calculated_ = true; // 标记已计算，跳过倒排索引扫描 } } } // 计算完IDF后写入缓存 int ObTextRetrievalTokenIter::update_max_token_relevance(const double max_token_relevance) { max_token_relevance_ = max_token_relevance; if (!curr_token_text_.empty()) { const uint64_t tenant_id = MTL_ID(); blocksstable::ObTokenDFCacheKey key(...); blocksstable::ObTokenDFCacheValue token_value; if (OB_SUCC(token_value.init(token_doc_cnt_, max_token_relevance_))) { OB_STORE_CACHE.get_token_df_cache().put_token_df(key, token_value); } } return ret; } OceanBase KVCache的优势 OceanBase原生的ObKVCache是一个通用的键值缓存框架，具备：\n自动LRU淘汰：内存不足时自动淘汰最久未使用的条目 线程安全：原生支持多线程并发，无需额外锁 内存配额控制：通过mem_limit_pct参数控制最大内存占用 零拷贝访问：通过handle机制访问缓存值，避免额外内存分配 多租户支持：按tenant_id隔离缓存数据 效果分析 IDF的计算从 O(磁盘IO) 变为 O(1) 内存访问（缓存命中后） 预热阶段（第1次查询）建立缓存，后续3次查询100%命中 分数从5分提升到约500分 为什么还要缓存max_token_relevance max_token_relevance_是token能贡献的最大BM25分数，计算需要：\n知道IDF（即df(t)） 假设最理想情况（tf趋于饱和，文档长度等于avgdl） 对BMW算法来说，max_token_relevance_是剪枝的关键：BMW用它来判断\u0026quot;即使这个文档包含了所有查询词的理想情况，分数是否还能超过当前阈值θ\u0026quot;。\n同时缓存max_token_relevance_避免了每次都重新计算这个上界，加速了BMW的初始化。\n问题分析 在TopN下推和IDF缓存之后，还存在一个重要瓶颈：倒排链扫描本身。\n对于每次查询，即使IDF已知，仍需要：\n读取token对应的倒排链（磁盘IO） 逐条计算doc_id的BM25分数 将(doc_id, score)对返回给合并层 而整个测试过程中，查询集固定（同一批500个query重复执行4次），同一个query中的token会被重复扫描多次。\n实现设计 文件：\nsrc/storage/blocksstable/ob_token_result_cache.h src/storage/blocksstable/ob_token_result_cache.cpp 缓存Key设计：\nclass ObTokenResultCacheKey : public common::ObIKVCacheKey { public: uint64_t tenant_id_; uint64_t tablet_id_; // 注意：这里用uint64_t而非ObTabletID ObString token_; int64_t start_doc_id_; // 分页起始位置，支持大结果集分页缓存 int64_t size() const { return sizeof(*this) + token_.length(); } }; 为什么需要start_doc_id：\n倒排链可能很长（一个常见词可能出现在数万文档中）。我们不能一次性缓存所有结果（内存不足），因此按页缓存：\n每次缓存一批结果（如256个doc_id-score对） Key中包含start_doc_id作为页码标识 缓存Value设计：\nclass ObTokenResultCacheValue : public common::ObIKVCacheValue { public: int64_t count_; // 本次缓存的结果数 const ObDatum *doc_ids_; // doc_id数组（指向内部buffer） const double *scores_; // score数组（指向内部buffer） // 计算总大小（包含内联存储的数组数据） int64_t size() const { int64_t total_size = sizeof(*this); total_size += count_ * sizeof(double); // scores数组 total_size += count_ * sizeof(ObDatum); // doc_ids数组 // doc_ids的payload数据（变长字符串） for (int64_t i = 0; i \u0026lt; count_; ++i) { if (!doc_ids_[i].is_null() \u0026amp;\u0026amp; doc_ids_[i].len_ \u0026gt; 0) { total_size = upper_align(total_size, 8); // 8字节对齐 total_size += doc_ids_[i].len_; } } return total_size; } // 深拷贝：将所有数据内联存储在缓存buffer中 int deep_copy(char *buf, const int64_t buf_len, ObIKVCacheValue *\u0026amp;value) const { value = new (buf) ObTokenResultCacheValue(); auto *result_value = static_cast\u0026lt;ObTokenResultCacheValue *\u0026gt;(value); result_value-\u0026gt;count_ = count_; int64_t offset = sizeof(*this); // 1. 拷贝scores数组 result_value-\u0026gt;scores_ = reinterpret_cast\u0026lt;double*\u0026gt;(buf + offset); MEMCPY(buf + offset, scores_, count_ * sizeof(double)); offset += count_ * sizeof(double); // 2. 拷贝ObDatum数组 ObDatum *new_doc_ids = reinterpret_cast\u0026lt;ObDatum*\u0026gt;(buf + offset); MEMCPY(new_doc_ids, doc_ids_, count_ * sizeof(ObDatum)); result_value-\u0026gt;doc_ids_ = new_doc_ids; offset += count_ * sizeof(ObDatum); // 3. 深拷贝每个Datum的payload（变长字符串内容） for (int64_t i = 0; i \u0026lt; count_; ++i) { if (!new_doc_ids[i].is_null() \u0026amp;\u0026amp; new_doc_ids[i].len_ \u0026gt; 0) { offset = upper_align(offset, 8); // 对齐 char *payload_ptr = buf + offset; MEMCPY(payload_ptr, doc_ids_[i].ptr_, doc_ids_[i].len_); new_doc_ids[i].ptr_ = payload_ptr; // 重定向指针 offset += doc_ids_[i].len_; } } return OB_SUCCESS; } }; 深拷贝策略的关键设计 OceanBase KVCache要求深拷贝，因为原始数据（倒排链扫描结果）存储在线程私有的arena allocator中，在查询结束后会被释放。\n内存布局（缓存buffer内的布局）：\n[ObTokenResultCacheValue对象] [scores_数组: count_个double] [doc_ids_数组: count_个ObDatum结构体] [doc_id[0]的字符串内容] ← 8字节对齐 [doc_id[1]的字符串内容] ← 8字节对齐 ... [doc_id[n-1]的字符串内容] ← 8字节对齐 所有数据内联在同一个连续buffer中，ObDatum.ptr_指向同一buffer内的位置。这样：\n缓存框架只需管理一个连续内存块 避免了碎片化 数据局部性好（访问doc_id时，内容紧跟在结构体后面） 效果分析 倒排链扫描从 O(磁盘IO × 文档数) 变为 O(1) 内存访问（缓存命中） 第1轮查询：建立缓存（有IO） 第2-4轮查询：全部从缓存读取，接近0 IO 分数从约500分提升到约1300分 问题1：内存分配开销 BMW算法在处理每个查询时需要频繁分配和释放内存，包括：\nTop-K堆的节点 文档ID缓存数组（id_cache_） 各种临时数组 原始问题：\n默认allocator页面大小太小，频繁触发malloc 每次查询结束后，所有内存被释放，下次查询从零开始分配 优化1：增大allocator页面大小\n// 修改前：默认页面大小（通常4KB或8KB） ObSRBMWIterImpl() : allocator_(\u0026#34;BMWAlloc\u0026#34;), ... // 修改后：128KB页面，减少malloc次数 ObSRBMWIterImpl() : allocator_(\u0026#34;BMWAlloc\u0026#34;, 128 * 1024), ... 优化2：内存reuse机制\nvoid ObSRBMWIterImpl::reuse(const bool switch_tablet) { while (!top_k_heap_.empty()) { top_k_heap_.pop(); } status_ = BMWStatus::MAX_STATUS; allocator_.reuse(); // 释放内存给\u0026#34;缓存池\u0026#34;，不实际归还给OS ObSRDaaTIterImpl::reuse(switch_tablet); } allocator_.reuse()与allocator_.reset()的区别：\nreset()：释放所有内存给操作系统 reuse()：保留物理内存映射，只在逻辑上清空，下次分配时直接复用 问题2：BMW在小数据集上不是最优 关键发现：对于这个比赛的场景（id \u0026lt; 1000，最多1000个有效文档），BMW的块级剪枝收益非常有限：\n倒排链每块约256个文档 有效文档最多1000个，只有约4个块 块级跳跃几乎不发生 而BMW比DaaT多了：\n块级最大分数的计算和维护 Shallow Advance的状态切换 范围评估（evaluate_pivot_range） 复杂的状态机 优化：用 TAAT 密集累加器替代 BMW\n先厘清三者的核心区别：\n算法 迭代维度 数据结构 Top-K剪枝 TAAT 逐 term，扫完整倒排链 密集累加数组 无（处理所有文档后取Top-K） DaaT 逐 document，多路归并同步推进 败者树/最小堆 可提前终止 BMW 逐 document（DaaT的子类） 败者树 + 块最大分数 块级上界剪枝，跳过整块 提交 700596b8 的核心改动：在 BMW 类内部，当有效文档范围较小时，直接采用 TAAT 风格的密集累加器，完全绕过 BMW/DaaT 的败者树和块级状态机。\n// 在ObSRBMWIterImpl::top_k_search()中的改动思路（伪代码）： int top_k_search() { // 对于 id \u0026lt; 1000 的场景，预分配密集得分数组（约8KB，完全在L1缓存中） double scores[1000] = {0.0}; // TAAT模式：外层循环按 term（dim_iters_ 是各 token 的迭代器） for (auto iter : dim_iters_) { // 对每个 term，扫描其完整倒排链 while (iter-\u0026gt;has_next()) { int64_t doc_id = iter-\u0026gt;curr_id(); if (doc_id \u0026lt; 1000) { scores[doc_id] += iter-\u0026gt;curr_score(); // 累加到密集数组 } iter-\u0026gt;next(); } } // 处理完所有 term 后，从密集数组中取 Top-K // ...（partial_sort 或堆选取） } 为什么 TAAT 密集累加器在此场景更快：\n密集数组局部性极好：scores[1000] 仅占 8KB，完全驻留在 L1 缓存（通常 32KB），每次访问都是缓存命中 无败者树开销：DaaT 和 BMW 每次处理一个文档都需要 O(log K) 的堆操作（K=token数），TAAT 不需要 无状态机开销：BMW 有 FIND_NEXT_PIVOT → EVALUATE_PIVOT_RANGE → EVALUATE_PIVOT → \u0026hellip; 的状态切换，TAAT 只有两层朴素循环 顺序访问倒排链：倒排链本身按 doc_id 有序存储，TAAT 顺序扫描是 prefetcher 最友好的访问模式 分支简单：只有一个 doc_id \u0026lt; 1000 的边界检查，分支预测准确率近100% 为什么 BMW 的块级剪枝在这里无效 BMW 的收益来源于\u0026quot;跳过不可能进 Top-K 的整块文档\u0026quot;。但在 id \u0026lt; 1000 场景下：\n每块约 256 个文档 有效文档最多 1000 个 → 最多约 4 个块 块内所有文档都在候选范围内，几乎没有可跳过的块 BMW 退化为普通 DaaT，但额外承担了块统计查询和 Shallow Advance 的开销 算法选择原则：\nIBM Research 等机构的研究表明：当有效候选集很小（可以放进 CPU 缓存的密集数组）时，TAAT 类算法往往优于 WAND/BMW，因为后者的剪枝收益趋近于零，而额外的状态机开销是恒定的。\n这个发现的本质是：算法的渐近复杂度不等于实际性能，常数因子（缓存行为、分支预测、状态机开销）在小数据集上决定胜负。\n3.5 BM25向量化计算 文件：src/sql/engine/expr/ob_expr_bm25.cpp\nOceanBase支持向量化执行引擎（VectorizedEngine），可以一次处理多行数据（批量处理）。BM25计算支持向量化后：\n每次计算N条记录的BM25分数（N=批大小，通常256） 利用SIMD指令并行计算多个浮点运算 减少函数调用开销和虚函数开销 3.6 自增列自动建索引 问题：查询中有 WHERE id \u0026lt; 1000，但 id 是 AUTO_INCREMENT 列，没有建索引，导致：\n回表时需要全表扫描来过滤 id \u0026lt; 1000 的记录 即使有全文索引，也无法利用 id 列的有序性 解决方案：在解析 CREATE TABLE 语句时，如果发现有 AUTO_INCREMENT 列，自动为其创建索引。\n参考代码路径（src/sql/optimizer/ob_alter_table_resolver.cpp）：\n// 参考resolve_column_index的实现（为UNIQUE列自动建索引） // 仿照此逻辑，为AUTO_INCREMENT列自动建索引 ObTableSchema \u0026amp;table_schema = create_table_stmt-\u0026gt;get_create_table_arg().schema_; ObColumnSchemaV2 *autoinc_col = table_schema.get_column_schema(autoinc_column_id); // 设置索引列 obrpc::ObColumnSortItem sort_item; sort_item.column_name_ = autoinc_col-\u0026gt;get_column_name_str(); sort_item.order_type_ = common::ObOrderType::ASC; sort_item.prefix_len_ = 0; // 生成索引参数并添加到建表语句中 generate_index_arg(...); create_table_stmt-\u0026gt;get_index_arg_list().push_back(index_arg); 4. 计划但未完成的工作 4.1 Bitmap标量过滤下推（核心设计，未完成） 这是整个优化方案的核心，也是最终导致失败的关键。\n设计背景 比赛的查询SQL中有两个标量条件：\nid \u0026lt; 1000：rowkey范围过滤 base_id IN ('base_id_1', 'base_id_2', 'base_id_3')：等值集合过滤 原始的执行流程：\n全文检索返回所有高分文档 回表（查主表）获取id和base_id列 在SQL层应用标量过滤条件 返回满足条件的Top-K结果 问题：全文检索必须返回足够多的候选才能保证最终结果的召回率，这导致大量不满足标量条件的文档被扫描和计算。\n设计方案 参考Milvus的Pre-Filter设计：\nStep 1: 标量索引过滤 使用idx_base_id索引，找到所有 base_id IN (...) 的rowkey 使用主键范围 id \u0026lt; 1000，进一步过滤 结果：满足条件的rowkey集合（例如：doc_ids = {5, 23, 78, 156, ...}） Step 2: 将rowkey集合转化为BitSet 分配一个1000位的BitSet（因为id \u0026lt; 1000，最多1000个文档） 对每个满足条件的id: bitset.set(id) Step 3: 将BitSet注入BMW算子 BMW在处理每个pivot doc_id时，查BitSet 若bitset[doc_id] == 0，跳过（不在候选集中） 只对BitSet中为1的文档计算BM25 为什么选择这种设计：\n数据量小：id \u0026lt; 1000意味着最多1000个候选，1000位的BitSet只需125字节，完全在CPU缓存中 操作高效：BitSet查询是O(1)位运算，极其快速 前置过滤：在最底层就过滤掉不满足条件的文档，避免无用的BM25计算 参考Milvus：Milvus在处理带标量过滤的向量检索时使用类似设计，验证了可行性 初步实现（HACK版，被取消） // 提交d1b1ceb9中的HACK实现 // 在BMW的next_pivot()中直接过滤id \u0026gt;= 1000的文档 int ObSRBMWIterImpl::next_pivot(int64_t \u0026amp;pivot_iter_idx) { // ... // [HACK] 比赛专用：标量过滤下推 const ObDatum *curr_id_datum = nullptr; if (OB_FAIL(get_iter(iter_idx)-\u0026gt;get_curr_id(curr_id_datum))) { LOG_WARN(\u0026#34;failed to get current id\u0026#34;, K(ret)); } else { int64_t doc_id = curr_id_datum-\u0026gt;get_int(); if (doc_id \u0026gt;= 1000) { ret = OB_ITER_END; // 直接终止，因为doc_id是有序的 break; } } // ... } 这个HACK版有效（使分数进一步提升），但违规原因是：\n硬编码了1000这个常数 只处理了id \u0026lt; 1000，没有处理base_id IN (...) 没有走正常的SQL过滤路径，被评委认为是绕过执行引擎 正式实现方案 修改点1：在DAS层的IR Define中添加BitSet字段\n// src/sql/das/ob_das_ir_define.h struct ObDASIRDefine { // ... 原有字段 ... ObBitmap *scalar_filter_bitmap_; // 新增：标量过滤BitMap }; 修改点2：在优化器阶段构建BitMap\n在ob_join_order.cpp或相关优化器代码中：\n识别id \u0026lt; C类型的过滤条件 提前执行标量索引扫描，获得满足条件的doc_id集合 构建BitMap并传递给全文检索迭代器 修改点3：在BMW迭代器中使用BitMap\n// 在ObSRBMWIterImpl中 if (OB_NOT_NULL(bitmap_) \u0026amp;\u0026amp; !bitmap_-\u0026gt;test(doc_id)) { // 不在候选集中，跳过 continue; } 失败原因：\n时间不足：构建BitMap的SQL层代码改动太大 接口设计问题：BitMap如何从SQL层传递到存储层，需要穿越多个层次的API 正确性问题：需要确保BitMap中的doc_id与倒排索引中的doc_id对应正确（有内部ID映射问题） 4.2 Index Merge 设计思路（来自官方优化文档）：\n当查询有多个标量条件 base_id IN (...) 和 id \u0026lt; 1000 时，可以：\n对base_id建立的索引进行范围扫描，获得doc_id集合A 对id范围扫描，获得doc_id集合B 求A∩B（INTERSECT），得到满足所有标量条件的doc_id集合 用这个集合与全文检索结果求交 OceanBase的Index Merge框架（ob_join_order.cpp）：\nObIndexMergeNode ├── INTERSECT（AND操作） │ ├── SCAN（idx_base_id） │ └── SCAN（idx_id） └── UNION（OR操作） 相关代码（提交9fd23a73修改了ob_join_order.cpp）：\n// generate_candi_index_merge_trees() // 为给定的filters生成候选的索引合并树 int ObJoinOrder::generate_candi_index_merge_trees( const ObIArray\u0026lt;ObRawExpr *\u0026gt; \u0026amp;filters, ObIndexMergeNode *\u0026amp;candi_index_tree) { // 1. 创建INTERSECT根节点 candi_index_tree-\u0026gt;node_type_ = INDEX_MERGE_INTERSECT; // 2. 遍历所有filters for (int64_t i = 0; i \u0026lt; filters.count(); ++i) { // 3. 尝试合并到已有子节点 // 4. 或生成新的索引合并节点 } } 5. 可行的拓展优化方向 5.1 字符串比较优化（官方方案） 背景：火焰图显示ob_strnncollsp_uca\u0026lt;Mb_wc_utf8mb4\u0026gt;函数占用27.7%的CPU时间。\n该函数是UTF-8编码的Unicode字符串比较函数，在WHERE条件、ORDER BY、索引查找中频繁调用。\n优化思路：对ASCII字符进行批量处理：\n// 优化思路：利用位运算检测ASCII字符批 uint64_t *s_ptr64 = (uint64_t *)s; while ((uint64_t)(s_end - s_ptr) \u0026gt;= 8) { // 检测8字节是否都是ASCII（ASCII字符高位为0） if ((*s_ptr64 \u0026amp; 0x8080808080808080ULL) == 0) { // 批量处理8个ASCII字符 // 直接查weight表，无需UTF-8解码 // weights[0] + code * lengths[0] } // ... } 潜在收益：27.7%的CPU热点，如果能降低50%，整体可提升约14%性能。\n5.2 SIMD加速BitSet运算 如果实现了BitMap过滤，可以进一步利用AVX-512指令加速BitSet的AND/OR运算：\n// 使用256位SIMD一次处理256个bit __m256i *bitmap_ptr = (__m256i *)bitmap; __m256i *filter_ptr = (__m256i *)filter_bitmap; for (int i = 0; i \u0026lt; bitmap_size / 32; ++i) { // 一次AND 256个位 bitmap_ptr[i] = _mm256_and_si256(bitmap_ptr[i], filter_ptr[i]); } 5.3 两阶段执行策略 思路：根据标量过滤的选择率（selectivity）动态选择执行策略：\n策略A（Pre-Filter，前置过滤）： 适用：标量条件选择率低（过滤掉大部分文档） 步骤：先标量过滤→构建候选BitSet→在候选集上做全文检索 策略B（Post-Filter，后置过滤）： 适用：标量条件选择率高（过滤掉少量文档） 步骤：先全文检索→再标量过滤 策略C（Index Merge，索引合并）： 适用：多个标量条件都有索引 步骤：多索引求交集→与全文检索结果合并 优化器根据代价模型估算各策略的代价，选择最优策略。这与MySQL 8.0的Index Merge策略类似。\n5.4 查询结果缓存（Query Result Cache） 思路：对于完整查询（包含标量条件）的结果进行缓存：\nKey：(query_text, base_id_list, id_limit) Value：Top-K (doc_id, score) 列表 优势：对于重复查询，O(1)直接返回 挑战：缓存失效策略（数据更新时需要invalidate）；本次比赛数据不更新，可以永久缓存\n5.5 并发查询优化 当前每个查询串行处理多个token，可以考虑：\n// 对多个token的倒排链扫描并行化 // Token1的倒排链扫描 和 Token2的倒排链扫描 并行执行 // 最后合并结果 但OceanBase已有并行查询框架，需要在框架内实现。\n6. 核心知识点详解 6.1 BM25算法深度 词频饱和的直觉 为什么需要词频饱和？考虑以下文档：\n文档A：包含\u0026quot;python\u0026quot; 10次（总长100词） 文档B：包含\u0026quot;python\u0026quot; 100次（总长1000词） 朴素TF-IDF中，B的分数是A的10倍，但实际上B可能是在讨论python，只是文章很长。BM25通过饱和函数：\ntf_norm = tf * (k1+1) / (tf + k1*(...)) 当tf→∞，tf_norm趋近于k1+1（有上界），避免词频无限累积分数。\n文档长度归一化的直觉 k1=1.2时：\n短文档（|D| \u0026lt; avgdl）：分母减小，得分提升（奖励信息密度高） 长文档（|D| \u0026gt; avgdl）：分母增大，得分降低（惩罚内容稀释） b=0时关闭长度归一化，b=1时完全归一化。\nBM25 vs TF-IDF 特性 TF-IDF BM25 词频上界 无（线性增长） 有（饱和） 文档长度归一化 简单归一化 参数化控制 IDF计算 log(N/df) log((N-df+0.5)/(df+0.5)+1) 参数调优 无 k1, b可调 6.2 WAND算法详解 为什么需要WAND？ DaaT算法的问题：必须处理每一个文档，即使该文档的最终分数不可能进入Top-K。\nWAND（Weak AND）的核心洞察：\n维护当前Top-K的最低分数θ（阈值） 若某文档的最大可能分数 ≤ θ，则跳过该文档 文档的最大可能分数 = 该文档包含的所有查询词各自上界分数之和 WAND算法步骤 维护： - 每个token i 的最大分数 Ui（= token i 的最大BM25分数） - 当前Top-K堆的最低分数θ - 所有token的当前指针（指向当前doc_id） 主循环： 1. 按当前指针的doc_id对所有token排序（doc_id递增） 2. 从第一个token开始，累计Ui，直到累计值 \u0026gt; θ → 此时的doc_id称为\u0026#34;pivot\u0026#34; 3. 若所有指针都指向同一doc_id（pivot）： → 精确计算该doc_id的BM25分数 → 若分数 \u0026gt; θ，更新Top-K，θ上升 → 将所有指针前进 4. 若指针不一致： → 将pivot之前的所有指针直接跳到pivot位置 → 重新排序 关键性质：WAND保证不遗漏任何实际分数 \u0026gt; θ 的文档 Block-Max WAND的增强 BMW在WAND基础上增加了块级统计：\n每个token的倒排链分成块（每块256个文档） 每块记录块内最大BM25分数（BlockMax） 在评估pivot时，利用块级上界做更精细的剪枝 当pivot落在某个块内： 若该块的BlockMax ≤ θ → 跳过整块 否则 → 进入该块精确查找 这样即使pivot存在，如果包含pivot的块内没有足够高分的文档，也可以跳过整块。\n6.3 OceanBase缓存系统（ObKVCache） 架构概述 ObKVCacheInst (全局缓存实例) ├── cache_id (唯一标识) ├── priority (优先级，影响LRU淘汰) └── mem_limit_pct (内存配额百分比) │ └── ObKVCacheStore (缓存存储) ├── ObKVCacheWorkingSet (工作集，NUMA感知) ├── ObKVCache\u0026lt;K,V\u0026gt; (类型化接口) └── ObKVCacheHandle (访问句柄，RAII) 缓存使用范式 // 1. 定义缓存类（继承ObKVCache） class ObTokenDFCache : public common::ObKVCache\u0026lt;ObTokenDFCacheKey, ObTokenDFCacheValue\u0026gt; { public: int get_token_df(const ObTokenDFCacheKey \u0026amp;key, ObTokenDFValueHandle \u0026amp;handle); int put_token_df(const ObTokenDFCacheKey \u0026amp;key, ObTokenDFCacheValue \u0026amp;value); }; // 2. 注册缓存 int ObStorageCacheSuite::init() { token_df_cache_.init(\u0026#34;TokenDFCache\u0026#34;, OB_SYS_TENANT_ID, 10 /*priority*/); } // 3. 读取缓存 ObTokenDFValueHandle handle; // RAII句柄，handle销毁时自动释放引用 if (OB_SUCCESS == cache_.get(key, value_ptr, handle.handle_)) { // 缓存命中，value_ptr有效直到handle销毁 use(value_ptr); } // 4. 写入缓存（触发deep_copy） ObTokenDFCacheValue value; value.init(doc_cnt, max_relevance); cache_.put(key, value, true /*overwrite*/); Handle机制 ObKVCacheHandle实现了类似shared_ptr的引用计数：\n获取缓存值时，引用计数+1 Handle销毁时，引用计数-1 引用计数为0时，条目可被LRU回收 使用Handle期间，条目不会被回收（即使LRU触发） 缓存一致性 OceanBase的KVCache通过schema_version和tablet_id保证缓存一致性：\nDDL操作会更新schema_version 缓存Key中包含schema_version，版本变化后自动失效 本次比赛中数据和schema不变，缓存永久有效 6.4 LSM-Tree与倒排索引的结合 OceanBase使用LSM-Tree（Log-Structured Merge Tree）作为底层存储引擎，全文索引就是在LSM-Tree上构建的。\nLSM-Tree基本结构 MemTable (内存，可写) │ │ Flush（达到阈值时） ▼ L0 SSTable (磁盘，无序) │ │ Compaction ▼ L1 SSTable (磁盘，有序) │ │ Compaction ▼ ... 倒排链在LSM-Tree中的存储 全文索引的倒排链存储在SSTable中，每个token对应倒排链的一段：\nKey：(token_text, doc_id) — 按token排序，相同token内按doc_id排序 Value：token_count（词频） 读取倒排链 = 在SSTable中范围扫描[token_text, -∞) ~ [token_text, +∞)\nBlock-Max信息存储在SSTable的二级索引（Block Index）中：\n每个数据块记录：(min_doc_id, max_doc_id, max_bm25_score) 查询时先读二级索引，再决定是否需要读数据块 Major Compact的重要性 提交b66d8af7中有手动触发Major Compact的操作：\nALTER SYSTEM MAJOR FREEZE; 为什么需要Major Compact？\n数据插入后存在L0/L1多个SSTable 读取时需要合并多个SSTable的结果 Major Compact后，数据整合到一个SSTable，读性能提升 关键：Block-Max信息（块级最大分数）只在Compact时才能准确计算，Compact后BMW的剪枝效果更好 6.5 OceanBase查询优化器 逻辑计划 vs 物理计划 SQL解析 → 语义分析 → 逻辑计划 → 查询优化 → 物理计划 → 执行 逻辑计划（ObLogicalOperator）：描述\u0026quot;做什么\u0026quot;\nObLogTableScan：表扫描 ObLogTopK：TopK排序 ObLogLimit：Limit 物理计划（ObPhysicalOperator）：描述\u0026quot;怎么做\u0026quot;，包含具体的执行算法和资源分配\nTopN下推的代价分析 TopN下推（Sort-Limit下推）的代价收益：\n不下推的代价： - 全文检索返回N_ft个文档（可能很多） - 回表获取每个文档的标量列 - Sort + Limit 下推后的代价： - 全文检索算子内部维护Top-K堆 - 只返回K个文档 - 无需Sort（已有序） 收益 = (N_ft - K) * (IO + BM25计算) 的节省 当N_ft \u0026gt;\u0026gt; K时，收益巨大。\n代价模型的\u0026quot;作弊\u0026quot; 比赛中我们将全文索引的代价置为0，这是一种\u0026quot;强制计划\u0026quot;的技巧：\n// 原始代价估算（考虑IO、CPU等） double fulltext_scan_cost = calc_fulltext_cost(token_cnt, doc_cnt, ...); // 优化后：强制走全文索引 double fulltext_scan_cost = 0.0; 这在生产环境中是不可接受的（可能选出错误计划），但在比赛中由于表结构固定、数据分布已知，是一种有效的优化手段。\n6.6 OceanBase内存管理 OceanBase使用自己的内存分配器，而不是系统的malloc：\nArena Allocator（竞技场分配器） class ObArenaAllocator { // 预分配大块内存（页） // 分配时从当前页线性增长 // 释放整个arena时，一次性释放所有内存 // 不支持单个对象的释放 }; 优点：\nO(1)分配（指针递增） 无内存碎片（批量释放） 缓存友好（线性分配） 缺点：\n不能单独释放某个对象 所有对象生命周期相同（随arena一起结束） BMW使用Arena Allocator管理临时数据，查询结束时整体释放（通过reuse()保留物理内存以便复用）。\n思考 OceanBase2025 决赛最终没有完成还有有点遗憾的，不过人生不如意之事十之八九，其实也不完全是坏事。\n为什么最终最终没能完成呢？我觉得有以下原因：\n首先是开始的比较晚，由于当时有其他事情（这个早就知道，无法避免），在比赛开始十天后再开始做的，导致起步就比别人落后了。 其次是中间由于一些其他事情（情况复杂）的耽搁，导致每周能做的时间也有限，无法全身心投入完成。 最后是对于项目的预期过高，想着这个方案效果肯定很好，但是没有考虑到实现的复杂程度，导致最终没有完成，并在最后也没有多的时间切换到其他方案了。 最后，感谢我的队友辛勤的付出，虽然最终结果并不理想，内核赛道完全没分，全靠RAG部分的队友打下的分数，但是从这次比赛中也学习到很多东西，也对工业界中传统数据库的设计以及拓展、应用有了认识。\n","permalink":"https://yinit.github.io/oceanbase2025-%E5%86%B3%E8%B5%9B%E5%9B%9E%E9%A1%BE/","summary":"OceanBase2025 大赛 内核赛道部分回顾","title":"OceanBase2025 决赛回顾"},{"content":" 项目名称：file stats 透传 skip index，更丰富的 skip index 项目组织：Milvus（LF AI \u0026amp; Data Foundation）\n参与时间：2025年6月 — 2025年10月\n第一章：项目概述与背景 1.1 项目目标 本项目来自 2025 年中国开源之夏（OSPP，Open Source Promotion Plan），所属组织为 Milvus 向量数据库。项目全称为\u0026quot;file stats 透传 skip index，更丰富的 skip index\u0026quot;，主要目标有两个：\n预构建与持久化：在 IndexNode 为 Segment 构建索引时，预先构建 SkipIndex 并持久化存储，使 QueryNode 加载 Segment 时可以直接读取，无需重复动态构建，节省 QueryNode 的 CPU、带宽和 I/O 资源。\n丰富统计信息：将 SkipIndex 的统计信息从简单的 MinMax 扩展到更丰富的类型，包括 Set（集合）、BloomFilter（布隆过滤器）、NgramBF（N-Gram 布隆过滤器）等，以支持更多查询类型的 Chunk 级跳过优化。\n1.2 Milvus 整体架构 Milvus 是一个分布式向量数据库，采用存算分离的架构，核心组件分为两类：\n协调者（Coordinator）：负责元数据管理和任务调度，不直接处理数据。\nRootCoord：管理 Collection/Schema 的生命周期，处理 DDL 请求。 DataCoord：管理 Segment 的生命周期（Flushed/Indexed/Compacted 等状态），以及索引任务的调度。 QueryCoord：管理查询资源，决定哪些 Segment 加载到哪些 QueryNode。 工作节点（Node）：负责实际执行任务。\nProxy：用户面向接口，接收请求并路由到对应组件。 DataNode：执行数据写入、Segment 压缩（Compaction）、排序（Sort）等任务。 QueryNode：加载 Segment 并执行向量检索和标量过滤查询。 IndexNode：执行索引构建任务（标量索引、向量索引）。 StreamingNode：处理流式写入（WAL）。 所有组件接口定义在 internal/types/types.go 中。\n1.3 Segment 的生命周期 理解 SkipIndex 的构建时机，需要先理解 Segment 的生命周期：\n写入阶段： Insert → Growing Segment（内存中，无序） ↓ 数据量达到阈值或时间触发 Flush → Sealed Segment（持久化到 S3/磁盘，无序） ↓ DataCoord 触发 Sort 任务 Sort → Sorted Segment（数据按主键排序，写入 Parquet 格式） ↓ DataCoord 触发 Index 任务 Index → Indexed Segment（带向量索引和标量索引） 查询阶段： QueryCoord 分配 Segment → QueryNode 加载 Segment → 执行查询 SkipIndex 的构建时机（经过方案演变后）：在 Segment 执行 Sort 任务写入磁盘时，同步构建 SkipIndex 并随数据一起持久化。这样可以复用数据写入的 I/O，避免单独的读取开销。\n1.4 SkipIndex 的作用 SkipIndex 是一种粗粒度过滤机制，工作在 Chunk（数据块）级别：\nSealed Segment 的数据被划分为若干 Chunk（对应 Parquet 文件中的 RowGroup） 每个 Chunk 存储了该块数据的统计信息（最小值、最大值、布隆过滤器等） 执行查询时，先通过 SkipIndex 判断整个 Chunk 是否可以跳过 若可以跳过，则无需加载和扫描该 Chunk 的实际数据，节省 I/O 和 CPU 与传统索引的区别：\n传统倒排索引：精确定位到具体的行，但构建开销大，占用更多内存 SkipIndex：粗粒度跳过整个 Chunk，开销极小，是一种轻量级的优化手段 两者互补：有精确索引时用索引，无索引时用 SkipIndex 减少扫描量 1.5 项目时间线 时间 事件 2025年6月 提交项目申请书，初步方案：SkipIndex 作为独立文件与 bm25stats 同级 2025年7月初 导师发来设计草稿（SkipIndex-Draft.md），方案改为嵌入 Parquet footer 2025年7月中 大量阅读 Milvus 和 milvus-storage 源码，理清写入链路 2025年8月 忙完其他事务后，最终确定方案，开始编码 2025年9月底 提交结项报告，提交 Issue #44584 和 PR #44581 2025年10月 社区质疑 Parquet footer 方案的格式绑定问题，需改为独立文件方案 2025年10月底 时间不足，仅完成 SkipIndex 扩展部分，持久化链路未完全打通 第二章：SkipIndex 现有实现分析 2.1 整体设计架构 SkipIndex 的实现分布在以下几个文件中：\ninternal/core/src/index/ ├── SkipIndex.h # 对外公开的 SkipIndex 类 ├── SkipIndex.cpp # SkipIndex 方法实现 └── skipindex_stats/ ├── SkipIndexStats.h # 统计信息类定义（FieldChunkMetrics 层次） ├── SkipIndexStats.cpp # 统计信息构建实现 └── utils.h # 辅助函数（类型判断、N-gram 提取） 整体设计遵循关注点分离原则：\nFieldChunkMetrics：存储一个 Chunk 的统计信息，并提供跳过判断接口（只读） SkipIndexStatsBuilder：从原始数据或 Parquet 统计信息构建 FieldChunkMetrics（写） SkipIndex：持有每个字段的 CacheSlot\u0026lt;FieldChunkMetrics\u0026gt;，提供查询接口（管理） Translator：连接 CacheSlot 和数据源，实现按需（Lazy）加载 2.2 Metrics 类型系统 在 SkipIndexStats.h 中，统计信息的值使用 std::variant 定义：\n// 文件: internal/core/src/index/skipindex_stats/SkipIndexStats.h using Metrics = std::variant\u0026lt;bool, int8_t, int16_t, int32_t, int64_t, float, double, std::string, std::string_view\u0026gt;; std::variant 是 C++17 的类型安全联合体（Tagged Union），相比 union 的优势：\n类型安全：运行时知道当前存储的是哪种类型 无需手动管理构造/析构 可以与 std::visit 结合，以 visitor 模式处理不同类型 此外还有辅助类型别名：\ntemplate \u0026lt;typename T\u0026gt; using MetricsDataType = std::conditional_t\u0026lt;std::is_same_v\u0026lt;T, std::string\u0026gt;, std::string_view, T\u0026gt;; 这里使用了 std::conditional_t，当 T 是 std::string 时，返回 std::string_view（避免不必要的字符串拷贝）；否则返回 T 本身。\n2.3 FieldChunkMetrics 类层次结构 FieldChunkMetrics 是抽象基类，定义了所有统计信息对象必须支持的接口：\nclass FieldChunkMetrics { public: virtual std::unique_ptr\u0026lt;FieldChunkMetrics\u0026gt; Clone() const = 0; virtual FieldChunkMetricsType GetMetricsType() const = 0; // 一元范围查询：field OP val virtual bool CanSkipUnaryRange(OpType op_type, const Metrics\u0026amp; val) const = 0; // 二元范围查询：lower_val OP field OP upper_val virtual bool CanSkipBinaryRange(const Metrics\u0026amp; lower_val, const Metrics\u0026amp; upper_val, bool lower_inclusive, bool upper_inclusive) const { return false; // 默认不跳过（保守实现） } // IN 查询：field IN [v1, v2, ...] virtual bool CanSkipIn(const std::vector\u0026lt;Metrics\u0026gt;\u0026amp; values) const { return false; } // 缓存层接口 cachinglayer::ResourceUsage CellByteSize() const; void SetCellSize(cachinglayer::ResourceUsage cell_size); private: cachinglayer::ResourceUsage cell_size_{0, 0}; }; 设计要点：\nClone() 方法：实现深拷贝，用于缓存层将 Cell 从 Translator 取出后复制 默认实现返回 false（不跳过），这是保守策略——宁可多扫描，不能漏数据 CanSkipBinaryRange 和 CanSkipIn 有默认实现，子类可以选择是否重写 具体子类：\n2.3.1 NoneFieldChunkMetrics class NoneFieldChunkMetrics : public FieldChunkMetrics { public: FieldChunkMetricsType GetMetricsType() const override { return FieldChunkMetricsType::NONE; } bool CanSkipUnaryRange(OpType, const Metrics\u0026amp;) const override { return false; // 永远不跳过 } }; 用于：\n不支持的数据类型（如 JSON） 数据为空的 Chunk 类型不匹配时的 fallback 2.3.2 BooleanFieldChunkMetrics class BooleanFieldChunkMetrics : public FieldChunkMetrics { private: bool has_true_ = false; bool has_false_ = false; public: FieldChunkMetricsType GetMetricsType() const override { return FieldChunkMetricsType::BOOLEAN; } bool CanSkipUnaryRange(OpType op_type, const Metrics\u0026amp; val) const override; bool CanSkipIn(const std::vector\u0026lt;Metrics\u0026gt;\u0026amp; values) const override; }; 布尔类型只有两个可能值，不需要 min/max，而是跟踪该 Chunk 中是否存在 true 和 false：\nhas_true_ = true：该 Chunk 含有 true 值 has_false_ = true：该 Chunk 含有 false 值 跳过逻辑示例：\n查询 field = true，若 has_true_ == false，可以跳过 查询 field IN [true]，若 has_true_ == false，可以跳过 2.3.3 FloatFieldChunkMetrics\u0026lt;T\u0026gt; template \u0026lt;typename T\u0026gt; // T = float 或 double class FloatFieldChunkMetrics : public FieldChunkMetrics { private: T min_; T max_; public: FieldChunkMetricsType GetMetricsType() const override { return FieldChunkMetricsType::FLOAT; } bool CanSkipUnaryRange(OpType op_type, const Metrics\u0026amp; val) const override; bool CanSkipBinaryRange(...) const override; bool CanSkipIn(const std::vector\u0026lt;Metrics\u0026gt;\u0026amp; values) const override; }; 浮点类型仅使用 min/max，没有 Bloom Filter。原因：\n浮点数的精确相等比较本身就是危险的操作（浮点精度问题） Bloom Filter 对浮点等值查询效果有限 范围查询（\u0026gt;, \u0026lt;, BETWEEN）用 min/max 就足够了 2.3.4 IntFieldChunkMetrics\u0026lt;T\u0026gt; template \u0026lt;typename T\u0026gt; // T = int8/16/32/64 class IntFieldChunkMetrics : public FieldChunkMetrics { private: T min_; T max_; BloomFilterPtr bloom_filter_; // 可选 public: FieldChunkMetricsType GetMetricsType() const override { return FieldChunkMetricsType::INT; } bool CanSkipUnaryRange(OpType op_type, const Metrics\u0026amp; val) const override; bool CanSkipBinaryRange(...) const override; bool CanSkipIn(const std::vector\u0026lt;Metrics\u0026gt;\u0026amp; values) const override; }; 整数类型的特点：\n同时使用 min/max（范围查询）和 Bloom Filter（等值查询） Bloom Filter 是可选的，低基数（取值少）的字段可以用 Set 替代 等值查询时先用 Bloom Filter，范围查询用 min/max 2.3.5 StringFieldChunkMetrics class StringFieldChunkMetrics : public FieldChunkMetrics { private: std::string min_; std::string max_; BloomFilterPtr bloom_filter_; // 用于等值查询 BloomFilterPtr ngram_bloom_filter_; // 用于 LIKE 查询 public: FieldChunkMetricsType GetMetricsType() const override { return FieldChunkMetricsType::STRING; } bool CanSkipUnaryRange(OpType op_type, const Metrics\u0026amp; val) const override; bool CanSkipBinaryRange(...) const override; bool CanSkipIn(const std::vector\u0026lt;Metrics\u0026gt;\u0026amp; values) const override; }; 字符串类型最为复杂，包含：\nmin/max：字符串的字典序比较，用于范围查询 bloom_filter_：等值查询（field = 'abc'） ngram_bloom_filter_：LIKE 查询（field LIKE '%abc%'） 2.4 SkipIndex 核心 API SkipIndex 类位于 internal/core/src/index/SkipIndex.h，是对外暴露的主接口：\nclass SkipIndex { private: // 类型约束 trait template \u0026lt;typename T\u0026gt; struct IsAllowedType { static constexpr bool isAllowedType = std::is_integral\u0026lt;T\u0026gt;::value || std::is_floating_point\u0026lt;T\u0026gt;::value || std::is_same\u0026lt;T, std::string\u0026gt;::value || std::is_same\u0026lt;T, std::string_view\u0026gt;::value; static constexpr bool isDisabledType = std::is_same\u0026lt;T, milvus::Json\u0026gt;::value || std::is_same\u0026lt;T, bool\u0026gt;::value; // 整体是否支持：允许类型 且 非禁用类型 static constexpr bool value = isAllowedType \u0026amp;\u0026amp; !isDisabledType; // 是否支持算术运算（仅整数，且非 bool） static constexpr bool arith_value = std::is_integral\u0026lt;T\u0026gt;::value \u0026amp;\u0026amp; !std::is_same\u0026lt;T, bool\u0026gt;::value; // 是否支持 IN 查询（比 value 宽松） static constexpr bool in_value = isAllowedType; }; // 高精度类型推导：整数用 int64_t，其他保持原类型 template \u0026lt;typename T\u0026gt; using HighPrecisionType = std::conditional_t\u0026lt;std::is_integral_v\u0026lt;T\u0026gt; \u0026amp;\u0026amp; !std::is_same_v\u0026lt;bool, T\u0026gt;, int64_t, T\u0026gt;; IsAllowedType 的设计思路：\nisAllowedType：必须是整数、浮点数或字符串类型（这些类型有天然的比较语义） isDisabledType：JSON 类型（结构复杂，没有单一比较语义）和 bool（bool 的范围比较无意义，用 BooleanFieldChunkMetrics 专门处理） 最终 value = isAllowedType \u0026amp;\u0026amp; !isDisabledType，确保两个条件同时满足 HighPrecisionType 的设计思路：\n算术运算（加减乘除）可能导致整数溢出 用 int64_t 作为中间计算类型，延迟溢出检测到最后 浮点数保持原类型（float/double 不需要精度提升） 2.4.1 CanSkipUnaryRange — 一元范围查询 template \u0026lt;typename T\u0026gt; std::enable_if_t\u0026lt;SkipIndex::IsAllowedType\u0026lt;T\u0026gt;::value, bool\u0026gt; CanSkipUnaryRange(FieldId field_id, int64_t chunk_id, OpType op_type, const T\u0026amp; val) const { auto pw = GetFieldChunkMetrics(field_id, chunk_id); auto field_chunk_metrics = pw.get(); return field_chunk_metrics-\u0026gt;CanSkipUnaryRange(op_type, index::Metrics{val}); } std::enable_if_t 实现了基于类型约束的函数重载（SFINAE）：\n若 T 满足 IsAllowedType\u0026lt;T\u0026gt;::value，编译器选择此重载（返回实际跳过结果） 若 T 不满足，编译器选择另一个重载（直接返回 false，不触发错误） 2.4.2 CanSkipBinaryArithRange — 算术范围查询 这是最复杂的一个接口，处理 field OP value 这种算术表达式，例如：\nWHERE age + 5 \u0026gt; 30 WHERE salary * 2 \u0026lt; 100000 WHERE score / 10 \u0026gt;= 8 template \u0026lt;typename T\u0026gt; std::enable_if_t\u0026lt;SkipIndex::IsAllowedType\u0026lt;T\u0026gt;::arith_value, bool\u0026gt; CanSkipBinaryArithRange(FieldId field_id, int64_t chunk_id, OpType op_type, ArithOpType arith_type, const HighPrecisionType\u0026lt;T\u0026gt; value, const HighPrecisionType\u0026lt;T\u0026gt; right_operand) const { auto check_and_skip = [\u0026amp;](HighPrecisionType\u0026lt;T\u0026gt; new_value_hp, OpType new_op_type) { // 溢出检测：转换回 T 类型时是否会溢出？ if constexpr (std::is_integral_v\u0026lt;T\u0026gt;) { if (new_value_hp \u0026gt; std::numeric_limits\u0026lt;T\u0026gt;::max() || new_value_hp \u0026lt; std::numeric_limits\u0026lt;T\u0026gt;::min()) { return false; // 溢出，无法安全比较，保守策略：不跳过 } } return CanSkipUnaryRange\u0026lt;T\u0026gt;(field_id, chunk_id, new_op_type, static_cast\u0026lt;T\u0026gt;(new_value_hp)); }; switch (arith_type) { case ArithOpType::Add: // field + C \u0026gt; V =\u0026gt; field \u0026gt; V - C return check_and_skip(value - right_operand, op_type); case ArithOpType::Sub: // field - C \u0026gt; V =\u0026gt; field \u0026gt; V + C return check_and_skip(value + right_operand, op_type); case ArithOpType::Mul: // field * C \u0026gt; V =\u0026gt; field \u0026gt; V / C（C != 0） // 若 C \u0026lt; 0，需要翻转比较运算符 if (right_operand == 0) return false; OpType new_op = right_operand \u0026lt; 0 ? FlipComparisonOperator(op_type) : op_type; return check_and_skip(value / right_operand, new_op); case ArithOpType::Div: // field / C \u0026gt; V =\u0026gt; field \u0026gt; V * C（C != 0） if (right_operand == 0) return false; // 除以零 OpType new_op = right_operand \u0026lt; 0 ? FlipComparisonOperator(op_type) : op_type; return check_and_skip(value * right_operand, new_op); } } 数学推导：以 field + C \u0026gt; V 为例，等价于 field \u0026gt; V - C。这样就把对 (field + C) 的范围判断转化为对 field 的范围判断，再用 SkipIndex 中存储的 min/max 判断即可。\n负数翻转的数学依据：对于乘法 field * C \u0026gt; V：\n若 C \u0026gt; 0：两边除以 C，不等号方向不变：field \u0026gt; V/C 若 C \u0026lt; 0：两边除以 C（负数），不等号方向改变：field \u0026lt; V/C 2.5 RangeShouldSkip 跳过逻辑 核心跳过判断函数，位于 SkipIndexStats.h：\ntemplate \u0026lt;typename T\u0026gt; inline bool RangeShouldSkip(const T\u0026amp; value, // 查询值 const T\u0026amp; lower_bound, // Chunk 最小值 const T\u0026amp; upper_bound, // Chunk 最大值 OpType op_type) { bool should_skip = false; switch (op_type) { case OpType::Equal: { // value == field？若 value 在 [min, max] 外，肯定无匹配 should_skip = value \u0026gt; upper_bound || value \u0026lt; lower_bound; break; } case OpType::LessThan: { // field \u0026lt; value？若 value \u0026lt;= min，则所有 field 都 \u0026gt;= value，无匹配 should_skip = value \u0026lt;= lower_bound; break; } case OpType::LessEqual: { // field \u0026lt;= value？若 value \u0026lt; min，则所有 field \u0026gt; value，无匹配 should_skip = value \u0026lt; lower_bound; break; } case OpType::GreaterThan: { // field \u0026gt; value？若 value \u0026gt;= max，则所有 field \u0026lt;= value，无匹配 should_skip = value \u0026gt;= upper_bound; break; } case OpType::GreaterEqual: { // field \u0026gt;= value？若 value \u0026gt; max，则所有 field \u0026lt; value，无匹配 should_skip = value \u0026gt; upper_bound; break; } } return should_skip; } 为什么这里的逻辑是正确的？以 Equal 为例：\nChunk 中的所有值都在 [lower_bound, upper_bound] 范围内 若查询值 value 在此范围外，则 Chunk 中不可能存在等于 value 的记录 因此可以安全跳过整个 Chunk 2.6 缓存层架构 SkipIndex 采用了 Milvus 的缓存层（Caching Layer）机制，避免重复计算和内存浪费：\nSkipIndex └── fieldChunkMetrics_: unordered_map\u0026lt;FieldId, CacheSlot\u0026lt;FieldChunkMetrics\u0026gt;\u0026gt; │ └── CacheSlot\u0026lt;FieldChunkMetrics\u0026gt; │ └── Translator（数据来源） ├── FieldChunkMetricsTranslator │ （从 ChunkedColumn 按需计算） └── FieldChunkMetricsTranslatorFromStatistics （从 Parquet Statistics 预计算） CacheSlot 的工作原理：\nCacheSlot 是一个带有缓存的数据容器 数据按 cid_t（Cell ID，对应 chunk_id）组织 首次访问 chunk_id 时，通过 Translator::get_cells() 加载数据到缓存 后续访问命中缓存，直接返回 PinWrapper：RAII 风格的缓存引用计数器：\nconst cachinglayer::PinWrapper\u0026lt;const index::FieldChunkMetrics*\u0026gt; GetFieldChunkMetrics(FieldId field_id, int chunk_id) const; PinWrapper 确保在使用期间缓存不会被驱逐：\n构造时：增加引用计数，\u0026ldquo;固定\u0026quot;对应的 Cache Cell 析构时：减少引用计数，允许 GC 驱逐 两种 Translator 对比：\n特性 FieldChunkMetricsTranslator FieldChunkMetricsTranslatorFromStatistics 数据来源 ChunkedColumnInterface（内存数据） Parquet Statistics（已预计算） 计算时机 首次访问时按需计算（Lazy） 构造时就已全部计算（Eager） 适用场景 动态构建（无预构建数据时 fallback） 预构建（已持久化 SkipIndex 时加载） 性能 首次慢，后续快 构造时快，全程快 第三章：表达式层中 SkipIndex 的使用 3.1 总体集成模式 SkipIndex 通过回调函数（skip_func）集成到表达式求值框架中：\n// 典型使用模式（伪代码） auto skip_func = [\u0026amp;](const SkipIndex\u0026amp; skip_index, FieldId field_id, int chunk_id) -\u0026gt; bool { return skip_index.CanSkipXxx(field_id, chunk_id, ...); }; // 传递给 ProcessDataChunks ProcessDataChunks(filter_func, skip_func, ...); 表达式求值时，对每个 Chunk：\n先调用 skip_func 检查是否可以跳过 若可以跳过，设置该 Chunk 所有行的结果为 false（不匹配），更新内部游标 若不能跳过，调用实际的 filter_func 对每行数据进行过滤 这种设计的优点：\n正交性：SkipIndex 逻辑与过滤逻辑完全分离 可选性：skip_func 是可选参数，不影响正确性 可扩展性：新增 SkipIndex 类型只需修改 skip_func，不影响表达式框架 3.2 PhyTermFilterExpr（IN 查询） 位于 internal/core/src/exec/expression/TermExpr.cpp：\n// IN 查询的 SkipIndex 优化 auto skip_func = [\u0026amp;](const SkipIndex\u0026amp; skip_index, FieldId field_id, int chunk_id) -\u0026gt; bool { // 对每个查询值，检查是否存在于该 Chunk return skip_index.CanSkipInQuery\u0026lt;T\u0026gt;(field_id, chunk_id, terms_); }; CanSkipInQuery 的工作原理：\n将所有查询值打包成 vector\u0026lt;Metrics\u0026gt; 调用 FieldChunkMetrics::CanSkipIn() 对于 IntFieldChunkMetrics：先用 Bloom Filter 检查每个值，若所有值都不在 BF 中，则跳过 对于 FloatFieldChunkMetrics：只能用 min/max 范围，若所有值都在范围外，则跳过 3.3 BinaryArithOpEvalRangeExpr（算术范围查询） 位于 internal/core/src/exec/expression/BinaryArithOpEvalRangeExpr.cpp：\n// 处理 field + C \u0026gt; V 这类表达式 auto skip_func = [\u0026amp;](const SkipIndex\u0026amp; skip_index, FieldId field_id, int chunk_id) -\u0026gt; bool { return skip_index.CanSkipBinaryArithRange\u0026lt;T\u0026gt;( field_id, chunk_id, op_type_, arith_op_type_, value_, right_operand_); }; 3.4 LogicalUnaryExpr（逻辑否定） 位于 internal/core/src/exec/expression/LogicalUnaryExpr.cpp：\n// NOT 表达式的处理 // NOT (field \u0026gt; V) =\u0026gt; field \u0026lt;= V // 注意：SkipIndex 的跳过逻辑对 NOT 表达式需要取反 3.5 JsonContainsExpr（JSON 包含查询） 位于 internal/core/src/exec/expression/JsonContainsExpr.cpp：\n// JSON 字段的 CONTAINS 查询 // 利用 JsonKeyStats（JSON 键统计）进行 Chunk 级跳过 3.6 ProcessDataChunks 的执行流程 Expr.h 中定义了通用的 Chunk 处理框架：\n// 简化示意 template \u0026lt;typename Func, typename SkipFunc\u0026gt; void ProcessDataChunks(Func filter_func, SkipFunc skip_func) { auto\u0026amp; skip_index = segment_-\u0026gt;GetSkipIndex(); for (int chunk_id = 0; chunk_id \u0026lt; num_chunks; ++chunk_id) { // 先检查是否可以跳过 if (skip_func \u0026amp;\u0026amp; skip_func(skip_index, field_id_, chunk_id)) { // 跳过：将这个 Chunk 的所有行标记为不匹配 // 但仍需更新内部游标（bitset 位置等） HandleSkippedChunk(chunk_id); continue; } // 不能跳过：实际过滤 auto chunk_data = GetChunkData(chunk_id); for (int row = 0; row \u0026lt; chunk_size; ++row) { result[offset + row] = filter_func(chunk_data[row]); } offset += chunk_size; } } 跳过时的处理：被跳过的 Chunk 不是什么都不做，而是将其结果设为全 false，并正确更新内部状态（如 bitset 偏移量），以保证后续 Chunk 的结果正确写入到 bitset 的正确位置。\n第四章：存储链路分析 4.1 Milvus Storage V2 架构 Milvus 的存储体系分为 V1 和 V2 两个版本：\n特性 Storage V1 Storage V2 底层格式 自定义二进制格式（binlog） Parquet（通过 milvus-storage） Parquet 库 不使用 Apache Arrow Parquet（Rust 实现，通过 milvus-storage 跨语言调用） 文件组织 每个字段每个 Chunk 一个文件 列分组（Column Group），多个 RowGroup，~1MB/RowGroup 写入方式 Milvus 直接控制，逐行/逐批写入 通过 milvus-storage 库，攒批（buffer）后整 RowGroup 写入 元数据 写入 Milvus 自有格式头 Parquet footer（key-value metadata，可扩展） 耦合程度 Milvus 直接控制写入细节 通过 milvus-storage 库接口解耦，Milvus 不感知 RowGroup 边界 Chunk/RowGroup 对应 每个文件即一个 Chunk 读取阶段每个 RowGroup 对应一个 Chunk（write phase 无 Chunk 概念） Storage V1 格式说明：V1 使用 Milvus 自定义的 binlog 格式，每个字段单独存储为一个文件（insert binlog），格式头包含数据类型、行数等元信息，后续跟实际数据的二进制编码。这种格式由 Milvus 完全自主控制，但不能利用 Parquet 的列式存储优化（如编码压缩、谓词下推、原生 Statistics 等）。\nStorage V2 的关键改进：\nParquet 格式：利用列式存储的压缩和编码优势，降低存储和 I/O 开销 milvus-storage 库：独立仓库，封装了 Parquet 的读写细节；其底层 Parquet 库使用 Rust 实现（通过 Apache Arrow 的 Rust 绑定），并在 milvus-storage 内部通过 FFI（Foreign Function Interface）跨语言调用，Go/C++ 侧通过 CGO 调用 milvus-storage 的 C 接口 攒批写入（Buffered Write）：PackedRecordBatchWriter 将多个 RecordBatch 积累到内存缓冲区，达到约 1MB 阈值后一次性写入一个完整的 RowGroup，减少写放大和小 I/O 可扩展元数据：Parquet footer 的 key-value metadata 可存储自定义统计信息（如 SkipIndex），是本项目 Parquet footer 方案的基础 Column Group 的概念：Storage V2 将字段按类型分组到不同的 Parquet 文件：\n宽列（向量字段、文本字段）：每个字段独占一个 Column Group 窄列（标量字段）：所有标量字段共享一个 Column Group Segment 写入 → Column Group 0（标量字段：id, age, name, ...）→ file_0.parquet Column Group 1（向量字段：embedding） → file_1.parquet Column Group 2（文本字段：content） → file_2.parquet 4.2 数据写入链路全景 从 DataNode 发起到 Parquet 文件落盘的完整链路：\nDataNode (Go) └── Sort Task 触发 └── PackedBinlogRecordWriter.Write(Record) [record_writer.go] └── packedRecordWriter.Write(Record) [record_writer.go] └── PackedWriter.WriteRecordBatch() [packed_writer.go, CGO] └── C.WritePackedRecordBatch() [CGO 桥接] └── C++: milvus_storage::PackedRecordBatchWriter::Write() └── ParquetFileWriter.WriteRowGroup() ├── 积累到 buffer_size 阈值 ├── 调用 MetadataBuilder.Append(batch) └── 写入 Parquet RowGroup 关键点：milvus-storage 中的 PackedRecordBatchWriter 是真正控制 RowGroup 边界的地方。它积累 RecordBatch，当内存占用达到阈值（默认约 1MB）时，触发 RowGroup 的写入。这就是 SkipIndex 需要在这里构建的原因。\n4.3 Go 层的 packedRecordWriter 文件：internal/storage/record_writer.go\ntype packedRecordWriter struct { writer *packed.PackedWriter // CGO 桥接 bufferSize int64 columnGroups []storagecommon.ColumnGroup pathsMap map[typeutil.UniqueID]string schema *schemapb.CollectionSchema arrowSchema *arrow.Schema rowNum int64 writtenUncompressed uint64 columnGroupUncompressed map[typeutil.UniqueID]uint64 columnGroupCompressed map[typeutil.UniqueID]uint64 storageConfig *indexpb.StorageConfig } func (pw *packedRecordWriter) Write(r Record) error { var rec arrow.Record // 处理普通 Record 转换为 Arrow Record... // 统计未压缩大小 pw.rowNum += int64(r.Len()) for col, arr := range rec.Columns() { size := calculateActualDataSize(arr) pw.writtenUncompressed += size // 按 ColumnGroup 分类统计 for _, columnGroup := range pw.columnGroups { if lo.Contains(columnGroup.Columns, col) { pw.columnGroupUncompressed[columnGroup.GroupID] += size break } } } // 实际写入：调用 CGO return pw.writer.WriteRecordBatch(rec) } Go 层的主要职责：\n将 Milvus 内部的 Record 转换为 Arrow Record（使用 Arrow 标准格式） 统计写入的数据量（用于 binlog 元数据记录） 将 Arrow Record 通过 CGO 传递给 C++ 层 4.4 CGO 桥接层（packed_writer.go） 文件：internal/storagev2/packed/packed_writer.go\nfunc NewPackedWriter(filePaths []string, schema *arrow.Schema, ...) (*PackedWriter, error) { // Step 1: 将 Go string 转换为 C string cFilePaths := make([]*C.char, len(filePaths)) for i, path := range filePaths { cFilePaths[i] = C.CString(path) defer C.free(unsafe.Pointer(cFilePaths[i])) // RAII: 函数返回时释放 } // Step 2: 导出 Arrow Schema 到 C Data Interface var cas cdata.CArrowSchema cdata.ExportArrowSchema(schema, \u0026amp;cas) cSchema := (*C.struct_ArrowSchema)(unsafe.Pointer(\u0026amp;cas)) defer cdata.ReleaseCArrowSchema(\u0026amp;cas) // Step 3: 构建 Column Splits（列分组信息） cColumnSplits := C.NewCColumnSplits() for _, group := range columnGroups { // 分配 C 内存存储列索引数组 cGroup := C.malloc(C.size_t(len(group.Columns)) * C.size_t(unsafe.Sizeof(C.int(0)))) defer C.free(cGroup) cGroupSlice := (*[1 \u0026lt;\u0026lt; 30]C.int)(cGroup)[:len(group.Columns):len(group.Columns)] for i, val := range group.Columns { cGroupSlice[i] = C.int(val) } C.AddCColumnSplit(cColumnSplits, (*C.int)(cGroup), C.int(len(group.Columns))) } // Step 4: 调用 C++ 创建 PackedWriter var cPackedWriter C.CPackedWriter status = C.NewPackedWriterWithStorageConfig(cSchema, cBufferSize, ...) if err := ConsumeCStatusIntoError(\u0026amp;status); err != nil { return nil, err } return \u0026amp;PackedWriter{cPackedWriter: cPackedWriter}, nil } func (pw *PackedWriter) WriteRecordBatch(recordBatch arrow.Record) error { // 将每一列导出为 Arrow C Data Interface cArrays := make([]CArrowArray, recordBatch.NumCols()) cSchemas := make([]CArrowSchema, recordBatch.NumCols()) for i := range recordBatch.NumCols() { var caa cdata.CArrowArray var cas cdata.CArrowSchema // ExportArrowArray：零拷贝，通过指针传递数据 cdata.ExportArrowArray(recordBatch.Column(int(i)), \u0026amp;caa, \u0026amp;cas) cArrays[i] = *(*CArrowArray)(unsafe.Pointer(\u0026amp;caa)) cSchemas[i] = *(*CArrowSchema)(unsafe.Pointer(\u0026amp;cas)) } // 调用 C++ 写入 status = C.WritePackedRecordBatch(\u0026amp;pw.cPackedWriter, ...) return ConsumeCStatusIntoError(\u0026amp;status) } Arrow C Data Interface 是实现 Go → C++ 零拷贝数据传递的关键：\n定义了一套标准的 C 结构体（ArrowSchema, ArrowArray） 数据通过指针传递，不进行数据拷贝 使用引用计数管理内存，由释放回调（release 函数指针）处理 4.5 BM25Stats 参照模式 BM25Stats 是 SkipIndex 设计时参照的统计信息构建模式，理解它有助于理解 SkipIndex 的设计思路。\nBM25Stats 的 Go 层实现（internal/storage/stats.go）：\ntype BM25Stats struct { rowsWithToken map[uint32]int32 // token ID → 含该 token 的行数 numRow int64 // 总行数 numToken int64 // 总 token 数 } // 增量更新（每次写入一条记录时调用） func (m *BM25Stats) AppendBytes(datas ...[]byte) { for _, data := range datas { dim := typeutil.SparseFloatRowElementCount(data) for i := 0; i \u0026lt; dim; i++ { index := typeutil.SparseFloatRowIndexAt(data, i) value := typeutil.SparseFloatRowValueAt(data, i) m.rowsWithToken[index] += 1 // 该 token 出现的行数 +1 m.numToken += int64(value) // 累加 token 权重 } m.numRow += 1 } } // 二进制序列化（版本兼容格式） func (m *BM25Stats) Serialize() ([]byte, error) { buffer := bytes.NewBuffer(...) binary.Write(buffer, common.Endian, BM25VERSION) // 版本号 binary.Write(buffer, common.Endian, m.numRow) // 行数 binary.Write(buffer, common.Endian, m.numToken) // token 总数 for key, value := range m.rowsWithToken { binary.Write(buffer, common.Endian, key) // token ID binary.Write(buffer, common.Endian, value) // 该 token 出现行数 } return buffer.Bytes(), nil } StatsCollector 接口模式（internal/storage/stats_collector.go）：\ntype StatsCollector interface { Collect(r Record) error // 阶段1：积累 Digest(...) (map[FieldID]*datapb.FieldBinlog, error) // 阶段2：完成 } // BM25 统计收集器 type Bm25StatsCollector struct { bm25Stats map[FieldID]*BM25Stats } func (c *Bm25StatsCollector) Collect(r Record) error { for fieldID, stats := range c.bm25Stats { field, _ := r.Column(fieldID).(*array.Binary) for i := 0; i \u0026lt; r.Len(); i++ { stats.AppendBytes(field.Value(i)) // 增量更新 } } return nil } func (c *Bm25StatsCollector) Digest(...) (...) { for fid, stats := range c.bm25Stats { bytes, _ := stats.Serialize() // 序列化 // 写入独立的 stats 文件（作为 binlog） blobsWriter(blobs) } } BM25Stats 的存储方式（最终作为独立 binlog 文件）正是 SkipIndex 最初方案和最终决定的方案：作为独立文件，与数据文件（insert binlog）并列，通过 Milvus 的元数据管理系统（etcd）记录其存在。\n第五章：milvus-storage Metadata 接口设计 5.1 设计背景与核心挑战 这是整个项目中最具挑战性的技术决策，也是最终的创新点之一。\n核心问题：SkipIndex 的构建需要在每个 RowGroup 写入时触发，但：\nRowGroup 的划分逻辑在 milvus-storage 内部（PackedRecordBatchWriter） milvus-storage 是一个独立仓库，对 Milvus 不可见 不能在 milvus-storage 中直接引用 Milvus 的 SkipIndex 类（破坏解耦） 不能仅为 SkipIndex 设计一个特殊接口（不够通用，难以被社区接受） 解决思路：设计一个通用的元数据构建接口，让 milvus-storage 只知道有一个\u0026quot;元数据构建者\u0026rdquo;（MetadataBuilder），而不知道具体是什么元数据。Milvus 这边实现 SkipIndex 版本的 MetadataBuilder，注册到 milvus-storage 的写入流水线中。\n5.2 Metadata 抽象类 文件：milvus-storage/cpp/include/milvus-storage/common/metadata.h\nclass Metadata { public: virtual ~Metadata() = default; // 序列化为字符串（用于存储到 Parquet footer） virtual std::string Serialize() const = 0; // 从字符串反序列化（用于从 Parquet footer 读取） virtual void Deserialize(const std::string_view data) = 0; }; 设计特点：\n最小化接口：只有两个纯虚函数，极其简洁 字符串序列化：使用 std::string 和 std::string_view，不依赖任何序列化框架 无状态接口：不持有数据，只定义 I/O 契约 5.3 MetadataBuilder 设计 class MetadataBuilder { private: struct MetadataHeader { uint32_t magic = 0; // 魔数，用于数据校验 uint32_t version = 0; // 版本号，用于向前兼容 uint32_t count = 0; // 条目数量（= RowGroup 数量） }; static constexpr uint32_t kMagicNumber = 0x4D424C44; // \u0026#34;MBLD\u0026#34; = Metadata BuiLD static constexpr uint32_t kCurrentVersion = 1; public: // 每写入一个 RowGroup 时调用 void Append(const std::vector\u0026lt;std::shared_ptr\u0026lt;arrow::RecordBatch\u0026gt;\u0026gt;\u0026amp; batch) { metadata_collection_.emplace_back(Create(batch)); // 调用子类的 Create } // 写入完成后调用，返回序列化的整个 metadata 集合 std::string Finish() { return MetadataBuilder::Serialize(metadata_collection_); } // 静态序列化方法：生成带 header 的二进制格式 static std::string Serialize( const std::vector\u0026lt;std::unique_ptr\u0026lt;Metadata\u0026gt;\u0026gt;\u0026amp; metadata_list) { std::stringstream ss(std::ios::binary | std::ios::out); // 写入 header（固定 12 字节） MetadataHeader header; header.magic = kMagicNumber; header.version = kCurrentVersion; header.count = metadata_list.size(); ss.write(reinterpret_cast\u0026lt;const char*\u0026gt;(\u0026amp;header), sizeof(header)); // 写入每个 Metadata（长度前缀 + 数据） for (const auto\u0026amp; meta : metadata_list) { std::string data = meta-\u0026gt;Serialize(); uint32_t len = data.length(); ss.write(reinterpret_cast\u0026lt;const char*\u0026gt;(\u0026amp;len), sizeof(len)); // 4字节长度 ss.write(data.data(), len); // 实际数据 } return ss.str(); } // 模板反序列化：从 Parquet footer 的字符串还原 Metadata 列表 template \u0026lt;typename MetadataT\u0026gt; static std::vector\u0026lt;std::unique_ptr\u0026lt;MetadataT\u0026gt;\u0026gt; Deserialize( const std::string_view data) { // 读取 header，验证 magic 和 version MetadataHeader header; std::memcpy(\u0026amp;header, data.data() + offset, sizeof(header)); if (header.magic != kMagicNumber || header.version != kCurrentVersion) { return {}; // 格式不匹配，安全返回空 } std::vector\u0026lt;std::unique_ptr\u0026lt;MetadataT\u0026gt;\u0026gt; result; result.reserve(header.count); // 逐个读取：先读 4 字节长度，再读数据 for (uint32_t i = 0; i \u0026lt; header.count; ++i) { uint32_t len; std::memcpy(\u0026amp;len, data.data() + offset, sizeof(len)); offset += sizeof(uint32_t); std::string_view meta_data(data.data() + offset, len); offset += len; auto meta = std::make_unique\u0026lt;MetadataT\u0026gt;(); meta-\u0026gt;Deserialize(meta_data); // 调用具体子类的 Deserialize result.emplace_back(std::move(meta)); } return result; } protected: // 纯虚方法：子类实现具体的 Metadata 对象创建逻辑 virtual std::unique_ptr\u0026lt;Metadata\u0026gt; Create( const std::vector\u0026lt;std::shared_ptr\u0026lt;arrow::RecordBatch\u0026gt;\u0026gt;\u0026amp; batch) = 0; std::vector\u0026lt;std::unique_ptr\u0026lt;Metadata\u0026gt;\u0026gt; metadata_collection_; }; 5.4 二进制格式设计 序列化后的二进制格式如下：\n+----------------+----------------+----------------+ | magic (4B) | version (4B) | count (4B) | \u0026lt;- Header (12 bytes) +----------------+----------------+----------------+ | len_0 (4B) | data_0 (len_0 bytes) | \u0026lt;- Metadata 0 +----------------+----------------------------------+ | len_1 (4B) | data_1 (len_1 bytes) | \u0026lt;- Metadata 1 +----------------+----------------------------------+ | ... | +-------------------------------------------------- + | len_n (4B) | data_n (len_n bytes) | \u0026lt;- Metadata n +----------------+----------------------------------+ 魔数 0x4D424C44 的含义： 将 4 字节十六进制值按 ASCII 解码：0x4D='M', 0x42='B', 0x4C='L', 0x44='D' → \u0026ldquo;MBLD\u0026rdquo;，即 \u0026ldquo;Metadata BuiLD\u0026rdquo; 的缩写。这是一种常见的**魔数（Magic Number）**设计，用于快速识别文件/数据格式。\n5.5 PackedFileMetadata — 读取接口 文件：milvus-storage/cpp/include/milvus-storage/common/metadata.h\nclass PackedFileMetadata { public: // 模板方法：从 Parquet footer 中读取指定 key 对应的 Metadata 列表 template \u0026lt;typename MetadataT\u0026gt; std::vector\u0026lt;std::unique_ptr\u0026lt;MetadataT\u0026gt;\u0026gt; GetMetadataVector( std::string_view key) const { // 从 Parquet 文件的 key-value metadata 中读取 auto key_value_metadata = parquet_metadata_-\u0026gt;key_value_metadata(); auto metadata = key_value_metadata-\u0026gt;Get(key); if (!metadata.ok()) { return {}; // key 不存在（向后兼容） } const std::string\u0026amp; metadata_str = metadata.ValueOrDie(); // 调用 MetadataBuilder::Deserialize 进行反序列化 return MetadataBuilder::Deserialize\u0026lt;MetadataT\u0026gt;( std::string_view(metadata_str)); } private: std::shared_ptr\u0026lt;parquet::FileMetaData\u0026gt; parquet_metadata_; RowGroupMetadataVector row_group_metadata_; std::map\u0026lt;FieldID, ColumnOffset\u0026gt; field_id_mapping_; GroupFieldIDList group_field_id_list_; std::string storage_version_; }; 使用示例（Milvus 中如何读取 SkipIndex）：\n// 假设 ChunkSkipIndex 是 Metadata 的子类 auto packed_metadata = column_group-\u0026gt;GetPackedFileMetadata(); std::vector\u0026lt;std::unique_ptr\u0026lt;ChunkSkipIndex\u0026gt;\u0026gt; skip_indices = packed_metadata-\u0026gt;GetMetadataVector\u0026lt;ChunkSkipIndex\u0026gt;(\u0026#34;skip_index\u0026#34;); // 将加载的 SkipIndex 注册到 SkipIndex 对象 for (size_t chunk_id = 0; chunk_id \u0026lt; skip_indices.size(); ++chunk_id) { skip_index.RegisterPrebuilt(field_id, chunk_id, std::move(skip_indices[chunk_id])); } 5.6 设计权衡：虚函数 vs CRTP 在设计 MetadataBuilder 时，我考虑了两种方案：\n方案一：虚函数（最终选择）\nclass MetadataBuilder { protected: virtual std::unique_ptr\u0026lt;Metadata\u0026gt; Create(...) = 0; // 虚函数 }; 方案二：CRTP（Curiously Recurring Template Pattern）\ntemplate \u0026lt;typename Derived\u0026gt; class MetadataBuilder { protected: // 静态多态，编译时确定 std::unique_ptr\u0026lt;Metadata\u0026gt; Create(...) { return static_cast\u0026lt;Derived*\u0026gt;(this)-\u0026gt;CreateImpl(...); } }; 最终选择虚函数的原因：\n异构容器需求：ParquetFileWriter 需要持有多个不同类型的 MetadataBuilder，需要类型擦除 复杂度：CRTP 会导致模板参数层层传递，增加代码复杂度 性能：Create() 是每个 RowGroup 调用一次，频率较低，虚函数开销可忽略 可维护性：虚函数对使用者更友好，没有模板知识也能理解 第六章：SkipIndex 扩展设计 6.1 统计信息类型全览 在我的方案中，SkipIndex 被扩展以支持以下统计信息类型：\n类型 适用场景 数据结构 误判率 内存开销 MinMax 范围查询（\u0026gt;, \u0026lt;, BETWEEN） 2个值（min + max） 0% 极小 Set 低基数等值/IN 查询 哈希集合 0% 与基数成正比 BloomFilter 高基数等值/IN 查询 位数组 1%（可配置） ~1.14MB/1M行 NgramBF LIKE 查询（模式匹配） N-gram 位数组 概率性 较大 TokenBF 全文搜索（已决定去除） Token 位数组 概率性 较大 TokenBF 被去除的原因：与 Milvus 中已有的 TextMatchIndex（全文搜索索引）功能重叠，且 SkipIndex 作为轻量级过滤手段，不应承担全文搜索的职责。\n6.2 构建策略选择逻辑 // 伪代码：根据数据类型决定构建哪些统计信息 FieldChunkMetrics* BuildMetrics(DataType type, const ColumnData\u0026amp; data) { switch (type) { case BOOL: // 只需要 has_true / has_false return BuildBoolMetrics(data); case INT8/INT16/INT32/INT64: // 首先构建 MinMax auto metrics = new IntFieldChunkMetrics(min, max); // 根据基数决定 Set 还是 BloomFilter if (data.cardinality() \u0026lt; SET_THRESHOLD) { metrics-\u0026gt;BuildSet(data); // 低基数：精确集合 } else { metrics-\u0026gt;BuildBloomFilter(data); // 高基数：概率过滤器 } return metrics; case FLOAT/DOUBLE: // 只有 MinMax，不使用 BloomFilter（浮点精度问题） return new FloatFieldChunkMetrics(min, max); case VARCHAR/STRING: // MinMax（字符串字典序） auto metrics = new StringFieldChunkMetrics(min, max); // 等值查询优化：Set 或 BloomFilter if (data.cardinality() \u0026lt; SET_THRESHOLD) { metrics-\u0026gt;BuildSet(data); } else { metrics-\u0026gt;BuildBloomFilter(data); } // LIKE 查询优化：NgramBF metrics-\u0026gt;BuildNgramBloomFilter(data); return metrics; } } 6.3 N-Gram Bloom Filter 什么是 N-Gram：\nN-Gram 是将字符串分割为长度为 N 的子串序列的技术：\n字符串: \u0026#34;hello\u0026#34; 2-gram: {\u0026#34;he\u0026#34;, \u0026#34;el\u0026#34;, \u0026#34;ll\u0026#34;, \u0026#34;lo\u0026#34;} 3-gram: {\u0026#34;hel\u0026#34;, \u0026#34;ell\u0026#34;, \u0026#34;llo\u0026#34;} 在 SkipIndex 中的作用：\n对于 LIKE '%abc%' 查询（substring search），我们可以：\n将查询字符串 \u0026ldquo;abc\u0026rdquo; 分割为 N-gram 检查每个 N-gram 是否在 Chunk 的 N-gram Bloom Filter 中 若任意一个 N-gram 不在 BF 中，则整个 Chunk 可以跳过 这是一种必要不充分条件的应用：\nBF 中所有 N-gram 都存在 → 该 Chunk 可能包含目标字符串（不能跳过） BF 中有 N-gram 不存在 → 该 Chunk 一定不含目标字符串（可以跳过） ICU Unicode 支持：\nutils.h 中使用 ICU（International Components for Unicode）库进行 N-gram 提取：\n// ExtractNgrams 函数（简化） std::vector\u0026lt;std::string\u0026gt; ExtractNgrams(std::string_view text, int min_n, int max_n) { // 使用 ICU 将文本转换为 Unicode code points // 以 code point 为单位进行 N-gram 分割 // 保证多语言字符（中文、日文等）的正确处理 } 这很重要：如果直接按字节分割，一个中文字符可能被截断。使用 ICU 按 Unicode code point 分割，保证了多语言正确性。\n6.4 类型系统设计的演变 在最终设计之前，我考虑过以下方案：\n方案A（被放弃）：模板 + 组合\ntemplate \u0026lt;typename T\u0026gt; class StatsHolder { // MinMax\u0026lt;T\u0026gt; // Set\u0026lt;T\u0026gt; // BloomFilter\u0026lt;T\u0026gt; // 通过组合实现\u0026#34;一种数据类型持有多种统计信息\u0026#34; }; 放弃原因：\n模板实例化组合爆炸（每种统计信息 × 每种数据类型 = 大量模板实例） 统计信息的组合逻辑复杂，维护困难 过于泛化，超出了当前需求 方案B（最终选择）：继承\nclass IntFieldChunkMetrics : public FieldChunkMetrics { // 直接包含该类型可能用到的所有统计信息 T min_, max_; BloomFilterPtr bloom_filter_; // 可选 }; class StringFieldChunkMetrics : public FieldChunkMetrics { std::string min_, max_; BloomFilterPtr bloom_filter_; BloomFilterPtr ngram_bloom_filter_; }; 选择理由：\nYAGNI 原则（You Aren\u0026rsquo;t Gonna Need It）：当前数据类型种类少，不需要过度泛化 简单直接：每个子类直接表达\u0026quot;这种类型需要这些统计信息\u0026quot; 可读性好：不需要理解复杂的模板机制就能理解代码 开销小：继承的虚函数开销在查询时可以接受 第七章：方案演变与设计争议 7.1 初始方案（2025年6月） 方案描述：SkipIndex 作为独立文件存储，与 bm25stats、textstats 同级。\nSegment 元数据： - insert binlog: /segments/{id}/insert/ - stats binlog: /segments/{id}/stats/ （主键统计） - bm25 binlog: /segments/{id}/bm25stats/ （BM25 统计） - skip binlog: /segments/{id}/skipstats/ （SkipIndex，新增） 触发时机：Segment Sealed 后，DataCoord 下发 BuildSkipIndex 任务给 DataNode，DataNode 读取数据，计算每个 Chunk 的统计信息，写入独立文件。\n优点：\n与现有模式（bm25stats）一致，架构清晰 存储格式无关（不依赖 Parquet） 元数据管理路径清晰（etcd 中有记录） 缺点（当时认为）：\n需要单独读取数据来计算统计信息（额外 I/O） 需要新增任务调度链路（DataCoord → DataNode） 7.2 导师方案（2025年7月） 方案描述：将 SkipIndex 存储到 Parquet 文件的 footer 中，与数据共存。\nParquet 文件结构： RowGroup 0 RowGroup 1 ... RowGroup N Footer: - Parquet 原生 Statistics（每个列每个 RowGroup 的 min/max） - Key-Value Metadata: \u0026#34;skip_index_field_1\u0026#34;: \u0026lt;serialized SkipIndex for field 1\u0026gt; \u0026#34;skip_index_field_2\u0026#34;: \u0026lt;serialized SkipIndex for field 2\u0026gt; 触发时机：在 Sort Task 中，写入 Parquet 数据的同时，同步构建并写入 SkipIndex（就像 milvus-storage 写入 RowGroupMetadata 一样）。\n优点（导师认为）：\n数据与统计信息共置（co-located），读取时一次 I/O 即可获取 不需要额外的任务调度 复用现有的写入流水线 挑战：\nmilvus-storage 与 Milvus 解耦，需要设计通用接口 构建时机从\u0026quot;后台任务\u0026quot;改为\u0026quot;写入时同步\u0026quot;，逻辑更复杂 7.3 Issue #44584 的挑战（2025年10月） 讨论背景：我在提交 PR #44581 时，同时提交了 Issue #44584 描述整体设计。Milvus 官方人员（Reviewer）在 Issue 中提出了质疑。\n质疑的核心：\nMilvus 计划支持新的存储格式（Vortex、Lance 等），将 SkipIndex 存储在 Parquet footer 中意味着 SkipIndex 的存储与 Parquet 格式强绑定。当切换到新格式时，SkipIndex 的读写逻辑需要全部重写。\n这是一个非常合理的架构问题——扩展性。\n最终决定：回到最初的独立文件方案。具体来说：\nSkipIndex 序列化为独立的二进制文件 通过 Milvus 的元数据系统（etcd）记录 SkipIndex 文件的路径 读取时通过文件路径加载，与存储格式无关 影响：这意味着整个写入和读取链路需要重新设计，而此时已是十月中旬，距离项目截止还剩两周。\n7.4 最终完成状态 功能模块 完成状态 备注 SkipIndex 统计信息扩展（BF、NgramBF、Set） ✅ 基本完成 在 PR #44581 中 表达式层集成（更多 OpType 支持） ✅ 完成 TermExpr、BinaryArithExpr 等 milvus-storage Metadata 接口设计 ✅ 完成（但后来方案变更） 接口设计本身是有价值的 SkipIndex 构建触发链路 ⚠️ 未完全打通 需要重新按独立文件方案实现 SkipIndex 持久化（写入） ⚠️ 未完全打通 独立文件方案需要时间 SkipIndex 预加载（读取） ⚠️ 未完全打通 独立文件方案需要时间 第八章：技术知识点深度解析 8.1 C++ 模板元编程 8.1.1 SFINAE（Substitution Failure Is Not An Error） SkipIndex 中大量使用 SFINAE 技术，通过 std::enable_if_t 实现类型约束：\n// 版本1：T 满足约束时启用 template \u0026lt;typename T\u0026gt; std::enable_if_t\u0026lt;SkipIndex::IsAllowedType\u0026lt;T\u0026gt;::value, bool\u0026gt; CanSkipUnaryRange(FieldId field_id, int64_t chunk_id, OpType op_type, const T\u0026amp; val) const { // 实际逻辑 } // 版本2：T 不满足约束时启用（fallback） template \u0026lt;typename T\u0026gt; std::enable_if_t\u0026lt;!SkipIndex::IsAllowedType\u0026lt;T\u0026gt;::value, bool\u0026gt; CanSkipUnaryRange(FieldId field_id, int64_t chunk_id, OpType op_type, const T\u0026amp; val) const { return false; // 不支持的类型，保守策略 } std::enable_if_t\u0026lt;Cond, ReturnType\u0026gt; 的工作原理：\n若 Cond == true：enable_if_t 等于 ReturnType，函数签名合法，参与重载解析 若 Cond == false：enable_if_t 不存在，函数签名非法，但这不是错误（SFINAE），只是从重载集合中排除 这样保证了：调用 CanSkipUnaryRange\u0026lt;Json\u0026gt;() 时，只有版本2可以实例化，不会编译错误。\n8.1.2 std::variant 与 std::visit Metrics 使用 std::variant 实现类型安全的联合体：\nusing Metrics = std::variant\u0026lt;bool, int8_t, int16_t, int32_t, int64_t, float, double, std::string, std::string_view\u0026gt;; // 使用 std::get_if 安全访问 void ProcessMetrics(const Metrics\u0026amp; m) { if (auto* val = std::get_if\u0026lt;int64_t\u0026gt;(\u0026amp;m)) { // 是 int64_t 类型 std::cout \u0026lt;\u0026lt; \u0026#34;int64: \u0026#34; \u0026lt;\u0026lt; *val \u0026lt;\u0026lt; std::endl; } else if (auto* val = std::get_if\u0026lt;std::string_view\u0026gt;(\u0026amp;m)) { // 是 string_view 类型 std::cout \u0026lt;\u0026lt; \u0026#34;string: \u0026#34; \u0026lt;\u0026lt; *val \u0026lt;\u0026lt; std::endl; } } // 或者使用 std::visit（更通用） std::visit([](const auto\u0026amp; val) { // val 的类型在编译时确定 std::cout \u0026lt;\u0026lt; val \u0026lt;\u0026lt; std::endl; }, metrics); 相比 C union 的优势：\nunion 不追踪当前存储的类型，访问错误类型是 UB（Undefined Behavior） variant 追踪当前类型，访问错误类型会抛出 std::bad_variant_access variant 在存储对象时调用构造函数，在析构时调用析构函数（RAII 友好） 8.1.3 std::conditional_t // 根据编译时条件选择类型 template \u0026lt;typename T\u0026gt; using HighPrecisionType = std::conditional_t\u0026lt;std::is_integral_v\u0026lt;T\u0026gt; \u0026amp;\u0026amp; !std::is_same_v\u0026lt;bool, T\u0026gt;, int64_t, // 满足条件时的类型 T\u0026gt;; // 不满足条件时的类型 std::conditional_t\u0026lt;B, T, F\u0026gt; 等效于：若 B 为真，则为 T，否则为 F。是编译时的三目运算符。\n8.1.4 if constexpr（C++17） auto check_and_skip = [\u0026amp;](HighPrecisionType\u0026lt;T\u0026gt; new_value_hp, OpType new_op_type) { if constexpr (std::is_integral_v\u0026lt;T\u0026gt;) { // 编译时 if // 只有整数类型才编译此块 if (new_value_hp \u0026gt; std::numeric_limits\u0026lt;T\u0026gt;::max() || new_value_hp \u0026lt; std::numeric_limits\u0026lt;T\u0026gt;::min()) { return false; // 溢出检测 } } // 浮点类型不进入上面的块 return CanSkipUnaryRange\u0026lt;T\u0026gt;(...); }; if constexpr 与普通 if 的区别：\n普通 if：两个分支都会编译，只是运行时不执行 if constexpr：条件为 false 的分支不参与编译，避免编译错误 这里如果用普通 if，std::numeric_limits\u0026lt;T\u0026gt;::max() 对浮点类型也会实例化，可能引发类型不匹配的问题。\n8.2 Bloom Filter 原理 Bloom Filter 是一种概率性数据结构，用于快速判断一个元素是否可能存在于集合中：\n工作原理：\n初始化：一个大小为 m 的位数组，全部置 0 插入元素 x：计算 k 个哈希值 h1(x), h2(x), \u0026hellip;, hk(x)，将位数组中对应位置置 1 查询元素 x：计算 k 个哈希值，若所有对应位都为 1，则可能存在；若有任何一位为 0，则一定不存在 特性：\n无假阴性（No False Negative）：若元素在集合中，BF 一定返回\u0026quot;存在\u0026quot; 有假阳性（False Positive）：若 BF 返回\u0026quot;可能存在\u0026quot;，元素不一定真的在集合中 这对 SkipIndex 来说是完美匹配：\n无假阴性保证不会漏掉匹配的数据（正确性） 有假阳性只是不能跳过某些 Chunk（不影响结果正确性，只影响性能） 大小计算：\n对于 n 个元素，误判率 p，最优 Bloom Filter 参数为：\nm（位数）= -n * ln(p) / (ln 2)^2 k（哈希函数数）= m/n * ln 2 以 n = 1,000,000，p = 0.01 为例：\nm = -1,000,000 * ln(0.01) / (ln 2)^2 ≈ 1,000,000 * 4.605 / 0.480 ≈ 9,585,058 位 ≈ 1.14 MB k = 9,585,058 / 1,000,000 * ln 2 ≈ 6.6 ≈ 7 8.3 设计模式详解 8.3.1 策略模式（Strategy Pattern） FieldChunkMetrics 的类层次结构是经典的策略模式：\nFieldChunkMetrics（接口/策略） ├── NoneFieldChunkMetrics （无跳过策略） ├── BooleanFieldChunkMetrics （布尔跳过策略） ├── FloatFieldChunkMetrics\u0026lt;T\u0026gt; （浮点跳过策略） ├── IntFieldChunkMetrics\u0026lt;T\u0026gt; （整数跳过策略） └── StringFieldChunkMetrics （字符串跳过策略） SkipIndex 持有 FieldChunkMetrics*，在运行时根据数据类型选择具体策略：\n调用 CanSkipUnaryRange() 时，不关心是整数还是字符串，统一接口 具体的跳过逻辑封装在各个子类中，互不干扰 8.3.2 建造者模式（Builder Pattern） SkipIndexStatsBuilder 和 MetadataBuilder 都是建造者模式的应用：\n// MetadataBuilder 的使用方式 MetadataBuilder* builder = new ChunkSkipIndexBuilder(); // 逐步构建（每个 RowGroup 调用一次） builder-\u0026gt;Append(batch_0); // RowGroup 0 的数据 builder-\u0026gt;Append(batch_1); // RowGroup 1 的数据 builder-\u0026gt;Append(batch_2); // RowGroup 2 的数据 // 最终产出（序列化所有 RowGroup 的统计信息） std::string result = builder-\u0026gt;Finish(); 建造者模式将\u0026quot;对象的构建过程\u0026quot;与\u0026quot;对象的表示\u0026quot;分离：\n构建过程：Append() 逐步积累状态 表示：Finish() 返回最终序列化结果 好处：支持增量构建，可以在数据流式到达时逐步处理 8.3.3 缓存模式（Cache-Aside） SkipIndex 的 CacheSlot + PinWrapper + Translator 组合实现了 Cache-Aside 模式：\n查询操作： 1. 尝试从 CacheSlot 获取 FieldChunkMetrics（命中 → 返回） 2. 若未命中，通过 Translator 加载数据（计算 → 存入 Cache → 返回） 3. PinWrapper RAII 确保使用期间不被驱逐 这是 Milvus 通用缓存层（Caching Layer）的标准使用模式，SkipIndex 只是其中的一个用户。\n8.3.4 RAII（Resource Acquisition Is Initialization） PinWrapper 是 RAII 的典型应用：\n// 伪代码 class PinWrapper { CacheEntry* entry_; public: PinWrapper(CacheEntry* e) : entry_(e) { entry_-\u0026gt;pin_count++; // 构造时增加引用 } ~PinWrapper() { entry_-\u0026gt;pin_count--; // 析构时减少引用（自动释放） } const FieldChunkMetrics* get() const { return entry_-\u0026gt;data; } }; // 使用方式：自动管理生命周期 { auto pw = GetFieldChunkMetrics(field_id, chunk_id); // pw 存在期间，对应的 Cache Cell 不会被驱逐 auto result = pw.get()-\u0026gt;CanSkipUnaryRange(...); } // pw 析构，引用计数 --，Cell 可被驱逐 defer C.free(unsafe.Pointer(...)) 在 Go CGO 层也是类似的 RAII 思想——资源在获取时注册释放逻辑，函数返回时自动清理。\n8.3.5 类型擦除（Type Erasure） 在最初的设计中，为了让 ParquetFileWriter 持有不同类型的 MetadataBuilder，使用了类型擦除：\n// 问题：这两个 Builder 类型不同，无法放入同一容器 ChunkSkipIndexBuilder skip_builder; // 对 SkipIndex 的 MetadataBuilder SomeOtherMetadataBuilder other_builder; // 解决：MetadataBuilder 基类 + 指针多态（虚函数本质上就是类型擦除） std::vector\u0026lt;std::unique_ptr\u0026lt;MetadataBuilder\u0026gt;\u0026gt; builders; builders.push_back(std::make_unique\u0026lt;ChunkSkipIndexBuilder\u0026gt;()); builders.push_back(std::make_unique\u0026lt;SomeOtherMetadataBuilder\u0026gt;()); // 统一调用，无需知道具体类型 for (auto\u0026amp; builder : builders) { builder-\u0026gt;Append(batch); } 8.4 Parquet 文件格式 Parquet 是一种面向列的存储格式，广泛用于大数据生态：\n文件结构：\n┌──────────────────────────────────┐ │ Magic Number │ 4 bytes \u0026#34;PAR1\u0026#34; ├──────────────────────────────────┤ │ Row Group 0 │ │ ┌────────────────────────────┐ │ │ │ Column Chunk 0 (col A) │ │ │ │ Column Chunk 1 (col B) │ │ │ └────────────────────────────┘ │ ├──────────────────────────────────┤ │ Row Group 1 │ │ ┌────────────────────────────┐ │ │ │ Column Chunk 0 (col A) │ │ │ │ Column Chunk 1 (col B) │ │ │ └────────────────────────────┘ │ ├──────────────────────────────────┤ │ Footer │ │ ┌────────────────────────────┐ │ │ │ FileMetaData (Thrift) │ │ │ │ - schema │ │ │ │ - row_groups info │ │ │ │ - key_value_metadata │ │ ← SkipIndex 存储在这里 │ └────────────────────────────┘ │ │ Footer Length (4 bytes) │ │ Magic Number (4 bytes) \u0026#34;PAR1\u0026#34; │ └──────────────────────────────────┘ key_value_metadata：Parquet footer 的 FileMetaData 中有一个 key_value_metadata 字段，可以存储任意的 key-value 字符串对。这就是我们选择存储 SkipIndex 的位置（导师方案）。\nParquet 原生 Statistics：每个 Column Chunk 有内置的 Statistics，包括：\nmin_value, max_value：列在该 RowGroup 中的最小/最大值 null_count：空值数量 这实际上就是原始版本的 SkipIndex（MinMax），但只支持原生类型，不支持 Bloom Filter 等。\n8.5 CGO 与 Arrow C Data Interface 8.5.1 CGO 内存管理 Go 和 C 之间的内存管理是 CGO 编程的核心挑战：\n// C 字符串：C 分配的内存必须用 C.free 释放 cStr := C.CString(\u0026#34;hello\u0026#34;) defer C.free(unsafe.Pointer(cStr)) // 保证释放 // Go 字符串：只需普通 Go GC 管理 goStr := \u0026#34;hello\u0026#34; // Go 管理，不需要手动释放 // C 动态数组 cArr := C.malloc(C.size_t(n) * C.size_t(unsafe.Sizeof(C.int(0)))) defer C.free(cArr) // 必须手动释放 关键规则：\nC.CString() 创建的 C 字符串由 C 分配，必须用 C.free() 释放 defer C.free() 保证即使函数中途 panic 也能释放内存 Go 的 GC 不追踪 C 内存，因此永远不要把 Go 指针存到 C 结构体中（Go GC 可能移动 Go 对象） 8.5.2 Arrow C Data Interface Arrow C Data Interface（ABI）定义了在不同语言/库之间传递 Arrow 数据的标准 C 结构体：\nstruct ArrowSchema { const char* format; const char* name; const char* metadata; int64_t flags; int64_t n_children; struct ArrowSchema** children; struct ArrowSchema* dictionary; void (*release)(struct ArrowSchema*); // 释放回调 void* private_data; }; struct ArrowArray { int64_t length; int64_t null_count; int64_t offset; int64_t n_buffers; int64_t n_children; const void** buffers; struct ArrowArray** children; struct ArrowArray* dictionary; void (*release)(struct ArrowArray*); // 释放回调 void* private_data; }; 零拷贝原理：buffers 指针直接指向数据内存，不进行任何拷贝。消费者（C++层）通过指针读取数据后，调用 release 函数通知生产者（Go层）可以释放内存。\n8.6 数据库 Zone Map / Skip Index 理论 SkipIndex 本质上是数据库领域中\u0026quot;Zone Map\u0026quot;（也叫 Small Materialized Aggregates）的一种实现。\nZone Map 的基本思想：\n将数据按物理存储顺序划分为 Zone（区域，对应 Milvus 的 Chunk/RowGroup） 为每个 Zone 存储汇总统计信息（min, max, count, null_count 等） 查询时用这些统计信息快速判断 Zone 是否与查询条件相交 若不相交，跳过整个 Zone（无需读取实际数据） 与 LSM-Tree 的类比：\nMilvus 的存储结构与 LSM-Tree 有高度相似性：\nLSM-Tree Milvus MemTable Growing Segment（内存中，无序） Immutable MemTable Sealed Segment（不可写，等待持久化） L0 SSTable Flushed Segment（已持久化，可能无序） L1+ SSTable Sorted/Indexed Segment（有序，已建索引） Compaction Compaction（Merge/Sort Compaction） SSTable Block RowGroup/Chunk Block Index SkipIndex 在 RocksDB（典型 LSM 实现）中，SSTable 的 Block Index 存储每个 Block 的最后一个 key，用于快速定位；Milvus 的 SkipIndex 存储每个 Chunk 的统计信息，用于快速跳过。\n思考 回顾整个OSPP2025 Milvus的任务，从中学习了到很多东西——C++新语法、大型项目中的接口扩展性设计、大型项目代码阅读能力、AI Coding辅助等等。\n对于最终项目未能开发完成，也是多方面原因。\n首先是初次接触大型项目是一头雾水，只做过Bustub、TinyKV这种课程项目，对于Milvus这种大型项目光是阅读项目理解需求就花费了很多时间。 其次是对于开源社区协作开发开始时也并不了解，在提交PR时才意识到需要先开Issue，导致后续方案变动时已没有时间完成剩余任务。 最后是项目开发过程中缺少沟通交流，导致项目开发陷入自我思考的圈子，对于方案选择思考过度，想着一开始就选择最好最合适的方案，导致开发进展缓慢。 通过这次 OSPP2025 的活动，大型项目的开发最重要的是沟通交流、清晰的方案、协作开发流程以及对需求的精确认识，大部分的时间可能就花在了代码阅读、确立方案以及最后的测试上。\n附录：关键代码位置索引 模块 文件路径 核心内容 SkipIndex 主接口 internal/core/src/index/SkipIndex.h SkipIndex 类，IsAllowedType，HighPrecisionType SkipIndex 实现 internal/core/src/index/SkipIndex.cpp GetFieldChunkMetrics, LoadSkip 统计信息定义 internal/core/src/index/skipindex_stats/SkipIndexStats.h Metrics variant, FieldChunkMetrics 层次, RangeShouldSkip 统计信息构建 internal/core/src/index/skipindex_stats/SkipIndexStats.cpp SkipIndexStatsBuilder 实现 类型辅助工具 internal/core/src/index/skipindex_stats/utils.h SupportsSkipIndex, ExtractNgrams TermExpr 集成 internal/core/src/exec/expression/TermExpr.cpp IN 查询 SkipIndex 算术范围查询 internal/core/src/exec/expression/BinaryArithOpEvalRangeExpr.cpp 算术变换 SkipIndex Go 写入层 internal/storage/record_writer.go packedRecordWriter CGO 桥接 internal/storagev2/packed/packed_writer.go NewPackedWriter, WriteRecordBatch Metadata 接口 milvus-storage/cpp/include/milvus-storage/common/metadata.h Metadata, MetadataBuilder, PackedFileMetadata 附录：重要术语对照 术语 含义 Segment Milvus 中数据的基本存储单元，Growing（内存）或 Sealed（持久化） Chunk Sealed Segment 中数据的基本处理单元，对应一个 Parquet RowGroup RowGroup Parquet 文件中的逻辑行分组，包含该组所有列的数据 Column Group milvus-storage 中的列分组，多个字段共享一个 Parquet 文件 SkipIndex Chunk 级统计信息，用于快速跳过不匹配的 Chunk Zone Map 数据库领域 SkipIndex 的通用名称 DataCoord 管理数据 Segment 生命周期的协调者组件 DataNode 执行数据写入、排序、压缩的工作节点 QueryNode 加载 Segment 并执行查询的工作节点 Binlog Milvus 中数据文件的统称（insert binlog、stats binlog 等） SFINAE C++ 模板替换失败不是错误，用于编译时类型选择 CRTP 奇异递归模板模式，实现编译时多态 RAII 资源获取即初始化，通过对象生命周期管理资源 Arrow C Data Interface Apache Arrow 定义的跨语言零拷贝数据传输 ABI 附录：学习资源 Milvus 官方文档 Apache Parquet 格式规范 Apache Arrow C Data Interface Bloom Filter 原理（维基百科） Milvus GitHub 仓库 milvus-storage GitHub 仓库 相关 PR #44581 相关 Issue #44584 C++ 参考：Scott Meyers《Effective Modern C++》（涵盖模板、移动语义、std::variant 等） Go CGO 文档：https://pkg.go.dev/cmd/cgo ","permalink":"https://yinit.github.io/ospp2025-%E5%9B%9E%E9%A1%BE/","summary":"OSPP2025 Milvus SkipIndex扩展以及持久化和预加载","title":"OSPP2025 回顾"},{"content":"前言 在了解TinyKV中提到的badger过程中学习到LSM-Tree，故此记录\n什么是LSM-Tree LSM-Tree全称为Log-Structured Merge-Tree，日志结构合并树，下文简称LSM-Tree。LSM-Tree的架构分为内存部分和有序的磁盘部分，内存部分实现高速写，有序的硬盘实现高效查，整体架构如下图中LevelDB所示。\nLSM-Tree的思想是：借助于内存和日志文件将写入过程分为时间上不前后相连的两步，写入是只顺序写入磁盘的日志文件和内存，等系统空闲时再将内存中数据写入到磁盘。这样在处理写入请求时就省去了磁盘寻道、转动磁头的时间。\nLSM-Tree有以下几个特点：\nLSM-Tree工作原理 LSM-tree 核心是将写入操作与合并操作分离，通过将数据写入日志文件和内存缓存，然后定期进行合并操作来提高写入和查询的性能。\nLSM-Tree写入 LSM-Tree写入操作分为两步，分别执行，具体步骤如下：\n第一步为写入操作 写入日志文件（Write-Ahead Log, WAL）：当有新的 key-value需要写入时，首先将其追加到顺序写的日志文件中。这个操作称为预写日志（Write-Ahead Logging），它可以确保数据的持久性和一致性。 写入内存缓存（MemTable）：同时将新的 key-value写入内存中的数据结构，通常是跳表或红黑树等有序数据结构。内存缓存可以快速响应读取操作，并且具有较高的写入性能。 第二步为定期合并 内存与磁盘的合并（MemTable to SSTable）：当内存缓存达到一定大小或者其他触发条件满足时，将内存缓存中的 key-value写入到磁盘上的 SSTable 文件中。即在MemTable满时将其转为Immutable MemTable，同时构建新的MemTable，保证向内存写MemTable和向磁盘写新的SSTable可以同时进行。 SSTable的合并：当SSTable数量到达阈值时，进行合并。合并操作将多个 SSTable文件进行合并，消除重复的 key和删除的 key，并生成新的 SSTable文件。合并操作可以基于时间、文件大小或其他条件进行触发和控制。 LSM-Tree的一些特性：在执行Update和Delete操作时并不会真正去操作SSTable中的数据，如果在MemTable中已有记录则直接执行修改操作（用新的覆盖旧的），否则直接写入，有点类似于追加写入。由于读取时自上而下读取，自然会先读取到新的数据。在进行SSTable合并时也是利用时间戳去进行操作。\n可以从下面这张图直观感受LSM-Tree写入过程：\nLSM-Tree查找 LSM-Tree的查找分为三步，首先是查找内存，不中再查硬盘，具体步骤如下所示；\n先查内存中的MemTable，如果命中则返回； 再查Immutable MemTable，如果命中则返回； 最后查硬盘，依次从 L0 层的 SSTable 文件开始，逐层遍历。首先对每个SSTable对应的布隆过滤器进行过滤，如果判定为在该SSTable中，则查询该SSTable的索引表，索引表key有序，所以是二分查找。如果找到对应的key，则根据偏移值获取数据返回；如果未找到对应的key，则对下一个SSTable重复该过程。 三大组件 MemTable MemTable是内存中的数据结构，存储近期更新的记录值，因此可以采用一些有序集合来存储，比如跳表、红黑树都是非常不错的选择，整体来说，MemTable相对比较简单，采用的数据结构也比较常见。\nImmutable MemTable Immutable MemTable，可以理解成不可变表。它是因MemTable存储容量达到阈值后转变而来，所以采用的数据结构和MemTable一样。引入Immutable MemTable作为一种中间结构，避免 MemTable的读写冲突竞争，保证对MemTable的写入和向磁盘写入SSTable同时进行，从而提高系统性能。\nSSTable SSTable 是 LSM-Tree 的核心，主要是用于数据在磁盘上的持久化存储，存储一些按照 key 有序排列的键值对组成的 segment。另外，为了加快查询速度，通常需要给 SSTable 创建索引，索引存储在 SSTable的尾部，用于帮助快速查找特定的 segment。索引表在我们操作系统非常常见，像文件管理和存储中都用到了索引表。类似于我们操作系统中的索引表，SSTable也用一张存储表记录了SSTable存储的每个元素的位置。引入索引表后，当从SSTable中取数据的时候，不用把整个SSTable读进内存了，而是只需要读进非常小的索引表即可，极大减少了IO成本。同时索引的key是有序的，所以查找时二分查找方式也非常快。\n布隆过滤器 为了进一步减少数据查询时的磁盘IO和提高查询速度，LSM-Tree引入了布隆过滤器。利用布隆过滤器，对所要查询的数据进行一个初步判读。每个SSTable都有个布隆过滤器，如果布隆过滤器判定为没有，则改SSTable中一定不存在改数据；如果布隆过滤器判定为有，则改数据很可能存在于该SSTable中，这是再将索引表加载进内存，进行进一步的查找确认。如果找到则返回；如果未找到，则再匹配下一个SSTable的布隆过滤器。\n布隆过滤器结构如下所示：\nLSM-Tree优点与缺点 优点：写入快（增、删、改速度非常快），写吞吐量极大：写入时仅写入内存和顺序写入磁盘上的日志，不用关心是否写入磁盘。\n缺点：\n查询较慢，相比于B+树，查询速度较慢 范围查找能力较差 查询不存在的数据会进行全表扫描，非常慢 总结——我的理解 LSM利用内存写入速度远大于磁盘写入和磁盘批量顺序写的速度要远比随机写性能高出很多的特性，设计出这个结构。写入时分别写入MemTable和WAL中，利用红黑树保证MemTable写入时的有序性，同时写入WAL防止内存崩溃。在MemTable满时将其转为Immutable MemTable，同时构建新的MemTable，保证向内存写MemTable和向磁盘写新的SSTable可以同时进行。如此以来，外部向LSM-Tree中写入数据时分为两步，第一步向MemTable写入数据和向WAL写入日志，第二步实际向磁盘写入数据并调整内部多级Level SSTable，如此两步操作大大提高了写入效率。在LSM-Tree中执行读操作时，读取顺序为：MemTable -\u0026gt; Immutable MemTable -\u0026gt; L0 -\u0026gt; \u0026hellip; -\u0026gt; LN, 以这种方式逐层向下进行搜索，读取操作有两个特点：其一便是越靠近上层的数据越新，也就是说自上向下读取到的第一个数据便是当前有效数据；其二便是每个SSTable（包括MemTable）内部有序，能够使用二分查找的方式加快搜索效率。但是这种查找方式存在读放大问题，如果数据较旧，读取的层数可能较多，随着层数增加，读的范围会越来越大，导致读取速度降低。如此便有了优化策略——布隆过滤，在向MemTable中写入时，利用哈希表对key进行映射，存在便映射为1，这样在搜索时便可以利用key进行哈希判断是否不存在了（如果为0可以保证肯定不存在，为1可能存在——哈希映射问题），从而加快搜索效率。\n写在最后 LSM-Tree的应用还是非常广的，特别是在数据量特别大、写入特别多的数据库中，目前基于LSM实现的数据库有：LevelDB，RocksDB，Hbase，Cassandra，ClinkHouse等。\n我目前了解的badger数据库便是使用的是LSM-Tree，但是做了一些优化，将WAl Log改为了Value Log，同时将Key-Value 分离存储。LSM-Tree 里面存储的是一个 Key 以及 Value 的地址，Value 本身以 WAL 方式 append 到 Value Log 文件中。这样做有两个优势：由于 LSM-Tree 做 Compaction 时不需要重写 Value（每个Value大小相同），大大减小了写放大。同时 LSM-Tree 更小，一个 Block 能存储更多的 key，也会相应的降低读放大，能缓存的 Key 以及 Value 地址也更多，缓存命中率更高。\n各家数据库优化好像也不太一样，具体我也没多了解，以后有机会会去多学习学习。\n参考文献 LSM-Tree LSM-Tree: NoSQL 崛起的顶梁柱 BadgerDB 原理及分布式数据库的中应用与优化 ","permalink":"https://yinit.github.io/lsm-tree/","summary":"LSM-Tree学习记录","title":"LSM Tree"},{"content":" docker中开启VPN虚拟网卡模式，劫持全局流量，可过AntiGravity检测\nDocker 创建和依赖下载 创建容器 docker run -itd \\ --name yjs_work \\ -p 22335:22 \\ --restart always \\ --cap-add=NET_ADMIN \\ --device=/dev/net/tun \\ ubuntu:22.04 进入容器 docker exec -it yjs_work bash 更新源并安装基础依赖和 VPN 客户端 apt update \u0026amp;\u0026amp; apt install -y \\ openssh-server curl git sudo build-essential \\ openvpn wireguard iproute2 nano nodejs npm vim SSH配置 创建密钥目录并写入你的公钥 # 1. 为 root 用户创建 .ssh 目录 mkdir -p /root/.ssh # 2. 设置目录的严格权限 (只有 root 可读写执行) chmod 700 /root/.ssh # 3. 使用 vim 编辑 authorized_keys 文件 vim /root/.ssh/authorized_keys # 4. 设置文件的严格权限 (只有 root 可读写) chmod 600 /root/.ssh/authorized_keys 修改 SSH 配置，关闭密码登录 # 1. 彻底禁用密码登录 (将 PasswordAuthentication 改为 no) sed -i \u0026#39;s/^#*PasswordAuthentication .*/PasswordAuthentication no/\u0026#39; /etc/ssh/sshd_config # 2. 允许 root 用户通过密钥登录 (设为 prohibit-password 最为严谨，意为允许root登录但禁止密码) sed -i \u0026#39;s/^#*PermitRootLogin .*/PermitRootLogin prohibit-password/\u0026#39; /etc/ssh/sshd_config # 3. 确保公钥验证功能是开启的 (通常默认开启，这是双保险) sed -i \u0026#39;s/^#*PubkeyAuthentication .*/PubkeyAuthentication yes/\u0026#39; /etc/ssh/sshd_config 启动 SSH 服务 service ssh start Rust环境配置 安装Rust工具链 curl --proto \u0026#39;=https\u0026#39; --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y source \u0026#34;$HOME/.cargo/env\u0026#34; 安装系统级编译依赖（C 语言工具链） apt update \u0026amp;\u0026amp; apt install -y build-essential pkg-config libssl-dev 安装 Rust 官方核心组件 rustup component add rust-analyzer rustfmt clippy VPN 配置 在容器内安装 Mihomo # 1. 下载预编译的二进制文件 (这里使用 amd64 架构的最新稳定版) curl -L -o mihomo.gz https://github.com/MetaCubeX/mihomo/releases/download/v1.18.3/mihomo-linux-amd64-v1.18.3.gz # 2. 解压 gzip -d mihomo.gz # 3. 赋予执行权限并移动到系统路径 chmod +x mihomo mv mihomo /usr/local/bin/ # 4. 验证安装 mihomo -v 机场节点导入 mkdir ~/.clash 在 Windows 上获取配置：开启你本地的代理工具，复制你的“机场 Clash 订阅链接”，在浏览器里打开它，会下载下来一个 .yaml 或 .yml 文件。\n重命名并传入容器：将这个文件重命名为 config.yaml。然后在 VS Code 里，直接把这个文件拖拽到容器的 /root/ 目录下。\n开启TUN模式 一般的机场配置默认只是开启了 HTTP/SOCKS 端口，我们需要手动在 config.yaml 里加上 TUN 模式的配置，让它接管全局流量。\ntun: enable: true stack: system auto-route: true auto-detect-interface: true 创建 Mihomo 的 Service 脚本 在容器的终端里，直接复制并粘贴下面这一整段命令并回车。这段代码会在 /etc/init.d/ 目录下创建一个名为 mihomo 的服务管理脚本：\ncat \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; \u0026gt; /etc/init.d/mihomo #!/bin/sh ### BEGIN INIT INFO # Provides: mihomo # Required-Start: $network $local_fs $remote_fs # Required-Stop: $network $local_fs $remote_fs # Default-Start: 2 3 4 5 # Default-Stop: 0 1 6 # Short-Description: Start Mihomo VPN daemon ### END INIT INFO DAEMON=/usr/local/bin/mihomo WORKDIR=/root/.clash LOGFILE=/var/log/mihomo.log case \u0026#34;$1\u0026#34; in start) echo \u0026#34;* Starting Mihomo VPN service...\u0026#34; # 使用 nohup 把它放到后台运行，并将输出日志保存到 /var/log/mihomo.log nohup $DAEMON -d $WORKDIR \u0026gt; $LOGFILE 2\u0026gt;\u0026amp;1 \u0026amp; ;; stop) echo \u0026#34;* Stopping Mihomo VPN service...\u0026#34; pkill -x mihomo ;; restart) $0 stop sleep 2 $0 start ;; *) echo \u0026#34;Usage: /etc/init.d/mihomo {start|stop|restart}\u0026#34; exit 1 esac exit 0 EOF 赋予脚本执行权限 chmod +x /etc/init.d/mihomo 后台管理 启动并在后台持续运行：\nservice mihomo start 停止 VPN：\nservice mihomo stop 查看 VPN 的运行日志\ntail -f /var/log/mihomo.log 编码问题 安装 Locale 工具 apt-get update \u0026amp;\u0026amp; apt-get install -y locales 生成 UTF-8 语言包 # 生成英文 UTF-8 环境 locale-gen en_US.UTF-8 设置系统默认环境变量 update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 让配置在当前终端立即生效 # 写入 bashrc 保证以后每次进终端绝对生效 echo \u0026#39;export LANG=en_US.UTF-8\u0026#39; \u0026gt;\u0026gt; ~/.bashrc echo \u0026#39;export LC_ALL=en_US.UTF-8\u0026#39; \u0026gt;\u0026gt; ~/.bashrc # 立即刷新当前环境 source ~/.bashrc 验证效果 # 查看当前语言环境变量，应该满屏都是 en_US.UTF-8 locale # 输出一段中文试试 echo \u0026#34;完美解决 Docker 中文乱码问题！\u0026#34; 配置git # 1. 配置用户名、邮箱 git config --global user.name \u0026#34;你的名字\u0026#34; git config --global user.email \u0026#34;你的邮箱@example.com\u0026#34; # 2. 验证配置 git config --global --list # 3. 配置默认编辑器为vim git config --global core.editor \u0026#34;vim\u0026#34; Claude Code 安装 安装Node.js # 1. 下载并运行 NodeSource 的安装脚本，配置 Node 20 的源 curl -fsSL https://deb.nodesource.com/setup_20.x | bash - # 2. 安装 Node.js (这会自动同时安装 node 和 npm) apt install -y nodejs # 3. 安装Cluade code npm install -g @anthropic-ai/claude-code Gemini Cli安装 安装Gemini Cli npm install -g @google/gemini-cli OAuth 账号直接登录 gemini login 日常重启后流程 # 1. 进入容器 docker exec -it yjs_work bash # 2. 唤醒 SSH 和 VPN service ssh start service mihomo start ","permalink":"https://yinit.github.io/docker%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83%E9%85%8D%E7%BD%AE/","summary":"docker中开启VPN虚拟网卡模式，劫持全局流量，高权限全局代理沙盒开发环境","title":"Docker开发环境配置"},{"content":"前言 断断续续写了好几周，终于是把 B+树部分写完了，没想到最耽误时间的还是 p1 部分，p1 部分遗留的错误导致我在写 p2 时 Contention 测试一直出问题，我还以为是 p2 哪里写错了，检查了很久，最终还是排查到 p1 了。这个故事告诉我们，不要过早优化，当然，我这也是因为原本 23fall 的代码在 24fall 有问题才想起来去修改的，情有可原（嘿嘿）。\n相比于可扩展哈希，B+树层数更多，并且支持顺序读操作，相对来说效率和功能性更强，怪不得没见过用可扩展哈希的数据库。在实现上，相比于可扩展哈希，B+树的实现也不算复杂，甚至感觉更简单，也可能是写的多了。具体实现主要是增、删、查这三个操作，又以删最为复杂。24fall 有意思的一点是有 Contention 测试检查是否对 B+树的并发做了优化，即通过加大锁检查乐观锁有没有实现。\n下面是最终结果，挑了个有意思的。\n1. 整体架构概览 1.1 层次结构 BufferPoolManager（磁盘 \u0026lt;-\u0026gt; 内存页缓冲） │ ▼ BPlusTreeHeaderPage（固定根页ID，防止根页并发竞争） │ ▼ root_page_id_ BPlusTreeInternalPage（内部节点：m 个 key + m+1 个 child page_id） │ ▼ child page_id BPlusTreeLeafPage（叶子节点：m 个 key + m 个 RID，单向链表） 1.2 核心类关系 类 文件 职责 BPlusTree\u0026lt;K,V,Cmp\u0026gt; b_plus_tree.h/.cpp 对外 API：Insert/Remove/GetValue/Begin/End BPlusTreePage b_plus_tree_page.h/.cpp 公共 Header（page_type / size / max_size） BPlusTreeInternalPage b_plus_tree_internal_page.h/.cpp 内部节点，存储路由键和子页 ID BPlusTreeLeafPage b_plus_tree_leaf_page.h/.cpp 叶子节点，存储实际 key→RID 映射 BPlusTreeHeaderPage b_plus_tree_header_page.h 仅存 root_page_id_ IndexIterator index_iterator.h/.cpp 叶子链表顺序扫描迭代器 Context b_plus_tree.h Insert/Remove 时跟踪访问路径上的页面写锁 1.3 模板参数 整个 B+树系统通过以下宏实现模板化：\n#define INDEX_TEMPLATE_ARGUMENTS template \u0026lt;typename KeyType, typename ValueType, typename KeyComparator\u0026gt; 实例化时使用 GenericKey\u0026lt;N\u0026gt;（N=4/8/16/32/64 字节）+ RID + GenericComparator\u0026lt;N\u0026gt;，在编译期固化键长度，避免运行时动态内存分配。\n2. Task 1：B+树页面层设计 2.1 内存布局思想：In-Place Reinterpret Cast B+树的页面直接复用 BufferPool 返回的 4KB 物理页（Page 对象），通过 reinterpret_cast 将页面数据解释为各种 B+树页类型，因此：\n所有构造函数/析构函数均被 = delete，防止 C++ 对象生命周期管理干扰内存布局。 使用 Init() 方法代替构造函数显式初始化页面元数据。 这是一种\u0026quot;内存映射对象\u0026quot;模式（Memory-Mapped Object Pattern）。 2.2 BPlusTreePage（公共基类） 文件：src/storage/page/b_plus_tree_page.h\nHeader（12 字节）： ┌─────────────────┬──────────────────┬──────────────────┐ │ page_type_ (4) │ size_ (4) │ max_size_ (4) │ └─────────────────┴──────────────────┴──────────────────┘ 关键方法：\nauto IsLeafPage() const -\u0026gt; bool; // page_type_ == LEAF_PAGE auto GetSize() const -\u0026gt; int; // 当前存储的 kv 对数 auto GetMinSize() const -\u0026gt; int; // (max_size_ + 1) / 2 （向上取整） 细节：GetMinSize() 使用 (max_size_ + 1) / 2 而非 max_size_ / 2，保证奇数 max_size 下最小填充也满足 ≥⌈n/2⌉ 的 B+树性质。\n2.3 BPlusTreeInternalPage（内部节点） 文件：src/include/storage/page/b_plus_tree_internal_page.h\nHeader（12 字节）+ 数据区： ┌────────────────────────────────────────────────────────┐ │ HEADER (12B) │ ├────────────────┬───────────┬─────────────┬─────────────┤ │ KEY[0](无效) │ KEY[1] │ KEY[2] │ ... │ ├────────────────┼───────────┼─────────────┼─────────────┤ │ PAGE_ID[0] │ PAGE_ID[1]│ PAGE_ID[2] │ ... │ └────────────────┴───────────┴─────────────┴─────────────┘ 关键设计：KEY[0] 永远无效\n内部节点存储 n 个 key 和 n+1 个子指针，但此实现将两个数组对齐为等长（都是 INTERNAL_PAGE_SLOT_CNT），通过让 KEY[0] 无效来对齐索引——PAGE_ID[i] 对应的子树满足 KEY[i] ≤ K \u0026lt; KEY[i+1]，PAGE_ID[0] 对应最左子树（无下界）。\n容量计算：\n#define INTERNAL_PAGE_SLOT_CNT \\ ((BUSTUB_PAGE_SIZE - INTERNAL_PAGE_HEADER_SIZE) / (sizeof(KeyType) + sizeof(ValueType))) // 实际 max_size = INTERNAL_PAGE_SLOT_CNT - 1（因 KEY[0] 无效，有效 key 数 = size - 1） 主要操作：\n方法 说明 KeyAt(i) / SetKeyAt(i, key) 读写 key_array_[i]，i=0 时为无效 key ValueAt(i) / SetValueAt(i, v) 读写 page_id_array_[i] Insert(i, key, value) 在 index i 处插入（右移现有元素），size+1 Remove(i) 删除 index i（左移后续元素），size-1 MoveRight(num) 整体右移 num 位（用于 redistribution 接收） MoveLeft(num) 整体左移 num 位（用于 redistribution 给出） 2.4 BPlusTreeLeafPage（叶子节点） 文件：src/include/storage/page/b_plus_tree_leaf_page.h\nHeader（16 字节）：额外比 BPlusTreePage 多一个 next_page_id_ (4B) ┌─────────────────┬──────────────────┬──────────────────┬──────────────────┐ │ page_type_ (4) │ size_ (4) │ max_size_ (4) │ next_page_id_(4) │ └─────────────────┴──────────────────┴──────────────────┴──────────────────┘ 数据区： ┌───────────┬───────────┬─────────────┐ ┌───────────┬───────────┬─────────────┐ │ KEY[0] │ KEY[1] │ ... │ + │ RID[0] │ RID[1] │ ... │ └───────────┴───────────┴─────────────┘ └───────────┴───────────┴─────────────┘ 叶子节点通过 next_page_id_ 构成单向链表，支持高效顺序扫描（迭代器依赖此结构）。与内部节点不同，叶子节点的 KEY[0] 是有效的。\n2.5 BPlusTreeHeaderPage（头页） 文件：src/include/storage/page/b_plus_tree_header_page.h\nclass BPlusTreeHeaderPage { public: page_id_t root_page_id_; }; 极简设计，仅存一个 root_page_id_。\n为什么需要独立的 HeaderPage？\n在并发场景下，根节点会发生分裂和合并（root_page_id 会改变），如果没有一个持久的、固定的页面存储\u0026quot;当前根在哪里\u0026quot;，多个线程同时读取根节点 ID 会产生竞争条件（race condition）。HeaderPage 的 page_id 在 B+树整个生命周期中固定不变，通过对其加锁来保护根 ID 的原子更新。\n3. Task 2：核心操作实现 3.1 二分查找 所有查找操作强制使用二分查找，否则在 Gradescope 上会超时。\nInternalBinarySearch（b_plus_tree.cpp:50）：\n// 找到满足 KEY[result-1] \u0026lt;= key \u0026lt; KEY[result] 的 result // 返回值 result 作为 ValueAt(result-1) 的索引（指向正确子树） int left = 1, right = internal_page-\u0026gt;GetSize(); while (left \u0026lt; right) { int mid = left + (right - left) / 2; if (comparator_(key, internal_page-\u0026gt;KeyAt(mid)) \u0026gt;= 0) left = mid + 1; else right = mid; } return left; // 下取子节点：ValueAt(left - 1) LeafBinarySearch（b_plus_tree.cpp:69）：\n// 找到第一个 KEY[result] \u0026gt;= key 的位置 int left = 0, right = leaf_page-\u0026gt;GetSize(); while (left \u0026lt; right) { int mid = left + (right - left) / 2; if (comparator_(key, leaf_page-\u0026gt;KeyAt(mid)) \u0026gt; 0) left = mid + 1; else right = mid; } return left; 两个二分的语义不同：Internal 找\u0026quot;最后一个 ≤ key 的位置的下一个\u0026quot;，Leaf 找\u0026quot;第一个 ≥ key 的位置\u0026quot;。\n3.2 Search（GetValue） 1. ReadPage(header) -\u0026gt; 获取 root_page_id 2. 释放 header 的读锁（获取 root 的读锁前） 3. 循环：while (!IsLeafPage) ReadPage(child) -\u0026gt; 释放父节点读锁（隐式：read_guard 被覆盖） 4. LeafBinarySearch 定位 key 5. 比较确认 key 相等后返回 RID 并发安全：使用 ReadPageGuard（RAII 读锁），每次 read_guard = bpm_-\u0026gt;ReadPage(next) 时，旧 guard 析构释放锁，实现读锁的\u0026quot;手拉手传递\u0026quot;（hand-over-hand locking）。\n3.3 Insert（插入） 实现思路概述 Insert 操作有多种情况，需要分别判断，整体流程如下：\n判断有无根节点，无根节点就创建叶子节点作为根节点并插入数据然后 return，有根节点则往下继续执行。 向下搜索叶节点，同时保存中间的 InternalPage 和搜索路径（即 InternalPage 搜索过程中 Value 的下标 Index），将其放入 write_set_ 和 index_set_ 中。 在叶子节点中搜索，如果已经存在，直接 return，如果不存在，则在搜索到的位置执行 Insert 操作。 接下来便是一波循环操作，利用 write_set_ 实现自下而上的反向搜索：如果 PageSize 大于 MaxSize，表示需要进行分裂操作，将 Page 进行分裂，将分裂后的两个 Page 的中间节点插入父节点；循环执行直到 PageSize ≤ MaxSize 时 return，或者 write_set_ 为空。 执行到这里说明 write_set_ 已为空，这时候就需要根节点进行分裂，并将新的 root_page_id 写入 HeaderPage。 乐观锁实现：在进行向下搜索时，如果当前节点 PageSize \u0026lt; MaxSize，表明这个 Page 不会发生分裂。在这种情况下，可以保证之后不会对它的父节点执行操作，直接清空 write_set_ 和 index_set_，同时将 ctx 中的 header_page_ 设置为空。此外，如果是插入最左边，需要更新最左边的 key，这个操作利用 InternalPage 中第一个 key，使后续插入和删除不需要对 write_set_ 之外的 InternalPage 节点进行修改。\n完整流程 Insert(key, value) │ ├─ [空树] 创建叶子页 → 更新 header.root_page_id → return true │ └─ [非空] 持有 header 写锁，向下遍历 │ ├─ 下行（while !IsLeafPage）： │ ├─ 若当前内部节点 size \u0026lt; max_size（\u0026#34;安全节点\u0026#34;）： │ │ 释放 header 写锁（ctx.header_page_ = nullopt） │ │ 释放 write_set_ 前端（已不需要的祖先锁） │ ├─ 维护 key[0]：若 key \u0026lt; internal.key[0]，更新 key[0]（最左路径特殊处理） │ ├─ 将当前内部节点写锁入 write_set_，记录 child index 到 index_set_ │ └─ 移动到下一层 │ ├─ 到达叶子节点： │ ├─ LeafBinarySearch 找插入位置 │ ├─ 若 key 已存在 → return false（unique key 约束） │ └─ 调用 leaf_page-\u0026gt;Insert(index, key, value) │ ├─ [叶子未满] leaf.size \u0026lt;= max_size → return true（提前退出，自动释放锁） │ └─ [叶子满了] 叶子分裂： ├─ NewPage() 创建 new_leaf ├─ 将后半部分（[mid, size)）移入 new_leaf ├─ 更新 next_page_id 链表：new_leaf.next = leaf.next; leaf.next = new_leaf ├─ 记录 right_key = new_leaf.key[0]，right_page_id = new_leaf_id └─ 向上传播（遍历 write_set_）： ├─ 在父内部节点 index+1 处插入 (right_key, right_page_id) ├─ 若父节点 size \u0026lt;= max_size → return true └─ 若父节点满 → 内部节点分裂（同上逻辑向上传播） └─ [根节点满] 创建新根，设置两个子节点，更新 header.root_page_id 关键细节：最左路径 key[0] 的维护 // b_plus_tree.cpp:175 if (comparator_(key, internal_page-\u0026gt;KeyAt(0)) \u0026lt; 0) { internal_page-\u0026gt;SetKeyAt(0, key); ++index; } else { index = InternalBinarySearch(internal_page, key); } 内部节点的 KEY[0] 在标准实现中本应无效，但本实现在下行时维护 KEY[0] 为该内部节点管辖的最小 key。这是为了在合并/重新分配时能够正确更新父节点的 separator key。这一设计决策增加了路径维护的复杂性，也是 Insert 中最容易出 bug 的地方。\n叶子分裂示意 分裂前（max_size=4，已插入第5个key）： leaf: [1, 3, 5, 7, 9] size=5 mid = 5/2 = 2 分裂后： leaf: [1, 3] size=2 new_leaf: [5, 7, 9] size=3 parent 中插入 separator key=5, pointing to new_leaf 根节点分裂 当分裂从叶子一路冒泡到根节点时，write_set_ 为空，在 Insert 末尾单独处理：\n// b_plus_tree.cpp:266 auto new_root_page_id = bpm_-\u0026gt;NewPage(); auto internal_page = ...; internal_page-\u0026gt;Init(internal_max_size_); internal_page-\u0026gt;Insert(0, left_key, left_page_id); // 原根（左半） internal_page-\u0026gt;Insert(1, right_key, right_page_id); // 新分裂出来的（右半） header_page-\u0026gt;root_page_id_ = new_root_page_id; // 更新根 树高度 +1。\n3.4 Remove（删除） 删除比插入复杂，需要处理 underflow（节点元素数低于 min_size）。\n实现思路概述 Remove 操作和 Insert 差不多，主要区别在于 Remove 时有一个借节点的操作，整体流程如下：\n判断有无根节点，没有根节点直接 return，有则继续执行。 和 Insert 中一样，向下搜索叶节点，同时保存中间的 InternalPage 和搜索路径放入 write_set_ 和 index_set_ 中。 在叶子节点中搜索，如果不存在，直接 return，如果存在，则在搜索到的位置执行 Remove 操作。如果当前叶子是根节点，判断删除后是否为空，为空则重置 header_page_ 中 root_page_id，否则直接 return。 之后是一波循环操作：如果 PageSize 小于 MinSize，表明不满足 B+树规则。首先向左边节点借 value，如果两个节点中 value 数量总和不大于 MaxSize，就将两个 Page 进行合并，否则执行借 value 操作；如果是第一个节点，则向右边借节点，同理判断合并还是借；如果 PageSize ≥ MinSize，直接 return。 执行到这里，说明删除到根节点了，如果 PageSize == 1，直接将根节点中第一个子节点替换根节点，修改 HeaderPage。 乐观锁实现：和 Insert 中基本一样，在进行向下搜索时，如果当前节点的 PageSize \u0026gt; MinSize，表明这个节点不会发生合并，直接清空 write_set_、index_set_，同时将 ctx 中的 header_page_ 设置为空。\n其他优化：\nSearch 使用二分查找 先执行 Insert 操作，再判断是否违反 B+树规则，将 InternalPageSize 和 LeafPageSize 上限各减一，避免出现 PageSize \u0026gt; MaxSize 时可能的越界 Remove 中借操作时，提前移位留足空间，保证每个元素只执行一次移位操作 完整流程 Remove(key) │ ├─ [空树] return │ └─ [非空] 持有 header 写锁，向下遍历 │ ├─ 下行（while !IsLeafPage）： │ ├─ 若当前内部节点 size \u0026gt; min_size（\u0026#34;安全节点\u0026#34;）： │ │ 释放 header 写锁 + write_set_ 前端的祖先锁 │ ├─ 提前预存 sibling： │ │ 若 index \u0026gt; 1 → 存左兄弟 write_guard 入 write_set_ │ │ 否则若 index \u0026lt; size → 存右兄弟 write_guard 入 write_set_ │ ├─ 存当前节点 write_guard 入 write_set_，记录 index 到 index_set_ │ └─ 移动到子节点 │ ├─ 到达叶子节点： │ ├─ LeafBinarySearch + 确认 key 存在 │ └─ leaf_page-\u0026gt;Remove(index) │ ├─ [叶子是根 且 变空] header.root_page_id = INVALID → return（树空） ├─ [叶子 size \u0026gt;= min_size] → return（无 underflow） │ └─ [叶子 underflow] 从 write_set_ 取出父节点 + sibling： ├─ 优先处理左兄弟（index \u0026gt; 0）： │ ├─ 若 left.size + leaf.size \u0026gt; max_size → redistribute（重新分配） │ │ mid = (left.size + leaf.size) / 2 │ │ 将 left 的后半部分移入 leaf 的前面 │ │ 更新父节点 separator key（index 位置）= leaf.key[0] │ └─ 否则 merge： │ 将 leaf 全部内容追加到 left │ 更新 left.next_page_id = leaf.next_page_id │ 删除父节点中 index 处的 key（Remove(index)） │ └─ 处理右兄弟（index == 0 且 parent.size \u0026gt; 1）： ├─ 若 right.size + leaf.size \u0026gt; max_size → redistribute │ 将 right 的前半部分移入 leaf 末尾 │ 更新父节点 separator key（index+1 位置）= right.key[0] └─ 否则 merge： 将 right 全部内容追加到 leaf 更新 leaf.next_page_id = right.next_page_id 删除父节点中 index+1 处的 key 内部节点 underflow 向上传播 叶子层处理完毕后，若父内部节点也发生 underflow，使用 while 循环向上传播，逻辑与叶子层镜像：\n// b_plus_tree.cpp:412 while (internal_page-\u0026gt;GetSize() \u0026lt; internal_page-\u0026gt;GetMinSize() \u0026amp;\u0026amp; !ctx.write_set_.empty()) { // 与叶子层相同的 redistribute / merge 逻辑，但操作的是 InternalPage } 根节点缩减 当根节点内部节点只剩 1 个指针（size == 1）时，唯一的子节点晋升为新根，树高度 -1：\n// b_plus_tree.cpp:470 if (internal_guard.GetPageId() == root_page_id \u0026amp;\u0026amp; internal_page-\u0026gt;GetSize() == 1) { header_page-\u0026gt;root_page_id_ = internal_page-\u0026gt;ValueAt(0); } Redistribute vs Merge 决策 条件：left.size + current.size \u0026gt; max_size → Redistribute（可以平衡两节点，不需要合并） 条件：left.size + current.size \u0026lt;= max_size → Merge（两节点合并为一个仍不超限） 等价于：当两节点合并后总量 ≤ max_size 时合并，否则重新分配。\n4. Task 3：索引迭代器 4.1 设计 文件：src/include/storage/index/index_iterator.h / src/storage/index/index_iterator.cpp\nclass IndexIterator { page_id_t page_id_; // 当前所在叶子页 ID（INVALID_PAGE_ID 表示 End） int index_; // 当前在页内的偏移 BufferPoolManager *bpm_; }; 4.2 关键操作 IsEnd()：page_id_ == INVALID_PAGE_ID\noperator++()：\nif (index_ + 1 \u0026lt; leaf_page-\u0026gt;GetSize()) { ++index_; // 同页内移动 } else { page_id_ = leaf_page-\u0026gt;GetNextPageId(); // 跨页：跳到下一叶子 index_ = 0; } operator*()：每次调用都重新 ReadPage，返回 pair\u0026lt;const KeyType\u0026amp;, const ValueType\u0026amp;\u0026gt;。\noperator==()：比较 page_id_ 和 index_ 两个字段。\n4.3 Begin / End 实现 // Begin()：找最左叶子（始终向 ValueAt(0) 走） // Begin(key)：向下找包含 key 的叶子，返回指向该 key 的迭代器 // End()：返回默认构造的 IndexIterator（page_id = INVALID_PAGE_ID） 5. Task 4：并发控制（Latch Crabbing） 5.1 Latch Crabbing / Latch Coupling 算法 Latch Crabbing（也叫 Latch Coupling）是 B+树并发控制的经典方案，核心思想：\n向下遍历时，先获取子节点的锁，再按条件决定是否释放父节点的锁，如同螃蟹\u0026quot;一松一抓\u0026quot;地前进。\n安全节点（Safe Node）定义：\n插入操作：节点当前 size \u0026lt; max_size（插入后不会分裂） 删除操作：节点当前 size \u0026gt; min_size（删除后不会 underflow） 5.2 实现细节 Search（只读）：\n获取 header 读锁 → 读取 root_page_id 获取 root 读锁 → 释放 header 读锁 循环：获取 child 读锁 → 释放父节点读锁 到达叶子 → 搜索 → 释放叶子读锁 使用 ReadPageGuard，直接赋值 read_guard = bpm_-\u0026gt;ReadPage(next) 会触发旧 guard 析构（自动释放读锁），干净利落。\nInsert（写）：\n获取 header 写锁（Context.header_page_） 获取 root 写锁，入 write_set_ 循环（while !IsLeafPage）： if (current.size \u0026lt; max_size)： 释放 header 写锁（ctx.header_page_ = nullopt） 释放 write_set_ 前端的所有\u0026#34;安全祖先\u0026#34; 获取 child 写锁，入 write_set_ 操作叶子 若需要分裂：write_set_ 中已有路径上的节点写锁，直接操作 自动析构时释放所有剩余写锁 Remove（写）：与 Insert 类似，但安全条件为 size \u0026gt; min_size。额外将 sibling 的写锁也提前放入 write_set_，避免后续需要回头加锁（deadlock 风险）。\n5.3 HeaderPage 的特殊处理 HeaderPage 是访问树的入口，只有在确认操作可能修改根（即没有安全节点在根路径上）时才需要持有其写锁。本实现中：\n遇到第一个\u0026quot;安全\u0026quot;内部节点时，立即释放 header_page_ 写锁（ctx.header_page_ = std::nullopt） 若整个路径都不安全（最终到达根需要分裂/合并），则写锁持续到操作结束 5.4 Context 类的设计作用 class Context { std::optional\u0026lt;WritePageGuard\u0026gt; header_page_; // header 写锁，nullopt = 已释放 page_id_t root_page_id_; // 避免重复读 header std::deque\u0026lt;WritePageGuard\u0026gt; write_set_; // 路径上的写锁（LIFO 访问） std::deque\u0026lt;int\u0026gt; index_set_; // 对应每个写锁节点选择的 child index std::deque\u0026lt;ReadPageGuard\u0026gt; read_set_; // 预留（本实现未大量使用） }; write_set_ 和 index_set_ 构成\u0026quot;路径记录\u0026quot;，使得分裂/合并时可以逆向回溯父节点进行更新，无需在节点中存储父指针（parent pointer）。\n6. 设计分析 6.1 RAII + PageGuard（内存安全与锁安全） ReadPageGuard / WritePageGuard 遵循 RAII 原则：\n构造时：从 BPM 获取页面，加锁 析构时：自动解锁并 Unpin 页面 这使得代码中无需手动 Unpin 和 Unlock，消除了大量资源泄漏和死锁风险。Context 的 write_set_ 使用 deque\u0026lt;WritePageGuard\u0026gt;，在 Context 析构时所有路径锁自动释放——即使提前 return 或抛出异常也不会泄漏。\n6.2 Context 类：局部状态封装 将一次 Insert/Remove 操作的\u0026quot;上下文状态\u0026quot;封装在 Context 对象中，而非使用全局变量或在 BPlusTree 类中新增成员变量。这是典型的局部状态对象模式，优点：\n每次操作独立隔离，天然线程安全 操作完成后 Context 析构，所有锁和页面自动释放 逻辑清晰，可读性好 6.3 模板化 + 显式实例化 使用 C++ 模板支持不同键长度，并通过显式实例化（在 .cpp 末尾 template class BPlusTree\u0026lt;GenericKey\u0026lt;4\u0026gt;, ...\u0026gt;）将实例化集中在一处，加速编译。\n6.4 叶子页单向链表支持范围扫描 叶子页通过 next_page_id_ 形成单向链表，使得范围扫描（Range Scan）和顺序扫描无需回到树的根部重新查找，时间复杂度为 O(k)（k 为结果数量）。这是 B+树相比 B-树的重要优势。\n6.5 分离的 HeaderPage 防止根节点竞争 根节点频繁发生分裂/合并，如果直接在 BPlusTree 对象中用一个成员变量存储 root_page_id，并发访问时需要对整个 BPlusTree 加锁。引入 HeaderPage 后，可以用页面粒度的锁（而非对象锁）保护根节点 ID，粒度更细，并发度更高。\n思考 到这里，CMU 15-445 的 B+Tree 部分算是告一段落了。相比于可扩展哈希，B+树的层次更多，并发场景也更复杂，但整体思路是清晰的——无论是 Latch Crabbing、RAII PageGuard，还是 Context 封装路径状态，都是值得反复回味的工程设计。写博客的过程也是对这些细节重新梳理的过程，希望这篇文章对后来做这个项目的同学有所帮助。\n","permalink":"https://yinit.github.io/bustub-b-tree-index/","summary":"CMU 15445 24fall Project2 Bustub B+Tree Index 实现细节、并发控制与复习总结","title":"Bustub B+Tree Index"},{"content":" 想法来由 在使用Vscode连接本地虚拟机写代码时，隔一段时间便发现虚拟机IP发生了变化，总是需要修改ssh连接的IP地址未免太过繁琐，便想要为虚拟机设置固定IP地址。同时，由于经常访问外网下载资源，也需要为虚拟机配置系统代理，让其能够使用主机上VPN的系统代理。\n开发环境 time: 2025-02-27 Windows11专业版 VMware Pro 17.6.2 Ubuntu22.04 配置固定IP 在VMware虚拟机配置中设置虚拟机网络为桥接模式 在虚拟机中配置固定IP # 设置网络配置 sudo vim /etc/netplan/00-installer-config.yaml 文件内容如下：\nnetwork: ethernets: ens33: dhcp4: no # 关闭 DHCP（分配IP） addresses: [192.168.138.128/24] # 你的虚拟机 IP，和主机在同一子网下 routes: - to: default via: 192.168.138.208 # 主机网关 IP nameservers: addresses: [8.8.8.8, 1.1.1.1] # 手动指定 DNS version: 2 可能会发现/etc/netplan文件夹下还有00-netcfg.yaml文件和50-cloud.init.yaml文件，我的选择是将其删除\n# 查询主机的IPv4地址和网关，不是VMware Network Adapter VMnet1和VMware Network Adapter VMnet8 ipconfig 启动配置 sudo netplan apply 重启就能发现虚拟机IP地址固定为你设置的IP地址了 配置VPN代理 在本地VPN代理中开启局域网连接，我使用的是Clash Verge 在.bashrc文件中添加代理 cd ~ vim .bashrc .bashrc添加内容如下：\n# 这里将IP地址修改为自己主机的IPv4地址 export http_proxy=http://192.168.138.180:7897 export https_proxy=http://192.168.138.180:7897 启动配置：\nsource .bashrc 配置Rust代理 Rust全局配置：\ncd ~ vim .cargo/config.toml config.toml添加如下配置内容：（这里将IP地址修改为自己主机的IPv4地址）\n[http] proxy = \u0026#34;socks5://192.168.138.180:7898\u0026#34; [https] proxy = \u0026#34;socks5://192.168.138.180:7898\u0026#34; 目前存在的问题 过了一段时间后发现，主机的IP和网关地址并不是固定不变的，这就需要每次修改虚拟机的配置，暂时还未解决，等待之后看看有没有什么比较好的解决办法吧\n虚拟机磁盘空间扩容 写TinyKV的时候发现虚拟机磁盘空间不够，需要扩展空间，故此记录下，以下内容来自deepseek回答\n# 查看磁盘空间利用情况，磁盘空间不够的话就需要扩容 df -h # 查看逻辑卷组空间（需物理卷有剩余空间），不够的话就在外部给虚拟机增加空间 sudo vgs # 扩展根逻辑卷（例如扩展 100G，多多的扩） sudo lvextend -L +100G /dev/mapper/ubuntu--vg-ubuntu--lv # 调整文件系统大小（ext4 文件系统） sudo resize2fs /dev/mapper/ubuntu--vg-ubuntu--lv 写在最后 看网上的内容陆陆续续配了好几次，总是这里或者那里有问题，今天终于是配好了，好耶。\n也使用NAT模式配过，也是网上推荐比较多的，但是网络配置总是有问题，连接不上网络或主机，之后有机会去认真学习一下计算机网络了。\n最后，有问题多问AI，还是挺有帮助的。\n","permalink":"https://yinit.github.io/%E6%9C%AC%E5%9C%B0ubuntu22.04%E8%99%9A%E6%8B%9F%E6%9C%BA%E9%85%8D%E7%BD%AE%E5%9B%BA%E5%AE%9Aip%E5%92%8Cvpn/","summary":"在Windows11上为VMware搭建的Ubuntu22.04虚拟机配置固定IP地址，共享主机VPN代理","title":"本地Ubuntu22.04虚拟机配置固定IP和VPN"},{"content":"bustub 项目历程 从去年十月初到今年一月中旬，历时一个学期，在实验室的安排下着手开始 CMU 15445 23fall 的数据库知识的学习和 Project 的实现，终于在过年前完成所有的 Project。正好寒假在家有时间，就着手建了一个个人博客，写一下我在实现 Project 过程中遇到的问题和收获。关于课程视频，我时间有限，就只看了前几个视频，如果有同学感兴趣，还是推荐看一下，理论知识的学习还是很重要的。\n前言 老规矩，先看一下最终排名，截止 2025/02/10，总分第 7，单榜第 1，打开 LeaderBoard 第一个就是我了（嘿嘿）\n个人认为 project4 完全做完应该是 4 个 project 中最难的，花的时间也是最多的。不过开始写的时候刚好考试比较多，没多少时间写，大概花了几天时间把前几个 task 完成了，最后在考试完后回家写了几天才写完。虽然可能没写太久，但是感觉花费的精力挺多的。\n一、MVCC 理论基础 1.1 什么是 MVCC？ MVCC（Multi-Version Concurrency Control，多版本并发控制） 是数据库系统中最主流的并发控制机制之一。其核心思想是：每次写操作不覆盖旧数据，而是创建数据的一个新版本；读操作通过时间戳（或版本号）找到它\u0026quot;应该\u0026quot;看到的历史版本。\n这样的设计带来了关键特性：读操作不会阻塞写操作，写操作不会阻塞读操作，读写之间完全不争锁，从根本上解决了传统 2PL（Two-Phase Locking）中读写互斥的性能瓶颈。\n1.2 MVCC 与 2PL 的对比 维度 2PL（两阶段锁） MVCC 读写关系 读写互斥（S 锁 / X 锁） 读写不互斥 写写关系 写写阻塞等待 写写冲突 → abort（First-Writer-Wins） 死锁风险 存在（需要死锁检测/预防） 无死锁（通过 abort 解决） 历史版本 不保留 保留多版本（UndoLog 链） 实现复杂度 锁管理复杂 版本链管理、GC 复杂 典型系统 InnoDB（历史版本同时也用 MVCC） PostgreSQL, MySQL InnoDB, BusTub 读性能 受写锁阻塞 高（始终读快照） 写吞吐 并行度受限 高并发（短事务） 补充：现代工业级数据库如 MySQL InnoDB 同时使用 2PL 和 MVCC：2PL 保证当前读（SELECT FOR UPDATE）的正确性，MVCC 提升普通读（快照读）的性能。BusTub 的实现更接近纯 MVCC（读不加锁）。\n1.3 Snapshot Isolation（快照隔离） 快照隔离（SI） 是 MVCC 提供的最常见隔离级别，其核心保证：\n每个事务在 Begin 时刻看到一个数据库的一致性快照，此后该事务只能看到这个快照中的数据，以及自身写入的数据。\nSI 的正式定义（三个约束）：\nSnapshot Read：事务 T 读到的是所有在 T.read_ts 之前已提交的数据版本 Consistent Snapshot：所有可见数据在同一时间点上是一致的（不会看到一半的事务） First-Writer-Wins：若两个并发事务 T1, T2 都想写同一行，先获得写权的事务赢，后来者 abort SI 可以解决的问题：\n脏读（Dirty Read）：✅ 不会读到未提交的数据 不可重复读（Non-Repeatable Read）：✅ 读的是固定快照 大部分幻读（Phantom Read）：✅ 快照是固定的 SI 无法解决的问题（异常）：\n写偏序（Write Skew）：经典例子是两个事务都读了同一个表并各自更新了不同行，违反了整体的完整性约束，但因为没有直接的写写冲突，两者都能提交。\n初始：doctor_on_call = {A: true, B: true}，要求至少一人值班 T1 读取：发现 A=true, B=true，将 A 设为 false（A 不值班了） T2 读取：发现 A=true, B=true，将 B 设为 false（B 不值班了） 结果：A=false, B=false → 无人值班，违反约束，但 SI 下两者都提交成功 1.4 Serializable Snapshot Isolation（SSI） SSI 是在 SI 基础上，通过在提交时做额外的冲突检测，将隔离级别提升到**可串行化（Serializability）**的方案。\nSSI 的核心思想（基于依赖分析）：\n标准的 SSI 分析事务间的三种依赖关系并检测危险结构（danger triangle）。BusTub 的实现采用的是一种简化版 SSI：在提交时，检查本事务写的行是否被其他在本事务开始后提交的事务修改过，且这些行满足本事务曾经扫描过的谓词（scan predicate）。\n即：检测 \u0026ldquo;幻读（Phantom）\u0026rdquo; 的发生，防止一类经典的不可串行化现象。\n1.5 First-Writer-Wins 原则 BusTub 实现的是 First-Writer-Wins（先写者获胜） 策略：\n若事务 T1 已经修改了某行（tuple_ts 变为 T1 的临时 ID），事务 T2 也想修改同一行 T2 检测到 tuple_ts \u0026gt; T2.read_ts 且 tuple_ts ≠ T2.GetTransactionId() T2 立即 abort（设置 TAINTED 状态，抛出异常） T1 继续正常执行 这与 2PL 的\u0026quot;后来者等待\u0026quot;不同，MVCC 不等待，直接 abort，适合短事务密集的 OLTP 场景。\n二、BusTub MVCC 整体架构 2.1 版本链设计 BusTub 的 MVCC 采用 Undo Log 链式存储（也叫 Delta Storage / Diff-based Storage）。\nTableHeap（最新版本，通常是正在进行中的事务或最后一次提交的结果） ↓ (通过 TransactionManager 中的 version_info_ 映射) VersionUndoLink { prev_: UndoLink, in_progress_: bool } ↓ prev_ UndoLog₁ { ts_, is_deleted_, modified_fields_, tuple_, prev_version_ } ↓ prev_version_ UndoLog₂ ↓ UndoLog₃ → INVALID (链尾) 关键设计决策：\n最新版本在 TableHeap 中（不在 undo log 中），读当前版本最快 undo log 存储的是\u0026quot;从当前版本回退一步的差异\u0026quot;，而非完整快照 版本链越新越靠前，沿链向后找到越旧的版本 对比两种常见的版本存储方式：\n存储方式 最新版本位置 历史版本位置 代表系统 Undo Log（BusTub, InnoDB） 主表（in-place） 回滚段（差量） MySQL, BusTub Append-Only（MVTO） 版本链最新节点 主表/版本链旧节点 PostgreSQL（Heap-based） 2.2 核心数据结构 TupleMeta（存储在 TablePage 中，每条 tuple 配套）\n// src/include/storage/table/tuple.h struct TupleMeta { timestamp_t ts_; // 双重含义： // \u0026lt; TXN_START_ID → 已提交事务的 commit_ts // \u0026gt;= TXN_START_ID → 正在进行事务的临时 txn_id bool is_deleted_; // 软删除标志（逻辑删除） }; static constexpr size_t TUPLE_META_SIZE = 16; // 固定 16 字节 UndoLink（版本链指针）\n// src/include/concurrency/transaction.h struct UndoLink { txn_id_t prev_txn_; // 指向拥有该 UndoLog 的事务 ID int32_t prev_log_idx_; // 该 UndoLog 在事务 undo_logs_ 数组中的下标 auto IsValid() const -\u0026gt; bool { ... } // prev_txn_ != INVALID_TXN_ID }; UndoLog（差量记录）\nstruct UndoLog { bool is_deleted_; // 这一步操作是否是删除 std::vector\u0026lt;bool\u0026gt; modified_fields_; // 位图：哪些列在这一步被修改了 Tuple tuple_; // 只存 modified_fields_ 为 true 的列（稀疏存储） timestamp_t ts_; // 这条 UndoLog 对应的事务提交时间 UndoLink prev_version_; // 指向更旧版本的 UndoLog }; 稀疏存储的意义： 假设一张 100 列的宽表，每次只更新 2 列，使用全量存储每次需要保存 100 列数据；使用稀疏存储（modified_fields_ 位图 + 只存变化列）只需要存 2 列数据 + 100 位的位图，空间节省 ~98%。\nVersionUndoLink（版本链头节点的包装）\n// src/include/concurrency/transaction_manager.h struct VersionUndoLink { UndoLink prev_; // 指向最新 UndoLog 的链接 bool in_progress_; // 当前是否有事务正在修改此 tuple static auto FromOptionalUndoLink(std::optional\u0026lt;UndoLink\u0026gt; undo_link) -\u0026gt; VersionUndoLink; }; in_progress_ 的作用是一个\u0026quot;轻量级写锁\u0026quot;标记，防止并发写同一 tuple（详见 Task #4 并发实现）。\n2.3 版本链存储的组织方式 // src/include/concurrency/transaction_manager.h struct PageVersionInfo { std::shared_mutex mutex_; // 保护此 Page 内所有 slot 的版本链头 std::unordered_map\u0026lt;slot_offset_t, VersionUndoLink\u0026gt; prev_version_; }; // TransactionManager 成员 std::unordered_map\u0026lt;page_id_t, std::shared_ptr\u0026lt;PageVersionInfo\u0026gt;\u0026gt; version_info_; 设计思路：\n不是全局一个大锁，而是以 Page 为粒度组织版本链头 每个 PageVersionInfo 有独立的 shared_mutex_，并发访问同一 Page 的不同 slot 时需要竞争同一锁（粒度介于全局锁和 slot 级锁之间） 通过 RID = (page_id, slot_offset) 定位到对应的 VersionUndoLink 2.4 TXN_START_ID 常量 // src/include/common/config.h static constexpr txn_id_t TXN_START_ID = 1LL \u0026lt;\u0026lt; 62; // 极大的数 ts_ 字段复用了 timestamp_t 和 txn_id_t 两种语义：\nts_ \u0026lt; TXN_START_ID：已提交的事务时间戳（commit_ts），具体数值从 0 单调递增 ts_ \u0026gt;= TXN_START_ID：正在进行的事务的临时 ID（txn_id = TXN_START_ID + 序号） 这样只需要一个 int64_t 字段，就能区分\u0026quot;已提交\u0026quot;和\u0026quot;未提交\u0026quot;两种状态，比用额外的 bool 字段更节省空间，也便于比较。\n三、Task #1 — Timestamps 与 Watermark 3.1 任务概述 task1 主要是熟悉这部分相关代码，写的部分比较简单，总共实现两个部分：\n事务管理器中创建事务，赋予时间戳，将其放入 watermark 中 watermark 管理所有正在运行的事务，保存正在运行的事务的最小时间戳，创建事务时向其中添加事务时间戳，提交事务时从中删除 3.2 时间戳分配策略 BusTub 使用逻辑时间戳（Logical Timestamp），而非物理时钟：\n事务 Begin 时：read_ts = last_commit_ts（当前最新提交的时间戳） 事务 Commit 时：commit_ts = ++last_commit_ts（原子递增获取新时间戳） 每个事务在开始时\u0026quot;拍下\u0026quot;一个快照时间戳（read_ts），此后只看 commit_ts ≤ read_ts 的已提交数据。Commit 时递增全局计数器获得唯一的提交时间戳。\nauto TransactionManager::Begin(IsolationLevel isolation_level) -\u0026gt; Transaction * { std::unique_lock\u0026lt;std::shared_mutex\u0026gt; l(txn_map_mutex_); auto txn_id = next_txn_id_++; auto txn = std::make_unique\u0026lt;Transaction\u0026gt;(txn_id, isolation_level); auto *txn_ref = txn.get(); txn_map_.insert(std::make_pair(txn_id, std::move(txn))); // 关键：read_ts = 当前最新提交时间戳（原子读取） txn_ref-\u0026gt;read_ts_ = last_commit_ts_.load(); // 注册到 Watermark 追踪器 running_txns_.AddTxn(txn_ref-\u0026gt;read_ts_); return txn_ref; } 3.3 Watermark 的定义与作用 Watermark（水印） 是所有活跃事务（正在运行的事务）的 最小 read_ts。\n活跃事务：T1(read_ts=5), T2(read_ts=7), T3(read_ts=9) Watermark = min(5, 7, 9) = 5 Watermark 的核心用途：垃圾回收（GC）\ncommit_ts ≤ Watermark 的版本对所有活跃事务都不再需要（因为所有事务都能看到比它更新的版本），可以安全回收。\n3.4 Watermark O(1) 均摊实现思路 关于这一部分的 O(1) 实现方法，利用事务管理器中的 last_commit_ts_，它赋予给所有创建的事务，并且是单调递增，在 commit 时 +1。这样便可以确定 watermark 中时间戳在一定的范围内，时间范围最大为 last_commit_ts_，最小为正在运行的事务中的最小时间戳，并且由于时间戳 +1 递增，watermark 也使用 +1 递增寻找符合的事务。\n具体实现：除非 watermark 为空，否则添加时不修改 watermark 值，删除时判断是否还有时间戳为 watermark 的事务，没有就递增搜索，直到 commit_ts。\nauto Watermark::AddTxn(timestamp_t read_ts) -\u0026gt; void { if (read_ts \u0026lt; commit_ts_) { throw Exception(\u0026#34;read ts \u0026lt; commit ts\u0026#34;); } // 若 map 为空（没有活跃事务），直接设置 watermark if (current_reads_.empty()) { watermark_ = read_ts; } ++current_reads_[read_ts]; // 频率计数 } auto Watermark::RemoveTxn(timestamp_t read_ts) -\u0026gt; void { auto iter = current_reads_.find(read_ts); if (iter == current_reads_.end()) { throw Exception(\u0026#34;read ts not found\u0026#34;); } if (iter-\u0026gt;second == 1) { current_reads_.erase(iter); // 最后一个使用此 read_ts 的事务退出 } else { --iter-\u0026gt;second; // 还有其他事务使用相同 read_ts } // 若移除的是当前 watermark，需要更新 watermark if (read_ts == watermark_ \u0026amp;\u0026amp; !current_reads_.empty()) { // 向前推进 watermark，找到下一个有活跃事务的 read_ts // current_reads_ 是有序 map，可直接取 begin()-\u0026gt;first，无需循环 } } 实现优化点： current_reads_ 是有序 std::map，RemoveTxn 的 watermark 更新可以直接取 begin()-\u0026gt;first 作为新 watermark，无需循环线性扫描，更加高效。\n四、Task #2 — 存储格式与顺序扫描 4.1 TupleMeta.ts_ 的完整语义 ts_ 值域分析： [0, TXN_START_ID) → 已提交事务的 commit_ts（小整数） [TXN_START_ID, MAX) → 进行中事务的临时 txn_id（极大整数） 可见性判断核心逻辑：\n令 tuple_ts = tuple_meta.ts_ 令 txn_ts = txn-\u0026gt;GetReadTs() 令 my_txn_id = txn-\u0026gt;GetTransactionId() // \u0026gt;= TXN_START_ID 情况1: tuple_ts \u0026lt;= txn_ts → tuple 已提交且时间 ≤ 当前快照，直接可见 情况2: tuple_ts == my_txn_id → tuple 是本事务自己修改的，可见（读自己写的数据） 情况3: tuple_ts \u0026gt; txn_ts \u0026amp;\u0026amp; tuple_ts != my_txn_id → tuple 被一个更新的已提交事务或另一个未提交事务修改 → 需要沿 undo log 链向后找到对当前快照可见的版本 → 可能的情况： 3a: tuple_ts \u0026gt;= TXN_START_ID（另一个正在进行的事务修改） 3b: tuple_ts \u0026lt; TXN_START_ID 但 \u0026gt; txn_ts（另一个较新的已提交事务修改） 4.2 UndoLog 稀疏存储的实现细节 struct UndoLog { bool is_deleted_; // true 表示\u0026#34;这步操作是删除\u0026#34; std::vector\u0026lt;bool\u0026gt; modified_fields_; // 大小 = schema.GetColumnCount() // modified_fields_[i] = true → 第 i 列在此 log 中有旧值 Tuple tuple_; // 只包含 modified_fields_ 为 true 的列 // 用 Schema::CopySchema() 生成子 schema 来解析 timestamp_t ts_; // 此 UndoLog 记录的版本对应的时间戳 UndoLink prev_version_; // 指向更旧的 UndoLog }; 示例： 一张 4 列的表 (a, b, c, d)，事务将 b 和 d 从 (2, 4) 改为 (20, 40)：\nmodified_fields_ = [false, true, false, true] tuple_.data_ = [(b=2), (d=4)] // 只存修改了的列的旧值 // 解析时用 CopySchema([1, 3]) 生成 2 列 schema 版本链中 ts_ 的含义：\nTableHeap tuple: ts_=TXN8（事务8临时id，表示事务8正在修改） ↓ UndoLog1: ts_=5（表示\u0026#34;若你想看 commit_ts=5 时的状态，apply 这个 log\u0026#34;） ↓ UndoLog2: ts_=3 ↓ INVALID（链尾，再往前没有版本记录） 读事务 T with read_ts=6 想读这行：TableHeap 的 ts_=TXN8 不可见，取 UndoLog1，ts_=5 ≤ 6 → apply 此 log 后的版本对 T 可见，返回 apply UndoLog1 后的 tuple。\n4.3 ReconstructTuple — 元组重建 函数签名与语义：\nauto ReconstructTuple(const Schema *schema, const Tuple \u0026amp;base_tuple, // TableHeap 中的最新 tuple const TupleMeta \u0026amp;base_meta, // 对应的元数据 const std::vector\u0026lt;UndoLog\u0026gt; \u0026amp;undo_logs // 从新到旧的 undo log 数组 ) -\u0026gt; std::optional\u0026lt;Tuple\u0026gt;; // 返回 nullopt 表示重建后的版本已被删除（即该历史版本不存在） 执行流程图：\nbase_tuple (TableHeap 当前版本) │ ▼ apply UndoLog₁ (最近的变更 → 还原这一步) 版本v₁ │ ▼ apply UndoLog₂ 版本v₂ ← 这可能就是我们需要的历史版本 │ ▼ apply UndoLog₃ 版本v₃（更古老的版本） 特别需要注意的点是对已删除数据的操作——执行前已删除、执行后已删除这两种情况，is_deleted 标志需要随 undo log 一起回退。\n版本链遍历顺序：\n新 ←————————————————→ 旧 TableHeap → UndoLog1 → UndoLog2 → UndoLog3 → INVALID (最近的变更) (最早的变更) ReconstructTuple 接收的 undo_logs 数组是新到旧顺序，apply 时需要正向遍历（先 apply 最新的 undo，再 apply 较旧的 undo），才能从 base_tuple 一步步回退到目标版本。\n4.4 顺序扫描（SeqScan）可见性逻辑 顺着版本链扫描：如果时间戳满足条件（情况1或2）直接读取；否则顺着 undo log 版本链构建历史版本。\n// SeqScan 中的使用（seq_scan_executor.cpp） std::vector\u0026lt;UndoLog\u0026gt; undo_logs; UndoLink undo_link = txn_manager-\u0026gt;GetUndoLink(*rid).value_or(UndoLink{}); auto optional_undo_log = txn_manager-\u0026gt;GetUndoLogOptional(undo_link); // 收集足够多的 undo log，直到时间戳满足条件 while (optional_undo_log.has_value() \u0026amp;\u0026amp; tuple_ts \u0026gt; txn_ts) { undo_logs.push_back(*optional_undo_log); tuple_ts = optional_undo_log-\u0026gt;ts_; undo_link = optional_undo_log-\u0026gt;prev_version_; optional_undo_log = txn_manager-\u0026gt;GetUndoLogOptional(undo_link); } // 检查是否找到了满足条件的版本 if (tuple_ts \u0026gt; txn_ts) { continue; // 整个版本链都比 txn_ts 新，此 tuple 对当前事务不可见 } auto new_tuple = ReconstructTuple(\u0026amp;schema, *tuple, tuple_meta, undo_logs); SERIALIZABLE 隔离级别处理： 构造 SeqScanExecutor 时若 isolation_level == SERIALIZABLE 且存在 filter_predicate_，需记录扫描谓词（txn-\u0026gt;AppendScanPredicate），用于提交时 VerifyTxn 检测幻读。\n情况 条件 处理 直接可见 tuple_ts ≤ txn_ts 直接使用 TableHeap 中的版本 本事务自写 tuple_ts == txn-\u0026gt;GetTransactionId() 直接使用（读自己写的数据） 需要回溯 tuple_ts \u0026gt; txn_ts \u0026amp;\u0026amp; tuple_ts ≠ my_txn_id 遍历 undo log 链重建历史版本 五、Task #3 — MVCC Executors task3 是这部分的核心内容，实现增删改查这些基本操作在事务下的执行过程以及事务的提交。\n5.1 Insert 向 table 中插入数据即可，需要同时向 write_set 中传入对应 rid（后续会在 commit 中统一修改对应 Tuple 的时间戳，表明该 Tuple 没有事务在执行；也可以用于 Abort 中取消对 Tuple 的操作）。\nauto InsertExecutor::Next([[maybe_unused]] Tuple *tuple, RID *rid) -\u0026gt; bool { if (has_inserted_) { return false; } // 确保只执行一次 has_inserted_ = true; int count = 0; auto txn = exec_ctx_-\u0026gt;GetTransaction(); auto txn_mgr = exec_ctx_-\u0026gt;GetTransactionManager(); auto catalog = exec_ctx_-\u0026gt;GetCatalog(); auto table_info = catalog-\u0026gt;GetTable(plan_-\u0026gt;GetTableOid()); auto indexes = catalog-\u0026gt;GetTableIndexes(table_info-\u0026gt;name_); while (child_executor_-\u0026gt;Next(tuple, rid)) { InsertTuple(txn, txn_mgr, table_info, indexes, *tuple); ++count; } // 输出受影响行数 std::vector\u0026lt;Value\u0026gt; result{{TypeId::INTEGER, count}}; *tuple = Tuple{result, \u0026amp;GetOutputSchema()}; return true; } 5.2 Commit commit 主要是两个操作：\n修改 last_commit_ts_ 以及当前事务的 commit_ts 读取 write_set，修改事务执行过写操作的 Tuple 的时间戳为当前事务提交的时间戳 commit_ts auto TransactionManager::Commit(Transaction *txn) -\u0026gt; bool { std::unique_lock\u0026lt;std::mutex\u0026gt; commit_lck(commit_mutex_); // 串行化提交 // commit_ts = ++last_commit_ts_ // 遍历 write_set_，将所有相关 tuple 的 ts_ 改为 commit_ts // running_txns_.RemoveTxn(txn-\u0026gt;read_ts_) return true; } 两把锁的用意：\ncommit_mutex_：序列化提交操作，确保 last_commit_ts_ 的递增是原子性的，避免两个事务获得同一个 commit_ts txn_map_mutex_：保护 txn_map_ 和 Watermark 的更新 5.3 Update \u0026amp;\u0026amp; Delete Update 和 Delete 操作差不多，都是根据读取的数据进行操作，需要判断读取的数据时间戳，有三种情况：\nTuple 时间戳 ≤ 当前事务时间戳：表明这个 Tuple 没有被当前事务之后的事务操作或正在被其他事务操作，直接操作 Tuple，然后写入 UndoLog、写入 writeSet、更新 VersionLink Tuple 的时间戳等于当前事务 ID：表明这个 Tuple 之前被当前事务操作过，需要更新之前提交的 UndoLog——UndoLog 合并 其他情况：包括时间戳比当前事务大（被当前事务之后的事务操作过了）、已经有其他事务在操作了——这两种情况都是写写冲突 UndoLog 合并（第一次写——创建）：\n当 tuple_meta.ts_ ≤ txn-\u0026gt;GetReadTs()（本事务第一次写这条 tuple）：\n// 计算哪些列发生了变化 std::vector\u0026lt;bool\u0026gt; new_modified_fields; std::vector\u0026lt;Value\u0026gt; new_modified_values; std::vector\u0026lt;uint32_t\u0026gt; cols; for (uint32_t i = 0; i \u0026lt; schema.GetColumnCount(); ++i) { auto old_value = cur_tuple.GetValue(\u0026amp;schema, i); if (old_value.CompareEquals(new_values[i]) == CmpBool::CmpTrue) { new_modified_fields.emplace_back(false); // 未变化 } else { new_modified_fields.emplace_back(true); // 已变化，记录旧值 new_modified_values.push_back(old_value); } } // 创建 UndoLog 并追加到事务的 undo_logs_ 数组 undo_link = txn-\u0026gt;AppendUndoLog(UndoLog{ tuple_meta.is_deleted_, new_modified_fields, new_modified_tuple, tuple_meta.ts_, // 旧版本的时间戳 undo_link // 指向更旧的版本 }); UndoLog 合并（同一事务再次写）：\n当 tuple_meta.ts_ == txn-\u0026gt;GetTransactionId()（本事务已经写过这条 tuple）时，合并现有 UndoLog 而非新建：\n// 目标：undo_log 始终记录\u0026#34;从事务开始前的状态到当前状态的完整差异\u0026#34; // 事务第一次写：a=1→2 → undo_log: {a: 1}（记录 a 的原始值） // 事务第二次写：a=2→3, b=5→6 → undo_log 合并为: {a: 1, b: 5} // （a 的原始值仍是 1，b 新加入，原始值是 5） // 若不合并而是新建： // undo_log1: {a: 2, b: 5} → undo_log0: {a: 1} // 回滚时需要 apply 两次，链条更长 txn-\u0026gt;ModifyUndoLog(undo_link.prev_log_idx_, UndoLog{ undo_log-\u0026gt;is_deleted_, merged_modified_fields, merged_modified_tuple, undo_log-\u0026gt;ts_, undo_log-\u0026gt;prev_version_ }); Update 两阶段执行——解决 Halloween Problem：\nUpdate 的实现分为两个明确的阶段：\n阶段一（收集）: while(child-\u0026gt;Next()) → old_tuples, new_tuples, rids 全部收集 阶段二（写入）: 遍历收集好的数据，执行实际的写操作 经典问题描述：假设对每个薪资 \u0026lt; 1000 的员工加薪 10%，若边读边写：\n扫描到 Alice（薪资 900），更新为 990 表迭代器继续推进，再次扫描到 Alice（新薪资 990，仍 \u0026lt; 1000）再次更新为 1089 循环往复，Alice 的薪资无限增长 先将所有满足条件的 (old_tuple, new_tuple, rid) 全部收集，然后批量写入，避免读到自己刚写的数据。\n5.4 Stop-the-world Garbage Collection 垃圾回收实现比较简单，主要利用 task1 中的 watermark：对于所有已提交的事务，如果它的提交时间比 watermark_ts 小或者 undo_log 为空，这两种情况都表明这个事务不再被访问，直接回收即可，总共就一个循环加上一个判断。\nwatermark 本来就是为了垃圾回收机制而设计的 undoLog 为空的事务：不会有版本回退访问到的情况 提交时间戳小于 watermark 的事务：undoLog 是存放修改之前的数据状态，正在运行的事务不会需要这段历史了（仔细想一想，undoLog 是存放修改之前的数据状态） 六、Task #4 — Primary Key Index task4 中引入索引，而且是单索引，这时候对版本链的操作发生了变化。由于索引指向的地址不会发生变化，对于同一个索引指向的 Tuple 存在删除数据后又重新插入的情况，同时在插入时需要判断索引冲突，引入了索引版本链才算完整（从 Reconstruction 的操作来看，存在从删除状态到未删除状态）。\n兼容性说明： 由于可扩展哈希不支持多索引指向同一个 Tuple，Project4 和 Project3 不兼容。底层实现也有冲突：Project3 更新操作是删除原数据、创建新的 Tuple（Tuple 大小不固定），但 Project4 是原地修改（Tuple 大小固定）。\n6.1 Inserts（含并发） 这一部分便是重构 Insert，考虑存在索引的情况。大致实现（包括并发）：\n判断索引冲突：有就直接报写写冲突，同时记录插入的 Tuple 的索引是否存在 索引存在时：判断是否存在写写冲突，即索引指向位置的 Tuple 是否已删除且没有其他事务正在操作，满足条件就直接插入，修改 versionLink、UndoLog 以及 writeSet 索引不存在时：和原本的 Insert 一样，插入数据，更新 VersionLink（第一次插入没有 undoLog），然后创建对应索引 并发处理关键：在步骤 3 中进行索引创建时检查冲突——如果有多个指向同一个索引的 Tuple 插入，便会发生竞争插入，最终只有一个插入成功，其余全部失败 RID 复用的重要性：\n当一行数据先被删除再被插入相同主键时，BusTub 复用原来的 RID：节省空间，维护索引一致性，简化 GC。\n提交状态: (deleted, ts=5) → UndoLog₁(is_deleted=false, ..., ts=3) → INVALID ↑ 旧的有效数据 插入后: (value=X, ts=TXN8) → UndoLog_new(is_deleted=true, ts=5) → UndoLog₁ → INVALID ↑ 新插入的 UndoLog 记录\u0026#34;我是在删除状态上覆盖的\u0026#34; 主键约束与并发插入：\n场景：两个事务同时插入相同主键 T1 begin(read_ts=5): InsertTuple(key=X) → 索引插入: key=X → RID=1, tuple_ts=TXN1 T2 begin(read_ts=5):（与 T1 并发） InsertTuple(key=X) → ScanKey 找到 RID=1，tuple_ts=TXN1 \u0026gt; read_ts=5 \u0026amp;\u0026amp; != T2的id → 写写冲突 → abort T2 这里体现了 First-Writer-Wins：T1 先完成索引插入，T2 看到冲突后 abort。\n6.2 Index Scan, Deletes and Updates（含并发） 由于引入了索引，根据 project3 中实现的优化，这里会调用 IndexScan，需要修改 IndexScan 使其能够适应事务的操作，实现大致和 SeqScan 差不多。Delete 和 Update 操作没有太大变化，主要是由于索引存在，版本链和之前不同，存在对已删除的地方进行插入，所以对 undoLog 和 versionLink 的操作有小部分修改。\n并发实现——原子自旋锁设计：\n并发使用 versionLink 来实现原子操作。实现的关键是 UpdateVersionLink 和 GetVersionLink 函数中有锁，由 txn_manager 管理，保证所有事务对 versionLink 的操作是原子的（原子操作是实现并发的关键——执行过程不会被其他事务插队）。这类似于操作系统中利用底层原子操作来实现并发的方法——自旋检查，检查过程是原子的，通过这部分实现并发。\n具体实现步骤：\n先检查写写冲突，有写写冲突直接退出 自旋检查 version_link 中 in_progress 是否为 false（表明没有其他事务在操作该 rid 指向的 tuple） 调用 UpdateVersionLink 函数，传入检查函数，原子地检查是否已经有其他事务获取了 in_progress，然后修改 in_progress 为 true，其他事务只能自旋等待。如果执行失败跳转步骤 1 重新检查并进入自旋等待 再次检查写写冲突（double-check，这部分貌似没有被执行过，但其他大佬都写了，我也写一下） 个人实现要点（其他大佬没写的部分）： versionLink 始终非空：在最开始插入时，原本的设计 versionLink 为空，但这样自旋判断存在问题（没有 versionLink 就没有 in_progress）。因此在一开始就插入 versionLink，让它指向一个不存在的 UndoLog（默认值），通过 GetUndoLogOptional 函数来判断是否到版本链终点，而不是用 UndoLog 指向为空来表示空链。这其实也是我最开始就有的想法——让 versionLink 的指针指向为空表示没有 UndoLog，而不是 UndoLog 指向为空，这样写也更舒服。 commit 时统一释放写锁：在 commit 时统一从 write_set 中修改 versionLink 的 in_progress 为 false，这样和 write_set 同步了，不需要额外的数据结构，也防止被其他事务插队，正好符合设计。 6.3 Primary Key Updates 主键更新也是大坑，倒是没有并发，但是是单索引，不符合 Project3 的测试。实现的关键点：\n首先获取所有的数据 对其进行更新操作，判断是否有索引冲突（可以通过 Expression 中修改的位置下标来判断是否有索引变化） 统一删除原本的数据（先获取所有数据再统一删除，防止影响自身的读取） 插入新的位置 这里 Delete 然后 Insert 同一行时，InsertTuple 会走 RID 复用 路径，因为 ScanKey 仍能找到旧的索引项对应的已删除 tuple。\n七、Bonus Task #1 — Abort 7.1 实现思路 Abort 主要通过 write_set 将事务操作过的数据恢复原本的状态，同时回退 versionLink 和 UndoLog。这部分主要是针对前面写写冲突抛出的 TAINTED 的事务，将其所做的操作复原。\n关键点：\n通过 write_set_ 精确知道\u0026quot;我修改了哪些行\u0026quot;，不需要全表扫描 对每行，通过第一个 UndoLog（本事务创建的）进行还原 若没有 UndoLog（新插入的 tuple），直接标记为删除并设 ts_=0 undo_log 本身不会从事务中删除，由 GC 负责 void TransactionManager::Abort(Transaction *txn) { std::unique_lock\u0026lt;std::mutex\u0026gt; commit_lck(commit_mutex_); // 遍历 write_set_ // 对每行：通过第一 UndoLog 还原，或直接删除（新插入行） running_txns_.RemoveTxn(txn-\u0026gt;read_ts_); } 7.2 并发控制方式的变化 这里更重要的一个变化是：并发的实现方式发生了改变。原本是利用 in_progress 来实现并发，从这里开始使用底层的 page 锁来实现并发了，原先的 versionLink 锁的部分可以删除了，并发的实现也变得更简单了。\n八、Bonus Task #2 — Serializable Verification 8.1 实现思路 这一部分主要检查在事务并发过程中，是否存在并发过程中事务执行顺序不同导致不同的结果——即序列化的正确性，如果存在这种情况就需要进行 Abort。主要实现 VerifyTxn 函数，检查提交在当前事务创建之后的已提交事务中是否存在和当前事务有序列化冲突的情况。\n8.2 SSI 检测原理 检测逻辑： 在本事务 read_ts 之后提交的其他事务，是否修改了本事务扫描谓词覆盖的数据行？\n事务时间线： T1 begin(read_ts=5) ─────────────── T1 commit T2 begin ──────── T2 commit(commit_ts=7) T2 的 write set: {row_A} T1 的 scan predicates: {table=employees, predicate=(salary \u0026lt; 10000)} VerifyTxn 检测： row_A 在 commit_ts=7 时是否满足 (salary \u0026lt; 10000)? 如果满足 → T1 扫描时\u0026#34;本应\u0026#34;看到这行变化 → 幻读 → abort T1 异常类型 SI SSI (BusTub 实现) 脏读 ✅ 防止 ✅ 防止 不可重复读 ✅ 防止 ✅ 防止 丢失更新 ✅ 防止（First-Writer-Wins） ✅ 防止 写偏序 ❌ 不完全防止 ✅ 防止（谓词检测） 幻读 部分防止 ✅ 防止 额外开销 无 Commit 时 VerifyTxn（O(txn×writes×predicates)） 8.3 VerifyTxn 的局限性 保守性（False Positive）：只检测\u0026quot;写集 × 谓词\u0026quot;的交集，而非精确的循环依赖图，可能误判（abort 了实际上可串行化的事务）\nT2 commit_ts=7：UPDATE employees SET dept=\u0026#39;HR\u0026#39; WHERE id=42 T1 scan predicate：dept=\u0026#39;Engineering\u0026#39; row_42 在 commit_ts=7 时 dept=\u0026#39;Engineering\u0026#39; → VerifyTxn 返回 false，T1 abort 但实际上先 T1 后 T2 完全可以串行化，不是真正的冲突 性能开销：需要遍历 txn_map_ 中所有已提交事务（O(n)）× 写集大小 × 谓词数量，高并发时开销显著\n谓词粒度粗：只记录 SeqScan/IndexScan 时的谓词，不记录点查、Join 等操作的访问集，可能漏掉部分冲突\n九、事务完整生命周期 9.1 数据流图 Begin() └── read_ts = last_commit_ts（原子读） └── txn_id = next_txn_id_++ └── 加入 txn_map_，注册到 Watermark 执行阶段（Read/Write） └── Read: 可见性检查 → 按需 ReconstructTuple └── Write: 冲突检测 → 更新 TableHeap → 创建/合并 UndoLog → 记录 write_set_ Commit() └── [commit_mutex_] commit_ts = ++last_commit_ts └── write_set_ 中所有 tuple: ts_ = commit_ts └── VersionLink.in_progress = false（释放\u0026#34;写锁\u0026#34;标记） └── running_txns_.RemoveTxn(read_ts) Abort() └── 遍历 write_set_，通过 UndoLog 还原所有修改 └── 无 UndoLog 的 tuple → is_deleted=true, ts_=0 └── running_txns_.RemoveTxn(read_ts) GarbageCollection() └── watermark = running_txns_.GetWatermark() └── 从 txn_map_ 删除 commit_ts \u0026lt; watermark 的已完成事务 9.2 一条 tuple 的版本演变示例 初始状态（commit_ts=2）: TableHeap: (a=1, b=5), ts_=2, is_deleted_=false version_info_: prev_ = INVALID T1(read_ts=2) 执行 UPDATE a=10: 创建 UndoLog: {is_deleted_=false, modified_fields_=[true,false], tuple_=(a=1), ts_=2, prev_=INVALID} TableHeap: (a=10, b=5), ts_=TXN1 version_info_: prev_ → UndoLog_T1 T1 Commit (commit_ts=5): TableHeap: (a=10, b=5), ts_=5 T2(read_ts=5) 执行 DELETE: 创建 UndoLog: {is_deleted_=false, modified_fields_=[true,true], tuple_=(a=10,b=5), ts_=5, prev_→UndoLog_T1} TableHeap: ts_=TXN2, is_deleted_=true version_info_: prev_ → UndoLog_T2 → UndoLog_T1 T2 Commit (commit_ts=8): TableHeap: (a=10, b=5), ts_=8, is_deleted_=true T3(read_ts=4) 读这行: tuple_ts=8 \u0026gt; read_ts=4 → 需要重建 collect: UndoLog_T2(ts_=5\u0026gt;4) → UndoLog_T1(ts_=2≤4), 停止 apply UndoLog_T2: 撤销 T2 的删除 → (a=10, b=5, deleted=false) 结果: (a=10, b=5) 可见（T1 的修改对 T3 可见，T2 的删除对 T3 不可见） 思考 到此 bustub 23fall 的 4 个 project 已经圆满完成，所有分数已经拿齐，能做的优化也基本做了，除了 project3 的 LeaderBoard，其他的都做了，也取得了不错的成绩。接下来按照安排是去实现 tinykv，可能也会写一写文章吧，还有 bustub 24fall 可能也会去做，稍微瞄了一眼，24fall 也改了不少，而且 B+树的实现是必须要去做的。总体感觉 bustub 一年比一年难了，也更加完整了，也是变得越来越好了，希望这门课程变得越来越好吧。通过这 4 个 project 也是学到了很多东西，对 C++、对数据库、对代码能力都是巨大的提升，有兴趣的同学都可以来写一写，还是挺不错的。\n最后，感谢 CMU 15445 的老师和助教们的辛勤付出。\n考虑到网上实现最后这部分内容的文章比较少，代码也是没有，我把我的代码放在这，这部分课程也是即将结束，应该也没几个人写了，有兴趣的同学可以参考一下。\n","permalink":"https://yinit.github.io/bustub-mvcc/","summary":"从 Project4 实现出发，系统复习 BusTub MVCC 设计原理和工程细节","title":"Bustub MVCC"},{"content":"bustub 项目历程 从去年十月初到今年一月中旬，历时一个学期，在实验室的安排下，着手开始 CMU 15445 23fall 的数据库知识的学习和 Project 的实现，终于在过年前完成所有的 Project。正好寒假在家有时间，就着手建了一个个人博客，写一下我在实现 Project 过程中遇到的问题和收获。关于课程视频，我时间有限，且没有太大兴趣，就只看了前几个视频就没看了，如果有同学感兴趣的话，还是推荐看一下，理论知识的学习还是很重要滴。\n前言 Project3 开始真正进入数据库的实现了，通过实现这一部分内容，你会对数据库的基本结构有一个清晰的认识。在这一部分中需要多读代码，具体实现较为简单，需要对数据库的整体实现结构有一个清晰的认识才好实现这一部分内容。这一部分的 leaderboard 我没有做，感觉设计不是很好，不太感兴趣，就没写这部分，看榜单也没几个人写，我这里就放一下 100 分的截图。\nProject3 总体概述 这里介绍下我对整体流程图的认识：首先由事务管理器创建事务，然后由事务执行 SQL 语句，然后开始解析 SQL 语句（Parser，在 bustub_instance 中），根据解析出来的语句与相应的关键字进行绑定（Binder），然后构建出执行树（Planner），再之后对语法树进行优化（Optimizer，调整结构，在 bustub 中是逻辑优化，按照固定顺序执行设定好的优化）。语法树优化完毕后便将其传递到执行器中执行（Executor，包含执行的上下文即事务、相关数据如索引表、table 表，Expression 如 where 表达式中的谓词），执行器使用火山模型，自底向上分层执行，上层调用下层获取执行结果，并将本层的执行结果传递给更上层。火山模型优点挺多，设计简单，各层解耦合，执行效率也比较高。\n总体来说，这一章并不难，关键在于与前面两章完全解耦（前面两章为索引和底层的缓冲池，索引和表都在用），在本章中需要阅读大量代码，对整个项目有一个基本的认识，才能够着手开始实现执行器和优化器中的业务逻辑，业务逻辑实现并不复杂，关键还是读代码学习简易数据库的设计。\nTask 概览 Project3 共分为四个 Task：\nTask #1 — Access Method Executors：实现基本增删改查，关键是需要理解数据库的整体设计，如表的设计、索引的使用（Project2 中设计），Expression 的使用，执行计划树结构，以及火山模型的执行器设计结构。还包含将顺序扫描优化成索引扫描的优化器规则。 Task #2 — Aggregation \u0026amp; Join Executors：聚合操作（根据 GROUP BY 结果键对应的 value 执行指定的聚合操作）以及多表联结（NestedLoopJoin）。 Task #3 — HashJoin Executor and Optimization：通过哈希表将 NLJ 的两层循环优化成一层循环，时间复杂度由 O(n²) 优化成 O(n)；并实现 NLJ → HashJoin 的优化器规则。 Task #4 — Sort + Limit + Window Functions + Top-N Optimization：Sort、Limit、TopN (Sort+Limit 的优化版)，以及 Project3 中最难的 Window Functions。 1. 查询处理全链路概览 在 BusTub 中，一条 SQL 语句从输入到输出经历以下完整链路：\nSQL 字符串 ↓ Parser（词法 + 语法分析） 抽象语法树 AST ↓ Binder（语义绑定，解析表名/列名） Bound AST（带 Schema 信息） ↓ Planner（生成逻辑计划） LogicalPlanNode 树 ↓ Optimizer（规则/代价优化） PhysicalPlanNode 树（AbstractPlanNodeRef） ↓ ExecutorFactory（为每个 PlanNode 创建对应 Executor） Executor 树 ↓ 根 Executor 调用 Next()，火山模型驱动执行 结果 Tuples EXPLAIN 命令可以输出 Optimizer 优化后的物理计划树，是理解执行过程的利器：\nEXPLAIN SELECT * FROM t1 WHERE id = 1; -- 输出类似： -- === OPTIMIZER === -- IndexScan { index_oid=0, filter=... } | (id:INTEGER, val:INTEGER) 2. 火山模型（Volcano / Iterator Model） 2.1 核心思想 火山模型（Volcano Model），也称迭代器模型（Iterator Model），是由 Goetz Graefe 在 1990 年提出的经典查询执行模型。BusTub 的整个执行引擎均基于此模型构建。\n核心接口定义在 src/include/execution/executors/abstract_executor.h：\nclass AbstractExecutor { public: explicit AbstractExecutor(ExecutorContext *exec_ctx) : exec_ctx_{exec_ctx} {} virtual ~AbstractExecutor() = default; // 必须在 Next() 之前调用，初始化算子（递归初始化子算子） virtual void Init() = 0; // 返回下一条 tuple；true = 有数据，false = 数据已耗尽 virtual auto Next(Tuple *tuple, RID *rid) -\u0026gt; bool = 0; // 返回该算子输出的 Schema virtual auto GetOutputSchema() const -\u0026gt; const Schema \u0026amp; = 0; protected: ExecutorContext *exec_ctx_; }; 整个执行过程是一棵**从根向叶拉取（pull-based）**的调用树：\n根节点 ProjectionExecutor ↑ Next() — 拉取下一条 tuple ↓ 调用子算子 Next() FilterExecutor ↑ Next() ↓ 调用子算子 Next() SeqScanExecutor ↑ Next() ↓ 读取 TableHeap TableHeap / BufferPool 2.2 执行流程图解 用户发起查询 │ ▼ ExecutorEngine::Execute(plan) │ ├─ ExecutorFactory::CreateExecutor(plan) // 递归创建 Executor 树 │ ├─ root_executor-\u0026gt;Init() // 递归初始化 │ └─ while (root_executor-\u0026gt;Next(\u0026amp;tuple, \u0026amp;rid)): results.push_back(tuple) 2.3 火山模型的优点 实现简洁：每个算子只需实现 Init() 和 Next()，算子之间完全解耦 内存效率高：每次只在管道中传递一条 tuple，内存占用低 提前终止：LIMIT N 只需拉取 N 条即可停止，无需扫描全表 组合灵活：任何算子都可以作为另一个算子的子算子，形成任意深度的执行树 2.4 火山模型的缺点（重要面试点） 大量虚函数调用：每输出一条 tuple 就调用一次 Next()，若表有 1 亿行，则有 1 亿次虚函数调用，CPU 分支预测器难以预测目标地址 无法 SIMD 向量化：SIMD 指令要求数据批量连续处理，one-tuple-at-a-time 的模型天然与此冲突 CPU 缓存利用率低：每次 Next() 跨越多个算子，破坏了数据的局部性 无法 JIT 优化：表达式求值（Evaluate()）是运行时多态调用，无法被编译器内联 2.5 替代模型对比 模型 粒度 优势 代表系统 火山模型 (Iterator) 一次一 tuple 实现简单，内存效率高 BusTub, PostgreSQL, MySQL 向量化模型 (Vectorized) 一次一批（1024~8192 行） SIMD 友好，缓存利用率高 DuckDB, VectorWise, Snowflake 编译执行 (Compilation) JIT 为特化机器码 消除所有虚函数开销，极致性能 HyPer, Umbra, SingleStore 推送模型 (Push-based) 一次一 tuple（反转控制流） 代码局部性好，Pipeline 友好 Noisepage 向量化模型的核心改进： 批量处理消除大部分虚函数调用（从 N 次降至 N/batch_size 次），且批量数据可利用 SIMD 指令（如 AVX-512 可同时处理 16 个 int32）。DuckDB 的实测表明，对于分析型查询，向量化比火山模型快 10-100 倍。\n3. 执行引擎核心架构 3.1 PlanNode 与 Executor 的职责分离 BusTub 严格将逻辑计划（PlanNode）与物理执行（Executor）分离，这是典型的策略模式应用：\nAbstractPlanNode（只描述\u0026#34;做什么\u0026#34;） ├─ 持有 output_schema_（输出 Schema） ├─ 持有 children_（子计划节点） └─ 只含元数据，不执行任何 I/O AbstractExecutor（实际执行\u0026#34;怎么做\u0026#34;） ├─ 持有 PlanNode 指针（读取计划参数） ├─ 持有 child executors（递归执行） └─ 实际操作 TableHeap、Index、Buffer Pool AbstractPlanNode 定义在 src/include/execution/plans/abstract_plan.h，支持的计划类型（PlanType 枚举）包含：\nenum class PlanType { SeqScan, IndexScan, Insert, Update, Delete, Aggregation, Limit, NestedLoopJoin, NestedIndexJoin, HashJoin, Filter, Values, Projection, Sort, TopN, TopNPerGroup, MockScan, InitCheck, Window }; 分离的好处：\n优化器只变换 PlanNode 树，不触碰 Executor，两者独立演进 同一个 PlanNode 类型可以有多种 Executor 实现（如：未来可以为不同硬件提供 CPU/GPU 版本） PlanNode 的 ToString() 和 EXPLAIN 输出方便调试 3.2 ExecutorContext — 执行上下文 每个 Executor 通过 ExecutorContext* 访问全部系统资源：\nclass ExecutorContext { public: auto GetCatalog() -\u0026gt; Catalog *; // 系统目录（表/索引元数据） auto GetTransaction() -\u0026gt; Transaction *; // 当前事务 auto GetTransactionManager() -\u0026gt; TransactionManager *; // MVCC 事务管理 auto GetBufferPoolManager() -\u0026gt; BufferPoolManager *; // 缓冲池 auto GetLockManager() -\u0026gt; LockManager *; // 锁管理器（2PL） }; 设计意图： Executor 不直接持有这些组件的指针，而是通过 Context 统一获取，符合**依赖注入（Dependency Injection）**原则，便于测试时替换 mock 对象。\n3.3 ExecutorFactory — 工厂模式 ExecutorFactory::CreateExecutor() 根据 PlanNode 类型递归创建整个 Executor 树，典型的工厂方法模式：\nauto ExecutorFactory::CreateExecutor(ExecutorContext *exec_ctx, const AbstractPlanNodeRef \u0026amp;plan) -\u0026gt; std::unique_ptr\u0026lt;AbstractExecutor\u0026gt; { switch (plan-\u0026gt;GetType()) { case PlanType::SeqScan: return std::make_unique\u0026lt;SeqScanExecutor\u0026gt;(exec_ctx, ...); case PlanType::HashJoin: { auto left = CreateExecutor(exec_ctx, plan-\u0026gt;GetChildAt(0)); // 递归 auto right = CreateExecutor(exec_ctx, plan-\u0026gt;GetChildAt(1)); return std::make_unique\u0026lt;HashJoinExecutor\u0026gt;(exec_ctx, ..., std::move(left), std::move(right)); } // ... } } 3.4 Tuple 与 Schema Tuple 是数据在执行层的基本单位：\nSchema: (id:INTEGER, name:VARCHAR(32), age:INTEGER) Tuple: [4字节: id值] [变长: name值] [4字节: age值] Tuple::GetValue(schema, col_idx) — 获取第 col_idx 列的值 Tuple(values, schema) — 从 Value 数组构造 Tuple 每个 Tuple 有对应的 RID (page_id, slot_num)，唯一标识其在 TableHeap 中的位置 Schema 描述 Tuple 的结构：\nclass Schema { std::vector\u0026lt;Column\u0026gt; columns_; // 每列的名称、类型、偏移量 static auto CopySchema(const Schema *from, const std::vector\u0026lt;uint32_t\u0026gt; \u0026amp;attrs) -\u0026gt; Schema; }; Schema::CopySchema() 在 MVCC undo log 记录中频繁使用，用于创建只包含\u0026quot;修改过的列\u0026quot;的子 Schema（稀疏存储优化）。\n4. Access Method Executors — 数据读写算子 4.1 SeqScanExecutor 文件： src/execution/seq_scan_executor.cpp\nSeqScan 是最基础的数据访问算子，顺序扫描整张表。\n4.1.1 核心实现 void SeqScanExecutor::Init() { // 创建 TableHeap 迭代器 table_iterator_ = std::make_unique\u0026lt;TableIterator\u0026gt;( exec_ctx_-\u0026gt;GetCatalog()-\u0026gt;GetTable(plan_-\u0026gt;GetTableOid())-\u0026gt;table_-\u0026gt;MakeIterator()); } auto SeqScanExecutor::Next(Tuple *tuple, RID *rid) -\u0026gt; bool { // 循环直到找到一条对当前事务可见的有效 tuple while (!table_iterator_-\u0026gt;IsEnd()) { *rid = table_iterator_-\u0026gt;GetRID(); // 加表页读锁 auto page_guard = table_info-\u0026gt;table_-\u0026gt;AcquireTablePageReadLock(*rid); auto page = page_guard.As\u0026lt;TablePage\u0026gt;(); auto [tuple_meta, tuple_data] = table_info-\u0026gt;table_-\u0026gt;GetTupleWithLockAcquired(*rid, page); *tuple = tuple_data; ++(*table_iterator_); // MVCC 可见性判断（详见第 10 节） auto tuple_ts = tuple_meta.ts_; auto txn_ts = txn-\u0026gt;GetReadTs(); if (tuple_ts \u0026gt; txn_ts \u0026amp;\u0026amp; tuple_ts != txn-\u0026gt;GetTransactionId()) { // 需要沿 undo log 链回溯重建历史版本 // ... } // 谓词过滤 if (!is_deleted \u0026amp;\u0026amp; 谓词满足) { return true; } } return false; } 4.1.2 谓词下推（Predicate Pushdown）优化 SeqScan 的 PlanNode 持有 filter_predicate_，在扫描过程中就地过滤，避免产生无效 tuple 传递给上层 FilterExecutor：\n// 在 SeqScan 内部过滤，减少传给上层算子的数据量 if (!is_deleted \u0026amp;\u0026amp; !(plan_-\u0026gt;filter_predicate_ != nullptr \u0026amp;\u0026amp; !plan_-\u0026gt;filter_predicate_-\u0026gt;Evaluate(tuple, schema).GetAs\u0026lt;bool\u0026gt;())) { return true; } 优化器（Filter Predicate Pushdown 规则）会自动将 FilterPlanNode(SeqScanPlanNode) 合并为带谓词的 SeqScanPlanNode。\n4.1.3 已知问题 // 问题：每次 Next() 调用时都重新查找 Catalog auto table_info = exec_ctx_-\u0026gt;GetCatalog()-\u0026gt;GetTable(plan_-\u0026gt;GetTableOid()); // 优化：应在 Init() 中缓存 table_info 指针 这个 unordered_map 查找虽然均摊 O(1)，但对高频调用的热路径仍有不必要开销。\n4.1.4 TableHeap 与 TableIterator TableHeap 是 BusTub 的堆文件存储结构：\n数据以 Page（4KB）为单位存储在 Buffer Pool 中 每个 Page 内部是 TablePage，使用 Slotted Page 格式 TableIterator 按 page_id 顺序、按 slot 顺序扫描所有 tuple TableHeap ├── Page 0 (TablePage) │ ├── Header (free space pointer, slot count, ...) │ ├── Slot 0 → Tuple data │ ├── Slot 1 → Tuple data (deleted) │ └── Slot 2 → Tuple data ├── Page 1 (TablePage) │ └── ... └── ... 4.2 InsertExecutor 文件： src/execution/insert_executor.cpp\n4.2.1 执行模式 — 一次性算子 Insert/Update/Delete 算子都有一个关键设计：使用 has_inserted_ 标志位，确保整个批量操作在 Next() 的第一次调用中完整执行，输出一条\u0026quot;受影响行数\u0026quot;的 tuple，第二次调用直接返回 false：\nauto InsertExecutor::Next([[maybe_unused]] Tuple *tuple, RID *rid) -\u0026gt; bool { if (has_inserted_) { return false; } // 幂等保护 has_inserted_ = true; int count = 0; while (child_executor_-\u0026gt;Next(tuple, rid)) { InsertTuple(txn, txn_mgr, table_info, indexes, *tuple); ++count; } // 输出受影响行数 std::vector\u0026lt;Value\u0026gt; result{{TypeId::INTEGER, count}}; *tuple = Tuple{result, \u0026amp;GetOutputSchema()}; return true; } 为什么要这样设计？ SQL 的 INSERT INTO ... SELECT ... 语义要求先完整读取所有源数据，再一次性写入。同时，上层调用者期望收到一条表示\u0026quot;受影响行数\u0026quot;的整数 tuple，这要求在所有行都插入完成后才能统计。\n4.2.2 InsertTuple 逻辑（execution_common.cpp） InsertTuple() 封装了完整的带 MVCC 的插入逻辑：\nInsertTuple(txn, txn_mgr, table_info, indexes, tuple): Step 1: 检查所有索引是否存在冲突 for each index: ScanKey(tuple.key) → 若找到 RID： 若对应 tuple 未删除 → 唯一键冲突，abort 事务 若对应 tuple 已删除但被其他进行中的事务操作 → 写-写冲突，abort 事务 Step 2A: 若无冲突（全新插入）: TableHeap::InsertTuple() → 分配新 RID UpdateVersionLink(new_rid, VersionUndoLink{}, nullptr) // 初始化版本链 AppendWriteSet(table_oid, new_rid) // 记录写集合（用于 abort 时回滚） for each index: InsertEntry(key, new_rid) // 更新所有索引 Step 2B: 若找到已删除的同键 tuple（重复利用旧 RID）: 创建 UndoLog 记录删除状态（用于事务回滚） UpdateTupleInPlace() 写入新数据，同时更新 ts_ = txn-\u0026gt;GetTransactionTempTs() 4.3 UpdateExecutor 文件： src/execution/update_executor.cpp\nUpdate 是所有算子中 MVCC 逻辑最复杂的，需要仔细阅读。\n4.3.1 两阶段执行 当前实现采用\u0026quot;先收集，再写入\u0026quot;的两阶段策略：\nPhase 1（收集）: 扫描所有待更新的 tuple，暂存到内存 while child_executor_-\u0026gt;Next(): 计算 new_values (by target_expressions_) old_tuples.push_back(*tuple) new_tuples.push_back(new_tuple) rids.push_back(*rid) Phase 2（写入）: 判断是否涉及索引列，选择更新策略 if has_index_update: for each rid: DeleteTuple() // 先全部删除 for each new_tuple: InsertTuple() // 再全部插入 else: for each (rid, new_tuple): InPlaceUpdate() + 维护 UndoLog 为什么要两阶段？\n避免 Halloween Problem：如果一边扫描一边更新，可能扫描到刚刚更新过的 tuple（被更新的 tuple 移动到了还未扫描的位置），导致一行被更新多次。两阶段将读写分离，彻底避免此问题。 索引更新的原子性：若索引列发生变化，需要先删除旧索引项再插入新索引项，批量操作更安全。 4.3.2 UndoLog 维护 — MVCC 精华 无索引更新路径中，需要为每条 tuple 维护 undo log（用于事务回滚和历史版本读取）：\n情况一：事务第一次写这条 tuple（tuple_meta.ts_ \u0026lt;= txn-\u0026gt;GetReadTs()）：\n1. 对比 cur_tuple 与 new_tuple 的每列值 2. 生成 modified_fields[] 位图（哪些列变了） 3. 生成 modified_values[] 存储旧值 4. 创建 UndoLog{is_deleted=false, modified_fields, modified_tuple, ts=tuple_meta.ts_, prev=old_undo_link} 5. undo_link = txn-\u0026gt;AppendUndoLog(undo_log) // 追加到事务 undo log 数组 6. txn_mgr-\u0026gt;UpdateVersionLink(rid, {undo_link, true}) // 更新版本链头 7. txn-\u0026gt;AppendWriteSet(table_oid, rid) // 加入写集 8. UpdateTupleInPlace(new_tuple) 写入最新数据 情况二：事务再次写同一条 tuple（tuple_meta.ts_ == txn-\u0026gt;GetTransactionId()）：\n// 目标：合并 undo log，保证 undo 操作始终能还原到事务开始时的原始状态 1. 取出当前 undo_log（记录的是第一次修改相对于原始状态的差异） 2. 通过 ReconstructTuple() 重建原始 tuple（事务开始时的状态） 3. 对比 new_values 与 original_tuple 的差异，生成新的 modified_fields 4. txn-\u0026gt;ModifyUndoLog(idx, new_undo_log) // 原地更新已有 undo log 为什么要合并而不是追加？ 若事务多次修改同一行（如 UPDATE t SET a=2 WHERE id=1; UPDATE t SET a=3 WHERE id=1;），若每次都追加 undo log，则 undo 链条会不断增长，增加回滚时的开销。合并后，undo log 只记录\u0026quot;相对事务开始时刻的净变化\u0026quot;，链条长度保持为 1。\n4.3.3 索引更新路径 // 检查 target_expressions_ 中是否有非 ColumnValueExpression 的表达式（即有实际计算） for (uint32_t col_idx = 0; col_idx \u0026lt; schema.GetColumnCount(); ++col_idx) { auto *column_value_expr = dynamic_cast\u0026lt;const ColumnValueExpression *\u0026gt;(expr.get()); if (column_value_expr == nullptr) { // 该列有实际修改 for (auto index : indexes) { if (该索引包含 col_idx) { has_index_update = true; } } } } 当涉及索引列更新时，必须走 Delete+Insert 路径，而不能 in-place 更新，原因是：\n索引存储的是旧的 key 值，in-place 更新 tuple 后，索引项仍指向旧 key，导致索引与数据不一致 必须先用旧 key 删除索引项，再用新 key 插入新索引项 4.4 DeleteExecutor 文件： src/execution/delete_executor.cpp\nDelete 逻辑相对简单，委托给 DeleteTuple() 辅助函数：\nauto DeleteExecutor::Next([[maybe_unused]] Tuple *tuple, RID *rid) -\u0026gt; bool { if (has_deleted_) { return false; } has_deleted_ = true; int count = 0; while (child_executor_-\u0026gt;Next(tuple, rid)) { DeleteTuple(txn, txn_mgr, table_info, *rid); ++count; } // 返回受影响行数 *tuple = Tuple{{TypeId::INTEGER, count}, \u0026amp;GetOutputSchema()}; return true; } DeleteTuple 的 MVCC 逻辑（execution_common.cpp:66）：\nDeleteTuple(txn, txn_mgr, table_info, rid): 加 TablePage 写锁 读取 tuple_meta 和当前 tuple 数据 Case 1: tuple_meta.ts_ \u0026lt;= txn-\u0026gt;GetReadTs() （已提交事务的数据） → 创建 UndoLog{is_deleted=false, all_fields=true, 完整旧 tuple, ts=原始ts, prev=old_link} → UpdateVersionLink(VersionUndoLink{new_link, true}) → AppendWriteSet(rid) → page-\u0026gt;UpdateTupleMeta({txn_temp_ts, is_deleted=true}) // 标记删除 Case 2: tuple_meta.ts_ == txn-\u0026gt;GetTransactionId() （本事务已修改过） → 若上次操作是非删除的修改： 取出已有 undo_log，重建原始 tuple 修改 undo_log 中的 modified_fields 为全 true，记录完整旧值 ModifyUndoLog() 原地更新 → 将 tuple 标记 is_deleted=true（如果之前是 Insert，则直接变成\u0026#34;从未存在\u0026#34;） Case 3: tuple_meta.ts_ \u0026gt; txn-\u0026gt;GetReadTs() （写-写冲突） → txn-\u0026gt;SetTainted() + throw ExecutionException(\u0026#34;write-write conflict\u0026#34;) 4.5 IndexScanExecutor 文件： src/execution/index_scan_executor.cpp\n通过哈希索引进行点查（Point Lookup），是 SeqScan 的优化替代方案（由优化器决策）。\n4.5.1 执行流程 auto IndexScanExecutor::Next(Tuple *tuple, RID *rid) -\u0026gt; bool { if (has_scanned_) { return false; } // 点查只执行一次 has_scanned_ = true; // 从计划节点获取查询键值 auto value = plan_-\u0026gt;pred_key_-\u0026gt;val_; std::vector\u0026lt;Value\u0026gt; values{value}; Tuple index_key(values, \u0026amp;key_schema); // 哈希索引点查，获取 RID 列表 std::vector\u0026lt;RID\u0026gt; rids; auto htable = dynamic_cast\u0026lt;HashTableIndexForTwoIntegerColumn *\u0026gt;(index_info-\u0026gt;index_.get()); htable-\u0026gt;ScanKey(index_key, \u0026amp;rids, exec_ctx_-\u0026gt;GetTransaction()); if (rids.empty()) { return false; } *rid = rids[0]; // 读取 tuple + MVCC 可见性判断（与 SeqScan 逻辑完全相同） // ... } 4.5.2 哈希索引的局限 BusTub 在 Project 2 实现的是可扩展哈希索引（Extendible Hash Table），因此 IndexScan 只支持等值查询，无法支持范围查询（col \u0026gt; 5、col BETWEEN 3 AND 7）。若需支持范围查询，需要 B+ 树索引（BusTub 另有实现，但 P3 不涉及）。\n4.5.3 MVCC 在 IndexScan 中的处理 IndexScan 获取到 RID 后，同样需要走完整的 MVCC 可见性判断流程，与 SeqScan 的逻辑完全一致。这说明 MVCC 逻辑与数据访问方式无关，所有访问路径都需要经过可见性过滤。\n5. Expression 表达式系统 表达式系统是执行引擎的基础设施，所有算子都依赖它来：\n计算过滤谓词（Filter） 计算列值（Projection） 计算连接键（HashJoin） 计算聚合输入值（Aggregation） 5.1 表达式类层次 AbstractExpression（抽象基类） │ virtual Value Evaluate(const Tuple *, const Schema \u0026amp;) const = 0 │ virtual Value EvaluateJoin(const Tuple *left, const Schema \u0026amp;, ...) const = 0 │ ├── ColumnValueExpression // 取某列的值，如 #0.1（第 0 个 child 的第 1 列） ├── ConstantValueExpression // 常量，如 42, \u0026#39;hello\u0026#39; ├── ComparisonExpression // 比较，如 a = 1, b \u0026gt; 2 ├── ArithmeticExpression // 算术，如 a + b, a * 2 ├── LogicExpression // 逻辑，如 a AND b, a OR b └── AggregateValueExpression // 引用聚合结果 5.2 表达式的树形结构 以 WHERE id = 1 AND age \u0026gt; 18 为例：\nLogicExpression(AND) ├── ComparisonExpression(=) │ ├── ColumnValueExpression(col=0) // id 列 │ └── ConstantValueExpression(1) └── ComparisonExpression(\u0026gt;) ├── ColumnValueExpression(col=2) // age 列 └── ConstantValueExpression(18) 求值时递归调用 Evaluate(tuple, schema)，叶节点取值，父节点合并：\n// 以 ComparisonExpression 为例 auto Value = ComparisonExpression::Evaluate(tuple, schema) { auto left = GetChildAt(0)-\u0026gt;Evaluate(tuple, schema); auto right = GetChildAt(1)-\u0026gt;Evaluate(tuple, schema); return left.CompareEquals(right) == CmpBool::CmpTrue ? ValueFactory::GetBooleanValue(true) : ValueFactory::GetBooleanValue(false); } 5.3 NULL 值的三值逻辑 SQL 遵循三值逻辑（3VL）：TRUE、FALSE、UNKNOWN（NULL）。BusTub 中 NULL 参与的比较返回 CmpBool::CmpNull，Filter 在判断谓词时必须显式处理：\n// FilterExecutor 中 auto value = filter_expr-\u0026gt;Evaluate(tuple, schema); if (!value.IsNull() \u0026amp;\u0026amp; value.GetAs\u0026lt;bool\u0026gt;()) { // NULL 不通过过滤 return true; } 6. Aggregation Executor — 聚合算子 文件： src/execution/aggregation_executor.cpp 头文件： src/include/execution/executors/aggregation_executor.h\n6.1 聚合函数类型 BusTub 支持以下聚合函数（AggregationType 枚举）：\nCountStarAggregate — COUNT(*)，计数所有行（包含 NULL） CountAggregate — COUNT(col)，计数非 NULL 值 SumAggregate — SUM(col)，对非 NULL 值求和 MinAggregate — MIN(col)，最小值（忽略 NULL） MaxAggregate — MAX(col)，最大值（忽略 NULL） 6.2 SimpleAggregationHashTable 聚合算子的核心数据结构，内部是一个 unordered_map：\nclass SimpleAggregationHashTable { std::unordered_map\u0026lt;AggregateKey, AggregateValue\u0026gt; ht_; // AggregateKey = {group_by_values}，如 (dept_id, location) 是 GROUP BY 的键 // AggregateValue = {aggregate_results}，如 (count, sum, min, max) 是聚合结果 }; 聚合键（AggregateKey）的哈希函数：\n// 通过 std::hash\u0026lt;AggregateKey\u0026gt; 特化实现 // 对多列键做组合哈希（Hash Combine） size_t curr_hash = 0; for (const auto \u0026amp;val : key.group_bys_) { curr_hash = HashUtil::CombineHashes(curr_hash, HashUtil::HashValue(\u0026amp;val)); } 6.3 CombineAggregateValues — 增量合并 void CombineAggregateValues(AggregateValue *result, const AggregateValue \u0026amp;input) { for (uint32_t i = 0; i \u0026lt; agg_exprs_.size(); i++) { switch (agg_types_[i]) { case CountStarAggregate: // COUNT(*): +1，不管输入值 result-\u0026gt;aggregates_[i] = result-\u0026gt;aggregates_[i].Add(Value{INTEGER, 1}); break; case CountAggregate: // COUNT(col): 非 NULL 才 +1 if (!input.aggregates_[i].IsNull()) { result-\u0026gt;aggregates_[i] = IsNull ? 1 : result + 1; } break; case SumAggregate: // SUM: 非 NULL 才加 if (!input.aggregates_[i].IsNull()) { result-\u0026gt;aggregates_[i] = IsNull ? input : result + input; } break; case MinAggregate: // MIN: 非 NULL 且更小才更新 if (!input.IsNull() \u0026amp;\u0026amp; (result.IsNull() || result \u0026gt; input)) { result-\u0026gt;aggregates_[i] = input; } break; case MaxAggregate: // MAX: 非 NULL 且更大才更新 // ... 类似 MIN，方向相反 break; } } } 初始值规则（重要）：\nCOUNT(*) 初始值为 0（计数从零开始） COUNT(col), SUM, MIN, MAX 初始值为 NULL（空集的聚合结果是 NULL） 6.4 Pipeline Breaker 与两阶段执行 void AggregationExecutor::Init() { child_executor_-\u0026gt;Init(); aht_ = std::make_unique\u0026lt;SimpleAggregationHashTable\u0026gt;(...); // ★ Pipeline Breaker：在 Init() 中消费所有子算子数据 Tuple tuple{}; RID rid{}; while (child_executor_-\u0026gt;Next(\u0026amp;tuple, \u0026amp;rid)) { aht_-\u0026gt;InsertCombine(MakeAggregateKey(\u0026amp;tuple), MakeAggregateValue(\u0026amp;tuple)); } aht_iterator_ = std::make_unique\u0026lt;Iterator\u0026gt;(aht_-\u0026gt;Begin()); } auto AggregationExecutor::Next(Tuple *tuple, RID *rid) -\u0026gt; bool { // Init() 已完成全部聚合，Next() 只需遍历哈希表 if (*aht_iterator_ != aht_-\u0026gt;End()) { // 拼接 group_by_keys + aggregate_values 输出 ++(*aht_iterator_); return true; } // 空表特殊处理 if (!has_executed_ \u0026amp;\u0026amp; plan_-\u0026gt;group_bys_.empty()) { has_executed_ = true; // SELECT COUNT(*) FROM empty_table → 返回 (0)，而非空结果 return true; } return false; } 空表的边界情况（面试常考）：\n查询 有无 GROUP BY 输入为空时的输出 SELECT COUNT(*) FROM t 无 一行：(0) SELECT SUM(col) FROM t 无 一行：(NULL) SELECT COUNT(*) FROM t GROUP BY dept 有 空（无行） 这是因为 SQL 标准规定：无 GROUP BY 时，即使输入为空，也要输出一行聚合结果；有 GROUP BY 时，无输入则无输出。\n7. Join Executors — 连接算子 7.1 NestedLoopJoinExecutor 算法： 经典嵌套循环连接（O(n×m) 复杂度）\n对外表（左表）的每一行 r: 对内表（右表）的每一行 s: if JoinCondition(r, s): 输出 (r, s) BusTub 实现： 内表每次都需要从头重新扫描（right_child_-\u0026gt;Init()），这是最朴素的 NLJ 实现。优化版本（Block NLJ）会将外表数据缓存到 Buffer Pool 块中，减少内表扫描次数。\nLEFT JOIN 处理： 对外表的每一行，若没有匹配的内表行，输出（外表行，NULL\u0026hellip;）。\n7.2 HashJoinExecutor 文件： src/execution/hash_join_executor.cpp 头文件： src/include/execution/executors/hash_join_executor.h\n7.2.1 核心数据结构 // 连接键：多列值组成的复合键 struct HashJoinKey { std::vector\u0026lt;Value\u0026gt; values_; auto operator==(const HashJoinKey \u0026amp;other) const -\u0026gt; bool { /* 逐列比较 */ } }; // 自定义哈希函数（注册到 std::hash） namespace std { template \u0026lt;\u0026gt; struct hash\u0026lt;bustub::HashJoinKey\u0026gt; { auto operator()(const HashJoinKey \u0026amp;key) const -\u0026gt; std::size_t { size_t curr_hash = 0; for (const auto \u0026amp;val : key.values_) { if (!val.IsNull()) { curr_hash = HashUtil::CombineHashes(curr_hash, HashUtil::HashValue(\u0026amp;val)); } } return curr_hash; } }; } // 哈希表：key → 右表中匹配的所有行（支持一对多） std::unordered_map\u0026lt;HashJoinKey, std::vector\u0026lt;HashJoinValue\u0026gt;\u0026gt; hash_table_; 7.2.2 Build + Probe 执行流程 // Build 阶段（在 Init() 中完成） void HashJoinExecutor::Init() { right_child_-\u0026gt;Init(); while (right_child_-\u0026gt;Next(\u0026amp;tuple, \u0026amp;rid)) { auto key = MakeHashJoinKey(\u0026amp;tuple, right_expressions, right_schema); hash_table_[key].push_back(MakeHashJoinValue(\u0026amp;tuple, right_schema)); } } // Probe 阶段（在 Next() 中流式进行） auto HashJoinExecutor::Next(Tuple *tuple, RID *rid) -\u0026gt; bool { // 先消耗已有缓存结果 if (!results_.empty()) { *tuple = results_.front(); results_.pop_front(); return true; } // 继续探测左表 while (left_child_-\u0026gt;Next(tuple, rid)) { auto key = MakeHashJoinKey(tuple, left_expressions, left_schema); if (hash_table_.find(key) != hash_table_.end()) { // 找到匹配：右表可能有多行匹配，全部放入 results_ 缓冲 for (auto \u0026amp;right_val : hash_table_[key]) { results_.push_back(合并左右tuple); } } else if (plan_-\u0026gt;GetJoinType() == JoinType::LEFT) { // LEFT JOIN：右侧填 NULL results_.push_back(左表行 + NULL...); } if (!results_.empty()) { *tuple = results_.front(); results_.pop_front(); return true; } } return false; // 左表耗尽 } 7.2.3 HashJoin 的优势 与 NestedLoopJoin 相比：\n时间复杂度： O(n + m) vs O(n × m) Build 阶段： O(m)，扫描右表一次建哈希表 Probe 阶段： O(n)，扫描左表一次，每行 O(1) 查哈希表 哈希冲突处理： 使用 unordered_map + 链式法（std::list 内置），不同 key 的哈希碰撞由 STL 自动处理；相同 key 的多行匹配由 vector\u0026lt;HashJoinValue\u0026gt; 存储。\n7.2.4 results_ 缓冲的设计意义 当一条左表行匹配多条右表行时（如 1 对 N 关系），Next() 一次只能返回一条 tuple。results_ deque 缓冲解决了\u0026quot;一次 probe 产生多个输出\u0026quot;的问题：\nLeft tuple A 匹配 Right 中的 [B1, B2, B3] results_ = [(A,B1), (A,B2), (A,B3)] Next() call 1: 返回 (A,B1)，results_ = [(A,B2), (A,B3)] Next() call 2: 返回 (A,B2)，results_ = [(A,B3)] Next() call 3: 返回 (A,B3)，results_ = [] Next() call 4: 继续读左表... 7.2.5 已知问题与改进 右表全量 in-memory：Build 阶段将整个右表读入内存，若右表很大会 OOM。生产级系统采用 Grace Hash Join：\n对两表按 join key 哈希分桶（partition），写入磁盘 每对分桶单独进行 in-memory hash join 理论上支持任意大小的输入 Build/Probe side 固定：当前实现固定右表为 build side，没有根据统计信息（行数、数据大小）自动选择。应将小表作为 build side，大表作为 probe side。\nresults_ 使用 list（实为 std::list\u0026lt;Tuple\u0026gt;）：pop_front() 是 O(1) 但有动态内存分配开销，改用 index 标记位置效率更高：\n// 优化前 std::list\u0026lt;Tuple\u0026gt; results_; // pop_front() 有内存分配 // 优化后 std::vector\u0026lt;Tuple\u0026gt; results_; size_t result_idx_ = 0; // 直接移动索引，避免内存分配 8. 高级算子 8.1 SortExecutor 文件： src/execution/sort_executor.cpp\n8.1.1 全内存排序实现 void SortExecutor::Init() { child_executor_-\u0026gt;Init(); tuples_.clear(); // ★ Pipeline Breaker：必须消费所有 tuple 才能开始排序 Tuple tuple; RID rid; while (child_executor_-\u0026gt;Next(\u0026amp;tuple, \u0026amp;rid)) { tuples_.emplace_back(tuple); } // 多列排序（ORDER BY col1 ASC, col2 DESC, ...） std::sort(tuples_.begin(), tuples_.end(), [this](const Tuple \u0026amp;lhs, const Tuple \u0026amp;rhs) { for (const auto \u0026amp;order_by : plan_-\u0026gt;GetOrderBy()) { auto order_by_type = order_by.first; auto expr = order_by.second.get(); auto lhs_val = expr-\u0026gt;Evaluate(\u0026amp;lhs, plan_-\u0026gt;OutputSchema()); auto rhs_val = expr-\u0026gt;Evaluate(\u0026amp;rhs, plan_-\u0026gt;OutputSchema()); if (lhs_val.CompareLessThan(rhs_val) == CmpBool::CmpTrue) { return order_by_type != OrderByType::DESC; // ASC: lhs \u0026lt; rhs → true } if (lhs_val.CompareGreaterThan(rhs_val) == CmpBool::CmpTrue) { return order_by_type == OrderByType::DESC; // DESC: lhs \u0026gt; rhs → true } // 相等则继续比较下一列 } return true; }); curr_pos_ = 0; } 8.1.2 外部排序（External Sort-Merge） 生产级系统对无法全量放入内存的数据需要外部排序：\nPhase 1 (Run Generation): 将数据按 Buffer Pool 大小分块读入 在内存中排序每块 → 生成有序的 Run 文件写入磁盘 Phase 2 (Merge): 使用 K 路归并（K-way Merge）合并所有 Run 文件 每次从每个 Run 的头部取最小值（最大堆辅助） → 输出全局有序数据 BusTub 当前实现不支持外部排序，这是生产环境中的重要限制。\n8.1.3 排序稳定性 std::sort 是不稳定排序（QuickSort/IntroSort），对于相等的元素不保证原始相对顺序。若需要稳定排序（如 ORDER BY col LIMIT N 后的确定性），应使用 std::stable_sort（基于 MergeSort）。\n当前 comparator 的 return true fallback（两 tuple 完全相等时）可能导致 UB（严格弱序要求 comp(x,x) 返回 false），更严格的实现应返回 false。\n8.2 LimitExecutor 文件： src/execution/limit_executor.cpp\nLimit 是少数几个**流式（Streaming）**执行的算子（非 Pipeline Breaker）：\nauto LimitExecutor::Next(Tuple *tuple, RID *rid) -\u0026gt; bool { if (count_ \u0026gt;= limit_) { return false; } // 达到上限，终止 if (child_executor_-\u0026gt;Next(tuple, rid)) { count_++; return true; } return false; } 提前终止的威力： 对于 SELECT * FROM large_table LIMIT 10，Limit 算子只拉取 10 条后即停止，子算子（如 SeqScan）不会继续扫描剩余数据，这是火山模型的核心优势之一。\n与 TopN 的区别： Limit 不做排序，直接截取前 N 条；TopN 配合 Sort 使用，取排序后的前 N 条（但 BusTub 优化器会将 Sort+Limit 替换为单个 TopN）。\n8.3 TopNExecutor — 堆优化 文件： src/execution/topn_executor.cpp 头文件： src/include/execution/executors/topn_executor.h\n8.3.1 优化动机 朴素的 ORDER BY col LIMIT N 需要：\n读取所有数据（O(n)） 全量排序（O(n log n)） 截取前 N 条 优化后的 TopN 算法（堆维护）：\n读取所有数据，维护大小为 N 的堆（O(n log N)） 直接输出堆中的 N 条数据 当 N \u0026laquo; n 时，O(n log N) 显著优于 O(n log n)。\n8.3.2 比较器设计 struct Compare { explicit Compare(const TopNPlanNode *plan) : plan_(plan) {} // 返回 true 意味着 lhs \u0026#34;更差\u0026#34;（应该被堆 pop 掉） // 对于 ASC 排序取前 N 小：使用最大堆，\u0026#34;更差\u0026#34; = 更大 auto operator()(const Tuple \u0026amp;lhs, const Tuple \u0026amp;rhs) const -\u0026gt; bool { for (const auto \u0026amp;order_by : plan_-\u0026gt;GetOrderBy()) { auto order_by_type = order_by.first; auto lhs_val = expr-\u0026gt;Evaluate(\u0026amp;lhs, ...); auto rhs_val = expr-\u0026gt;Evaluate(\u0026amp;rhs, ...); if (lhs_val.CompareLessThan(rhs_val) == CmpBool::CmpTrue) { return order_by_type != OrderByType::DESC; // ASC: lhs \u0026lt; rhs, lhs 更好，返回 false（lhs 不应被 pop） // 但 priority_queue 是 max-heap，比较器返回 true 意味着 lhs 优先级更低 } // ... } return true; } }; 堆的选择： std::priority_queue 是最大堆（最大元素在堆顶）。对于 ORDER BY col ASC LIMIT N（取最小的 N 个），我们希望堆里保留最小的 N 个，所以要把最大的 pop 出去。当堆顶（最大元素）比新来的元素大时，pop 堆顶，push 新元素。\n8.3.3 实现细节 void TopNExecutor::Init() { // 使用反序比较器的最大堆 auto top_entries = std::priority_queue\u0026lt;Tuple, std::vector\u0026lt;Tuple\u0026gt;, Compare\u0026gt;(Compare(plan_)); while (child_executor_-\u0026gt;Next(\u0026amp;tuple, \u0026amp;rid)) { top_entries.push(tuple); if (top_entries.size() \u0026gt; plan_-\u0026gt;GetN()) { top_entries.pop(); // 弹出最差的（最大的），保留最优的 N 个 } } // 堆中的元素是逆序的（最差的先出来），需要翻转 while (!top_entries.empty()) { top_tuples_.emplace_back(top_entries.top()); top_entries.pop(); } std::reverse(top_tuples_.begin(), top_tuples_.end()); // 还原为正确顺序 curr_pos_ = 0; } 8.4 WindowFunctionExecutor 文件： src/execution/window_function_executor.cpp 头文件： src/include/execution/executors/window_function_executor.h\n8.4.1 窗口函数概念 窗口函数（Window Function）与普通聚合函数的本质区别：\n特性 普通聚合（GROUP BY） 窗口函数（OVER） 输出行数 每组一行（行数减少） 与输入相同（行数不变） 数据访问 组内所有行 每行的\u0026quot;窗口\u0026quot;内的行 组合普通列 只能输出 GROUP BY 列 可以同时输出普通列 典型示例：\n-- 普通聚合：3 个部门 → 3 行结果 SELECT dept_id, SUM(salary) FROM employees GROUP BY dept_id; -- 窗口函数：每行都保留，额外计算部门内累计薪资 SELECT emp_id, dept_id, salary, SUM(salary) OVER (PARTITION BY dept_id ORDER BY hire_date) AS running_total FROM employees; -- 输出与输入等行数，但每行多了 running_total 列 8.4.2 PlanNode 结构 WindowFunctionPlanNode 的结构（来自 aggregation_plan.h）：\ncolumns_: [col0, col1, placeholder(-1), placeholder(-1)] ↑ ↑ ↑ ↑ 普通列 普通列 窗口函数列1 窗口函数列2 window_functions_: { 2: WindowFunction{ function_: SUM(salary) type_: SumAggregate partition_by_: [dept_id] order_by_: [(ASC, hire_date)] }, 3: WindowFunction{ function_: RANK() type_: Rank partition_by_: [dept_id] order_by_: [(DESC, salary)] } } col_idx == -1（实现中用 static_cast\u0026lt;uint32_t\u0026gt;(-1) = 最大 uint32）作为\u0026quot;占位符\u0026quot;标记，区分普通列和窗口函数列。\n8.4.3 SimpleWindowFunctionHashTable 为每个窗口函数维护一个独立的哈希聚合表：\nKey = PARTITION BY 列的值组合（分区键） Value = 该分区内的累积聚合值 例：PARTITION BY dept_id, ORDER BY hire_date 时： key=(10) → value=running_sum_for_dept_10 key=(20) → value=running_sum_for_dept_20 8.4.4 执行流程 void WindowFunctionExecutor::Init() { // Step 1: 读取所有 child tuples while (child_executor_-\u0026gt;Next(\u0026amp;tuple, \u0026amp;rid)) { tuples.emplace_back(tuple); } // Step 2: 若有 ORDER BY，对所有 tuples 排序（所有窗口函数共享同一排序） if (有 order_by) { std::sort(tuples.begin(), tuples.end(), 按 order_by 排序); } // Step 3: 为每个窗口函数创建 HashTable for (each window_function): SimpleWindowFunctionHashTable aht = ...; if (!has_order_by): for (each tuple): aht.InsertCombine(tuple); // 预聚合 ahts.push_back(aht); // Step 4: 遍历每条 tuple，组装最终输出 for (each tuple): values = [] for (each output_col): if (是普通列): values.push(tuple.GetValue(col_idx)) else: // 窗口函数列 if (has_order_by): values.push(aht.InsertCombine(tuple)) // 流式累积 else: values.push(aht.Find(tuple)) // 查预计算结果 results_.push_back(Tuple(values, output_schema)) } 8.4.5 RANK() 的特殊实现 RANK() 不是普通聚合函数，它的语义是\u0026quot;当前行在当前分区内的排名（相同值同名次，跳过）\u0026quot;：\n// SimpleWindowFunctionHashTable::CombineWindowAggregateValues case WindowFunctionType::Rank: ++rank_count_; // 全局计数器，统计已处理多少行 if (result-\u0026gt;GetAs\u0026lt;int32_t\u0026gt;() != input.GetAs\u0026lt;int32_t\u0026gt;()) { // 值变了（不同排名），更新 last_rank_count_ 为当前行号 *result = input; last_rank_count_ = rank_count_; } // 返回 last_rank_count_（相同值的行返回相同排名） return ValueFactory::GetIntegerValue(last_rank_count_); 注意：RANK() 的 build side 哈希表的 key 使用 ORDER BY 表达式的值，而其他聚合函数使用 PARTITION BY 的值。这是一个设计上的特殊处理，在 Window Executor 构造时区分：\nSimpleWindowFunctionHashTable aht = (window_function_type == WindowFunctionType::Rank) ? SimpleWindowFunctionHashTable{order_by[0].second, win_type, partition_by, schema} : SimpleWindowFunctionHashTable{function, win_type, partition_by, schema}; 8.4.6 已知限制 不支持 Window Frame：如 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW（滑动窗口） 当前实现的语义等价于： 有 ORDER BY 时：UNBOUNDED PRECEDING AND CURRENT ROW（前缀聚合） 无 ORDER BY 时：UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING（全区间聚合） RANK 的 rank_count_ 是成员变量，在同一执行中不可重复使用（不能 re-init） 9. 辅助算子 9.1 ProjectionExecutor 文件： src/execution/projection_executor.cpp\n最简单的算子，对子算子的每条 tuple 进行列变换：\nauto ProjectionExecutor::Next(Tuple *tuple, RID *rid) -\u0026gt; bool { Tuple child_tuple{}; if (!child_executor_-\u0026gt;Next(\u0026amp;child_tuple, rid)) { return false; } std::vector\u0026lt;Value\u0026gt; values{}; values.reserve(GetOutputSchema().GetColumnCount()); for (const auto \u0026amp;expr : plan_-\u0026gt;GetExpressions()) { // 每个表达式对子 tuple 求值，可以是列引用、运算、常量等 values.push_back(expr-\u0026gt;Evaluate(\u0026amp;child_tuple, child_executor_-\u0026gt;GetOutputSchema())); } *tuple = Tuple{values, \u0026amp;GetOutputSchema()}; return true; } Projection 实现了 SQL 的 SELECT 子句（列选择和计算），例如：\nSELECT id, salary * 1.1 AS new_salary FROM employees; -- GetExpressions() = [ColumnValueExpr(col=0), ArithmeticExpr(ColumnValueExpr(col=2), *, Constant(1.1))] 9.2 FilterExecutor 文件： src/execution/filter_executor.cpp\n实现 SQL 的 WHERE 子句过滤：\nauto FilterExecutor::Next(Tuple *tuple, RID *rid) -\u0026gt; bool { auto filter_expr = plan_-\u0026gt;GetPredicate(); while (true) { if (!child_executor_-\u0026gt;Next(tuple, rid)) { return false; } auto value = filter_expr-\u0026gt;Evaluate(tuple, child_executor_-\u0026gt;GetOutputSchema()); if (!value.IsNull() \u0026amp;\u0026amp; value.GetAs\u0026lt;bool\u0026gt;()) { return true; // 满足谓词，向上返回 } // 不满足，继续拉取子算子 } } 与 SeqScan 内置谓词的关系： 优化器的 Predicate Pushdown 规则会尝试将 FilterExecutor 的谓词下推到 SeqScan 的 filter_predicate_，从而消除单独的 FilterExecutor 节点，减少 tuple 在算子间的传递开销。\n9.3 ValuesExecutor 文件： src/execution/values_executor.cpp\n用于 INSERT INTO t VALUES (...) 中的常量值，或 SELECT 1, 'hello' 这类不需要扫描表的查询：\nauto ValuesExecutor::Next(Tuple *tuple, RID *rid) -\u0026gt; bool { if (cursor_ \u0026gt;= plan_-\u0026gt;GetValues().size()) { return false; } const auto \u0026amp;row_expr = plan_-\u0026gt;GetValues()[cursor_]; std::vector\u0026lt;Value\u0026gt; values{}; for (const auto \u0026amp;col : row_expr) { // dummy_schema_ 是空 Schema（VALUES 的列不来自任何表） values.push_back(col-\u0026gt;Evaluate(nullptr, dummy_schema_)); } *tuple = Tuple{values, \u0026amp;GetOutputSchema()}; cursor_ += 1; return true; } 10. MVCC 多版本并发控制 MVCC（Multi-Version Concurrency Control）允许读操作不阻塞写操作，通过为每个数据行维护多个历史版本来实现快照隔离（Snapshot Isolation）。\n10.1 版本链数据结构 TableHeap 中的最新版本 TupleMeta.ts_ = 提交事务的 commit_ts，或未提交事务的 TXN_TEMP_TS TupleMeta.is_deleted_ = bool（是否为删除标记） ↓ TransactionManager::GetVersionLink(rid) VersionUndoLink（版本链头） prev_ = UndoLink（指向第一个 UndoLog） in_progress_ = bool（是否有事务正在修改） ↓ UndoLink.prev_log_idx_ / txn_id UndoLog 1（最近一次修改的逆操作） is_deleted_: bool // 修改前是否已删除 modified_fields_: vector\u0026lt;bool\u0026gt; // 哪些列被修改了 tuple_: Tuple // 修改前的值（只含 modified 的列） ts_: timestamp_t // 修改前的时间戳 prev_version_: UndoLink // 指向更早的 UndoLog ↓ prev_version_ UndoLog 2 → UndoLog 3 → ... → nullptr 时间戳规则：\n已提交事务：ts_ = commit_ts（单调递增的逻辑时钟） 进行中事务：ts_ = TXN_TEMP_TS = txn-\u0026gt;GetTransactionTempTs()（基于 txn_id，格式为 TXN_START_ID + txn_id） 判断 tuple 是否正在被未提交事务修改：ts_ \u0026gt;= TXN_START_ID 10.2 可见性判断逻辑 对于事务 T（read_ts = T.GetReadTs()）读取 tuple 时： if tuple_ts == T.GetTransactionId(): → 这是 T 自己修改的，对 T 可见（读己之所写） elif tuple_ts \u0026lt;= read_ts: → 已提交且在 T 开始前提交，对 T 可见 else (tuple_ts \u0026gt; read_ts): → 在 T 开始后修改（未提交或其他事务提交） → 需要回溯 undo log，找到 ts_ \u0026lt;= read_ts 的版本 回溯逻辑（在 SeqScan / IndexScan 中实现）：\nif (tuple_ts \u0026gt; txn_ts \u0026amp;\u0026amp; tuple_ts != txn-\u0026gt;GetTransactionId()) { std::vector\u0026lt;UndoLog\u0026gt; undo_logs; UndoLink undo_link = txn_manager-\u0026gt;GetUndoLink(*rid).value_or(UndoLink{}); while (optional_undo_log.has_value() \u0026amp;\u0026amp; tuple_ts \u0026gt; txn_ts) { undo_logs.push_back(*optional_undo_log); tuple_ts = optional_undo_log-\u0026gt;ts_; // 更新为该 undo log 之前的时间戳 undo_link = optional_undo_log-\u0026gt;prev_version_; optional_undo_log = txn_manager-\u0026gt;GetUndoLogOptional(undo_link); } if (tuple_ts \u0026gt; txn_ts) { continue; } // 版本链全都太新，不可见 auto new_tuple = ReconstructTuple(\u0026amp;schema, *tuple, tuple_meta, undo_logs); if (!new_tuple.has_value()) { continue; } // 历史版本是已删除状态 } 10.3 ReconstructTuple 文件： src/execution/execution_common.cpp:17\n给定 base_tuple（最新版本）和按从新到旧排列的 undo_logs，逐步回滚恢复历史版本：\nauto ReconstructTuple(const Schema *schema, const Tuple \u0026amp;base_tuple, const TupleMeta \u0026amp;base_meta, const std::vector\u0026lt;UndoLog\u0026gt; \u0026amp;undo_logs) -\u0026gt; std::optional\u0026lt;Tuple\u0026gt; { bool is_deleted = base_meta.is_deleted_; std::vector\u0026lt;Value\u0026gt; values; // 初始化为最新版本的所有列值 for (uint32_t i = 0; i \u0026lt; schema-\u0026gt;GetColumnCount(); ++i) { values.push_back(base_tuple.GetValue(schema, i)); } for (const auto \u0026amp;undo_log : undo_logs) { if (undo_log.is_deleted_) { // 这个 undo log 表示在它之前的版本是\u0026#34;已删除\u0026#34;状态 is_deleted = true; for (auto \u0026amp;val : values) val = NULL; } else { is_deleted = false; // 按 modified_fields_ 位图还原被修改的列 std::vector\u0026lt;uint32_t\u0026gt; cols; for (uint32_t i = 0; i \u0026lt; undo_log.modified_fields_.size(); ++i) { if (undo_log.modified_fields_[i]) cols.push_back(i); } Schema undo_schema = Schema::CopySchema(schema, cols); for (uint32_t i = 0, j = 0; i \u0026lt; modified_fields.size(); ++i) { if (modified_fields[i]) { values[i] = undo_log.tuple_.GetValue(\u0026amp;undo_schema, j++); } } } } if (is_deleted) return std::nullopt; Tuple res = Tuple(values, schema); res.SetRid(base_tuple.GetRid()); return res; } UndoLog 的稀疏存储优化： modified_fields_ 是一个 vector\u0026lt;bool\u0026gt; 位图，tuple_ 中只存储发生变化的列值，而非整行。这样对于只修改少数列的 UPDATE，undo log 存储开销远小于整行存储。\n例如，一个 100 列的表，只更新第 3 列，undo log 的 tuple_ 中只有第 3 列的旧值，而不是 100 列的完整数据。\n10.4 写-写冲突检测 在所有写操作前：\n// 若 tuple_ts \u0026gt; txn-\u0026gt;GetReadTs() 且不是本事务修改的 // 说明在 T 的 snapshot 之后，有另一个事务（已提交或未提交）修改了这条 tuple if (tuple_meta.ts_ \u0026gt; txn-\u0026gt;GetReadTs() \u0026amp;\u0026amp; tuple_meta.ts_ != txn-\u0026gt;GetTransactionId()) { txn-\u0026gt;SetTainted(); throw ExecutionException(\u0026#34;write-write conflict\u0026#34;); } 这实现的是First-Writer-Wins规则：第一个修改某行的事务获胜，后来的事务需要 abort 并重试。\n11. 查询优化器规则 BusTub 的优化器采用规则优化（Rule-Based Optimization, RBO），按固定顺序应用优化规则对计划树进行变换。\n11.1 优化器的整体结构 优化器的入口在 src/optimizer/optimizer.cpp，规则按顺序应用：\n1. Filter Predicate Pushdown → 将 Filter 下推至 SeqScan 2. Column Pruning → 删除不必要的列（可选） 3. OptimizeSeqScanAsIndexScan → SeqScan+等值谓词 → IndexScan 4. OptimizeNLJAsHashJoin → NestedLoopJoin+等值条件 → HashJoin 5. OptimizeSortLimitAsTopN → Sort+Limit → TopN 6. ... 每条规则实现为 Optimizer::OptimizeXxx(const AbstractPlanNodeRef \u0026amp;plan) 方法，递归遍历计划树并替换匹配的子树。\n11.2 SeqScan → IndexScan 优化 文件： src/optimizer/seqscan_as_indexscan.cpp\n规则描述： 当 SeqScan 的谓词满足特定形式（单列等值）且该列上有索引时，替换为 IndexScan。\nauto Optimizer::OptimizeSeqScanAsIndexScan(const AbstractPlanNodeRef \u0026amp;plan) -\u0026gt; AbstractPlanNodeRef { // 递归优化子节点 std::vector\u0026lt;AbstractPlanNodeRef\u0026gt; children; for (const auto \u0026amp;child : plan-\u0026gt;GetChildren()) { children.emplace_back(OptimizeSeqScanAsIndexScan(child)); } auto optimized_plan = plan-\u0026gt;CloneWithChildren(children); // 匹配：SeqScan + 谓词为等值比较（col = const） if (optimized_plan-\u0026gt;GetType() == PlanType::SeqScan) { const auto \u0026amp;seq_scan_plan = dynamic_cast\u0026lt;SeqScanPlanNode \u0026amp;\u0026gt;(*optimized_plan); auto predicate = seq_scan_plan.filter_predicate_; if (predicate != nullptr) { auto comparison_expr = std::dynamic_pointer_cast\u0026lt;ComparisonExpression\u0026gt;(predicate); if (comparison_expr != nullptr \u0026amp;\u0026amp; comparison_expr-\u0026gt;comp_type_ == ComparisonType::Equal) { auto column_expr = std::dynamic_pointer_cast\u0026lt;ColumnValueExpression\u0026gt;(comparison_expr-\u0026gt;GetChildAt(0)); auto constant_expr = std::dynamic_pointer_cast\u0026lt;ConstantValueExpression\u0026gt;(comparison_expr-\u0026gt;GetChildAt(1)); if (column_expr != nullptr \u0026amp;\u0026amp; constant_expr != nullptr) { auto column_idx = column_expr-\u0026gt;GetColIdx(); // 查找该列上的索引 for (auto \u0026amp;index_info : catalog_.GetTableIndexes(table_name)) { auto attrs = index_info-\u0026gt;index_-\u0026gt;GetKeyAttrs(); if (std::find(attrs.begin(), attrs.end(), column_idx) != attrs.end()) { // 创建 IndexScanPlanNode 替换 SeqScanPlanNode return std::make_shared\u0026lt;IndexScanPlanNode\u0026gt;( output_schema, table_oid, index_oid, predicate, constant_expr.get()); } } } } } } return optimized_plan; } 规则的局限性：\n只支持等值谓词（ComparisonType::Equal）：WHERE id = 5 可以，WHERE id \u0026gt; 5 不行 只支持左列右常量（col = const）：1 = id 这种反向写法不匹配 只支持单列等值：WHERE id = 5 AND name = 'Alice' 的复合条件不支持 无代价估算：不检查索引的选择率，若列基数很低（大量重复值），走索引反而比 SeqScan 更慢（随机 I/O 开销 \u0026gt; 顺序 I/O 开销） 只支持当前哈希索引：若有 B+ 树索引，可以支持范围谓词 11.3 NestedLoopJoin → HashJoin 优化 规则描述： 当 NLJ 的连接条件是多个等值条件的合取（AND）时，替换为 HashJoin。\nNLJ(condition: a1=b1 AND a2=b2) → HashJoin(left_keys=[a1, a2], right_keys=[b1, b2]) 等值连接是数据库中最常见的 Join 类型，HashJoin 的 O(n+m) 相比 NLJ 的 O(n×m) 有数量级的优势。\n11.4 Sort + Limit → TopN 优化 规则描述： 识别 LimitPlanNode(SortPlanNode(...)) 的计划模式，替换为单个 TopNPlanNode。\nLimit(n=10) └─ Sort(ORDER BY salary DESC) → TopN(n=10, ORDER BY salary DESC) 优化效果： 时间复杂度从 O(n log n)（全排序）+ O(1)（取前 10）降至 O(n log 10)（维护大小 10 的堆）。\n思考 Project3 的难点还是在于读代码，了解整个项目的设计结构，具体实现也就是一些常用的执行器的实现以及实现了几个优化器，但是也并不难。整体来说还是收获到很多东西，这一部分可能写的不是很详细，一是时间已经过去很久了，记不太清了，二是在后续 Project4 中会修改很多地方，导致代码已经变化很多了。\n本文在当时的实现总结基础上，对 Project3 中的核心机制（火山模型、各类执行器、MVCC、优化器规则）进行了系统整理，补充了大量设计原理分析和面试常考知识点，希望对后来者有所帮助。\n附录：代码关键文件索引 文件 内容 src/execution/seq_scan_executor.cpp 顺序扫描，MVCC 可见性判断 src/execution/insert_executor.cpp 插入算子，一次性执行模式 src/execution/update_executor.cpp 更新算子，两阶段 + UndoLog 合并 src/execution/delete_executor.cpp 删除算子，DeleteTuple 封装 src/execution/index_scan_executor.cpp 索引点查 src/execution/aggregation_executor.cpp 哈希聚合，空表边界处理 src/execution/hash_join_executor.cpp Build+Probe 哈希连接 src/execution/sort_executor.cpp 全内存排序 src/execution/limit_executor.cpp 流式截断 src/execution/topn_executor.cpp 堆维护 TopN src/execution/window_function_executor.cpp 窗口函数 src/execution/execution_common.cpp MVCC 公共函数（ReconstructTuple, InsertTuple, DeleteTuple） src/optimizer/seqscan_as_indexscan.cpp SeqScan→IndexScan 优化规则 src/include/execution/executors/abstract_executor.h Executor 基类接口 src/include/execution/executors/aggregation_executor.h SimpleAggregationHashTable src/include/execution/executors/hash_join_executor.h HashJoinKey/Value 与哈希函数 src/include/execution/executors/topn_executor.h TopN Compare 函子 src/include/execution/executors/window_function_executor.h SimpleWindowFunctionHashTable src/include/execution/plans/abstract_plan.h PlanNode 基类，PlanType 枚举 ","permalink":"https://yinit.github.io/bustub-query-execution/","summary":"CMU 15445 23fall Project3 Query Execution 实现与深度复习","title":"BusTub Query Execution"},{"content":"项目经历 从去年十月初到今年一月中旬，历时一个学期，在实验室的安排下，着手开始 CMU 15445 23fall 的数据库知识的学习和 Project 的实现，终于是在过年前完成所有的 Project。正好寒假在家有时间，就着手建了一个个人博客，写一下我在实现 Project 过程中遇到的问题和收获。关于课程视频，我时间有限，且没有太大兴趣，就只看了前几个视频就没看了，如果有同学感兴趣的话，还是推荐看一下，理论知识的学习还是很重要滴。\nProject2 开始涉及到数据库索引的底层实现，当年（23fall）做的是可扩展哈希，相比于 B+ 树要简单不少，推荐学有余力的同学去做一下其他年份的 B+ 树。总体来说没做多久，比 Project1 花的时间稍多一点。LeaderBoard 结果截止 2025/01/30 排名第 6：\n时隔一年后重新整理这篇文章，结合当时的实现思路和现在更系统的理解，做一次完整的复习总结。\n1. 概述 本项目是 CMU 15-445/645 数据库系统课程（Fall 2023）的 Project 2，要求实现一个磁盘支持的可扩展哈希索引（Disk-Backed Extendible Hash Index），具备以下能力：\n支持唯一键的查找（GetValue）、插入（Insert）、删除（Remove） 支持桶的动态分裂（Split）与合并（Merge），以及目录的扩展与收缩 通过 BufferPoolManager 管理磁盘页面 线程安全，支持多线程并发操作（Latch Crabbing） 项目分为四个任务：\nTask 1：实现 BasicPageGuard、ReadPageGuard、WritePageGuard（RAII 式页面锁管理） Task 2：实现三种页面类——Header Page、Directory Page、Bucket Page Task 3：实现可扩展哈希的核心逻辑（Insert / GetValue / Remove） Task 4：实现并发控制（Latch Crabbing 算法） 2. 整体架构 2.1 三层存储结构 ┌─────────────────────┐ │ Header Page │ 固定 1 页，存储 Directory Page IDs │ directory_page_ids_ │ 用哈希值的高位（MSB）做索引 └─────────┬───────────┘ │ 1..N 个 Directory Pages ┌───────────────────┼───────────────────────┐ │ │ │ ┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐ │ Directory Page│ │ Directory Page│ ... │ Directory Page│ │ global_depth │ │ global_depth │ │ global_depth │ │ local_depths │ │ local_depths │ │ local_depths │ │ bucket_page_ids│ │ bucket_page_ids│ │ bucket_page_ids│ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │ 1..2^GD 个 Bucket Pages（部分共享） ┌─────┴─────┐ │Bucket Page│ 存储实际 KV 对 array_[] └───────────┘ 2.2 哈希路由策略 层次 使用哈希位 方法 Header → Directory 最高 header_max_depth 位（MSB） hash \u0026gt;\u0026gt; (32 - max_depth_) Directory → Bucket 最低 global_depth 位（LSB） hash \u0026amp; ((1 \u0026lt;\u0026lt; global_depth_) - 1) MSB 用于 Header、LSB 用于 Directory 的设计动机： Header 是全局唯一的热点页面，用 MSB 可以将不同前缀的 key 分散到不同 directory，减少对 Header 的竞争；Directory 内部用 LSB，与传统可扩展哈希一致，便于通过位翻转计算 split image。\n3. 页面设计详解 当时的实现体会（Task 2）： 这部分实现三层结构中各个 Page 的功能函数，包括初始化、插入、查找、删除等接口，基本按照函数名就能知道应该干什么，相应实现即可，整体比较简单。做的过程中顺便对三层结构有了更清晰的理解：bucket 层是实际存放 KV 的容器，所有的增删改查最终都落在这里，并且每个 key 在整个索引中是唯一的，插入前需要做重复性检查；directory 层是可扩展哈希的核心，负责向下管理桶的分裂与合并（也是整个项目最难的部分），同时向上提供索引指向；header 层则类似操作系统多级页表中的外层页表，用于将请求分发到不同的 directory，同时提升了并发度——不同 header 槽下的 directory 之间天然可以并发。整个设计利用二进制数作为索引，将哈希值的不同位段分别用于各层的寻址，配合桶的分裂合并极大提高了空间利用率。\n3.1 Header Page | max_depth_ (4B) | directory_page_ids_[512] (2048B) | max_depth_：Header 能路由的最大深度，固定不变（目录数上限 = 1 \u0026lt;\u0026lt; max_depth_） directory_page_ids_：存储 Directory Page 的 page_id，初始全为 INVALID_PAGE_ID，按需创建 关键方法：\n// 用哈希值的高位路由到 directory uint32_t HashToDirectoryIndex(uint32_t hash) const { if (max_depth_ == 0) return 0; return hash \u0026gt;\u0026gt; (sizeof(uint32_t) * 8 - max_depth_); // 取高 max_depth_ 位 } 3.2 Directory Page | MaxDepth (4B) | GlobalDepth (4B) | LocalDepths[512] (512B) | BucketPageIds[512] (2048B) | Free (1528B) | global_depth_：当前目录的全局深度，决定有效目录项数量 2^global_depth_ local_depths_[i]：每个目录槽对应桶的局部深度（uint8_t，节省空间） bucket_page_ids_[i]：目录槽指向的桶页面 ID 静态断言（编译期内存布局校验）：\nstatic_assert(sizeof(ExtendibleHTableDirectoryPage) == HTABLE_DIRECTORY_PAGE_METADATA_SIZE + HTABLE_DIRECTORY_ARRAY_SIZE + // local_depths 数组 sizeof(page_id_t) * HTABLE_DIRECTORY_ARRAY_SIZE); // bucket_page_ids 数组 static_assert(sizeof(ExtendibleHTableDirectoryPage) \u0026lt;= BUSTUB_PAGE_SIZE); 这是一个很好的防御性编程技巧——在编译阶段保证结构体恰好能放进一个磁盘页（4096B），不依赖运行时检查。\n关键方法：\n// Split Image：将 bucket_idx 在第 local_depth 位取反，得到分裂后的另一个桶索引 uint32_t GetSplitImageIndex(uint32_t bucket_idx) const { return bucket_idx ^ (1 \u0026lt;\u0026lt; GetLocalDepth(bucket_idx)); } // 目录扩展：将现有条目复制到 [i | (1 \u0026lt;\u0026lt; old_global_depth)] 位置 void IncrGlobalDepth() { for (uint32_t i = 0; i \u0026lt; Size(); ++i) { SetBucketPageId(i | (1 \u0026lt;\u0026lt; global_depth_), GetBucketPageId(i)); SetLocalDepth(i | (1 \u0026lt;\u0026lt; global_depth_), GetLocalDepth(i)); } ++global_depth_; } IncrGlobalDepth 采用\u0026quot;增量扩展\u0026quot;策略：新的目录槽直接复制旧槽的内容（指向相同的 Bucket），而不是分配新页面，最小化分裂代价。\n3.3 Bucket Page | size_ (4B) | max_size_ (4B) | array_[...] (4088B) | array_ 是 std::pair\u0026lt;K, V\u0026gt; 的紧凑数组，采用顺序存储（非链表） max_size_ 在 Init() 时由外部传入，取决于 KV 类型大小和页面大小 RemoveAt 的移位删除：\nvoid RemoveAt(uint32_t bucket_idx) { --size_; for (uint32_t i = bucket_idx; i \u0026lt; size_; ++i) { array_[i] = array_[i + 1]; // 向前移位填补空位 } } 这是 O(n) 的移位删除，保持数组紧密排列，避免\u0026quot;空洞\u0026quot;，查找时无需跳过已删条目。\n4. 核心算法实现 当时的实现体会（Task 3）： 这三个函数的实现是整个 Project 的核心，也花了最多时间。GetValue 是纯读操作，沿着 header → directory → bucket 的路径向下搜索，对应节点不存在则返回 false，逻辑较清晰。Insert 的难点在于桶满时的分裂：如果 global_depth 与 local_depth 相同，说明目录已经\u0026quot;用满\u0026quot;，需要先 IncrGlobalDepth 把目录翻倍再做桶分裂；否则直接分裂桶、更新受影响的目录槽即可。分裂完后需要重新尝试插入，且由于分裂后数据按哈希重新分布，可能分裂一次还是不满足条件，因此要循环进行，直到达到最大深度或插入成功为止。Remove 的难点在于删除后桶为空时的合并：合并需要借助位运算判断 split image 桶的 local_depth 是否一致，否则不能合并；合并同样需要循环进行，直到无法继续合并为止。\n4.1 查找（GetValue） hash(key) → MSB → directory_idx → 检查是否 INVALID，否则 return false → 获取 Directory Page (ReadLock) → drop Header Read Lock（锁蟹行） → LSB → bucket_idx → 检查是否 INVALID，否则 return false → 获取 Bucket Page (ReadLock) → drop Directory Read Lock → Bucket::Lookup(key, value) 路径上的锁在\u0026quot;不再需要时立即释放\u0026quot;，是 Latch Crabbing 在只读路径的体现。\n4.2 插入（Insert） 正常路径（桶未满且 key 不存在）：\nhash(key) → 定位 directory → 定位 bucket → Bucket::Insert → return true 桶满触发分裂的完整流程：\nBucket::Insert 返回 false，且 key 不重复 → 进入 while(SplitBucket(directory, bucket_idx)) 循环 ↓ SplitBucket(directory, bucket_idx): 1. 检查 local_depth == directory_max_depth，若是则无法分裂，return false 2. new_bucket_idx = bucket_idx ^ (1 \u0026lt;\u0026lt; local_depth) [GetSplitImageIndex] 3. 创建新 Bucket Page（NewPageGuarded） 4. 若 local_depth == global_depth，先 IncrGlobalDepth（目录翻倍） 5. new_local_depth = local_depth + 1 6. 更新所有指向 old bucket 和 new bucket 的目录槽： for i in 0..(1 \u0026lt;\u0026lt; (global_depth - new_local_depth)): SetLocalDepth(old_bucket_idx + (i \u0026lt;\u0026lt; new_local_depth), new_local_depth) SetLocalDepth(new_bucket_idx + (i \u0026lt;\u0026lt; new_local_depth), new_local_depth) SetBucketPageId(old_bucket_idx + ..., old_bucket_page_id) SetBucketPageId(new_bucket_idx + ..., new_bucket_page_id) 7. MigrateEntries：将 old bucket 中 hash 属于 new bucket 的条目迁移过去 → return true ↓ 分裂后重新计算 bucket_idx，尝试 Insert，成功则 return true，否则继续循环 MigrateEntries 的实现技巧：\nvoid MigrateEntries(old_bucket, new_bucket, new_bucket_idx, local_depth_mask) { uint32_t i = 0; while (i \u0026lt; old_bucket-\u0026gt;Size()) { if ((Hash(key) \u0026amp; local_depth_mask) == new_bucket_idx) { new_bucket-\u0026gt;Insert(key, value, cmp_); old_bucket-\u0026gt;RemoveAt(i); // 删除后不递增 i，因为后面元素前移了 } else { i++; } } } 通过\u0026quot;删除后不递增 i\u0026quot;避免跳过条目，这是处理原地删除迁移的经典技巧。\n4.3 删除（Remove） 正常路径（删除后桶非空）：\nhash(key) → 定位 bucket → Bucket::Remove → 若桶不为空，return true 桶空触发合并的完整流程：\nBucket::Remove 成功且桶变空 → 进入 while(MergeBucket(directory, bucket_idx)) 循环 ↓ MergeBucket(directory, bucket_idx): 1. 若 local_depth == 0，无法合并，return false 2. new_local_depth = local_depth - 1 3. idx = bucket_idx \u0026amp; ((1 \u0026lt;\u0026lt; new_local_depth) - 1) [低 new_local_depth 位] 4. split_bucket_idx = bucket_idx ^ (1 \u0026lt;\u0026lt; new_local_depth) [分裂对应桶] 5. 检查所有指向这两个桶的目录槽 local_depth 是否一致（防止非法合并） 6. 若当前桶空：将所有目录槽重定向到 split_bucket，设置 local_depth - 1 7. 若 split 桶空：将所有目录槽重定向到当前桶，设置 local_depth - 1 → return true（合并成功） ↓ 合并后，检查 CanShrink：所有 local_depth \u0026lt; global_depth → DecrGlobalDepth 继续尝试合并（可能出现连锁合并） CanShrink 的判断逻辑：\nbool CanShrink() { if (global_depth_ == 0) return false; for (uint32_t i = 0; i \u0026lt; 1 \u0026lt;\u0026lt; global_depth_; ++i) { if (GetLocalDepth(i) == global_depth_) return false; // 任意一个槽 LD == GD 则不能缩 } return true; } 5. 并发控制设计 当时的实现体会（Task 1）： Task 1 实现三种 PageGuard 的构造函数、析构函数、operator= 和 Drop 函数，整体不难，但有两个细节需要注意。第一是锁不能重复释放，析构和 Drop 之前都需要先判断当前 guard 是否仍然有效；第二是一个让我当时感到困惑的设计：锁在 ReadPageGuard/WritePageGuard 外部（即在 BufferPoolManager 的获取函数中）获取，但在内部（析构/Drop 时）释放，这种\u0026quot;外取内放\u0026quot;的模式导致我一开始写出了 bug。事后反思，这样设计大概是为了让 PageGuard 的构造足够轻量，把锁的获取职责留给调用方。但个人认为如果把锁的获取也放在构造函数内会更符合 RAII 的语义、也更优雅。\n5.1 PageGuard RAII 机制 三种 PageGuard 对应三种访问模式：\n类型 锁类型 对应 BPM 方法 用途 BasicPageGuard 无锁，仅 Pin NewPageGuarded 创建新页面 ReadPageGuard 共享锁（RLatch） FetchPageRead 只读访问 WritePageGuard 排它锁（WLatch） FetchPageWrite 读写访问 RAII 保证： Guard 析构时自动调用 UnpinPage（减引用计数）并释放锁，防止遗忘导致的死锁或内存泄漏。\nDrop() 提前释放的技巧：\n// 在 GetValue 中，读完 header 页面后立即释放，避免长时间持锁 header_page = nullptr; // 先置空指针，防止悬空使用 header_guard.Drop(); // 手动提前触发解锁+unpin 这是 Latch Crabbing 的实现形式：\u0026ldquo;抓住下一个锁之后，再释放上一个锁\u0026rdquo;。\n5.2 Latch Crabbing（锁蟹行）算法 读操作（GetValue）的锁序列：\n[RLock Header] → [RLock Directory] → [Drop Header RLock] → [RLock Bucket] → [Drop Directory RLock] → 读取 → [析构 Drop Bucket RLock] 写操作（Insert）的锁序列：\n[WLock Header] → 若 directory 已存在: [Drop Header WLock] → [WLock Directory] → [WLock Bucket] → 操作 → 若 directory 不存在: 持有 Header WLock 创建 Directory（InsertToNewDirectory） 关键约束： 必须先持有下层锁，再释放上层锁，避免另一个线程在\u0026quot;空窗期\u0026quot;修改页面结构导致当前线程读到脏数据。\n5.3 加锁粒度分析 操作 Header Directory Bucket GetValue RLock（早释放） RLock（早释放） RLock Insert（不分裂） WLock（早释放） WLock（早释放） WLock Insert（分裂） WLock（早释放） WLock（全程） WLock Remove WLock（早释放） WLock（全程） WLock Insert/Remove 在涉及结构修改（分裂/合并）时，Directory 的 WLock 全程持有直到操作完成，这是正确性的保证，但也是并发性能的瓶颈。\n6. 设计 6.1 RAII PageGuard 模式 PageGuard 是 RAII（Resource Acquisition Is Initialization）模式的典型应用。通过将\u0026quot;持有锁 + Pin 页面\u0026quot;的生命周期绑定到栈上对象，从根本上避免了忘记释放锁或 Unpin 页面的 Bug。这在并发数据库系统中尤为重要——一旦页面没有被 Unpin，BPM 就无法驱逐它，最终导致所有线程阻塞。\n6.2 静态断言保证页面布局 static_assert(sizeof(ExtendibleHTableDirectoryPage) \u0026lt;= BUSTUB_PAGE_SIZE); 使用 static_assert 在编译期验证结构体大小不超过磁盘页面大小（4096B），而非依赖运行时检查。这是一种零运行时开销的防御性编程技术。类似地，local_depths_ 使用 uint8_t（而非 uint32_t）节省了 1536B 空间，这是有意为之的紧凑设计。\n6.3 MSB + LSB 分离路由 传统两层可扩展哈希只有 Directory，用哈希值的 LSB 路由。BusTub 在上层增加了固定大小的 Header Page，用 MSB 路由到对应的 Directory。\n这个设计的好处：\n扩展容量：总共可以存储 2^(header_max_depth + directory_max_depth) 个桶 减少热点争用：不同高位前缀的 key 访问不同的 Directory，减少对单一目录页面的写锁竞争 Header 固定大小：不像 Directory 会扩缩，Header 永远只有一页，是稳定的入口 6.4 Template 泛型设计 整个索引实现是 C++ 模板类 DiskExtendibleHashTable\u0026lt;K, V, KC\u0026gt;，通过显式模板实例化支持不同 key 大小：\ntemplate class DiskExtendibleHashTable\u0026lt;GenericKey\u0026lt;4\u0026gt;, RID, GenericComparator\u0026lt;4\u0026gt;\u0026gt;; template class DiskExtendibleHashTable\u0026lt;GenericKey\u0026lt;8\u0026gt;, RID, GenericComparator\u0026lt;8\u0026gt;\u0026gt;; // ... 16, 32, 64 GenericKey\u0026lt;N\u0026gt; 是一个固定大小的字节数组包装，配合 Schema 提取 Tuple 中对应列的字节表示。这种设计使同一套索引实现可以服务于不同长度的索引键，无需为每种类型重写代码。\n6.5 IncrGlobalDepth 的增量复制 void IncrGlobalDepth() { for (uint32_t i = 0; i \u0026lt; Size(); ++i) { // 新槽 [i | (1 \u0026lt;\u0026lt; global_depth_)] 复制旧槽 [i] 的内容 SetBucketPageId(i | (1 \u0026lt;\u0026lt; global_depth_), GetBucketPageId(i)); SetLocalDepth(i | (1 \u0026lt;\u0026lt; global_depth_), GetLocalDepth(i)); } ++global_depth_; } 目录翻倍时，新增的那一半目录槽直接复制对应旧槽的 bucket page_id 和 local_depth，表示它们指向同一个桶。这避免了分配新桶和数据迁移，翻倍操作是 O(目录大小) 而非 O(数据量)，代价非常小。\n6.6 Adapter 模式（ExtendibleHashTableIndex） ExtendibleHashTableIndex 继承自 Index 基类，将 DiskExtendibleHashTable 封装为数据库索引接口：\nclass ExtendibleHashTableIndex : public Index { DiskExtendibleHashTable\u0026lt;KeyType, ValueType, KeyComparator\u0026gt; container_; public: bool InsertEntry(const Tuple \u0026amp;key, RID rid, Transaction *txn) override; void DeleteEntry(const Tuple \u0026amp;key, RID rid, Transaction *txn) override; void ScanKey(const Tuple \u0026amp;key, std::vector\u0026lt;RID\u0026gt; *result, Transaction *txn) override; }; 这是 **Adapter 模式（适配器模式）**的应用：DiskExtendibleHashTable 使用 GenericKey 作为键，而 Index 接口接收 Tuple。ExtendibleHashTableIndex 负责将 Tuple 转换为 GenericKey（通过 index_key.SetFromKey(key)），对上层透明。\n思考 可扩展哈希的实现还是挺有意思的，三层结构设计与二进制位运算的结合让分裂合并逻辑优雅而紧凑。不过正如当初做完就意识到的，可扩展哈希的局限性太多：并发度受 Directory 写锁制约、哈希冲突无法从结构上解决、也基本没有深入优化的空间。\n相比之下，B+ 树的应用更为广泛，结构也更复杂，学有余力的同学推荐去做一下其他年份的 B+ 树实现，对理解数据库索引原理会更有帮助。\n附录：关键常量与内存布局速查 常量 值 含义 BUSTUB_PAGE_SIZE 4096B 磁盘页大小 HTABLE_DIRECTORY_MAX_DEPTH 9 Directory 最大深度（最多 512 个桶槽） HTABLE_DIRECTORY_ARRAY_SIZE 512 Directory 桶槽数量上限 header max_depth（默认） - 构造函数传入，通常为 4-9 bucket max_size（默认） - 由 Key/Value 类型大小决定 页面 总大小 关键字段 Header Page ≤4096B max_depth(4) + directory_page_ids(2048) Directory Page 2572B max_depth(4) + global_depth(4) + local_depths(512) + bucket_page_ids(2048) Bucket Page 4096B size(4) + max_size(4) + array(4088) ","permalink":"https://yinit.github.io/bustub-extendible-hash-index/","summary":"CMU 15445 23fall Project2 可扩展哈希索引实现过程记录与系统性复习","title":"Bustub Extendible Hash Index"},{"content":" 本文是对 CMU 15-445 BusTub Project1（Buffer Pool Manager）的回顾与延伸，涵盖 23fall 和 24fall 两个版本的实现经历、并发优化思路，以及以 Milvus CachingLayer 为例的工业级缓存池设计演变分析。\n一、项目经历回顾 1.1 23fall Project1 从去年十月初到今年一月中旬，历时一个学期，在实验室的安排下，着手开始 CMU 15445 23fall 的数据库知识学习和 Project 的实现，终于在过年前完成所有的 Project。\nProject1 整体还是比较简单的，大概在第一周就完成了全部内容，然后在 Project2 完成之后，花了一周的时间实现了代码优化。优化结果：LeaderBoard 排名第10（2025/1/27）\n给前面几位神仙跪了，断层领先（有理由怀疑在 hack，各项数据太吓人了）。我的这个排名差不多就是我的极限了，能做到的都已经做了。\n1.1.1 Task1 - LRU-K Replacement Policy 在 Task1 中需要使用 lru-k 策略实现内存页调度策略。每一个 frame 对应一个内存中的 page（数量有限），而在 BufferManager 中的 page 是实际存储数据的物理 page（相对无限）。\n我的实现：\n使用两个队列存放所有使用的 frame： 一个 history_list 队列，存放访问次数少于 k 次的 frame，先进先出策略 一个 lru_list 队列，存放所有访问次数大于等于 k 次的 frame，Evict 时顺序遍历求访问时间最小的 frame（lru_list 的 Evict 为 O(n) 操作，但对 frame 的操作为 O(1)） lru-k 策略： 访问次数小于 K 次：不作更改（小于 K 频次时为 FIFO） 访问次数等于 K 次：将结点从 history_list_ 移动到 lru_list_ 访问次数大于 K 次：逐出结点中最早的访问记录，重新插入 lru_list 优化尝试：\n尝试将 lru_list 设置为有序，使 Evict 效率变为 O(1)，但最终结果表明效率没有提升（可能是实现有问题，这部分代码已被删掉） 利用 access_type 属性，稍微调整 lru-k 的实现策略（LeaderBoard 开启了 16 个线程，8个随机读写，8个顺序读写），效率提升不少 1.1.2 Task2 - Disk Scheduler 这一部分比较简单，主要实现 Schedule 函数，将 IO 操作独立出去，放在单独的线程中执行 IO 操作。\n优化：原本磁盘调度是单线程，将其修改为多线程。由于并发度不同，选择设置为动态线程池（开动态线程池貌似也没啥效率提升，LeaderBoard 测试太死了）。\n1.1.3 Task3 - Buffer Pool Manager 综合 lru-k 内存调度策略和磁盘调度实现缓冲池管理，实现对物理页的透明访问。\n实现内容：NewPage、FetchPage、UnPinPage、FlushPage、FlushAllPage、DeletePage。\n我的并发实现思路：\n刚开始为每一个使用的资源设置一个锁，遵守二阶段锁策略，尽量将各部分加锁部分分离 随着实现深入，理解了并发提升效率的本质：并发本质是将 IO 操作并发（这是可以并发的），其他 BufferPoolManager 属性加一把锁即可（多个线程都需要操作，基本没有并发可言） 最终结果：对 pages 外的所有数据使用一把大锁 latch_，对 pages_ 使用锁数组（防止对同一个 page 操作并发冲突），遵循二阶段锁策略，将 IO 操作和对 bpm 字段操作分离开分别加锁，利用 IO 并发提高效率 1.2 24fall Project1 开学后便开始启动 24fall 的 bustub，主要是因为 23fall 缺少了 B+ 树的部分，来做 24fall 补上。\n先说说 23 到 24 的变化，主要有以下几点：\nFrameHeader 替换 Page：23fall 的 Page 在 24fall 变成了 FrameHeader 并放到了 buffer_pool_manager 文件中，底层的 Page 需要自己实现，自由度更高 删除 BasicPageGuard：只保留 ReadGuard 和 WriteGuard，保证从 BufferManager 中读取的页面必须加了读锁或写锁，这样实现了将加锁和解锁放在 ReadGuard 和 WriteGuard 中（好耶） 修改 NewPage 和 FetchPage 的分工：24fall 中 NewPage 只获取 page_id，所有页面读取放在 ReadPage 和 WritePage 中，这样只需实现一个 FetchPage，然后分别在 ReadPage 和 WritePage 中构建 ReadGuard 和 WriteGuard，获取页面只需写一个逻辑了（舒服了） lru_k 小修改：Evict 返回 optional 值，而不是原先通过指针和 bool 变量返回两个值，更合理的设计 总的来说，24fall 设计得比 23fall 更好了，自由度更高，并发也稍难了，因为 page 的读写需要自己实现。\nLeaderBoard 排名第6（2025/03/07）\n1.2.1 并发问题与解决过程 23fall 的并发思路（及其隐藏的错误） 23fall 中的思路：对所有线性执行部分加 bpm 锁，对 frame 的操作（有 IO 操作）根据 frame_id 加锁，保证每个 frame_id 的操作能独立执行。先获取 bpm 锁，再获取 frame 锁，然后释放 bpm 锁，最后释放 frame 锁，保证顺序执行不会被插队。\n但这样写实际上还存在问题，只是在 23fall 中的测试没有测出来，在 24fall 中遇到了，折腾了好久。\n并发失败的原因：不能够保证 page 的顺序执行。\n错误的尝试 有两种解决办法：\n在线程1写入页面B后释放 bpm 锁，保证 page 读写的顺序执行，但并发度会大幅度下降 添加 page_mutexes_ 锁，对每一个 page 加锁，使用 page_mutexes[page_id % 6400] 获取 page 锁，获取 page 锁放在获取 frames_mutexes 锁之前 这个方法能通过 p1 的测试，但是在 p2 的测试中存在问题（获取 frame 锁后获取 page 锁失败，导致持有 bpm 锁和 frame 锁，其他线程无法执行，因为释放 PageGuard 需要获取 bpm 锁和 frame 锁）。\n最终方案：引入条件变量 既然问题是写入和读取在并发时不能保证顺序执行，那就引入条件变量，在从内存中读取页面 A 之前判断是否有页面 A 的脏页面没有写入或正在写入。\n在 BufferPoolManager 添加属性：\n/** @brief A set of dirty pages that need to be flushed to disk. */ std::unordered_set\u0026lt;page_id_t\u0026gt; dirty_pages_; /** @brief A mutex to protect the dirty pages set. */ std::mutex flush_mutex_; /** @brief A condition variable to notify the flusher thread. */ std::condition_variable flush_cv_; 操作流程：\nFetchPage 获取新页面时，在释放 bpm_latch 锁前，判断原本的 frame 是否是脏页，如果是就将其写入 dirty_pages_ 中 释放 bpm_latch 锁后进行脏页写入，成功写入后将对应 page_id 从 dirty_pages_ 中删除，并使用 notify_all 唤醒等待线程 在读取页面之前，使用 flush_wait 判断是否有脏页未写入，如果有就等待，释放 flush_mutex_ 锁（不释放 frame 锁） 最终优化结果：\n并发还是博大精深，先入为主地认为原本的设计是正确的，折腾了太久（沉默）。\n二、BusTub 缓存池深度解析 在分析工业级设计之前，先把 BusTub 的每个组件彻底吃透，这样后面的演变才有对照的基础。\n2.1 整体架构与组件关系 BusTub 的缓存池由三个组件构成，各司其职：\n上层 (B+ Tree, SQL Executor 等) ↓ CheckedReadPage / CheckedWritePage ┌──────────────────────────────┐ │ BufferPoolManager │ │ ┌─────────┐ ┌────────────┐ │ │ │page_table│ │free_frames │ │ ← 内存管理 │ └─────────┘ └────────────┘ │ │ ┌──────────────────────────┤ │ │ LRUKReplacer │ ← 驱逐决策 │ └──────────────────────────┤ │ ┌──────────────────────────┤ │ │ DiskScheduler │ ← 异步 I/O │ └──────────────────────────┤ └──────────────────────────────┘ ↓ ReadPage / WritePage 磁盘文件 (DiskManager) 三者的职责分工：\nLRUKReplacer：只负责\u0026quot;选谁被驱逐\u0026quot;——维护访问历史，决定淘汰顺序 BufferPoolManager：负责\u0026quot;内存帧的分配与映射\u0026quot;——哪个物理帧存哪个逻辑页 DiskScheduler：负责\u0026quot;把 I/O 请求异步化\u0026quot;——不让读写磁盘阻塞主线程 2.2 LRU-K Replacer 详解 为什么需要 LRU-K？ 普通 LRU 只记录\u0026quot;最近一次访问时间\u0026quot;，容易被顺序扫描（Sequential Scan）污染：全表扫描时，每个页只访问一次，但它们会把真正的热页挤出内存。\nLRU-K 的核心思想：只有访问次数达到 K 次的页，才算真正\u0026quot;热\u0026quot;。访问次数不足 K 次的页，使用 FIFO 策略——先进先出，谁最早进来就先被驱逐。\n双队列实现（2Q 策略） BusTub 实现的是一种 2Q（Two Queue） 变体：\n// 来自 lru_k_replacer.h std::list\u0026lt;frame_id_t\u0026gt; history_list_; // 历史队列：访问次数 \u0026lt; k 的页，FIFO 淘汰 std::list\u0026lt;frame_id_t\u0026gt; cache_list_; // 缓存队列：访问次数 \u0026gt;= k 的页，LRU 淘汰 LRUKNode 的数据结构：\nclass LRUKNode { std::list\u0026lt;size_t\u0026gt; history_; // 时间戳历史（最多保留 k 个） bool is_evictable_{false}; // 是否可被驱逐（pin_count == 0 时才为 true） std::list\u0026lt;frame_id_t\u0026gt;::iterator pos_; // 指向所在链表中的位置，O(1) 删除 }; pos_ 是链表迭代器，这是一个重要的优化：std::list 的迭代器在插入/删除其他元素时不会失效，所以可以提前保存迭代器，在需要删除时直接 list.erase(pos_)，时间复杂度 O(1)。\nRecordAccess 的状态转换逻辑 void LRUKReplacer::RecordAccess(frame_id_t frame_id, AccessType access_type) { auto \u0026amp;node = node_store_[frame_id]; node.history_.push_back(current_timestamp_++); if (node.history_.size() == 1) { // Case 1：首次访问，进入 history_list_（从头部插入） history_list_.push_front(frame_id); node.pos_ = history_list_.begin(); } else if (node.history_.size() == k_) { // Case 2：达到 k 次，晋升到 cache_list_ history_list_.erase(node.pos_); cache_list_.push_front(frame_id); node.pos_ = cache_list_.begin(); } else if (node.history_.size() \u0026gt; k_) { // Case 3：已在 cache_list_，LRU 更新（移到头部，并丢弃最旧时间戳） node.history_.pop_front(); // 只保留最近 k 个时间戳 cache_list_.erase(node.pos_); cache_list_.push_front(frame_id); node.pos_ = cache_list_.begin(); } } 状态转换图：\n首次访问 → [history_list_ 头部] ↓ 第 k 次访问 [cache_list_ 头部] ←→ 每次访问后移到头部（LRU） Evict 的优先级逻辑 auto LRUKReplacer::Evict() -\u0026gt; std::optional\u0026lt;frame_id_t\u0026gt; { // 优先从 history_list_ 尾部驱逐（访问次数不足 k，且是最早进入的） if (evict_from_list(history_list_)) return frame_id; // 其次从 cache_list_ 尾部驱逐（最久未访问的\u0026#34;热页\u0026#34;） if (evict_from_list(cache_list_)) return frame_id; return std::nullopt; } 关键点：evict_from_list 从链表尾部扫描（反向迭代器），找第一个 is_evictable_ == true 的节点。链表头部是最近访问的，尾部是最久没访问的。\n2.3 BufferPoolManager 详解 核心数据结构 class BufferPoolManager { // 物理内存帧（固定数量，启动时分配完毕） std::vector\u0026lt;std::shared_ptr\u0026lt;FrameHeader\u0026gt;\u0026gt; frames_; // 页 ID → 帧 ID 的映射表（类似 OS 的页表） std::unordered_map\u0026lt;page_id_t, frame_id_t\u0026gt; page_table_; // 空闲帧列表（没有存放任何页的帧） std::list\u0026lt;frame_id_t\u0026gt; free_frames_; // 脏页集合（需要写回磁盘的页） std::unordered_set\u0026lt;page_id_t\u0026gt; dirty_pages_; // 驱逐器（LRU-K） std::shared_ptr\u0026lt;LRUKReplacer\u0026gt; replacer_; // 磁盘调度器 std::unique_ptr\u0026lt;DiskScheduler\u0026gt; disk_scheduler_; }; FrameHeader 的结构：\nclass FrameHeader { frame_id_t frame_id_; // 帧编号 page_id_t page_id_; // 当前存放的页 ID std::atomic\u0026lt;size_t\u0026gt; pin_count_; // 引用计数（多少个线程在使用） bool is_dirty_; // 是否被修改（需要写回磁盘） std::vector\u0026lt;char\u0026gt; data_; // 实际数据（4KB） std::shared_mutex rwlatch_; // 读写锁（供 PageGuard 使用） }; FetchPage 的完整流程 FetchPage 是 BPM 的核心函数，理解它就理解了 BPM 的全部逻辑：\nFetchPage(page_id) │ ├── 命中？（page_table_ 中存在） │ ↓ YES │ RecordAccess + SetEvictable(false) + pin_count_++ │ 直接返回 frame │ └── 未命中？ │ ├── free_frames_ 非空？ │ ↓ YES │ 从 free_frames_ 取一个空闲帧 │ └── free_frames_ 为空？ ↓ YES replacer_-\u0026gt;Evict() 选出受害者帧 │ ├── 受害者帧是脏页？ │ ↓ YES │ 先写回磁盘（DiskScheduler 同步等待） │ └── 从磁盘读取目标页到帧 RecordAccess + SetEvictable(false) + pin_count_++ 返回 frame Pin 机制详解 pin_count_ 是防止\u0026quot;正在使用的页被驱逐\u0026quot;的关键机制：\npin_count_ \u0026gt; 0：页面正在被某个线程使用，不能被驱逐 pin_count_ == 0：页面空闲，可以被驱逐 // 获取页时：pin_count_ 增加，SetEvictable(false) ++frames_[frame_id]-\u0026gt;pin_count_; replacer_-\u0026gt;SetEvictable(frame_id, false); // 用完（PageGuard 析构）时：pin_count_ 减少，SetEvictable(true) --frames_[frame_id]-\u0026gt;pin_count_; if (frames_[frame_id]-\u0026gt;pin_count_ == 0) { replacer_-\u0026gt;SetEvictable(frame_id, true); } 问题：BusTub 中，pin_count_ 需要上层调用者手动管理（通过 PageGuard 的 RAII 析构）。如果忘记释放 Guard，帧永远无法被驱逐，最终导致 BPM 耗尽内存。\n脏页（Dirty Page）管理 写操作不会立即写磁盘（Write-Back 策略）：\n修改帧内数据，标记 is_dirty_ = true 只有在驱逐该帧或显式调用 FlushPage时，才写回磁盘 这是数据库缓存的标准策略，避免每次写操作都触发磁盘 I/O。\n2.4 DiskScheduler 详解 生产者-消费者模型 DiskScheduler 使用经典的生产者-消费者模式，将 I/O 请求与执行解耦：\nclass DiskScheduler { std::queue\u0026lt;std::function\u0026lt;void()\u0026gt;\u0026gt; tasks_; // 任务队列 std::vector\u0026lt;std::thread\u0026gt; workers_; // 工作线程池 std::mutex queue_mutex_; // 保护任务队列 std::condition_variable condition_; // 通知工作线程 bool stop_ = false; // 停止信号 }; Schedule 函数（生产者）：\nvoid DiskScheduler::Schedule(DiskRequest r) { auto request = std::make_shared\u0026lt;DiskRequest\u0026gt;(std::move(r)); { std::unique_lock\u0026lt;std::mutex\u0026gt; lock(queue_mutex_); tasks_.emplace([this, request = std::move(request)]() mutable { // 实际的磁盘读/写操作 if (request-\u0026gt;is_write_) { disk_manager_-\u0026gt;WritePage(request-\u0026gt;page_id_, request-\u0026gt;data_); } else { disk_manager_-\u0026gt;ReadPage(request-\u0026gt;page_id_, request-\u0026gt;data_); } request-\u0026gt;callback_.set_value(true); // 通知调用者完成 }); } condition_.notify_one(); // 唤醒一个工作线程 } StartWorkerThread（消费者）：\nvoid DiskScheduler::StartWorkerThread() { while (true) { std::function\u0026lt;void()\u0026gt; task; { std::unique_lock\u0026lt;std::mutex\u0026gt; lock(queue_mutex_); condition_.wait(lock, [this] { return !tasks_.empty() || stop_; }); if (stop_ \u0026amp;\u0026amp; tasks_.empty()) return; task = std::move(tasks_.front()); tasks_.pop(); } task(); // 锁外执行，不阻塞其他线程取任务 } } std::promise / std::future —— 同步等待异步结果 BusTub 使用 std::promise\u0026lt;bool\u0026gt; 和 std::future\u0026lt;bool\u0026gt; 实现\u0026quot;提交请求后等待完成\u0026quot;：\n// 调用方： auto promise = disk_scheduler_-\u0026gt;CreatePromise(); // std::promise\u0026lt;bool\u0026gt; auto future = promise.get_future(); // std::future\u0026lt;bool\u0026gt; disk_scheduler_-\u0026gt;Schedule({false, data_ptr, page_id, std::move(promise)}); future.get(); // 阻塞等待工作线程调用 promise.set_value(true) 概念解释：\nstd::promise\u0026lt;T\u0026gt;：一端写入结果（set_value()） std::future\u0026lt;T\u0026gt;：另一端等待结果（get() 阻塞直到有结果） 两者是配对的\u0026quot;通信信道\u0026quot;，适合\u0026quot;一次性事件通知\u0026quot; 局限性：future.get() 会阻塞当前线程。虽然 I/O 操作在工作线程中异步执行，但调用方线程仍然需要等待。这在高并发场景下会成为瓶颈。\n2.5 BusTub 设计的局限性 BusTub 的设计目的是教学，为了简洁清晰做了很多简化，这些简化在工业环境中都会遇到问题：\n局限性 具体问题 工业影响 固定 4KB 粒度 所有数据必须以 4KB 页为单位管理 向量索引一个文件可能几十GB，以4KB分页管理开销极大 全局大锁 bpm_latch_ 保护所有操作，同一时间只有一个线程能操作BPM 高并发查询时严重竞争，吞吐量无法水平扩展 同步阻塞 I/O future.get() 阻塞线程等待磁盘读完 磁盘延迟 ms 级，线程被白白占用 硬编码 DiskManager 只能从本地磁盘读页，无法接入 S3/HDFS 等远端存储 云原生场景无法使用 手动 Pin/Unpin 调用者需要记住 unpin，否则内存泄漏 复杂查询调用链中容易出错 只有内存一种存储 要么在内存，要么在磁盘，没有中间层 无法利用 mmap、大页内存等 OS 特性 无资源预约 加载数据时不提前声明用量，可能加载中途OOM 生产系统不可接受 无物理内存感知 只管逻辑帧数，不关心实际物理内存压力 容器环境内存超售时会OOM 这些局限性就是 Milvus CachingLayer 要一一解决的问题。\n三、技术演变：8个核心维度（以 Milvus 为例） 演变 1：数据管理粒度 BusTub（固定4KB页）：\n磁盘文件：[Page 0][Page 1][Page 2]... 4KB 4KB 4KB 内存帧： [Frame 0][Frame 1]... 4KB 4KB 所有数据必须是 4KB 的倍数。读取 100 字节的数据，也要把整个 4KB 页加载进内存。\nMilvus（可变粒度 Cell）：\n磁盘： [Parquet 文件: Row Group 0][Row Group 1][Row Group 2]... 不固定大小，由列数据决定 内存： [CacheCell 0][CacheCell 1][CacheCell 2]... 大小由 Translator.estimated_byte_size_of_cell() 决定 Milvus 的\u0026quot;Cell\u0026quot;对应 BusTub 的\u0026quot;Page\u0026quot;，但大小完全由业务数据决定。一个 Cell 可能是：\n一个 Parquet RowGroup（几十MB） 一个向量索引分片（几GB） 一个倒排索引的 term 词典 这个演变的意义：数据库页大小固定是因为需要随机寻址（B+ 树靠页偏移定位）。向量数据库的访问模式是\u0026quot;给我整个列段\u0026quot;，不需要按固定大小寻址，可变粒度更自然。\n关键接口：Translator::estimated_byte_size_of_cell(cid_t cid) 告诉框架每个 Cell 的大小，而不是框架自己假设所有 Cell 一样大。\n演变 2：替换算法 BusTub（LRU-K 双队列）：\nhistory_list_: [新页] → ... → [最老的新页] ← 从尾部驱逐 cache_list_: [最近访问] → ... → [最久未访问] ← 从尾部驱逐 LRU-K 解决了顺序扫描污染问题。但在高并发场景下有一个新问题：每次 unpin 都要把节点移到链表头部，这需要对整个链表加锁。\nMilvus（LRU + Touch Window 优化）：\nMilvus 的 DList 也是 LRU 双向链表，但增加了 Touch Window 机制：\nvoid touchItem(ListNode* node, ...) { auto now = steady_clock::now(); // 如果距上次 touch 时间 \u0026lt; touch_window，跳过不更新 if (now - node-\u0026gt;last_touch_ \u0026lt; eviction_config_.cache_touch_window) { return; } // 超过时间窗口才真正移到链表头部 node-\u0026gt;last_touch_ = now; moveToHead(node); // 需要对链表加锁 } 为什么这个优化重要？\n假设有 1000 个查询线程并发访问同一个热点 Cell，每次访问结束（unpin）都要把 Cell 移到 LRU 链表头部。没有优化的情况下，1000 次 moveToHead 操作都要争抢链表锁，形成严重的锁竞争。\n有了 Touch Window（比如 1 秒），这 1000 次操作中，只有第一次会真正移动节点，其余 999 次直接跳过。链表锁竞争从 1000 次降到约 1 次（每秒）。\n对比总结：\n特性 BusTub LRU-K Milvus DList 算法 双队列 2Q 单 LRU 链表 每次访问更新链表？ 是 有时间窗口限制 链表锁竞争 高（每次unpin都要加锁） 低（窗口内跳过） try_lock 驱逐 否（驱逐时会阻塞） 是（跳过正在被用的节点） 演变 3：I/O 模型 BusTub（同步 Promise-Future）：\n// BusTub 调用方：提交请求后阻塞等待 auto promise = disk_scheduler_-\u0026gt;CreatePromise(); auto future = promise.get_future(); disk_scheduler_-\u0026gt;Schedule({...promise...}); future.get(); // ← 线程被阻塞在这里，等磁盘 I/O 完成 线程关系：\n调用线程： ────[提交请求]────[future.get() 阻塞]────────────[继续执行] 工作线程： [读磁盘] → [promise.set_value()] 虽然 I/O 发生在工作线程，但调用线程仍然被占用等待，浪费了 CPU 资源。\nMilvus（Folly 异步 Future）：\n// Milvus 调用方：获取 Future，可以去做别的事 folly::SemiFuture\u0026lt;NodePin\u0026gt; future = cell-\u0026gt;pin().second; // ...可以先处理其他 cell... // 最终统一等待所有 Future auto all_pins = SemiInlineGet(folly::collect(futures)); 线程关系（完全异步时）：\n调用线程： ─[提交加载]─[去做其他事情]─────────────────[collect 等待]─[继续] 加载线程： [读磁盘...] → [SharedPromise.fulfill()] 关键区别：folly::SharedPromise\nBusTub 的 std::promise 只能有一个 future。但 Milvus 用 folly::SharedPromise，允许多个等待者等待同一个结果：\n线程A: cell-\u0026gt;pin() → 返回 SemiFuture（等待加载完成） 线程B: cell-\u0026gt;pin() → 返回 SemiFuture（复用同一个 SharedPromise！） 线程C: cell-\u0026gt;pin() → 返回 SemiFuture（同上） 加载线程：只执行一次 get_cells()，fulfill SharedPromise → 线程A/B/C 同时唤醒 这避免了重复加载：多个线程同时需要同一个 Cell，不会各自去读一遍磁盘，只读一次，大家共享结果。BusTub 没有这个机制（因为它是教学项目，通常假设单个上层操作），工业环境中必须处理这种并发。\n演变 4：资源管理 BusTub（隐式，按帧数限制）：\nBusTub 的内存限制非常简单：最多 num_frames 个帧，每帧 4KB，总内存 = num_frames * 4KB。\n问题：\n不知道\u0026quot;加载中\u0026quot;的数据占用多少内存（加载过程中可能需要临时内存） 同时发起 100 个加载请求时，可能总共需要 100 * 4KB * 2 的临时内存，但只检查最终的 num_frames 限制 Milvus（显式预约机制 + 双水位控制）：\n资源预约（Reserve-Charge-Refund） 加载前：ReserveLoadingResourceWithTimeout(estimated_loading_size) → total_loading_size_ += estimated_size ← 预约：先声明占用 加载中：translator_-\u0026gt;get_cells() ← 实际 I/O 加载完：ChargeLoadedResource(actual_loaded_size) → total_loaded_size_ += actual_size ← 计费：正式计入 → RAII: ReleaseLoadingResource() → total_loading_size_ -= estimated_size ← 释放预约 驱逐时：RefundLoadedResource(evicted_size) → total_loaded_size_ -= evicted_size ← 退款：归还资源 这个三步流程保证在任何时刻都能知道：\n已加载数据：total_loaded_size_ 正在加载数据：total_loading_size_ 总内存压力：两者之和 加载前就声明，防止所有线程同时开始加载，导致超出内存上限。\n双水位控制 内存使用量 │ 100%├────────────────────────────── max_resource_limit_ （拒绝新请求） │ 80%├────────────────────────────── high_watermark_（后台主动驱逐） │ ↑ 触发异步驱逐，目标：降到低水位 60%├────────────────────────────── low_watermark_（驱逐停止目标） │ 0%└────────────────────────────── 为什么需要两个水位，而不是一个阈值？\n如果只有一个阈值（比如 80% 触发驱逐），会产生\u0026quot;抖动\u0026quot;：\n内存 80% → 触发驱逐 → 降到 79% → 停止驱逐 → 新数据进来 → 又到 80% → 再驱逐\u0026hellip; 每次驱逐一点点，频繁触发驱逐逻辑，产生大量开销。\n有了双水位：\n超过高水位（80%）→ 触发驱逐，目标降到低水位（60%） 一次驱逐 20% 的数据，很长时间内不需要再驱逐 提供了\u0026quot;缓冲带\u0026quot;，减少频繁抖动 类比：空调的制冷设定点。如果把目标温度设为 26°C，空调不会在 26.1°C 开、26.0°C 关（这样会频繁启停）。实际上是\u0026quot;高于 28°C 开，低于 24°C 关\u0026quot;，即双水位。\n物理内存感知 Milvus 还额外检测真实物理内存使用率：\nbool DList::checkPhysicalMemoryLimit() { // 通过读取 /proc/meminfo 获取系统实际内存使用率 auto usage_ratio = getSystemMemoryUsageRatio(); return usage_ratio \u0026gt; overloaded_memory_threshold_percentage; } 即使逻辑配额（max_resource_limit_）未超，如果物理内存已经很紧张（比如容器内存超售），也会触发驱逐。BusTub 完全不关心物理内存，只关心帧数——这在容器化环境中很危险。\n演变 5：并发控制 BusTub（全局大锁）：\n// BPM 几乎所有操作都先加这把锁 std::unique_lock\u0026lt;std::mutex\u0026gt; latch_lock(*bpm_latch_); 影响：同一时刻只有一个线程能进行 FetchPage、DeletePage 等操作。10 个查询线程只有 1 个在工作，9 个在等锁。\nMilvus（分层细粒度锁策略）：\n层次 1：DList::list_mtx_ （std::mutex） 保护：链表结构、total_loaded_size_、waiting_requests_ 持锁时间：短（只做链表操作） 层次 2：ListNode::mtx_ （std::shared_mutex） 保护：每个 Cell 的状态机转换 读操作：shared_lock（多读者并行） 写操作：unique_lock（独占） 关键优化：tryEvict 中的 try_lock\n驱逐路径是最容易和业务路径冲突的地方。BusTub 中驱逐操作和正常访问共享同一把锁，会互相阻塞。\nMilvus 的驱逐路径使用 try_lock：\n// tryEvict 遍历链表时： auto\u0026amp; lock = item_locks.emplace_back(node-\u0026gt;mtx_, std::try_to_lock); if (!lock.owns_lock()) { item_locks.pop_back(); continue; // 这个节点正在被访问，跳过它，找下一个候选 } 效果：驱逐线程永远不会阻塞业务查询线程。驱逐时遇到\u0026quot;锁被业务线程持有\u0026quot;的节点，直接跳过找下一个候选，而不是等待。最坏情况下驱逐线程跳过很多节点，但它绝不会让正在服务的查询变慢。\nstd::shared_mutex 读写锁：对于 pin_count 的检查（读操作），使用 shared_lock，多个驱逐线程可以同时读取状态而不互相阻塞；只有状态转换（写操作）才使用 unique_lock。\n演变 6：数据加载策略 BusTub（硬编码 DiskManager）：\n// DiskScheduler 只会调用 DiskManager 的两个函数 disk_manager_-\u0026gt;WritePage(request-\u0026gt;page_id_, request-\u0026gt;data_); disk_manager_-\u0026gt;ReadPage(request-\u0026gt;page_id_, request-\u0026gt;data_); 加载逻辑完全固定：从本地磁盘按页 ID 读写 4KB 数据。无法扩展到其他存储介质或数据格式。\nMilvus（策略模式 Translator）：\n// 纯虚接口：业务代码实现\u0026#34;如何加载\u0026#34;，框架控制\u0026#34;何时加载\u0026#34; template \u0026lt;typename CellT\u0026gt; class Translator { public: // 告诉框架：总共有多少个 Cell virtual size_t num_cells() const = 0; // 告诉框架：给定行号，对应哪个 Cell virtual cid_t cell_id_of(uid_t uid) const = 0; // 告诉框架：这个 Cell 有多大（加载前和加载中的估算） virtual std::pair\u0026lt;ResourceUsage, ResourceUsage\u0026gt; estimated_byte_size_of_cell(cid_t cid) const = 0; // 真正执行加载：框架在需要时调用，业务代码决定从哪里、怎么加载 virtual std::vector\u0026lt;std::pair\u0026lt;cid_t, std::unique_ptr\u0026lt;CellT\u0026gt;\u0026gt;\u0026gt; get_cells(OpContext* ctx, const std::vector\u0026lt;cid_t\u0026gt;\u0026amp; cids) = 0; }; 为什么这个设计优雅？\n对比一下：\nBusTub 的思路： 框架：我来读磁盘 业务：我也要读磁盘？那改框架代码吧 Milvus 的思路： 框架：我来决定什么时候加载、加载多少、何时驱逐 业务：我来决定如何加载（从 S3？本地？Parquet 格式？向量索引格式？） 这是控制反转（IoC）：框架不调用业务代码的具体逻辑，而是提供接口，让业务代码来\u0026quot;填空\u0026quot;。\n实际上，Milvus 中不同数据类型有不同的 Translator：\nChunkTranslator：加载 Parquet 列数据（标量字段） VectorIndexTranslator：加载 HNSW/IVF 向量索引 InvertedIndexTranslator：加载倒排索引（用于标量过滤） 三者共享同一个 CachingLayer 框架，只需各自实现 Translator 接口。\n可选的预取（Bonus Cells）：\n// Translator 可以声明\u0026#34;如果要加载这些 cells，顺便也把相邻的也加进来\u0026#34; virtual std::vector\u0026lt;cid_t\u0026gt; bonus_cells_to_be_loaded(const std::vector\u0026lt;cid_t\u0026gt;\u0026amp; cids) const { return {}; // 默认不预取 } 这类似于操作系统的预读（Read-Ahead）：顺序读取时提前加载后续页面，利用局部性原理减少未来的 I/O。BusTub 没有这个概念，因为教学环境通常不考虑 I/O 优化。\n演变 7：Pin 机制 BusTub（手动管理，Guard 辅助）：\n// BusTub 的 PageGuard 析构时 unpin class ReadPageGuard { ~ReadPageGuard() { if (frame_ != nullptr) { --frame_-\u0026gt;pin_count_; if (frame_-\u0026gt;pin_count_ == 0) { replacer_-\u0026gt;SetEvictable(frame_id_, true); } } } }; 虽然用了 RAII（Guard 析构时自动 unpin），但 pin 的生命周期和 Guard 对象绑定。如果上层代码把 Guard 存进一个容器，或者在不同线程之间传递，很容易出现生命周期管理错误。\nMilvus（NodePin + PinWrapper 双层 RAII）：\n// 第一层：NodePin 管理单个 Cell 的 pin 状态 class NodePin { ListNode* node_; public: NodePin(NodePin\u0026amp;\u0026amp;) noexcept; // 只允许移动，不允许复制 NodePin(const NodePin\u0026amp;) = delete; // 复制 = pin_count 计数错误 ~NodePin() { if (node_) node_-\u0026gt;unpin(); } // 析构自动 unpin }; 为什么禁止复制？如果允许复制，两个 NodePin 对象指向同一个节点，析构两次，pin_count 会减 2 而不是 1，导致计数错误，节点可能被提前驱逐。\n// 第二层：PinWrapper\u0026lt;T\u0026gt; 把 pin 生命周期与业务数据指针绑定 template \u0026lt;typename T\u0026gt; class PinWrapper { std::shared_ptr\u0026lt;CellAccessor\u0026lt;CellT\u0026gt;\u0026gt; ca_; // 内部持有 NodePin（集合） T data_; // 指向 Cell 内数据的指针 public: T\u0026amp; operator*() { return data_; } T* operator-\u0026gt;() { return \u0026amp;data_; } // 析构时：ca_ 析构 → NodePin 析构 → unpin() }; 使用方式：\n// 上层代码：完全感知不到 pin 机制 auto pw = column-\u0026gt;DataOfChunk(op_ctx, chunk_id); // pw 是 PinWrapper\u0026lt;const char*\u0026gt;，直接当指针用 for (int i = 0; i \u0026lt; size; i++) { process(pw[i]); } // pw 退出作用域 → 自动 unpin → Cell 可以被驱逐 对比 BusTub：BusTub 的 PageGuard 还需要上层知道\u0026quot;我在用一个 Guard\u0026quot;，PinWrapper 则把这个细节完全隐藏——上层代码只是使用一个\u0026quot;看起来像指针的东西\u0026quot;，完全不知道缓存的存在。\nskip_pin_ 快速路径：\n当某些数据被标记为\u0026quot;永远不驱逐\u0026quot;（如常驻内存的向量索引），预热完成后设置 skip_pin_ = true：\nauto future = slot-\u0026gt;PinCells(op_ctx, {cid}); // 内部逻辑： if (skip_pin_.load(std::memory_order_relaxed)) { return immediately_without_lock; // 完全绕过锁和引用计数 } 对于热路径（每秒数百万次查询），完全绕过锁操作能带来显著的性能提升。BusTub 所有操作都要加锁，没有这样的优化空间。\n演变 8：存储层次 BusTub（单层 DRAM）：\n[DRAM 帧] ↔ [本地磁盘文件] 只有两层：内存中（DRAM）或磁盘上（本地文件）。\nMilvus（多层存储支持）：\n[DRAM（CachingLayer）] ↔ [本地磁盘（CachingLayer）] ↕ ↕ [mmap 映射文件（MmapChunkManager）] ↕ [远端存储：S3 / MinIO / HDFS（通过 Translator 的 get_cells）] Milvus 有两套并行的缓存体系：\n体系一：CachingLayer（本文重点）\n管理 DRAM 中的数据 适用于需要频繁随机访问的数据（向量搜索） 支持驱逐到\u0026quot;卸载状态\u0026quot;，数据从 DRAM 中释放 体系二：MmapChunkManager\n通过 mmap(MAP_SHARED) 将文件映射到虚拟地址空间 数据\u0026quot;存在于\u0026quot;磁盘，但通过内存地址访问 OS 的缺页中断（Page Fault）机制透明地加载数据 适用于访问模式不规律、无法预知热点的数据 ResourceUsage 的双维度资源管理：\nstruct ResourceUsage { int64_t memory_bytes; // 内存维度（DRAM） int64_t file_bytes; // 磁盘维度（mmap文件） }; estimated_byte_size_of_cell() 返回 {memory_bytes, file_bytes}，框架同时管理两个维度的资源上限，而不仅仅是内存。BusTub 只有一个维度（帧数）。\n四、设计模式详解 4.1 策略模式（Strategy Pattern） 定义：定义一系列算法或行为，将它们封装起来，并使它们可以互换，使算法的变化独立于使用算法的客户端。\nBusTub 中的类比：BusTub 的 DiskManager 是\u0026quot;固定策略\u0026quot;——只能从本地文件读写，没有接口可以替换。\nMilvus 中的实现：Translator\u0026lt;CellT\u0026gt; 就是策略接口：\nCachingLayer 框架（Context） │ 使用 ▼ Translator\u0026lt;CellT\u0026gt;（Strategy 接口） ▲ 实现 ├── ChunkTranslator（从 Parquet 加载列数据） ├── VectorIndexTranslator（从文件加载向量索引） └── InvertedIndexTranslator（从文件加载倒排索引） 开闭原则：框架代码对\u0026quot;扩展\u0026quot;开放（随时可以添加新的 Translator），对\u0026quot;修改\u0026quot;关闭（添加新数据类型不需要改框架代码）。\n实际意义：Milvus 的工程师可以在不修改缓存池框架的情况下，为新的数据格式（比如未来新的索引类型）添加缓存支持，只需实现一个新的 Translator 子类。\n4.2 状态机（State Machine） 定义：用一组明确的状态和状态之间的转换规则，描述对象的生命周期，避免非法状态组合。\nBusTub 中的类比：BusTub 隐式地有状态（is_dirty_、pin_count_），但没有明确的状态机，状态逻辑分散在各处。\nMilvus 中的实现：CacheCell 有 4 个明确状态：\nNOT_LOADED ──[首次 pin()]──▶ LOADING │ [get_cells() 完成 mark_loaded()] │ ▼ LOADED ──[evictable=true 时 unpin()]──▶ CACHED │ [pin_count==0] │ NOT_LOADED ◀──[unload() 驱逐]─────────┘ 每个状态的含义：\nNOT_LOADED：数据不在内存，访问时触发加载 LOADING：某线程正在加载，其他线程等待 SharedPromise（避免重复加载） LOADED：数据在内存但不在 LRU 链表（不可驱逐的数据，如常驻索引） CACHED：数据在内存且在 LRU 链表，pin_count==0 时可以被驱逐 状态机的好处：\n防止非法转换：LOADING 状态的节点不能被驱逐（unload() 只能在 CACHED 状态调用） 并发安全：LOADING 状态作为\u0026quot;正在加载的锁\u0026quot;，后来的 pin 请求等待而不是发起新加载 清晰的语义：通过状态名就能理解 Cell 当前处于什么阶段 4.3 RAII（Resource Acquisition Is Initialization） 定义：资源的获取和释放与对象的构造和析构绑定，利用 C++ 的析构函数自动调用机制保证资源被释放。\nBusTub 中的应用：\nPageGuard：析构时 unpin std::lock_guard / std::unique_lock：析构时解锁 Milvus 中的扩展：\n// NodePin：管理 pin_count 的 RAII 守卫 NodePin::~NodePin() { if (node_) node_-\u0026gt;unpin(); // 自动减少 pin_count } // folly::makeGuard：管理\u0026#34;加载中资源预约\u0026#34;的 RAII 守卫 auto defer_release = folly::makeGuard([this, \u0026amp;resource]() { dlist_-\u0026gt;ReleaseLoadingResource(resource); // 无论成功还是异常，都释放预约 }); // MmapChunkDescriptorPtr：shared_ptr + 自定义 Deleter // 引用计数归零时自动调用 UnRegister，释放所有关联的 mmap block folly::makeGuard 的威力：\n// RunLoad 的简化版： void RunLoad() { // 第一步：预约资源（如果函数任何地方抛异常，预约也要释放） dlist_-\u0026gt;ReserveLoadingResource(size); auto defer = folly::makeGuard([\u0026amp;]() { dlist_-\u0026gt;ReleaseLoadingResource(size); }); // 第二步：实际 I/O（可能抛出异常） auto data = translator_-\u0026gt;get_cells(ctx, cids); // 第三步：标记加载完成 cell-\u0026gt;mark_loaded(data); // defer 析构时释放\u0026#34;加载中\u0026#34;资源（切换到\u0026#34;已加载\u0026#34;计费） // 无论第二步是否抛异常，都会执行 } 这在 BusTub 中是手动处理的，工程师容易忘记在异常路径上清理资源。\n4.4 Promise-Future 演进：从同步到异步 BusTub：std::promise / std::future（一对一，同步等待）\nstd::promise\u0026lt;bool\u0026gt; ──set_value()──\u0026gt; std::future\u0026lt;bool\u0026gt; ──get()─\u0026gt; 阻塞等待 限制：\n一个 promise 只能对应一个 future get() 必须阻塞 Milvus：folly::SharedPromise / folly::SemiFuture（一对多，可组合）\nfolly::SharedPromise\u0026lt;NodePin\u0026gt; ├── getSemiFuture() → SemiFuture 1（线程A等待） ├── getSemiFuture() → SemiFuture 2（线程B等待） └── getSemiFuture() → SemiFuture 3（线程C等待） ↓ 加载完成 setValue(NodePin) → 同时 fulfill 所有 SemiFuture SemiFuture vs Future：\nFuture：绑定到特定的 Executor（执行器），会立即调度 SemiFuture：延迟的 Future，必须显式绑定 Executor 才会执行 SemiInlineGet(future) = 将 SemiFuture 绑定到当前线程的内联执行器，等价于在当前线程同步等待——这是当前 Milvus 的过渡方案，将来全异步化后可以去掉这个调用，真正实现非阻塞 pin。\nfolly::collect(futures)：\n// 等待多个 SemiFuture 全部完成 auto all_pins = SemiInlineGet(folly::collect(std::move(futures))); // 等价于：对每个 future 调用 get()，但 folly 的实现更高效 这让 Milvus 可以批量 pin 多个 Cell，一次等待所有加载完成，比一个个等待更高效。\n4.5 双水位控制（Hysteresis Control） 这是控制论中的概念，中文叫\u0026quot;滞回控制\u0026quot;或\u0026quot;迟滞控制\u0026quot;。\n问题：如果阈值只有一个值，系统会在阈值附近频繁振荡（开 → 关 → 开 → 关\u0026hellip;）。\n解决方案：设置两个阈值，触发条件和停止条件不同：\n日常生活的例子： 冰箱压缩机：温度 \u0026gt; 8°C 时开启，温度 \u0026lt; 4°C 时关闭 （不是\u0026#34;超过 6°C 开，低于 6°C 关\u0026#34;） Milvus 缓存驱逐： 使用量 \u0026gt; high_watermark 时触发驱逐，使用量 \u0026lt; low_watermark 时停止驱逐 WaitingRequest 等待队列：\n当资源不足时，加载请求不会立即失败，而是进入等待队列：\nstruct WaitingRequest { ResourceUsage needed; // 需要多少资源 steady_clock::time_point deadline; // 超时时间 folly::Promise\u0026lt;void\u0026gt; promise; // 资源就绪时 fulfill }; // 优先级规则： // 1. deadline 越早 → 优先级越高（快超时的先处理） // 2. needed 越小 → 优先级越高（小请求更容易被满足） 当驱逐释放了资源，handleWaitingRequests() 被调用，按优先级唤醒等待的请求。\n4.6 Arena 分配器与对象池 背景：new/delete 每次都要向操作系统申请/释放内存，涉及系统调用和内存碎片整理，开销不小。\nMmapBlock 的 Bump Allocator（线性分配器）：\nvoid* MmapBlock::Get(const uint64_t size) { if (file_size_ - offset_.load() \u0026lt; size) return nullptr; return (void*)(addr_ + offset_.fetch_add(size)); // 原子加法，O(1) 分配 } 这是最简单的 Arena 分配器：\n从一大块预分配的内存中线性分配，offset_ 单调递增 分配 O(1)，但不支持单独释放单个对象 整个 Arena（MmapBlock）在 Descriptor 生命周期结束时一次性释放 适用场景：同一个 Segment 的数据生命周期一致（Segment 加载时分配，Segment 卸载时一起释放），非常适合 Arena 分配。\nMmapBlock 对象池：\n固定大小的 MmapBlock（小于 fix_file_size 的数据）用完后不销毁，而是重置后放回池子：\nvoid MmapBlocksHandler::returnBlock(MmapBlockPtr block) { block-\u0026gt;Reset(); // 重置 offset_=0，清空数据 if (fix_size_blocks_cache_.size() \u0026lt; pool_capacity) { fix_size_blocks_cache_.push(std::move(block)); // 放回池子 } // 否则超出池子容量，直接销毁（munmap） } 避免的开销：mmap 和 munmap 是系统调用，延迟在微秒级别。对象池通过复用已有的内存映射，避免了反复的系统调用。\n五、Milvus CachingLayer 逐组件讲解 现在用 BusTub 的视角来解读 Milvus 的每个组件：\n5.1 Manager（对应 BusTub 的 BufferPoolManager 的\u0026quot;控制中心\u0026quot;） class Manager { std::shared_ptr\u0026lt;internal::DList\u0026gt; dlist_; // 全局 LRU 链表（所有 CacheSlot 共享） shared_ptr\u0026lt;CPUThreadPoolExecutor\u0026gt; prefetch_pool_; // 预取线程池 bool eviction_enabled_; // 是否启用驱逐 chrono::milliseconds loading_timeout_; // 加载超时 }; 类比：\nBusTub 的 BufferPoolManager 管理所有帧，是单一的中心 Milvus 的 Manager 是单例，管理全局的驱逐策略，但具体的数据管理分散到各个 CacheSlot std::call_once 单例初始化：\nstatic void Manager::ConfigureTieredStorage(...) { static std::once_flag init_flag; std::call_once(init_flag, [\u0026amp;]() { // 全局只初始化一次，即使多线程同时调用 instance_.dlist_ = std::make_shared\u0026lt;DList\u0026gt;(...); }); } std::call_once 保证在多线程环境下，初始化代码只执行一次。比 if (!initialized) { mutex.lock(); if (!initialized) { init(); initialized = true; } mutex.unlock(); } 的双重检查锁（DCLP）更简洁安全。\n5.2 CacheSlot（对应 BusTub 的 BufferPoolManager 的\u0026quot;每列数据管理单元\u0026quot;） BusTub：一个 BufferPoolManager 管理所有页。\nMilvus：每个列（Column）有自己的 CacheSlot，管理该列所有 Cell：\nSegment（分片） ├── Column \u0026#34;id\u0026#34; → CacheSlot\u0026lt;Chunk\u0026gt;（管理 id 列的所有 RowGroup） ├── Column \u0026#34;vector\u0026#34; → CacheSlot\u0026lt;VectorIndex\u0026gt;（管理向量索引分片） └── Column \u0026#34;tag\u0026#34; → CacheSlot\u0026lt;Chunk\u0026gt;（管理 tag 列的所有 RowGroup） 好处：不同列可以有不同的预热策略、不同的驱逐优先级，而不是所有数据共用一套策略。\nCacheSlot 的关键状态变量：\ntemplate \u0026lt;typename CellT\u0026gt; class CacheSlot { std::unique_ptr\u0026lt;Translator\u0026lt;CellT\u0026gt;\u0026gt; translator_; // 知道如何加载 std::vector\u0026lt;CacheCell\u0026gt; cells_; // 所有 Cell std::atomic\u0026lt;bool\u0026gt; skip_pin_{false}; // 快速路径开关 bool evictable_; // 是否允许驱逐 }; 5.3 CacheCell（对应 BusTub 的 FrameHeader） BusTub 的 FrameHeader：\nclass FrameHeader { page_id_t page_id_; // 存放的页 ID std::atomic\u0026lt;size_t\u0026gt; pin_count_; bool is_dirty_; std::vector\u0026lt;char\u0026gt; data_; // 4KB 数据 }; Milvus 的 CacheCell（ListNode 的子类）：\nclass CacheCell : public ListNode { std::unique_ptr\u0026lt;CellT\u0026gt; cell_; // 实际数据（任意类型，不是固定4KB） ResourceUsage loaded_size_; // 实际占用资源（内存+磁盘双维度） State state_; // 状态机：NOT_LOADED/LOADING/LOADED/CACHED std::atomic\u0026lt;int\u0026gt; pin_count_{0}; // 引用计数（类似 FrameHeader 的 pin_count_） folly::SharedPromise\u0026lt;NodePin\u0026gt; loading_promise_; // 加载中等待用 }; 关键区别：\nBusTub 的 FrameHeader 总是有数据（data_），Milvus 的 CacheCell 可能没数据（NOT_LOADED） BusTub 的 pin_count_ 是个数字，Milvus 额外维护了 state_ 状态机区分\u0026quot;已加载未在LRU\u0026quot;和\u0026quot;已加载在LRU\u0026quot; BusTub 没有 loading_promise_，因为不需要处理多线程等待同一个数据加载 5.4 DList（对应 BusTub 的 LRUKReplacer） BusTub 的 LRUKReplacer：\nclass LRUKReplacer { std::list\u0026lt;frame_id_t\u0026gt; history_list_; // 访问不足k次 std::list\u0026lt;frame_id_t\u0026gt; cache_list_; // 访问满k次 std::mutex latch_; // 一把锁保护所有 }; Milvus 的 DList：\nclass DList { std::list\u0026lt;ListNode*\u0026gt; lru_list_; // 单一 LRU 链表 std::mutex list_mtx_; // 链表操作的锁 std::atomic\u0026lt;ResourceUsage\u0026gt; total_loaded_size_; // 已加载资源 std::atomic\u0026lt;ResourceUsage\u0026gt; total_loading_size_; // 加载中预约资源 std::atomic\u0026lt;ResourceUsage\u0026gt; evictable_size_; // 可驱逐资源 ResourceUsage low_watermark_; ResourceUsage high_watermark_; ResourceUsage max_resource_limit_; std::priority_queue\u0026lt;WaitingRequest\u0026gt; waiting_requests_; // 等待队列 }; 两者对比：\n功能 BusTub LRUKReplacer Milvus DList 驱逐算法 LRU-K 双队列 LRU 单队列 + Touch Window 资源计量 帧数（隐式） 字节数（显式，双维度） 等待机制 无（驱逐失败返回 nullopt） 有（WaitingRequest 队列） 水位控制 无（超帧数就失败） 双水位（低/高水位） 锁策略 单锁保护所有 链表锁 + 节点读写锁分层 驱逐并发性 阻塞式 try_lock 非阻塞 5.5 NodePin + PinWrapper（对应 BusTub 的 PageGuard） BusTub 的 ReadPageGuard：\nclass ReadPageGuard { page_id_t page_id_; std::shared_ptr\u0026lt;FrameHeader\u0026gt; frame_; // 析构时 unpin + SetEvictable(true) }; Milvus 的双层封装：\n// 底层：NodePin 管理单个 Cell 的 pin（不可复制，只能移动） NodePin pin = cell-\u0026gt;acquire_pin(); // pin_count++ // NodePin 析构 → pin_count-- → 可能插回 LRU // 中层：CellAccessor 管理一批 Cell 的 NodePin 集合 class CellAccessor { std::vector\u0026lt;NodePin\u0026gt; pins_; // 对应一次 PinCells 请求中所有 Cell 的 pin CellT* get_cell_of(cid_t cid); }; // 上层：PinWrapper\u0026lt;T\u0026gt; 对用户暴露业务数据指针 PinWrapper\u0026lt;const char*\u0026gt; pw = slot-\u0026gt;PinCells(op_ctx, {cid}); const char* data = *pw; // 直接访问数据，不感知缓存 层次设计的目的：\nNodePin：最细粒度，确保 pin 计数安全 CellAccessor：批量管理多个 Cell 的 pin（一次查询可能跨多个 Cell） PinWrapper\u0026lt;T\u0026gt;：对业务代码隐藏缓存概念，像普通指针一样使用 六、总结：工业缓存池的设计哲学 6.1 演变脉络回顾 BusTub（教学） Milvus（工业） ───────────────────────────────────────────────────────── 固定4KB页 → 可变粒度Cell，由业务决定大小 LRU-K 双队列 → LRU + Touch Window，减少锁竞争 同步阻塞 std::future → 异步 folly::SemiFuture + SharedPromise 隐式帧数限制 → 显式资源预约 + 双水位驱逐 全局大锁 bpm_latch_ → 分层细粒度锁 + try_lock 无阻塞驱逐 硬编码 DiskManager → 策略模式 Translator，可插拔加载逻辑 手动 pin/unpin → 双层 RAII（NodePin + PinWrapper） 单维度 DRAM → 内存 + mmap 双轨，双维度资源计量 无物理内存感知 → 周期检测物理内存压力 6.2 核心设计原则 原则一：框架控制调度，业务实现加载\nBusTub 把\u0026quot;何时读磁盘\u0026quot;和\u0026quot;读什么\u0026quot;混在一起。Milvus 把\u0026quot;何时加载、何时驱逐、资源如何分配\u0026quot;全部放在框架，把\u0026quot;数据在哪里、如何解析\u0026quot;交给业务的 Translator。这是关注点分离（Separation of Concerns）的体现。\n原则二：保守优先于激进\n\u0026ldquo;先预约，再加载\u0026quot;比\u0026quot;先加载，再检查\u0026quot;安全得多。宁愿有时候预约了但没用到（浪费一点预约空间），也不能超出内存限制导致 OOM。生产系统要优先保证稳定性。\n原则三：热路径上的每一行代码都值得优化\nTouch Window：热点 Cell 每次 unpin 都要更新 LRU，用时间窗口减少锁竞争 skip_pin_：常驻内存的数据完全绕过 pin/unpin 路径 try_lock：驱逐绝不阻塞查询 在数据库系统中，\u0026ldquo;热路径\u0026rdquo;（一个查询中被调用最频繁的路径）的延迟直接影响 P99 响应时间。\n原则四：并发安全是最高优先级\nBusTub 用全局锁保证安全，简单但性能差。Milvus 使用：\n细粒度锁（每个 Cell 一把锁） 读写锁（读多写少的状态查询） 原子操作（ResourceUsage 的 CAS 更新） 无锁数据结构（skip_pin_ 的 std::atomic\u0026lt;bool\u0026gt;） 锁序约定（避免死锁） 6.3 CachingLayer 的本质 CachingLayer 本质上是对操作系统 Page Cache 的用户态模拟与增强。\n操作系统的 Page Cache 很好，但它：\n不区分向量索引和普通数据的重要性 不知道哪些数据属于同一个查询（无法按查询粒度管理生命周期） 不能感知应用层的工作负载模式（全量预热 vs 按需加载） Milvus 的 CachingLayer 在用户态精确复现了 OS Page Cache 的功能，并在此基础上增加了：\n按查询语义的 pin/unpin（而不是按内存页的引用计数） 业务感知的驱逐策略（向量场景 vs 标量场景不同预热） 可配置的资源限制（内存限制 + 磁盘限制双维度） 代价是增加了实现复杂度（约数千行代码 vs 几百行的 BusTub），换来了对向量数据库工作负载的精准适配。\n","permalink":"https://yinit.github.io/bustub-bufferpool/","summary":"从 CMU 15445 BusTub Project1 的实现经历出发，深入解析缓存池设计，并对比 Milvus 工业级缓存池实现","title":"Bustub BufferPool"},{"content":" 前言 Hugo 是纯静态博客，本身不具备评论功能，需要借助第三方评论系统。\n最早我尝试自建 Artalk，在本地跑起来后，发现 GitHub Pages 强制走 HTTPS，而自建后端没有 SSL 证书，Mixed Content 导致评论直接加载失败。随后折腾了域名和 Let\u0026rsquo;s Encrypt 证书，结果京东云服务器没有备案，HTTPS 请求全部被拦截，彻底宣告失败。\n兜兜转转，最终选择了 Giscus——基于 GitHub Discussions 的评论系统，零运维、完全免费，与 GitHub Pages 天然契合，非常适合技术博客使用。\n评论系统选型 方案 是否需要服务器 是否需要 GitHub 账号 数据存储 Artalk ✅ 需要 ❌ 自建数据库 Utterances ❌ ✅ GitHub Issues Giscus ❌ ✅ GitHub Discussions Twikoo 可选（Vercel） ❌ MongoDB / LeanCloud Waline 可选（Vercel） ❌ LeanCloud 等 我的博客受众主要是开发者，使用 GitHub 账号登录评论完全合理，因此 Giscus 是最合适的选择。\nGiscus 工作原理 Giscus 利用 GitHub Discussions API 将评论存储在仓库的 Discussions 中。每次加载文章页面时，前端脚本会通过 pathname 映射到对应的 Discussion 条目并渲染评论，用户使用 GitHub 账号授权后即可留言。\n配置 Giscus 第一步：开启 GitHub Discussions 进入博客仓库 → Settings → Features，勾选 Discussions。\n第二步：获取仓库 ID 和分类 ID 访问 giscus.app，填入仓库名（如 username/username.github.io），Discussion 分类建议选 Announcements（仅维护者可新建，防止滥用）。配置完成后，页面底部会生成如下脚本，其中 data-repo-id 和 data-category-id 是后续需要用到的关键值：\n\u0026lt;script src=\u0026#34;https://giscus.app/client.js\u0026#34; data-repo=\u0026#34;username/username.github.io\u0026#34; data-repo-id=\u0026#34;R_kgD...\u0026#34; data-category=\u0026#34;Announcements\u0026#34; data-category-id=\u0026#34;DIC_kwD...\u0026#34; ...\u0026gt; \u0026lt;/script\u0026gt; 第三步：在 Hugo 中集成 新建 layouts/partials/giscus.html，将 Giscus 脚本封装为 Hugo partial，并通过 config.yaml 中的参数注入 ID，便于后续维护：\n\u0026lt;script src=\u0026#34;https://giscus.app/client.js\u0026#34; data-repo=\u0026#34;{{ site.Params.giscus.repo }}\u0026#34; data-repo-id=\u0026#34;{{ site.Params.giscus.repoID }}\u0026#34; data-category=\u0026#34;{{ site.Params.giscus.category }}\u0026#34; data-category-id=\u0026#34;{{ site.Params.giscus.categoryID }}\u0026#34; data-mapping=\u0026#34;pathname\u0026#34; data-strict=\u0026#34;0\u0026#34; data-reactions-enabled=\u0026#34;1\u0026#34; data-emit-metadata=\u0026#34;0\u0026#34; data-input-position=\u0026#34;top\u0026#34; data-theme=\u0026#34;preferred_color_scheme\u0026#34; data-lang=\u0026#34;zh-CN\u0026#34; data-loading=\u0026#34;lazy\u0026#34; crossorigin=\u0026#34;anonymous\u0026#34; async\u0026gt; \u0026lt;/script\u0026gt; 其中两个关键属性说明：\ndata-input-position=\u0026quot;top\u0026quot;：评论输入框显示在评论列表上方，体验更顺手 data-loading=\u0026quot;lazy\u0026quot;：懒加载，仅当用户滚动到评论区附近时才加载 iframe，减少页面初始开销 第四步：同步亮/暗主题 PaperMod 支持亮暗模式切换，需要监听主题切换事件并通知 Giscus iframe 同步。由于开启了懒加载，iframe 插入 DOM 的时机不确定，因此使用 MutationObserver 等待 iframe 出现后再绑定事件：\nfunction setGiscusTheme(theme) { const iframe = document.querySelector(\u0026#39;iframe.giscus-frame\u0026#39;); if (!iframe) return; iframe.contentWindow.postMessage( { giscus: { setConfig: { theme } } }, \u0026#39;https://giscus.app\u0026#39; ); } function watchThemeToggle() { const themeToggle = document.getElementById(\u0026#39;theme-toggle\u0026#39;); if (!themeToggle) return; themeToggle.addEventListener(\u0026#39;click\u0026#39;, () =\u0026gt; { setTimeout(() =\u0026gt; { const isDark = document.body.classList.contains(\u0026#39;dark\u0026#39;); setGiscusTheme(isDark ? \u0026#39;dark\u0026#39; : \u0026#39;light\u0026#39;); }, 300); }); } const observer = new MutationObserver(() =\u0026gt; { if (document.querySelector(\u0026#39;iframe.giscus-frame\u0026#39;)) { observer.disconnect(); watchThemeToggle(); } }); observer.observe(document.body, { childList: true, subtree: true }); 第五步：更新 comments.html 修改 layouts/partials/comments.html，将原来调用的 artalk.html 改为 giscus.html：\n{{ if or .Params.comments .Site.Params.comments }} \u0026lt;div class=\u0026#34;article-comments\u0026#34;\u0026gt;{{- partial \u0026#34;giscus.html\u0026#34; .}}\u0026lt;/div\u0026gt; {{ end }} 第六步：填写配置并开启评论 在 config.yaml 中填入对应参数：\nparams: comments: true giscus: repo: \u0026#34;username/username.github.io\u0026#34; repoID: \u0026#34;R_kgD...\u0026#34; # 从 giscus.app 获取 category: \u0026#34;Announcements\u0026#34; categoryID: \u0026#34;DIC_kwD...\u0026#34; # 从 giscus.app 获取 注意每篇文章的 front matter 中如果有 comments: false，会覆盖全局配置，需要一并改为 comments: true。\n参考资料 Giscus 官网 GitHub Discussions 文档 ","permalink":"https://yinit.github.io/%E5%8D%9A%E5%AE%A2%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/","summary":"Hugo 博客评论系统搭建全记录：从 Artalk 自建后端折腾到最终迁移 Giscus","title":"博客评论系统"},{"content":" 开发环境 Ubuntu22.04 京东云2h4g服务器 Hugo version: 0.141(下载的时候没注意，直接就下了最新版了) PaperMod version: 2025-01-22最新版本(git安装的) 相关文档 官方文档 Hugo中文文档 PaperMod GitHub官网 参考文章 Hugo PaperMod 主题精装修 我是如何建立自己的个人博客的？ Hugo-papermod主题的优化记录 PaperMod主题配置 开始 hugo安装 # 从github下载需要版本的hugo wget https://github.com/gohugoio/hugo/releases/download/v0.141.0/hugo_extended_0.141.0_Linux-64bit.tar.gz # 解压 tar -xvzf hugo_extended_0.141.0_Linux-64bit.tar.gz # 移动hugo到/usr/local/bin/ sudo mv hugo /usr/local/bin/ # 查看是否安装成功 hugo version 安装主题 我使用的是PaperMod主题，在PaperMod的基础上进行了一些魔改，参考这个网站，PaperMod下载按官网流程即可\n# 配置文件用yaml，别问为什么，都是这样推荐的，能用就行 hugo new site MyFreshWebsite --format yaml # replace MyFreshWebsite with name of your website cd MyFreshWebsite # 初始化git git init # 安装PaperMod git clone https://github.com/adityatelange/hugo-PaperMod themes/PaperMod --depth=1 # 这部分应该是在git仓库里建了一个子仓库，方便从github更新PaperMod，我觉得没啥必要，更新的情况太少，能跑够用就行了，需要的话手动更新就行了 cd themes/PaperMod git pull cd ../.. git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod git submodule update --init --recursive # needed when you reclone your repo (submodules may not get cloned automatically) git submodule update --remote --merge # 这部分记不太清了，不搞明白有什么作用 hugo mod init YOUR_OWN_GIT_REPOSITORY 配置文件 新版配置文件名称默认为hugo.yaml\n参考的其他人的介绍的配置文件，这个注释较多就用这个了，请根据需要修改\n主页显示我用的profileMode，这个好看点，默认和文章界面重复了\n# 起始 URL（换成您自己的域名） baseURL: \u0026#39;https://hugo-start.pages.dev\u0026#39; # 网站标题 title: \u0026#39;Hugo Start\u0026#39; # 每页显示的文章数量 paginate: 5 # 主题名称 theme: PaperMod # 语言代码（zh-简体中文） languageCode: \u0026#39;zh\u0026#39; DefaultContentLanguage: \u0026#39;zh\u0026#39; # 是否有 CJK 语言（中-日-韩） hasCJKLanguage: true # 是否生成 robots.txt enableRobotsTXT: true # 是否构建草稿 buildDrafts: false # 是否构建未来的文章 buildFuture: false # 是否构建过期的文章 buildExpired: false # 是否启用 Emoji enableEmoji: true # 是否启用 Git 信息 enableGitInfo: false # Google Analytics ID googleAnalytics: \u0026#39;\u0026#39; # 压缩输出静态文件 minify: # 是否不压缩 XML 文件 disableXML: true minifyOutput: true # 全局配置 params: env: production # 网站标题 title: \u0026#39;Hugo Start\u0026#39; # 网站描述 description: \u0026#39;Hugo Start with PaperMod\u0026#39; # 网站关键词（大部分搜索引擎已放弃，可注释掉） # keywords: [Blog, Portfolio, PaperMod] # 网站作者 author: \u0026#39;Your Name\u0026#39; # 多个作者写法 # author: [\u0026#34;Me\u0026#34;, \u0026#34;You\u0026#34;] # OpenGraph / Twitter Card 预览图片（/static 下面的文件名称） images: [\u0026#39;opengraph.webp\u0026#39;] # 日期格式 DateFormat: \u0026#39;2006-01-02\u0026#39; # 默认主题 defaultTheme: auto # dark, light # 是否启用主题切换按钮 disableThemeToggle: false # 是否启用阅读时间展示 ShowReadingTime: true # 是都启用分享按钮 ShowShareButtons: true ShowPostNavLinks: true # 是否启用面包屑导航 ShowBreadCrumbs: true # 是否显示代码复制按钮 ShowCodeCopyButtons: false # 是否显示字数统计 ShowWordCount: true # 是否在页面显示 RSS 按钮 ShowRssButtonInSectionTermList: true UseHugoToc: true disableSpecial1stPost: false # 是否禁用首页滚动到顶部 disableScrollToTop: false # 是否启用评论系统 comments: false # 是否隐藏 Meta 信息 hidemeta: false # 是否隐藏文章摘要 hideSummary: false # 是否显示目录 showtoc: false # 是否默认展开文章目录 tocopen: false assets: # disableHLJS: true # to disable highlight.js # disableFingerprinting: true # 网站 Favicon 图标相关信息 # 可在 https://realfavicongenerator.net/ 生成 # 将图片复制到 /static 目录下 # 然后修改下面代码中的文件名 favicon: \u0026#39;\u0026lt;link / abs url\u0026gt;\u0026#39; favicon16x16: \u0026#39;\u0026lt;link / abs url\u0026gt;\u0026#39; favicon32x32: \u0026#39;\u0026lt;link / abs url\u0026gt;\u0026#39; apple_touch_icon: \u0026#39;\u0026lt;link / abs url\u0026gt;\u0026#39; safari_pinned_tab: \u0026#39;\u0026lt;link / abs url\u0026gt;\u0026#39; label: # 使用文本替代 Logo 标签 text: \u0026#39;Hugo Start\u0026#39; # 网站 Logo 图片（/static 下面的文件名称） icon: /apple-touch-icon.png # 图标高度 iconHeight: 35 # 主页展示模式 # 个人信息模式 profileMode: enabled: false # needs to be explicitly set title: ExampleSite subtitle: \u0026#39;This is subtitle\u0026#39; imageUrl: \u0026#39;\u0026lt;img location\u0026gt;\u0026#39; imageWidth: 120 imageHeight: 120 imageTitle: my image buttons: - name: Posts url: posts - name: Tags url: tags # 主页 - 信息模式（默认） homeInfoParams: Title: \u0026#34;Hi there \\U0001F44B\u0026#34; Content: Welcome to hugo start, this is a example of Hugo and PaperMod # 主页 - 信息模式 图标展示 socialIcons: # - name: twitter # url: \u0026#34;https://twitter.com/\u0026#34; # - name: stackoverflow # url: \u0026#34;https://stackoverflow.com\u0026#34; - name: github url: \u0026#39;https://github.com/DejavuMoe/hugo-start\u0026#39; - name: mastodon url: \u0026#39;https://sink.love/@dejavu\u0026#39; # 站长验证 analytics: google: SiteVerificationTag: \u0026#39;\u0026#39; bing: SiteVerificationTag: \u0026#39;\u0026#39; yandex: SiteVerificationTag: \u0026#39;\u0026#39; # 文章封面设置 cover: hidden: true # hide everywhere but not in structured data hiddenInList: true # hide on list pages and home hiddenInSingle: true # hide on single page # 关联编辑 editPost: URL: \u0026#39;https://github.com/DejavuMoe/hugo-start/edit/master/content/posts\u0026#39; Text: \u0026#39;Edit on GitHub\u0026#39; # edit text appendFilePath: true # to append file path to Edit link # for search # https://fusejs.io/api/options.html fuseOpts: isCaseSensitive: false shouldSort: true location: 0 distance: 1000 threshold: 0.4 minMatchCharLength: 0 keys: [\u0026#39;title\u0026#39;, \u0026#39;permalink\u0026#39;, \u0026#39;summary\u0026#39;, \u0026#39;content\u0026#39;] # 顶部导航栏 menu: main: - identifier: \u0026#39;首页\u0026#39; name: \u0026#39;首页\u0026#39; url: / weight: 1 - identifier: \u0026#39;分类\u0026#39; name: \u0026#39;分类\u0026#39; url: /categories/ weight: 10 - identifier: \u0026#39;标签\u0026#39; name: \u0026#39;标签\u0026#39; url: /tags/ weight: 20 - identifier: \u0026#39;仓库\u0026#39; name: \u0026#39;仓库\u0026#39; url: https://github.com/DejavuMoe/hugo-start weight: 30 # Read: https://github.com/adityatelange/hugo-PaperMod/wiki/FAQs#using-hugos-syntax-highlighter-chroma pygmentsUseClasses: true markup: highlight: noClasses: false # anchorLineNos: true # codeFences: true # guessSyntax: true # lineNos: true # style: monokai privacy: vimeo: disabled: true enableDNT: true simple: true twitter: disabled: true enableDNT: true # 是否启用添加“请勿跟踪” HTTP 头。 simple: true # 如果启用简单模式，将建立一个静态的、无 JS 版本的推文。 instagram: disabled: true simple: true youtube: disabled: true privacyEnhanced: true services: instagram: disableInlineCSS: true # 禁用 Hugo 提供的内联样式 twitter: disableInlineCSS: true # 禁用 Hugo 提供的内联样式 默认模板 文章创建时的默认模板，相对于config全局配置，这里是局部配置，控制文章显示的必要属性\n--- title: \u0026#34;{{ replace .Name \u0026#34;-\u0026#34; \u0026#34; \u0026#34; | title }}\u0026#34; date: {{ .Date }} lastmod: {{ .Date }} draft: true # 是否为草稿 author: [\u0026#34;Yinit\u0026#34;] categories: [] tags: [] keywords: [] description: \u0026#34;\u0026#34; # 文章描述，与搜索优化相关 summary: \u0026#34;\u0026#34; # 文章简单描述，会展示在主页 weight: # 输入1可以顶置文章，用来给文章展示排序，不填就默认按时间排序 slug: \u0026#34;\u0026#34; comments: false autoNumbering: true # 目录自动编号 hideMeta: false # 是否隐藏文章的元信息，如发布日期、作者等 mermaid: true cover: image: \u0026#34;\u0026#34; caption: \u0026#34;\u0026#34; alt: \u0026#34;\u0026#34; relative: false --- \u0026lt;!-- more --\u0026gt; Github Pages部署网站 创建GitHub远程仓库 在Github创建仓库，仓库名填写[用户名].github.io，注意[用户名]部分必须是Github用户名，否则Github Pages不会正常工作。\n勾选Add a README file，点击Create Repository，创建仓库。\n将本地仓库推送到Github 在根目录下创建.gitignore，内容如下：\npublic resources .hugo_build.lock 创建远程仓库并提交\n# [username]替换为用户名 git remote add origin git@github.com:[username]/[username].github.io.git # 提交 git add . git commit -m \u0026#34;Hugo + PaperMod\u0026#34; # 推荐本地分支和远程分支名用main，免得不必要的麻烦（github安全检查） git push -u origin main 访问github仓库，选择 Settings \u0026gt; Pages , 将Build and deployment中source设置为Github Actions\n配置Github Actions 在本地仓库中创建文件.github/workflows/hugo.yaml，根据Hugo版本修改，内容如下：\n# 用于构建和部署Hugo网站到GitHub Pages的示例工作流程 name: 发布Hugo网站到Pages on: # 在目标为默认分支的推送上运行 push: branches: - main # 允许您手动从“Actions”标签运行此工作流程 workflow_dispatch: # 设置GITHUB_TOKEN的权限，以允许部署到GitHub Pages permissions: contents: read pages: write id-token: write # 仅允许一个并发部署，跳过在进行中的运行与最新排队的运行之间排队的运行。 # 但是，请不要取消进行中的运行，因为我们希望这些生产部署能够完成。 concurrency: group: \u0026#34;pages\u0026#34; cancel-in-progress: false # 默认使用bash defaults: run: shell: bash jobs: # 构建作业 build: runs-on: ubuntu-22.04 env: HUGO_VERSION: 0.141.0 steps: - name: 安装Hugo CLI run: | wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \\ \u0026amp;\u0026amp; sudo dpkg -i ${{ runner.temp }}/hugo.deb - name: 安装Dart Sass run: sudo snap install dart-sass - name: 检出 uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - name: 设置Pages id: pages uses: actions/configure-pages@v3 - name: 安装Node.js依赖 run: \u0026#34;[[ -f package-lock.json || -f npm-shrinkwrap.json ]] \u0026amp;\u0026amp; npm ci || true\u0026#34; - name: 使用Hugo构建 env: # 为了与Hugo模块的最大向后兼容性 HUGO_ENVIRONMENT: production HUGO_ENV: production run: | hugo \\ --gc \\ --minify \\ --baseURL \u0026#34;${{ steps.pages.outputs.base_url }}/\u0026#34; - name: 上传构建产物 uses: actions/upload-pages-artifact@v2 with: path: ./public # 部署作业 deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: - name: 部署到GitHub Pages id: deployment uses: actions/deploy-pages@v2 提交，推送至远程仓库\ngit add . git commit -m \u0026#34;Add workflow\u0026#34; git push 未完成 评论系统 目前选择的是artalk作为评论系统，但是目前还存在问题，这是当前进度。\n图床 随着文章数量增多，图片将会越来越多，而github仓库有大小上限，将图片放在github上是不合理的，之后会考虑构建一个图床，但是存在和评论系统同样的问题，暂时没有构建\n","permalink":"https://yinit.github.io/hugo%E4%B8%AA%E4%BA%BA%E5%8D%9A%E5%AE%A2%E6%90%AD%E5%BB%BA/","summary":"使用Hugo + PaperMod + GithubPages 搭建个人博客网站","title":"Hugo个人博客搭建"},{"content":"关于我 尹劲松，电子科技大学 2024 级研究生，计算机科学与技术专业。目前对数据库领域比较感兴趣，还在持续学习中。\n关于本站 记录一些学习过程中的收获，不定期更新。\n联系方式 联系方式就不留了（哈哈）\n","permalink":"https://yinit.github.io/about/","summary":"about","title":"🙋🏻‍♂️ 关于"}]