架构设计

DDIA 第三章:存储与检索

从追加日志和哈希索引出发,系统梳理 SSTable、LSM-Tree、B-Tree、数据库索引、OLTP/OLAP、行列存储与内存数据库,并总结读写空间放大及选型原则。

🗄️ 本章核心:存储引擎不存在绝对最优,设计始终是在读放大、写放大、空间放大、持久性与查询模式之间取舍。

一、知识主线

追加日志 → 哈希索引 → SSTable → MemTable + WAL → LSM-Tree → B-Tree → 二级索引 → OLTP/OLAP → 行存储/列存储 → 内存数据库

二、追加日志与哈希索引

  • 追加写:更新时不覆盖旧记录,而是在日志末尾写入新版本。
  • 读取规则:同一个键以最新记录为准。
  • 哈希索引:在内存中保存 key → 文件段、偏移量、长度,直接定位最新值。
  • 日志分段:当前段接收写入,旧段冻结为只读,便于后台整理。
  • 墓碑记录:删除操作写入 tombstone,防止旧值在查询或合并后复活。
  • 恢复:内存索引丢失时,可以按时间顺序扫描日志重建;实际系统还会使用校验和识别末尾的不完整记录。

适用与限制

  • 适合完整键的点查询。
  • 不擅长范围查询,因为哈希表不维护键顺序。
  • 内存索引大小取决于不同键的数量,磁盘日志大小取决于历史写入次数。

三、SSTable 与 LSM-Tree

核心组件

组件 位置与状态 作用
WAL 磁盘上的追加日志 恢复尚未进入 SSTable 的已确认写入
当前 MemTable 内存、可修改、有序 接收新写入并服务近期查询
不可变 MemTable 内存、已冻结 等待或正在写成 SSTable
SSTable 磁盘、不可变、有序 长期保存数据并支持点查询和范围查询
Bloom Filter 概率型辅助结构 快速判断某个 SSTable 一定不包含目标键
Compaction 后台过程 合并 SSTable,清理旧版本与墓碑

写入路径

  1. 写入先追加到 WAL,并在需要持久性保证时可靠落盘。
  2. 同一次写入更新当前 MemTable。
  3. MemTable 达到阈值后冻结,同时创建新的 MemTable 继续接收写入。
  4. 不可变 MemTable 被顺序写成新的 SSTable。
  5. 多个 SSTable 在后台执行 Compaction。

查询路径

  1. 当前 MemTable。
  2. 尚未落盘的不可变 MemTable。
  3. 从新到旧查询 SSTable。
  4. Bloom Filter 跳过一定不包含目标键的文件。
  5. 遇到第一条值或墓碑,即得到最新状态。

什么时候生成新的 SSTable

  • MemTable 达到大小阈值、时间阈值或内存压力阈值时。
  • WAL 积累过多,系统需要推进刷盘时。
  • 数据库关闭或手动执行刷新时。
  • Compaction 合并旧 SSTable 时;输出过大时可能按键范围拆成多个新文件。

SSTable 写成后不再修改。更新先进入 MemTable,随后生成新 SSTable,旧版本最终由 Compaction 清理。

Compaction 策略

  • Size-tiered:相近大小的 SSTable 合并,通常降低写入压力,但文件和旧版本可能更多。
  • Leveled:按层级和键范围组织 SSTable,通常降低读放大和空间放大,但可能提高写放大。

四、三种放大

  • 写放大:用户写入一份数据,WAL、刷盘和多轮 Compaction 导致磁盘实际写入多份。
  • 读放大:一次查询需要检查多个内存结构或 SSTable。
  • 空间放大:同一个键的多个历史版本暂时同时占用空间。

更频繁的 Compaction 通常降低读放大和空间放大,但提高写放大;降低 Compaction 强度则相反。

五、B-Tree

  • 把数据组织成固定大小的页,通过根页、中间页和叶子页逐层缩小查询范围。
  • 更新通常找到目标页并维护现有树结构。
  • 插入导致页面超过容量时,会发生页分裂并更新父页。
  • 与页修改对应的 WAL 必须先于修改后的数据页持久化,确保崩溃后可以重做或撤销未完整完成的修改。

