2.1 存储布局


2.1 存储布局

本节摘要:存储布局决定数据的物理形态——一行记录在磁盘上是连续存放还是按列拆开,页如何组织,文件里的记录是否排序,内存与磁盘之间如何缓冲。本节先讲页这个最小交换单位,再对比行式与列式两种摆法,接着梳理堆文件到有序文件的组织演进,最后拆解 SSTable 与 MemTable 这对搭档如何把随机写变成顺序写。读完你能判断:当一次查询是"点查"还是"扫描"时,数据摆成什么样子代价最小。

阅读收获

阅读完本节,你应当能够:

  1. 说清页为什么是内存与磁盘之间的最小交换单位,以及页头页尾各自承担什么职责
  2. 对比行式与列式布局在点查、范围扫描、聚合、更新上的 I/O 差异
  3. 区分堆文件、有序文件、索引组织表三种文件组织方式的适用场景
  4. 解释 MemTable 与 SSTable 如何配合,把散乱的随机写转化为磁盘上的顺序写
  5. 说出写放大、页分裂、空闲空间管理这些工程坑各自的成因

一、问题与直觉

先看一个每天都在发生的场景。一张订单表攒了几亿行,业务要统计过去一个月的成交额,SQL 只碰 order_id、created_at、amount 三列。但表是按行存的,每条记录还拖着几百字节的收货地址、备注、JSON 扩展字段。于是数据库把整张表从磁盘搬进内存,再逐行拆出三个字段。查询慢,内存被打满,磁盘带宽被无关数据白白占掉。这不是索引没建好,而是布局没摆对——数据在磁盘上的"摆法"错了,再好的索引也救不了聚合扫描。

这里的核心矛盾来自磁盘的物理脾气。磁盘是块设备,读写以页为单位,一次读 4KB 还是 8KB 都算一次 I/O;顺序访问比随机访问快一个数量级以上。而查询的访问模式五花八门:有的只摸一行,有的要扫全表,有的只挑两列做聚合。不同的模式要求数据摆成不同的样子,但数据在磁盘上只有一份物理副本。所谓存储布局,就是在这份副本上做取舍:让哪种查询省 I/O,就得让另一种查询多付代价。

二、页:内存与磁盘之间的最小交换单位

不管行式还是列式,最终都要落到"页"上。数据库的页不是操作系统页缓存的简单镜像,它是存储引擎自己定义的一块固定大小的字节容器,常见 4KB、8KB、16KB。页是 I/O 的最小单位,也是加锁、写日志、崩溃恢复的最小单位。为什么要把大小固定下来?因为固定块让随机定位变得可预测:给定页号,就能算出磁盘偏移,一次 I/O 载入一整页;页内部再用偏移量数组做行级定位,做到 O(1) 找到某一行。

一个典型的数据页,可以拆成四个功能区:

区域 职责 关键内容
页头 元信息中枢 页类型、日志序列号、校验和、行目录
数据区 存放真实数据 行式放变长记录,列式放压缩列块
空闲区 预留扩容缓冲带 行式供就地更新,列式基本禁用就地写
页尾 一致性锚点 校验和、undo 信息或系统变更号

页的固定大小还间接决定了索引的高度。一个页能装的键越多,树就越矮,从根到叶要跳的层数越少,随机 I/O 次数也就越少。更重要的是,页是恢复的粒度:预写日志里记的不是"某条 SQL 更新了订单",而是"对第几页第几偏移的多少个字节做了覆写",重放的最小单位就是页。所以页面设计直接定义了事务与恢复的成本下限,页怎么摆,恢复就怎么跑。

页的大小不是越大越好,也不是越小越好。页太大,一次点查会把一大块用不上的字节拖进内存,白白污染缓冲池;页太小,一个索引节点装不下几个键,扇出下降,树变高,随机 I/O 次数跟着涨。InnoDB 默认用 16KB 页,就是在点查的搬运成本和树的层数之间取了一个折中。云原生数据库更进一步,把页抽象成逻辑页:物理载体可以是对象存储片段、云盘卷或远端内存,大小可以伸缩,甚至一个逻辑页能跨多个物理设备——页从"磁盘上固定的一块"变成了"可编排的一段字节流"。

三、行式与列式:两种摆法

行式和列式的区别,用两个办公用品就能说清。行式像一本"每人一页"的员工档案本:翻到某人的那一页,他的姓名、工龄、工资全都在。列式像财务部的一摞台账:工资册、工龄册、考勤册各一本,每本只记一个字段,按员工编号对齐。两者不是"横着切"和"竖着切"那么简单,而是"以行为第一公民"还是"以列为第一公民"的根本分歧。

行式把一条记录的所有字段连续存放。它的赢面在点查和更新:给定主键定位到页和偏移,一次 I/O 就把整行拿全;单行更新就地覆写一段连续字节,预写日志也能紧凑地表达成"页号加偏移加长度加新值"。代价是扫描浪费:查询只挑两列,却要把含大字段的整行拖过总线。列式把每一列独立成一段连续序列,同列数据挨着存。它的赢面在扫描聚合:只顺序读相关列,还能借现代 CPU 的向量化指令并行累加;同质数据的熵低,压缩比也远高于行式——性别列用游程编码能压到接近零成本,时间戳列做差值编码后常缩到原尺寸的几分之一。代价是行重建:要返回完整一行,得从多列按行号把字段拼回来,这不再是 O(1) 的寻址。

下面这张表把两条路的取舍摊开:

维度 行式 列式
点查 优,一次 I/O 拿整行 差,需跨列按行号拼接
范围扫描 一般,拖整行 优,只读相关列
聚合 差,逐行拆字段 优,向量化批量累加
单行更新 优,就地覆写 差,多列搬迁且留旧版本
压缩率 一般 高,同质数据熵低

