做了几年数据库性能优化之后,我越来越觉得,很多问题不是 SQL 写错了,而是开发者心里缺一张“物理数据模型”的全景图。关系数据库里的空间存储与索引,听起来像是教科书里专门拿来考试的名词,但实际上每天都在影响慢查询、磁盘占用和莫名其妙的执行计划。这篇文章想把这些知识点串起来讲清楚,尤其侧重两块:数据在磁盘上怎么说、怎么摆;索引为什么能提速,什么时候反而帮倒忙。适合正在做后端开发、存储设计,或者准备系统设计面试的读者。
1. 数据到底存在哪:空间、块和页的真相
很多人在设计表结构时,脑子里只有一张二维表:有行有列,主键唯一,外键关联。这是逻辑模型。但数据库实际上是一个存储系统,最终得把行放进磁盘文件里。物理数据模型回答的就是几个看起来简单的问题:一行记录占多少字节?一行记录在磁盘哪个位置?读取一行要触几次存储设备?这三个问题不弄清楚,后面理解索引就是空中楼阁。
1.1 块、页和 IO 的最小单位
我常跟人打比方:数据库读取数据,不是像查字典一样精准地翻到某个字,而是像从仓库里搬箱子,一次至少要搬一整箱。这一箱就是数据库页,也叫数据块。不同系统的默认页大小不太一样,常见有 4KB、8KB、16KB 这类规格。
为什么一次要读一整页?因为存储设备的最小 IO 单位通常是块,而数据库希望把多次逻辑操作打包到一次物理 IO 里。比如你想读某一行记录,存储系统并不知道这行在哪,只能把包含这一行的整个页都读进内存,再从页里筛出目标记录。页越大,单次能装的行越多,顺序扫描时优势明显;但页越大,更新数据、做 WAL 日志、缓存管理也会更重。页大小这个参数一般不轻易动,默认值通常已经是对常见工作负荷的折中。
所以你在设计表时,可以估算一下一页能放多少行:如果平均行长度是 200 字节,一页大概能装几十行。行越短,同样一块磁盘能放的记录越多,扫描时可能触发的页就越少,性能就越稳。这个估算值也是后续判断索引占用空间的基础。
1.2 页内部的记录布局:槽、变长字段和 NULL
如果页是一整箱,那箱子里面的排列方式就很关键。最朴素的做法是每行固定长度,一行挨着一行,像表格一样整整齐齐。但现实表里几乎都有VARCHAR、TEXT、时间戳这类变长字段,不同行的长度差异很大。如果仍然按固定长度分配,就得按所有行的最大长度分配空间,浪费严重。
主流的关系库采用一种更聪明的做法:页头放元信息和槽目录,页体里放实际的记录。每条记录在页内的起始位置不是硬编码在固定偏移量上,而是由一个“槽”来记录,类似一个位置索引。读取某一行时,先根据行号找到槽,再从槽里取出记录的真正位置。这样做的直接好处是:记录在页内移动时,只需要更新槽里的指针,不需要让索引去记住具体物理地址。
变长字段带来的另一个问题是“行溢出”。如果一个字段特别大,比如一个超长的字符串,把整行还放在原页会导致一页只能放一两行,空间利用率非常难看。很多数据库会把超长字段挪到单独的存储页,原行里保留一个指向外部位置的指针。这样做的取舍很清楚:常规小字段的扫描保持紧凑;真要查询超长字段时,再额外跳一次 IO。
NULL 值也值得单独说。在逻辑层面,NULL 就是“未知”,但在物理存储里,NULL 既不会被当成某个特殊值存成定长数据,也不能让每个 NULL 字段都占一块空间。大多数库会用一个空值位图或 NULL 标志位来记录“这一列有没有值”。也就是说,可空的列往往不需要为 NULL 实际分配字段长度,这样整行能省下不少空间。你想让表更紧凑,就应该尽量把可空列集中设计、把频繁为空的字段放到表的尾部,这是从物理模型里反推出来的实操技巧。
1.3 表空间和文件:逻辑层到物理层的桥
表建完之后,数据最终要去磁盘上的文件里。关系数据库普遍有一个“表空间”或“文件组”的概念,相当于逻辑存储空间和实际文件之间的桥。你可以把表、索引、系统元数据分别放在不同的表空间,再把表空间映射到一个或多个物理文件上。
为什么要多这层抽象?因为物理文件不能随便移动和改名,但表空间可以扩展。当你发现某个盘快满的时候,可以往表空间里加新文件,数据被分散到新位置,业务侧基本无感。另一个常见动作是把索引和表放到不同的物理磁盘上。表在磁盘 A 上连续扫描,索引在磁盘 B 上随机定位,两个设备的 IO 可以并行,这在机械盘时代是个很明显的优化,今天用 SSD 也仍有减少争用的意义。
我见过有人在设计阶段根本没考虑过表空间,默认全放一起,结果数据量涨到几个 TB 之后,备份、扩容、分区迁移全都变得很被动。物理模型不是表结构的前缀约束,而是你给未来运维留的操作空间。至少要知道自己用的库支持哪些存储对象名,知道表和索引应该放在哪里。
2. 索引的物理形态:B+ 树为什么是主角
逻辑模型里的索引可以理解为一本书的目录,但物理模型里的索引是一套有自己存储结构的数据集合。关系数据库里最常见的索引结构是 B+ 树,面试喜欢问,实际调优也绕不开。
2.1 B+ 树的页结构和检索路径
B+ 树的“B”和二叉树没有直接关系,它强调的是多路平衡查找。一棵典型的 B+ 树由根页、内部页和叶页组成。每个页都可以容纳多个有序键值和对应指针。查找一个键时,从根页开始,根据键值落在哪个区间,跳到下一层的子页,直到叶页。
为什么 B+ 树而不是二叉搜索树?二叉搜索树高度太高,查询几个层级就可能触发几次随机 IO。B+ 树每个节点是一个页,内部能放下成百上千个键值,树高通常只有三到四层。也就是说,定位一个键值的基础代价,很多时候就是三到四次页读取。如果热点页能常驻内存,那实际触盘的次数还会更低。
还有一个关键设计:B+ 树的叶节点之间是有指针串联的,整层叶子页在逻辑上形成有序链表。这意味着它既支持单个键值的等值查找,也支持范围查找。你想看“年龄小于 30 的所有员工”,只要定位到小于 30 的边界叶页,然后顺着叶子链表往后扫,就能批量取到结果。二叉搜索树很难做到这种连续范围访问的高效性。
2.2 聚簇索引与非聚簇索引的存储差异
这是初学者最容易混的问题:索引和数据到底是不是分开存的?答案是,看聚簇还是非聚簇。
在一个“聚簇表”里,表的数据行本身按主键或指定键排序,并且数据的物理顺序和索引的顺序一致。这种情况下,通常只有一个聚簇索引。你把主键值插入时,记录会被放到合适的位置,而不是简单追加到文件末尾。带来的好处是:按聚簇键范围查询时,数据在页上基本连续,磁盘读取非常顺畅;但坏处是,如果聚簇键是随机变化的,插入可能引起大量数据搬动,这就是页分裂的根源之一。
非聚簇索引则是一棵独立的 B+ 树,叶子节点里存的是索引键和指向实际数据行的定位信息。这个定位信息在不同系统里有不同做法,有的直接是一个物理行标识,有的存聚簇键的值。无论哪种,查询都需要先走索引树,再根据存下来的信息回到数据表取完整行。这个过程就是常说的“回表”。回表意味着多一次随机 IO,所以不是所有走索引的查询都一定比全表扫描快,尤其当选择性不高时。
做物理设计时,聚簇键的选择非常讲究。我一般建议优先选唯一、递增、不轻易更新的字段。自增整型是很典型的聚簇键候选:新记录基本是追加写,很少触发中间数据的大量移位。反过来,如果用 UUID 做聚簇键,随机性很强,插入时经常要把已存记录挪来挪去,数据碎片和页分裂都会明显变重。
2.3 联合索引的列序和前缀命中
联合索引也是 B+ 树,只是索引键变成了多个列。树的排序逻辑是:先按第一个列排序,第一个列相同再按第二个列排序,依次类推。这个排序规则直接决定了查询能不能用到索引。
比如建立(dept_id, age)联合索引,查找条件WHERE dept_id = 100 AND age = 30可以充分利用索引;只查WHERE dept_id = 100也可以用,因为第一个列已经可以提供精准定位;但如果只查WHERE age = 30,就无法有效使用这个联合索引。原因很简单,B+ 树里先按dept_id组织,单独的age在整棵树里没有全局顺序可用。
所以联合索引列序的核心原则是:把等值查询的列放前面,把范围查询的列放后面。因为等值查询可以把范围缩小到确定的那一段叶子,而范围查询需要遍历同一区间内更多页。另外,选择性高的列通常也优先放前面,这样索引树能更快收敛。不过选择性不是唯一标准,还要结合真实查询频度去定。我曾经接手过一张订单表,业务上几乎总是先按用户查,再按时间排序,那(user_id, create_time)就是明显比(create_time, user_id)合理的组合。
3. 哈希索引与位图索引:别的路不一定更好走
B+ 树是万金油,但绝不代表所有索引用 B+ 树都最优。哈希索引和位图索引在各自场景下的表现,可能比 B+ 树好一个数量级,只是它们的限制也特别明显。
3.1 哈希索引只认等值
哈希索引的结构很简单:对索引键算出一个哈希值,再按哈希值映射到桶,每个桶里放对应的行指针或键值。查找时只需要算哈希、定位桶、再在桶里比对,整个过程基本都是 O(1) 级别的定位,比 B+ 树的三四次页跳转要直接。
但哈希结构天生只适合等值匹配。WHERE user_id = 12345这种查询非常快;可一旦变成WHERE user_id > 12345或者ORDER BY user_id,哈希索引就完全帮不上忙,因为哈希后的值打乱了键的原始顺序,你无法在哈希表上做范围遍历和排序。
另一个容易被忽略的问题是哈希冲突与溢出。无论哈希函数设计得多好,桶里总会因为碰撞而堆积数据。数据库通常给每个桶分配一个或多个页,冲突严重时还要拉出溢出页链表。读期望时 O(1),但冲突多了以后,链表扫描会退化成 O(n)。你在设计表时,如果预料到某列会频繁做等值查询,而且是高并发高吞吐的点查场景,哈希索引确实值得考虑;一旦查询涉及范围条件,还是回到 B+ 树更稳妥。
3.2 位图索引的空间魔法和更新代价
位图索引算是“空间存储”这个主题里很有代表性的结构。它适合基数很低、取值有限的列,比如性别、状态类型、是否删除。原理是给每个可能值画一张位图,位图长度等于表行数,某一行取到该值就把对应位置为 1,否则为 0。
想象一个千万行的大表,status只有“待处理、处理中、已完成”三种值。三张位图每一张是一千多万 bit,整体也就是几 MB,比普通 B+ 树索引占用小得多。两个条件组合时,直接对位图做位运算AND、OR,速度极快。这在数据仓库、统计报表场景里尤其厉害,一条复杂筛选可以变成几个位运算操作。
但位图索引的更新代价也比较高。每当一行数据发生更新,除了维护表本身,还要修改对应值的那一位。如果并发事务同时对同一个位图页上的不同位做更新,锁冲突和 CPU 开销会迅速增长。所以在线事务系统里很少用位图索引,更常见的场景是读多写少、值域稳定、大范围汇总查询的分析型环境。
3.3 三类索引的适用场景对照
做索引选型时,我会先画一张简单的对照表,把查询类型、存储成本和更新成本列出来,再决定方案。烈度很高的点查用哈希;范围查询和排序用 B+ 树;低基数的组合筛选和报表聚合用位图,但这只是第一层判断。物理模型还会继续追问:索引页占多少空间、写入时会不会把热点页打爆、统计信息准不准。
| 维度 | B+ 树索引 | 哈希索引 | 位图索引 |
|---|---|---|---|
| 等值查询 | 快,复杂度与树高相关 | 极快,O(1) 定位 | 不快,位图扫描量大 |
| 范围查询 | 支持,叶子链顺序扫 | 不支持 | 适合与其它位图做位运算 |
| 排序支持 | 天然有序 | 用于排序需要额外加工 | 不适合 |
| 典型场景 | 常规 OLTP、主键/外键 | 高并发点查、缓存加速 | 数据仓库低基数列 |
| 更新代价 | 中等 | 中等 | 较高 |
4. 物理模型帮我们把索引建到刀刃上
很多开发者的索引设计停留在“哪个字段查得多就给谁建索引”,这不够。物理模型要求你把每个索引看作一个真实占空间的物理对象,它不仅加速查询,也会拖慢写入,还会和表竞争缓存和 IO。
4.1 堆表、聚簇表与回表成本
一张表如果没有聚簇键,记录按插入顺序堆叠存放,这种形态叫堆表。堆表和聚簇表的本质区别,体现在辅助索引的存储内容上。在堆表里,辅助索引叶子节点存的是物理行标识,一行在磁盘上的地址基本稳定,不会因为数据插入而频繁变动。在聚簇表里,辅助索引存的是聚簇键值,如果聚簇键被更新,辅助索引里被牵连的内容也要跟着维护。
这里有一个常被忽视的成本:回表。假设一个查询用到了辅助索引,但需要返回的列不在索引页里,那么每一行都要拿着定位信息回到主表再去读一次数据页。如果你查出来的行数很多,例如 20% 的行都满足条件,那么索引扫描加回表的随机 IO,实际上可能比全表顺序扫描还慢。物理模型思维会提醒你:选择索引不是看“有没有索引对象”,而是看“从索引页到数据页的路径是否划算”。
4.2 覆盖索引和索引只读
如果查询所需的列全部包含在索引本身的叶子页里,那么数据库根本不需要回表。这种索引叫覆盖索引。它是最直接的“空间换 IO”手段。
比如建立(dept_id, age)联合索引后,执行SELECT dept_id, age FROM employee WHERE dept_id = 100:索引树已经包含了这两个列的数据,数据库可以直接从索引页返回结果,不再碰主表。这就是我为什么建议在频繁执行的统计类查询里,尽量把投影列也塞进联合索引。代价很清楚:索引页变大,写入会更重。所以覆盖索引不要无脑加列,更不要每个查询都“复制”一份大索引。通常只针对高频、热点、结果集小的查询。
4.3 统计信息、可选择性与执行计划
索引建了,走不走,常常不是索引本身决定的,而是优化器根据统计信息算出来的。统计信息包括表行数、数据页数、列去重数、直方图、NULL 占比等等。其中“列去重数”直接决定选择性:如果一列有一千万行,但只有两个不同值,这条列的杀手 选择性极低,跑一个带有该列的等值查询,优化器大概率认为走全表扫描更划算。
这类问题在实际环境中特别常见:索引明明存在,执行计划却不去用它。原因可能是统计信息过期,也可能是基数估算偏差。物理模型里的索引选择不是静态的,它和表的实际存储分布强相关。当你在优化一条慢查询时,先看执行计划里估算的行数和实际返回行数是否差了一个数量级。如果差很多,刷新统计信息往往比改 SQL 更有效。
5. 实操高频问题:为什么索引失效,空间越来越重
把理论落回实战,我会把真实环境里最常踩的坑分成三类:索引建立了但查询用不上、写多索引重导致页频繁分裂、索引碎片堆积导致空间膨胀。这三个坑分别对应索引的选择、 B+ 树的物理维护、以及空间的回收。
5.1 建了索引却走不上:隐式转换、函数和左模糊
有一种常见索引失效,根本不是统计信息问题,而是查询写法让自己成了“不可用谓词”。B+ 树只有在键值本身的有序性可以被利用时才能发挥威力。只要你让这一列在查询条件中被函数、运算或类型转换包裹,有序性往往就失效了。
最典型的三个类型:
- 隐式类型转换:数值列写成字符串条件,例如
WHERE emp_id = '123',数据库可能把列隐式转成字符串再比较,转换后的值无法直接沿用原始数值索引。 - 在列上应用函数:例如
WHERE DATE(create_time) = '2024-01-01',把索引键放进函数里,B+ 树无法直接定位。正确做法是改写为WHERE create_time >= ... AND create_time < ...。 - 前缀模糊:
LIKE '%abc'以通配符开头,B+ 树的有序前缀失效;LIKE 'abc%'则可以正常走前缀匹配。
这些不是 SQL 语法问题,而是“物理有序性”问题。一旦你意识到 B+ 树的叶子页是一串有序键值,你就会明白为什么函数和左模糊会破坏它。
5.2 填充因子和页分裂:写偏多场景怎么设
B+ 树为了保持平衡,插入新键时如果目标叶子页已经满了,就会把页拆成两个或三个,再调整父节点的指针。这个过程叫页分裂。分裂本身不复杂,但会使原本物理相邻的键跑到不同页里,后续范围扫描可能要多跳几个页;频繁分裂还会产生大量半空的页,让索引占用越来越多空间。
控制页分裂的核心参数叫填充因子。它表示索引页在创建或重建时预留多少空间。默认值通常是 90% 或 100%,也就是把索引页尽可能装满。对于只读表,填充因子越高越好,空间利用率高、顺序扫描快。但如果你这张表写入特别频繁,例如每天往订单表插大量数据,100% 的填充因子会让新插入的键几乎马上挤爆某个叶子页,触发大量分裂。
我个人处理这种情况时,会把填充因子调到 70%~90%,甚至更低,视插入频率和键分布而定。叶子页留出空闲,新键可以先落到本页,不立刻分裂。代价是索引体积变大,扫描页数多一些。这是很典型的“空间存储”取舍:用空间余量换写入稳定性。别忘了,调整填充因子后通常需要重建索引,否则已有页不会自动重新编排。
5.3 碎片、IO 放大和我踩过的整理教训
空间碎片有两类。第一类是逻辑碎片,指叶子页之间的物理物理顺序不匹配。B+ 树在理想状态下,所有叶子页在磁盘上连续排列,一次大范围扫描就像读一段连续文件。但经过多次插入、更新、分裂之后,相邻键的逻辑邻居可能不在物理相邻的页上,扫描就要来回跳转,IO 放大。第二类是页内碎片,指页里有很多空闲字节,却能放下新数据而不满。
我遇到过一张大表,索引逻辑上相当规整,但实际物理顺序乱得很,查询计划依然选择索引扫描,耗时却比以前涨了十几倍。当时第一反应是加缓存,后来查看索引碎片率,才发现问题出在页分布上。处理手段通常是重建索引,让叶子页重新按逻辑序排列。不过重建索引是个需要挑时间窗口的活,因为它在物理上要新开一个存储区域,期间会占用额外空间和 IO。
经验上,如果表的写入和删除比例高,不要只把精力放在优化 SQL 上,定期看索引碎片率和表空间使用情况反而更有效。碎片到了 30% 以上,重建或重组一次,效果往往立竿见影。
6. 几条写在最后的实操体会
空间存储与索引这块内容,单看每个章节都不难,真正考验人的地方是把它串起来用。我在实际优化中养成了几个习惯,也算给自己这些年的经验做个总结。
第一,建表和建索引之前,先估算行长度和索引页占用。用VARCHAR不要随便给 2000 长度的默认值,用变长字符串也不要忽略 NULL 位图带来的节省。一个表几千万行,行平均短 20 字节,可能就是几 GB 的差距。第二,把索引当作存储对象去管理。不要攒一堆与业务割裂的单列索引,尽可能组合成覆盖高频查询的联合索引;每次新增索引时,都要同时问自己:它让查询少读了多少页,又让每次插入多付了多少写代价。第三,所有索引失效问题,优先从“B+ 树对有序性的要求”去思考,再排查统计信息和执行计划。手里如果只有一把锤子,看到所有钉子都会想敲一下;物理模型就是让你看清,哪些查询本质上需要一把扳手。