
在实际数据库开发或性能调优过程中我们常常会遇到一些令人困惑的现象为什么这个查询在数据量翻倍后慢了十倍为什么增加索引后写入性能急剧下降为什么内存足够数据库却频繁进行磁盘IO很多开发者习惯于将数据库视为一个“黑盒”通过调整配置参数或优化SQL语句来解决问题但这往往治标不治本。理解数据库内部的运行机制——从数据在内存和磁盘上的组织方式到查询的执行路径再到事务与并发控制——是进行深度优化和解决复杂问题的关键。然而数据库内核理论往往抽象且复杂仅通过阅读论文或文档很难形成直观感受。如果能亲手实现一些核心组件的简化版本并对其进行测量和观察那么对这些机制的理解将变得深刻而具体。这正是本文的目标我们将使用 Rust 语言一个以高性能和内存安全著称的系统编程语言来逐步构建一个微型数据库的核心组件并通过实际的基准测试来量化不同设计决策带来的性能影响。通过这种“实现-测量-理解”的方式你将不再仅仅是一个数据库的使用者更能洞察其内部运作的奥秘从而在未来的工作中做出更明智的技术决策。本文适合有一定 Rust 基础并对数据库原理感兴趣的开发者。我们将从最基础的数据存储格式开始逐步深入到索引、事务和并发控制。每个章节都包含可运行的代码示例、性能对比数据以及背后的原理分析。1. 理解数据库存储引擎的核心从内存到磁盘数据库系统最根本的任务是持久化存储数据并高效地检索它们。这一切的起点就是存储引擎如何组织数据。一个常见的误区是认为数据库只是将数据一行行地写入文件。实际上为了平衡读写性能、空间利用率和持久性现代数据库采用了高度结构化的存储格式。1.1 行存 vs. 列存数据布局的哲学数据在磁盘上的排列方式主要分为两种行式存储和列式存储。行式存储将同一行的所有列值连续存放适合频繁进行整行插入和查询的 OLTP 场景。列式存储则将同一列的所有值连续存放适合需要进行大量聚合计算和只查询少数列的 OLAP 场景。为了直观感受其差异我们用 Rust 实现两种最简单的存储格式并测量其扫描性能。首先定义一个简单的数据结构Row#[derive(Debug, Clone)] struct Row { id: u32, name: String, // 假设平均长度 20 字节 value: f64, timestamp: i64, }行式存储模拟我们将多个Row序列化后连续写入一个Vecu8缓冲区模拟一个数据页。fn serialize_to_row_store(rows: [Row]) - Vecu8 { let mut buffer Vec::new(); for row in rows { buffer.extend(row.id.to_le_bytes()); buffer.extend(row.name.as_bytes()); buffer.extend([0u8; 20 - row.name.len()]); // 定长填充 buffer.extend(row.value.to_le_bytes()); buffer.extend(row.timestamp.to_le_bytes()); } buffer } // 扫描时需要按固定步长行大小遍历 buffer并反序列化整行。列式存储模拟我们将所有行的同一列数据分别集中存放。fn serialize_to_column_store(rows: [Row]) - (Vecu8, Vecu8, Vecu8, Vecu8) { let mut ids Vec::new(); let mut names Vec::new(); let mut values Vec::new(); let mut timestamps Vec::new(); for row in rows { ids.extend(row.id.to_le_bytes()); names.extend(row.name.as_bytes()); names.extend([0u8; 20 - row.name.len()]); values.extend(row.value.to_le_bytes()); timestamps.extend(row.timestamp.to_le_bytes()); } (ids, names, values, timestamps) } // 扫描时如果只查询 value 列则只需连续读取 values 这个 buffer。我们使用criterion库进行基准测试模拟一个“计算所有value列平均值”的查询。use criterion::{black_box, criterion_group, criterion_main, Criterion}; fn bench_scan(c: mut Criterion) { let data generate_test_rows(10000); // 生成1万行测试数据 let row_store serialize_to_row_store(data); let (_, _, col_values, _) serialize_to_column_store(data); c.bench_function(row_store_scan_avg, |b| { b.iter(|| { let mut sum 0.0; let row_size std::mem::size_of::u32() 20 std::mem::size_of::f64() std::mem::size_of::i64(); for chunk in row_store.chunks_exact(row_size) { // 需要跳过 id 和 name 字段定位到 value let offset std::mem::size_of::u32() 20; let value_bytes chunk[offset..offset std::mem::size_of::f64()]; let value f64::from_le_bytes(value_bytes.try_into().unwrap()); sum value; } black_box(sum / (row_store.len() / row_size) as f64); }) }); c.bench_function(column_store_scan_avg, |b| { b.iter(|| { let mut sum 0.0; for chunk in col_values.chunks_exact(std::mem::size_of::f64()) { let value f64::from_le_bytes(chunk.try_into().unwrap()); sum value; } black_box(sum / (col_values.len() / std::mem::size_of::f64()) as f64); }) }); }测量结果与解释在仅扫描单列的场景下列式存储的性能通常会显著优于行式存储。原因在于缓存友好性列式存储连续读取同一类型的数据CPU 缓存命中率极高。而行式存储读取时大量无关的id、name字段也被加载进缓存挤占了有效数据的空间。数据压缩同一列的数据类型和模式相同更容易进行高效压缩虽然我们的简单示例未实现。向量化处理现代 CPU 的 SIMD 指令集可以同时对多个同类型数据进行计算列式布局天然支持这种优化。关键结论数据布局是存储引擎设计的基石它从根本上决定了数据库擅长处理的工作负载类型。OLTP 数据库如 MySQL、PostgreSQL通常采用行存而 OLAP 数据库如 ClickHouse、Druid则采用列存。1.2 页结构数据库与磁盘交互的基本单位数据库不会以单行或单列为单位读写磁盘。磁盘 IO 的单位是扇区通常 512 字节或 4K但数据库会定义更大的逻辑单元——页Page通常 4KB、8KB 或 16KB。所有数据表数据、索引都被组织在页中。一个简单的页结构可能包含页头包含元信息如页ID、校验和、LSN日志序列号用于恢复、空闲空间起始位置等。行数据区从页尾部开始向前生长存放实际的序列化行数据。行指针区从页头部之后开始向后生长每个指针Slot指向数据区内某一行数据的起始偏移量和长度。这种间接寻址使得在页内移动行数据如填充空洞时无需更新所有引用该行的外部索引。空闲空间数据区和指针区之间的未使用区域。我们用 Rust 结构体来定义这个页const PAGE_SIZE: usize 8192; // 8KB const PAGE_HEADER_SIZE: usize 64; struct Page { id: u32, // 其他页头信息... data: [u8; PAGE_SIZE], } impl Page { fn new(id: u32) - Self { let mut data [0u8; PAGE_SIZE]; // 初始化页头例如将空闲空间起始位置设置为 PAGE_HEADER_SIZE // 将行指针区起始位置设置为 PAGE_SIZE Page { id, data } } fn insert_row(mut self, row_data: [u8]) - Optionu16 { // 1. 检查空闲空间是否足够行数据 一个新的行指针 // 2. 在行数据区尾部向前分配空间写入 row_data // 3. 在行指针区头部向后添加一个新的指针记录偏移和长度 // 4. 更新页头中的空闲空间和行指针区位置信息 // 5. 返回新行的 Slot ID todo!() } fn get_row(self, slot_id: u16) - Option[u8] { // 1. 根据 slot_id 找到对应的行指针 // 2. 从指针中解析出偏移量和长度 // 3. 返回 self.data[offset..offsetlength] todo!() } }为什么需要页结构减少磁盘 IO 次数一次性读写一个页如 8KB比多次读写几十字节的行要高效得多。空间管理页是空间分配和回收的基本单位。数据库可以跟踪哪些页是满的、哪些有空间从而高效地分配新行。并发控制的基础许多数据库的锁粒度可以到页级别。缓存的基础数据库的缓冲池Buffer Pool以页为单位在内存中缓存数据。常见坑行溢出当一行数据的大小超过页的可用空间时就会发生行溢出。不同的数据库处理方式不同有的会使用“行外存储”如 PostgreSQL 的 TOAST将大字段存到额外的页中有的则可能直接导致插入失败。在设计表结构时需要预估行宽避免单行过大导致频繁的行溢出这会严重损害性能。2. 构建高效的数据检索索引的实现与权衡如果没有索引数据库要找到满足条件的行只能进行全表扫描Sequential Scan即遍历所有页中的所有行。当数据量达到百万、千万级时这将是无法接受的。索引的核心思想是通过额外的数据结构以空间换时间快速定位到目标数据。2.1 哈希索引O(1) 查找的代价哈希索引是最直观的索引之一。它维护一个内存中的哈希表将键如id映射到数据在磁盘上的位置如(page_id, slot_id)。use std::collections::HashMap; struct HashIndex { map: HashMapu32, (u32, u16), // key - (page_id, slot_id) } impl HashIndex { fn get(self, key: u32) - Option(u32, u16) { self.map.get(key).copied() } fn insert(mut self, key: u32, location: (u32, u16)) { self.map.insert(key, location); } fn delete(mut self, key: u32) { self.map.remove(key); } }哈希索引的优缺点优点等值查询WHERE id 123速度极快接近 O(1)。缺点无法支持范围查询哈希函数打乱了键的顺序WHERE id 100 AND id 200这样的查询需要遍历所有键。内存占用哈希表通常完全驻留内存数据量巨大时内存消耗可观。哈希冲突需要处理冲突可能影响性能。动态扩展开销哈希表扩容时rehashing可能引起性能抖动。因此哈希索引适用于只有等值查询且内存充足的场景例如缓存或某些临时表。2.2 B-Tree 索引数据库的脊梁B-Tree及其变种 BTree是关系型数据库中最核心、最通用的索引结构。它保持数据有序同时通过多路平衡树的结构确保查找、插入、删除的时间复杂度稳定在 O(log n)并且能高效支持范围查询。一个 BTree 节点页通常包含是否是叶子节点的标记。一个有序的键数组。如果是内部节点则包含子节点指针数组指向其他页的ID。如果是叶子节点则包含值可能是行数据本身或指向行数据的指针数组并且叶子节点之间通过指针串联便于范围扫描。我们用简化的 Rust 代码描述其核心逻辑const FANOUT: usize 10; // 每个节点的最大子节点数/键数 enum BTreeNode { Internal { keys: Vecu32, children: Vecu32, // 子节点的页ID }, Leaf { keys: Vecu32, values: Vec(u32, u16), // 指向数据的 (page_id, slot_id) next_leaf: Optionu32, // 下一个叶子节点的页ID用于范围扫描 }, }插入键K的基本流程简化版从根节点开始找到应插入的叶子节点L。如果L有空间直接按顺序插入(K, V)。如果L已满则分裂L为L和L2将中间键提升到父节点。递归检查父节点是否需要分裂直到根节点。如果根节点分裂则树的高度增加。B-Tree 的优势自平衡始终保持树的高度较低通常 3-4 层就能存储海量数据。有序性支持高效的范围查询、排序和前缀匹配。高扇出每个节点可以有很多子节点减少了树的高度和磁盘 IO 次数。数据局部性相邻的键存储在相邻的页中顺序扫描性能好。实现 B-Tree 的常见坑并发控制多个线程同时修改 B-Tree 需要复杂的锁机制如 Latch Crabbing否则会导致数据损坏。分裂与合并的原子性分裂操作涉及多个页的修改必须通过 WALWrite-Ahead Logging保证原子性和持久性。填充因子节点不要填得太满预留空间可以减少分裂频率提升插入性能。但预留太多又会浪费空间。这是一个需要权衡的参数。2.3 测量索引性能理论 vs. 实践我们通过一个简单的基准测试来感受全表扫描、哈希索引和 B-Tree 索引在等值查询和范围查询上的差异。假设我们有 100 万条(id, value)数据id从 1 到 1,000,000。全表扫描遍历所有数据。哈希索引在内存中构建HashMapu32, usize直接定位内存数组下标模拟磁盘位置。B-Tree索引使用标准库的BTreeMapu32, usize模拟。use std::collections::{BTreeMap, HashMap}; use rand::seq::SliceRandom; fn benchmark_lookups(data: [(u32, f64)], lookups: [u32]) { // 1. 全表扫描 let start std::time::Instant::now(); for key in lookups { let _ data.iter().find(|(id, _)| *id key); } let seq_time start.elapsed(); // 2. 哈希索引 let mut hash_map HashMap::new(); for (i, (id, _)) in data.iter().enumerate() { hash_map.insert(*id, i); } let start std::time::Instant::now(); for key in lookups { let _ hash_map.get(key); } let hash_time start.elapsed(); // 3. B-Tree索引 let mut btree_map BTreeMap::new(); for (i, (id, _)) in data.iter().enumerate() { btree_map.insert(*id, i); } let start std::time::Instant::now(); for key in lookups { let _ btree_map.get(key); } let btree_time start.elapsed(); println!(顺序扫描: {:?}, seq_time); println!(哈希索引: {:?}, hash_time); println!(B-Tree索引: {:?}, btree_time); } fn benchmark_range_queries(data: [(u32, f64)], ranges: [(u32, u32)]) { // 全表扫描范围查询 let start std::time::Instant::now(); for (low, high) in ranges { let _: Vec_ data.iter().filter(|(id, _)| *id low *id high).collect(); } let seq_time start.elapsed(); // B-Tree索引范围查询 (利用有序性) let btree_map: BTreeMap_, _ data.iter().map(|(id, v)| (*id, *v)).collect(); let start std::time::Instant::now(); for (low, high) in ranges { let _: Vec_ btree_map.range(low..high).collect(); } let btree_time start.elapsed(); println!(范围查询 - 顺序扫描: {:?}, seq_time); println!(范围查询 - B-Tree索引: {:?}, btree_time); // 哈希索引不支持高效范围查询故不测试。 }预期结果分析对于等值查询哈希索引最快B-Tree 索引次之全表扫描极慢。对于范围查询B-Tree 索引依然高效对数时间复杂度而全表扫描和哈希索引需遍历所有键的性能会随数据量线性下降。这个测试清晰地展示了为什么数据库优化器会为不同的查询选择不同的执行计划。理解索引的原理是编写高效 SQL 和进行索引设计的前提。3. 保证数据正确性事务与并发控制初探当多个客户端同时读写数据库时会引发一系列问题脏读、不可重复读、幻读等。事务Transaction通过 ACID 特性来保证数据的一致性而并发控制机制是实现隔离性Isolation的关键。3.1 从最简单的锁开始读写锁最直观的并发控制是使用锁。我们可以为整个数据库、某张表、某个页甚至某行加锁。use std::sync::{RwLock, Arc}; struct SimpleTable { data: ArcRwLockVecString, } impl SimpleTable { fn read(self, id: usize) - OptionString { let guard self.data.read().unwrap(); // 获取读锁 guard.get(id).cloned() } fn write(self, id: usize, value: String) { let mut guard self.data.write().unwrap(); // 获取写锁 if id guard.len() { guard[id] value; } else { guard.push(value); } } }读写锁的问题粒度太粗锁整个表并发度极低。死锁风险如果两个事务以不同顺序请求多把锁可能形成循环等待。无法解决所有隔离性问题例如可重复读隔离级别要求在一个事务内多次读取同一范围的数据得到的结果一致简单的读写锁无法防止其他事务插入新行幻读。3.2 多版本并发控制MVCC 如何工作现代数据库如 PostgreSQL, MySQL InnoDB, Oracle广泛使用多版本并发控制来避免读写阻塞。MVCC 的核心思想是当写入数据时不直接覆盖旧数据而是创建数据的新版本。每个事务在开始时获得一个唯一的事务ID或时间戳它只能看到在该事务开始之前已提交的数据版本。我们需要扩展之前的数据行结构为其添加版本信息#[derive(Clone)] struct VersionedRow { id: u32, value: String, created_tx_id: u64, // 创建该版本的事务ID expired_tx_id: u64, // 删除或更新该版本的事务ID。0 表示仍有效。 }MVCC 下的基本操作插入新行的created_tx_id设为当前事务IDexpired_tx_id设为 0或一个极大值。更新将旧行的expired_tx_id设为当前事务ID标记为过期同时插入一条新行其created_tx_id为当前事务ID。删除将行的expired_tx_id设为当前事务ID。读取事务只能读取那些created_tx_id 当前事务ID且 (expired_tx_id 0 或expired_tx_id 当前事务ID) 的行。这意味着它能看到在它开始之前就已提交且尚未被它开始之后的事务删除或更新的数据版本。MVCC 的优势读写不阻塞读操作永远不会被写操作阻塞因为它读的是旧版本。写操作也不会被读操作阻塞除了可能更新同一行。实现快照隔离每个事务都像是在某个时间点的数据库快照上操作保证了可重复读。MVCC 的代价存储开销需要存储数据的多个版本。清理开销过期的版本没有任何活跃事务需要看到的版本需要被定期清理VACUUM。写冲突处理如果两个事务同时更新同一行后提交的事务需要处理冲突通常回滚或重试。3.3 实现一个简单的 MVCC 事务管理器我们设计一个极简的事务管理器来演示 MVCC 的核心流程。use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; struct Transaction { id: u64, snapshot: HashSetu64, // 事务开始时所有活跃事务的ID集合 status: TransactionStatus, } enum TransactionStatus { Active, Committed, Aborted, } struct MVCCStore { next_tx_id: AtomicU64, active_tx: RwLockHashMapu64, Transaction, data: RwLockHashMapu32, VecVersionedRow, // key - versions } impl MVCCStore { fn begin(self) - u64 { let tx_id self.next_tx_id.fetch_add(1, Ordering::SeqCst); let snapshot self.active_tx.read().unwrap().keys().cloned().collect(); let tx Transaction { id: tx_id, snapshot, status: TransactionStatus::Active }; self.active_tx.write().unwrap().insert(tx_id, tx); tx_id } fn read(self, key: u32, tx_id: u64) - OptionString { let data_guard self.data.read().unwrap(); if let Some(versions) data_guard.get(key) { // 找到对该事务可见的最新版本 for row in versions.iter().rev() { if row.created_tx_id tx_id !self.is_tx_in_snapshot(row.created_tx_id, tx_id) (row.expired_tx_id 0 || row.expired_tx_id tx_id || self.is_tx_in_snapshot(row.expired_tx_id, tx_id)) { return Some(row.value.clone()); } } } None } fn is_tx_in_snapshot(self, check_tx_id: u64, reader_tx_id: u64) - bool { // 简化检查 check_tx_id 是否在 reader_tx_id 的快照中即在 reader_tx_id 开始时仍在活跃 let active_tx_guard self.active_tx.read().unwrap(); if let Some(reader_tx) active_tx_guard.get(reader_tx_id) { reader_tx.snapshot.contains(check_tx_id) } else { false } } fn write(self, key: u32, value: String, tx_id: u64) - Result(), String { let mut data_guard self.data.write().unwrap(); let versions data_guard.entry(key).or_insert_with(Vec::new); // 检查写-写冲突是否有其他活跃事务修改了该key的最新版本 if let Some(latest) versions.last() { if latest.expired_tx_id 0 { // 该行仍有效 if self.is_tx_active(latest.created_tx_id) latest.created_tx_id ! tx_id { return Err(Write conflict: row modified by another active transaction.to_string()); } } } // 标记旧版本过期如果是更新 for row in versions.iter_mut() { if row.expired_tx_id 0 { row.expired_tx_id tx_id; } } // 插入新版本 versions.push(VersionedRow { id: key, value, created_tx_id: tx_id, expired_tx_id: 0, }); Ok(()) } fn commit(self, tx_id: u64) { let mut active_tx_guard self.active_tx.write().unwrap(); if let Some(tx) active_tx_guard.get_mut(tx_id) { tx.status TransactionStatus::Committed; } active_tx_guard.remove(tx_id); } fn rollback(self, tx_id: u64) { // 需要回滚该事务创建的所有版本将其标记为无效或删除 let mut data_guard self.data.write().unwrap(); for versions in data_guard.values_mut() { versions.retain(|row| row.created_tx_id ! tx_id); // 同时需要恢复被该事务标记为过期的行的 expired_tx_id如果该行之前有效 for row in versions.iter_mut() { if row.expired_tx_id tx_id { row.expired_tx_id 0; // 恢复为有效 } } } let mut active_tx_guard self.active_tx.write().unwrap(); active_tx_guard.remove(tx_id); } }这个简化实现展示了 MVCC 的核心版本链、快照隔离和写冲突检测。在生产数据库中事务管理器要复杂得多涉及死锁检测、隔离级别实现、日志记录WAL和恢复机制。4. 从原理到实践性能测量与优化启示通过前面的实现和测量我们不仅理解了概念还获得了直观的性能数据。现在让我们将这些知识串联起来形成一套数据库内核性能分析和优化的思维框架。4.1 性能问题排查清单当遇到数据库性能问题时可以按照以下层次进行排查问题现象可能的内核层面原因检查与验证思路查询缓慢点查1. 缺少合适的索引。2. 索引失效如函数操作。3. 哈希冲突严重哈希索引。4. B-Tree 深度过大。1. 使用EXPLAIN查看执行计划。2. 检查查询条件是否与索引匹配。3. 分析索引统计信息如 Cardinality。查询缓慢范围/全表扫描1. 确实需要全表扫描如无索引。2. 索引选择错误优化器误判。3. 数据页未缓存在内存中产生大量磁盘 IO。1. 评估是否可添加复合索引。2. 检查表统计信息是否过期。3. 查看缓冲池命中率。写入/更新缓慢1. 索引过多每次写入需更新多个索引。2. 事务提交过频WAL 刷盘开销。3. 锁竞争行锁、页锁、表锁。4. MVCC 版本链过长需要清理旧版本。1. 评估索引必要性。2. 考虑批量提交事务。3. 检查SHOW ENGINE INNODB STATUS中的锁信息。4. 检查长事务并定期执行VACUUM/PURGE。高并发下性能下降1. 锁争用。2. 事务冲突导致大量回滚。3. 缓冲池淘汰激烈LRU链竞争。4. 日志文件WAL写入成为瓶颈。1. 使用更细粒度的锁或乐观锁。2. 优化事务逻辑减少持有锁的时间。3. 增加缓冲池大小。4. 将日志文件放在高性能存储上。内存占用高1. 缓冲池配置过大。2. 连接数过多每个连接有私有内存。3. 排序、哈希等操作使用临时表内存。1. 监控缓冲池实际使用率。2. 优化查询减少内存临时表使用如避免SELECT *优化ORDER BY,GROUP BY。3. 合理设置连接池大小。4.2 设计最佳实践基于内核原理的决策理解了内部机制后我们在设计表结构和编写 SQL 时可以做出更明智的选择主键选择使用单调递增的整型如自增ID作为主键。在 BTree 中这能保证新数据顺序插入到索引的末尾减少页分裂和碎片。避免使用随机值如 UUID作为聚簇索引键除非经过特殊处理如时间前缀。索引设计最左前缀原则对于复合索引(a, b, c)它能加速WHERE a?、WHERE a? AND b?、WHERE a? AND b? AND c?的查询但无法加速WHERE b?。索引覆盖如果查询所需的所有列都包含在索引中数据库可以直接从索引中返回数据避免回表查询性能大幅提升。避免冗余索引(a, b)索引已经可以优化WHERE a?的查询单独的(a)索引通常是冗余的。事务设计保持事务短小尽快提交或回滚事务减少锁的持有时间和 MVCC 版本链的长度。避免在事务中执行耗时操作如网络调用、文件操作等。合理选择隔离级别在保证业务正确性的前提下选择最低的隔离级别如 Read Committed以获得更好的并发性能。数据类型选择使用最精确、最小的数据类型。INT比BIGINT省空间VARCHAR(50)比VARCHAR(255)更高效在某些存储引擎中。固定长度的列如CHAR适合完全定长或频繁更新的场景可变长度如VARCHAR通常更节省空间。4.3 扩展学习与下一步本文通过 Rust 实现和测量揭开了数据库存储、索引和事务并发控制的神秘面纱。但这仅仅是开始。要深入理解数据库内核还可以继续探索以下方向持久化与恢复深入研究 Write-Ahead Logging (WAL) 和 ARIES 恢复算法。实现一个简单的 WAL确保在任何崩溃后数据都能恢复到一致状态。查询优化与执行实现一个简单的 SQL 解析器、查询优化器基于规则的或基于成本的和执行引擎火山模型或向量化模型。分布式数据库核心了解分布式事务如 2PC、3PC、一致性协议如 Raft、Paxos和数据分片Sharding策略。现代存储硬件的影响SSD、NVMe、持久内存PMEM如何改变数据库的存储引擎设计例如减少 WAL 的必要性新的索引结构如 LSM-Tree 的优势更明显。动手是实现理解的最佳途径。建议你选择一个主题例如实现一个基于 LSM-Tree 的键值存储或者为现有的简单数据库添加 WAL 支持。在实现过程中持续使用基准测试来量化你的设计选择带来的影响这将使你的理解从理论层面真正落实到工程实践层面。