真正前沿的实践早已越过非此即彼。面向混合负载的系统会做行列混合布局:对高频点查的主键簇和热点属性保持行存,对分析型宽表和历史分区按列组织,再靠行号映射元数据在两者之间切换。行存区尾部和列存块头部各挂一份指针,系统就能在毫秒级把冷热数据在两套布局之间搬来搬去,不停机、不打断服务。

四、文件组织:从堆文件到有序文件

布局的上一层问题是:记录在文件里按什么顺序排。这里有三条主流路线,代表了"要不要为查找付排序的钱"的不同回答。

堆文件最简单,记录按插入顺序往文件里堆,不维护任何顺序。插入快到极致,代价是点查只能全表扫,必须靠额外的索引结构才能定位。有序文件则要求记录按某个键排序存储,范围查询和排序天然友好,二分查找就能定位,但插入一行要搬动半个文件,代价高到无法接受,工程上常用"溢出页"先接着、再批量重建来缓解。索引组织表走第三条路:数据直接存在 B+ 树的叶子里,主键即聚簇,表本身就是索引——MySQL 的 InnoDB 主键表就是这种形态,主键查询一次寻路直达叶子,无需回表。

这里藏着一条重要的线索:磁盘上"有序但不可变"的文件,正是 SSTable 的思想源头。排序让范围查询和二分查找成为可能,不可变让顺序写成为可能——而"又要有序、又要好写"这个看似矛盾的需求,就是下一节要拆解的那对搭档要解决的问题。

五、SSTable 与 MemTable:把随机写变成顺序写

直接往有序文件里插一行,要搬动半个文件,这在磁盘上等于灾难级的写放大。怎么既保持有序,又避免随机写?答案是把写入先在内存里接住。MemTable 是内存里的有序结构,通常用跳表或红黑树实现。写入只发生在内存,虽然随机,但内存随机访问便宜,平均只需对数级的比较就能插入。等 MemTable 长大到某个阈值,就把它冻结成不可变的 Immutable MemTable,后台线程把它整体排序后,一次性顺序写成一个磁盘文件——这就是 SSTable,即"有序字符串表"。SSTable 一旦落盘就只读,不再修改。

一个 SSTable 内部通常分三块:数据块存键值,索引块存每个数据块的最小键与偏移、常驻内存,布隆过滤器用极小的内存代价提前否定"这个键肯定不存在"的查询。三块配合,让一次磁盘上的二分查找几乎不落空。布隆过滤器这里有个值得记住的性质:它只会"误报存在",不会"漏报不存在"。也就是问它一个不存在的键,它有极小概率说"可能在",但问它一个存在的键,它一定说"在"。所以它能安全地用来提前跳过不存在的键查询,代价是偶尔白开一个文件——这个假阳性概率可以用内存大小精确换来。

MemTable 与 SSTable 的分工就清晰了:MemTable 负责吸收散乱的随机写,提供内存级的点查;SSTable 负责提供磁盘上有序、只读、可压缩的持久化。中间还横着一条预写日志 WAL——MemTable 里的数据掉电就没了,所以每次写入要先同步落一条 WAL,崩溃后用 WAL 重放把 MemTable 恢复回来。三个组件串成一条链:先写 WAL 保命,再写 MemTable 提速,MemTable 满了刷成 SSTable 持久化。

但问题还没完。时间一长,磁盘上会堆出很多个 SSTable,同一个键可能在多个文件里出现不同版本,点查就得挨个翻。于是需要后台的归并(compaction)把多个 SSTable 合并、去重、清旧版本。这套"多层 SSTable + 后台归并"的完整机制,正是下一节 LSM-Tree 的主体。

六、工程实践要点

把布局选对,比事后加索引更省事。判断布局好坏,最朴素的办法是数 I/O:这次查询到底碰了多少页,有没有把不相干的数据也拖进内存。

⚠️ 常见坑:在列式布局里做高频逐行更新,代价远高于行式——更新一行等于在每一列的分区里各改一处,还会留下旧版本等归并回收。所以列式引擎大多推荐"批量导入加按时间分区",而不是让业务对列存表做行级原地更新。另一个坑是把页设得过大或过小:页太大,点查拖进来一堆用不上的字节;页太小,索引节点扇出低,树变高,随机 I/O 次数上升。

💡 关键直觉:页大小、行式还是列式、记录排不排序,这三个选择共同决定一次查询要读多少页、做多少次随机 I/O。它们没有标准答案,只有"和当前负载合不合身"。慢查询来了,先别急着加索引,回头看看布局有没有把整张表都拖进来——布局错,索引也白搭。

本节速览

  • 页是 I/O、锁、恢复的最小粒度:固定大小让随机定位可预测,预写日志按页重放。
  • 行式适合点查与更新:整行一次 I/O 拿全、就地覆写,但扫描会拖入无关字段。
  • 列式适合扫描与聚合:只读相关列、压缩比高,但整行重建有代价。
  • 文件组织从堆到有序再到索引组织:排序换取范围查询,代价是插入变贵。
  • MemTable 吸收随机写,SSTable 提供有序只读:中间靠 WAL 保证掉电不丢。
  • SSTable 不可变,靠后台归并清理旧版本:这是 LSM-Tree 的伏笔。
  • 布局没有标准答案:好坏取决于它让当前负载多读了多少无关的页。

下一节我们站到这些布局之上,看真正决定"找得快不快"的三种索引结构——B+ 树、LSM-Tree 与哈希索引,以及它们各自在读放大和写放大之间下的注。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U