B-Tree 与 LSM-Tree

维度 B-Tree LSM-Tree
基本思路 持续维护一棵现有的树 产生新结构,再后台合并
磁盘结构 固定大小的可更新页 多个不可变 SSTable
写入特点 可能涉及随机页写入 将大量写入转为顺序写
后台工作 页分裂、页合并 Compaction
读取 通常沿树定位一次 可能检查多代文件

六、索引

  • 主键索引:根据唯一主键定位记录。
  • 二级索引:根据非主键字段定位一条或多条记录。
  • 堆文件:索引叶子保存数据位置,完整数据行存放在独立区域。
  • 聚簇索引:数据行本身按照某个索引的顺序组织,通常只能有一种主要聚簇顺序。
  • 覆盖索引:索引包含查询需要的全部字段,可以避免回表。
  • 多列索引:例如 (country, city, created_at),最适合从左侧连续使用索引列;跳过中间列时,后续列通常不能形成高效的连续范围。
  • 多维索引:R-Tree 等结构适合地理空间和多个维度同时做范围查询。
  • 全文索引:倒排索引适合按词语查询文档。

索引越多,读取通常越快,但写入、空间和维护成本也越高。

七、OLTP 与 OLAP

维度 OLTP OLAP
访问范围 少量记录 大量历史记录
常见操作 点查询、插入、更新 扫描、分组、聚合
延迟要求 通常很高 可以接受更长执行时间
主要视角 当前业务状态 历史趋势和整体分析
  • 为避免大规模分析影响在线业务,数据通常通过 ETL 或 ELT 从业务数据库进入数据仓库。
  • 数据仓库常采用星型模型:事件及可聚合指标放在事实表,品牌、地区、时间等描述性信息放在维度表。

八、行存储与列存储

  • 行存储:同一行的字段放在一起,适合根据主键读取或修改完整记录,常用于 OLTP。
  • 列存储:同一列的值连续保存,分析时只读取需要的列,常用于 OLAP。
  • 同列数据类型一致且重复值多,便于字典编码、游程编码等压缩。
  • 合理的排序顺序能提升压缩效果,并让查询跳过无关数据范围。
  • 向量化执行成批处理同类值,更好地利用 CPU 缓存与 SIMD。
  • 物化视图预先保存常用聚合结果,以增加写入和维护成本换取查询速度。

九、内存数据库

  • 主要数据结构常驻内存,但仍可使用 WAL、快照和复制实现恢复与高可用。
  • 快照之间若没有日志,崩溃后可能丢失快照之后的写入。
  • 复制主要应对机器故障,备份还要应对误删除、程序错误和数据损坏,二者不能互相替代。

十、选型速查

  • 低延迟点查询、频繁更新少量完整记录:倾向 B-Tree + 行存储
  • 写入量极大、可以接受后台整理:倾向 LSM-Tree
  • 大规模扫描少数列并聚合:倾向 列存储 OLAP
  • 高写入设备事件系统:可采用 LSM-Tree,并用 (device_id, timestamp) 作为有序键,支持按设备和时间范围查询。
  • 数据规模可放入内存且延迟极敏感:考虑内存数据库,同时设计日志、快照、复制与备份。

十一、易错点

  • 追加写不会自动解决并发冲突;并发仍需要锁、版本或事务机制。
  • Bloom Filter 的“可能存在”可能是假阳性,仍需查数据;“一定不存在”则可以安全跳过。
  • WAL 负责恢复尚未写成 SSTable 的近期数据;SSTable 保存已经落盘的数据。
  • 新 SSTable 中没有目标键,只说明需要继续查旧文件;从新到旧查询的真正原因是新值或墓碑会覆盖旧值。
  • 销售金额放入事实表,是因为它是可聚合的事件指标,而不是因为它永远不会变化。

整理日期:2026-09-10。内容基于《Designing Data-Intensive Applications》第三章的学习与问答总结。