☰
从页到LSM:数据存储结构如何决定数据库性能与选型
2026/10/10 19:55:54 网站建设 项目流程

两年前我接手过一个边缘设备数据上报平台,高峰期每天要写入几千万条状态数据,查询却集中在最近一小时的点查和少量跨天聚合。当时的表结构和索引是照搬旧项目的,结果线上跑了不到两周就频繁出现慢查询,磁盘IO也长时间打满。排查下去,根子不在SQL,而在于我始终没认真想过“数据到底是怎么在磁盘上放着的”。那次事故之后,我花了很长时间把各类数据存储结构从头梳理了一遍,发现只要搞懂了存储结构,选型、调优、排障都会清晰很多。这篇总结我就结合实战经验,把常见的数据存储结构拆开讲讲,尽量用能直接落地的角度,而不是教科书式的概念堆砌。

1. 页与块:几乎所有存储结构都绕不开的底层单位

1.1 为什么数据库偏要和磁盘“较劲”设计页

很多人一上来就谈B+树、LSM,却忽略了最底层的页(Page)和块(Block)。磁盘的读写并不是按字节来的,普通机械硬盘的一个扇区通常是512字节或4KB,SSD的读写单元也以页为粒度。一次磁盘IO如果只取一条记录,那网上所谓的“随机IO慢”会更加雪上加霜。所以数据库设计者干脆规定,内存和磁盘之间搬运数据的最小单位是一页,通常8KB到16KB。一页里能放几十条甚至几百条记录,读一次就能命中多行,这不只是减少IO次数,也是让预读、缓存这些优化成为可能。

这个逻辑跟我们在生活中翻纸质档案是一样的。你不会为了看一条信息就把整本档案柜都搬出来,但你会把包含这条信息的一叠纸先拿出来,因为同一叠里大概率还有其他相关信息。数据库的页就是这叠纸,操作系统按块,数据库按页,本质都是为了把机械设备“拨一次头”的成本摊薄到足够多的数据上。

1.2 记录在页里如何安放

页里怎么放行记录,这个细节直接关系到更新性能和存储浪费。几乎所有主流数据库的页结构都分三块:页头(Page Header)、记录区(User Records)、空闲区(Free Space)。记录在页内通常是从页面偏移量最大的地方往前分配的,空闲区从后往前推进,这样能尽量让新插入的记录和已有记录产生较少冲突。

记录本身也不是简单的“一行一坨”,里面有行头信息,包含事务ID、锁定信息、NULL值位图等控制字段;然后才是真实的列数据。这里有一个实操中经常被忽略的规律:列的位置和长度会对存储空间产生很大影响。比如把变长字段放在定长字段前面,页内偏移量数组的维护成本就会升高;把经常为NULL的列集中在一起,行头的NULL位图就能用很少的位表示。

我曾经在MySQL InnoDB里建过一张表,字段顺序完全按业务定义来,没有做任何调整。后来做压测发现,仅仅是把几个大varchar挪到表结构末尾,并把一组几乎恒为NULL的标记位合在一起,表空间就节省了将近12%,查询时扫描的页数也明显变少。原因很简单:定长字段放在前面,记录头部可以快速定位,变长字段放在后面,减少偏移量重算;NULL集中在少数字节,行头就能更紧凑。页的容量不变,行占的空间越小,一页能装的行越多,相同查询要读的页自然就越少。

1.3 我踩过的页分裂与行迁移坑

页分裂是我的一个老朋友。之前给一张日志表做主键,图方便用了随机UUID,结果写入时因为主键无序,新记录经常要插到已经满的页中间,导致页不断分裂,页内碎片也越来越严重。表面上的问题只是写入慢,实际上还有两个坑:一是页分裂会占用额外空间和额外IO,二是散落的碎片让顺序扫描读了很多“半空页”。后来我把主键改成自增ID后,写入从随机写变成了顺序追加,页分裂几乎消失,吞吐翻了一倍以上。

行迁移则是另一个容易忽视的问题,尤其是Oracle堆表和PG的更新机制。Oracle的行因为记录的初始位置固定,更新后如果记录变长放不下,会把整行迁移到新位置,原位置只留一个指针。这个指针会让每次访问都要多跳一次,时间一长性能就悄悄劣化。PostgreSQL则通过多版本并发控制,更新操作会生成新行版本,如果更新频繁,旧版本清理不及时,表膨胀就会很严重。解决手段不外乎几个方向:让主键有序、控制更新比、设置合适的填充因子(比如PG的fillfactor、InnoDB的page压缩)、定期做碎片整理。这些事看似和存储结构无关,但根子全在这一层。

2. 堆表、索引组织表与聚簇索引:三种最常见的表存储形态

2.1 堆表:数据放哪儿跟索引无关

PostgreSQL是堆表的典型代表。堆表的特点是,数据行按照插入顺序放到数据文件的堆里,表本身没有任何物理排序。索引是独立于数据文件存在的,索引条目里存的是行的物理地址(PG里叫ctid,也就是页码加行序号)。查询时如果用上索引,先找到指向堆的指针,再回堆里去取数据行。

堆表的好处是插入非常灵活,因为不需要维护一个全局物理顺序,任何一页有空闲就能写;同时并发插入时只要不同时落在同一页,冲突很小。但缺点是回表是随机IO。假设查询条件命中了1000行,二级索引把这些行的ctid全部找出来,接下来去堆里取1000行数据,这1000行的物理位置可能散布在几十个页里,Perl级随机读会拖慢响应。如果这个查询要取的行数很多,优化器干脆会觉得全表扫描更划算,索引反而帮不上忙。

2.2 索引组织表:让主键决定物理顺序

MySQL InnoDB走的是另一种路线:表本身就是按主键组织的B+树,叶子节点直接存整行数据,这就是索引组织表。主键索引就是数据文件,二级索引的叶子节点只存主键值。所以“回表”在InnoDB里的含义不是去堆里找行,而是用二级索引查到的主键值再去主键索引里走一次。

这种设计最大的收益是主键查询快,因为数据本身就按主键排好了,点查几乎只要走一遍B+树;范围查询主键也很舒服,因为相邻主键的行物理上也在相邻位置。但代价同样明显:主键必须是稳定且尽量短的类型。如果主键是UUID,那么每插入一条数据都可能把B+树某个叶子页撑爆,触发页分裂,数据会散落在不同页里,插入性能急剧劣化。更糟的是二级索引里要存主键值,UUID的二级索引体积会膨胀到一个不可忽略的程度。

我在做订单中心的时候,一直坚持用自增主键或雪花ID,原因是InnoDB的聚簇特性要求主键尽量顺序、简短。有人纠结订单号能不能做主键,从业务上看订单号是唯一的,但从存储结构上看订单号通常字符长且无规律,带来的存储碎片和维护成本极高。不如用内部自增代理键做聚簇主键,订单号建唯一索引,这样既保证业务查询,结构又稳。Oracle的索引组织表(IOT)也是类似思路,适合频繁按主键前缀访问、很少全表扫描的表,比如字典表、配置表。

2.3 聚簇表与覆盖索引的实际权衡

聚簇这个概念不止InnoDB有,SQL Server允许你手动指定聚簇索引放在哪一列上,这就是“聚簇表”。聚簇表本质上就是让数据在物理上按照指定键排序,好处是当多个查询都依赖这个键的范围时,IO从随机变成顺序,性能提升非常明显。

这里有个非常实用的工具叫覆盖索引:如果你建一个二级索引,列集合正好覆盖了查询所需的全部字段,那么就不需要回表,甚至不需要访问数据页。这个优化和存储结构关系很深。InnoDB二级索引指向主键值,覆盖索引可以避免主键索引查找;PG堆表也有类似效果,索引条目直接包含查询列,省掉ctid回表。我调优一个后勤报表时,就是在几个常用查询列上建立了覆盖索引,把原来每次要回表读数千行的慢查询降到了毫秒级。

聚簇和非聚簇之间的取舍,可以拉一张表看,我在实际评估时通常这样对照:

维度堆表(PG)索引组织表(InnoDB)手动聚集(SQL Server)
数据物理顺序按插入顺序按主键顺序按聚簇键顺序
插入灵活性高依赖主键顺序依赖聚集键顺序
主键点查一般,需回表极快快
二级索引回表去堆找ctid去主键B+树找行去聚簇键找行
常见坑堆膨胀、回表随机IOUUID主键、页分裂过度聚簇导致更新慢

这张表不是让你立刻换引擎,而是给你一个判断坐标系:如果你的核心操作是“按主键或者唯一键点查”,索引组织表优势明显;如果你的写入模式需要最大灵活性,堆表反而更省心。

3. B+树与LSM树:写密集场景下的宿命对决

3.1 B+树为什么统治OLTP读取场景

B+树是绝大多数单机事务数据库的默认存储结构,InnoDB、PostgreSQL的索引都是B+树。它的本质是多路平衡查找树,内部节点只存键和指针,所有数据都放在叶子节点,叶子节点之间用链表连接。这让B+树高度很低,一个几千万行的表,B+树三层就能覆盖,意味着从根到叶子最多三次磁盘IO,点查和范围查都非常稳定。

但B+树有一个天然的弱点:写入是随机写。插入一条新记录往往要定位到某个叶子页,如果页满了还得分裂;更新一行数据通常需要修改叶子页,甚至会导致后续索引变动。这里面的写入放大和随机IO让B+树在纯写密集场景下显得吃力。我维护过一个MySQL订单库,读多写少时运行良好,后来接入了一批高频上报任务,写入QPS冲到几千,半同步复制从库就经常追不上主库,最后查下来瓶颈是主库的B+树索引频繁更新引发的随机写。

3.2 LSM树的“读写不对称”设计

和B+树正相反,LSM树(日志结构合并树)把“随机写”变成了“顺序写”。写入先到内存的MemTable(通常是跳表结构),内存积累到阈值后一次性落盘成不可变的SSTable文件。多个SSTable之间通过后台的compaction(合并)不断排序、去重、淘汰旧版本。RocksDB、HBase、Cassandra、LevelDB背后都是这套逻辑。

LSM的读路径就比较辛苦了,因为数据可能同时存在于内存MemTable和多个层级的SSTable中,点查时需要一层层查过去,甚至把多个SSTable的结果合并起来。所以LSM天生有读放大问题,层数越深,读放大越厉害。它的补偿方案主要有两个:一是布隆过滤器,这个我后面单独讲;二是block cache,把热点数据块缓存起来,尽量躲过深层扫描。

我记得刚接触HBase时,有张表写了几个大Region,但是点查偶尔竟然超时,那时才意识到LSM读放大不是纸面理论。查了监控才发现,compaction还没来得及把数据压实,点查要跨好几个SSTable找一条记录。那时候我学会了调compaction策略、控制SSTable层级,也学会在压测时模拟真实读写比而不是只看QPS。

3.3 从RocksDB参数看两种选型的分水岭

那么到底什么时候该用B+树,什么时候选LSM?我的经验是先算三笔账:写放大、读放大、空间放大。

RocksDB的典型参数如level_compaction_dynamic_level_bytes、max_bytes_for_level_base直接影响L1到L6各层的容量增长。如果用level风格compaction,数据逐层往下压,全局有序性好,读放大低,但每层都要读一遍,写放大通常较高。用tiered风格compaction,新SSTable先放在同层,合并不频繁,写放大低,但是读放大和空间放大会明显上升。这其实就是一个跷跷板:写密集就牺牲一点读,读敏感就多付写代价。

我在一个风控特征平台里做过一次切换。原来用MySQL存特征变更记录,每天6000万次写入,B+树索引更新已经把主键聚簇页压得很疼,读倒是很少。后来换了RocksDB做底层,把写入变成顺序append,再异步compaction,整体写入吞吐由不到2万QPS提升到接近8万,读延迟因为加了布隆过滤器也基本可控。这个案例不是要推翻MySQL,而是说明:读模式极强时B+树依然是王者,写模式极强而读要求没那么苛刻时,LSM的收益非常实在。

4. 行式存储与列式存储:分析型负载的加速密码

4.1 行存为什么让复杂统计头疼

事务系统里,我们习惯用行式存储,一条记录的所有列连续放在一起,这样插入、更新、点查都很自然。但分析型查询往往只关心一张宽表中的少数几列。假设一张表有80个字段,分组聚合只用到3个字段,行式存储也会把80个字段全部从磁盘读出来,再把不需要的列丢弃。这个损耗在数据量小的时候无所谓,一旦表达到几十亿行,“读出来的数据量”会把有限的磁盘带宽直接打满。

我接过一个报表系统,底层是MySQL大表,跑一次“按用户维度统计七日活跃”的SQL动辄几十分钟,用上所有索引和覆盖索引也一样慢,因为聚合需要扫描的原始数据量太大,索引帮不了。后来把这份数据同步到列式存储引擎,同样的口径只扫需要的列,运行时间从几十分钟降到了几十秒。这种性能差不是SQL写法能弥补的,而是存储布局决定的。

4.2 列存的压缩与向量化执行

列式存储把一列的数据连续存放,这一列通常只有一种数据类型,值分布往往有规律,所以压缩手段非常多。比如字典编码:一个低基数列(如性别、地区编码)可以映射成很小的整数;Run-Length编码(RLE)适合连续重复值;Delta编码适合递增时间戳。压缩率比行存高一个数量级很常见,这意味着同样的磁盘容量能存更多数据,大扫描时的传输量也大幅降低。

列存的另一个杀手锏是向量化执行。传统行存的查询是一行一行处理,每行判断条件再计算结果;列存可以一次取一片列数据,交给CPU做批量计算。ClickHouse、Doris这些分析引擎能把亿级聚合跑到秒级,靠的就是“数据升华”这个组合。

但列存也有先天短板:单行点查很难受,因为一行数据被拆到好多列文件里,想要一条完整记录就得访问多列存储块;高频更新更麻烦,列存文件通常不可变,更新一行往往意味着重写整个数据块。所以列存适合数据追加多、更新少、查询偏OLAP的场景,不适合事务型系统。如果你想让线上OLTP能同时回答分析问题,往往得借助实时数仓同步或HTAP方案,而不是直接给事务库换列存引擎。

4.3 PAX混合布局与HTAP的折中

Parquet、ORC这类开源文件格式常用PAX(Partition Attributes Across)混合布局。它把一个完整的数据行组(Row Group)内部按列分成一个个列块(Column Chunk),既有列存的压缩和IO优势,又允许从每个列块取数据后重组出一行完整记录,兼顾了点查和批量查询。数据湖分析里默认选Parquet,就是因为它把行存语义和列存加速做了折中。

HTAP是这几年不断被提起的方向,本质是让同一份数据既能支撑事务,又能支撑分析,不想再“导出到数仓再等回流”。TiDB、OceanBase这类NewSQL引擎把行存和列存放进同一个存储层,或者用列存副本来服务分析查询,核心思路就是让行存继续跑TP,列存复制同步跑AP。我在实际落地时很少让一个引擎包打天下,更多是分层:在线事务库保持行存,用CDC或ETL把数据同步到列存引擎里做分析。这样既避免事务引擎被分析SQL拖垮,又能享受列存的性能。

5. 哈希、跳表与布隆过滤器:辅助结构的正确打开方式

5.1 哈希索引:只适合点查的“暴脾气”

哈希索引的查找复杂度是理论上最快的O(1),但它只能支持等值匹配,不支持范围查询,也不支持排序。它的物理结构是一个哈希表:根据键的哈希值定位到桶(Bucket),每个桶里通常用链表或专用结构处理冲突。Redis里的哈希类型、Memcached的存储、不少数据库的哈希分区都在用这个思路。

哈希索引用得少不是因为它慢,而是因为它太“偏科”。事务库的查询模式几乎都会掺杂范围条件,比如WHERE create_time BETWEEN ... AND ...,哈希索引直接废掉。PG有独立的Hash Index,但默认场景依然推荐B+树,也是这个原因。我在项目里最常见的哈希应用是缓存旁路:把热点用户维度的最新数据塞进Redis哈希,击穿概率显著下降,因为点查命中后根本不需要碰数据库。

5.2 跳表:内存有序结构的性价比之王

跳表(Skip List)是一种概率性的有序数据结构,通过多层稀疏链表加速查找。它不像B+树那样维护复杂的树结构,靠随机抛硬币来决定每层索引,代码实现要简单得多,而且并发控制比B+树容易,因为可以基于链表节点做无锁或者细粒度锁。Redis的有序集合ZSet底层就是跳表加哈希,既能高效插入删除,又能支持范围查询和按分值排序。

有时会有人问:内存里为什么不用B+树?我的理解是,B+树的分裂和合并写起来复杂度高,在纯内存场景里跳表的随机层级设计反而带来了更平稳的更新代价。LevelDB那些LSM引擎的内存表也选跳表,因为内存表就是另外一套支持有序访问的HashMap。你在设计一个需要实时排名的系统时,跳表是很实用的选择。

5.3 布隆过滤器:用错误率换取读取加速

布隆过滤器本身不存储数据,它只回答两个问题:某个元素“一定不存在”或者“可能存在”。实现上用一个很长的bit数组加多个哈希函数,写入时把元素映射到若干个bit置1;查询时只要这些bit里有一个是0,就说明绝对不存在,如果全是1,则只能说可能存在。这个“假阳性”概率可以通过增大位数组和增加哈希函数数量来压低,但无法完全消除。

布隆过滤器在LSM读路径上的价值极大。前面说的读放大,很大程度是每个点查都要判断“数据在不在这个SSTable”,没有布隆过滤器就要逐个二分查找,有了它就能在绝大部分情况下直接跳过不相关的SSTable。RocksDB和HBase默认都带布隆过滤器,开启后点查性能往往提升数倍甚至一个数量级。我在做缓存穿透防护时也会用布隆过滤器:先判断请求的key是否在合法key集合里,如果判断不存在,就直接短路,省得恶意请求全部打到数据库。

布隆过滤器的参数选择有经验公式:假设预期元素个数为n,假阳性率为p,位数组长度m要满足m ≈ -n * ln(p) / (ln2)^2,哈希函数数量k ≈ 0.7 * m / n。举个例子,n=1000万,希望假阳性率控制在1%,那么m需要约9585万bit,约11.4MB,k约6.7个。在实际项目里,我一般把假阳性率目标定在0.1%到1%之间,太低的p会让位数组膨胀到不划算。

6. 从数据结构到真实选型:我建议按这个顺序做判断

6.1 先明确负载画像:点查多还是扫描多、写多还是读多

选型的第一个动作永远是画负载画像。我会先统计线上SQL的特征:等值点查占比多少,范围扫描占比多少,聚合查询和全表扫描占比多少;再看写入频率、写入是否有顺序性、更新是否集中在少数行。这个画像直接用数据库慢日志和审计日志就能拉出来,不需要太复杂的工具。

画像不同,答案会完全相反。一个用户点查、订单明细读多写少、强事务的场景,基本就是B+树行存服务;一个设备上报、日志流水、大吞吐写入的场景,LSM更合适;一个看板报表、指标分析、数据追加为主的场景,列存才是主力。我之前遇到有人拿着“所以LSM更强”的结论去重构账单系统,结果复杂事务和点查全跪,原因就是没先看负载画像,直接套了个写优化的结论。

6.2 再看事务与一致性要求

存储结构不仅决定性能,也决定了你能提供什么样的事务能力。B+树行存天然适合ACID事务,因为它可以在页级别加锁、记录版本链、支持回滚;LSM系引擎事务通常更弱或者需要额外组件配合,比如HBase在单行上的原子性容易,跨行跨表就麻烦;列存引擎的更新和事务能力普遍较弱。所以如果你业务里存在多行更新、外键关系、复杂join,那么即便写入量很大,也不该轻易拿LSM替换RDBMS。必须先把事务边界和一致性要求划清楚。

我在设计一个多租户订单平台时,一开始想用HBase来扛高并发点查,但业务里订单状态变更涉及多个分片表的联合更新,事务不可行,最后还是用MySQL分库分表,只把不可变的操作流水推送到HBase里做分析。这就是结构和事务能力共同作用的结果。

6.3 我的五步选型清单(可直接抄作业)

为了方便落地,我把自己的判断流程压缩成五个问题,你在新项目里可以直接过一遍:

  1. 写入模式:是大量顺序追加、偶发更新,还是随机高频更新?前两者偏向LSM或列存,后者传统B+树更稳。
  2. 查询模式:查询条件是唯一键点查、范围扫描,还是对十几列的大规模聚合?点查强时哈希结构或B+树,复合列聚合时列存优势明显。
  3. 事务要求:是否必须要强一致性、多行事务和复杂join?需要就留在RDBMS体系内,别硬切KV。
  4. 数据生命周期:数据会被频繁修改还是基本一次性写入长期保留?频繁修改选行存,不可变追加选列存或LSM。
  5. 运维成本:愿意接受compaction调参、region分裂、列存文件治理这些额外工作吗?如果团队运维能力有限,宁可牺牲一点极致性能也要选熟悉的结构。

我在一个物联网设备状态平台里就是按这个顺序定的方案:设备状态会被高频更新、需要单设备实时点查,这天然指向B+树行存,于是设备状态表用MySQL;设备上报的原始轨迹数据只追加不修改,要按设备和时间段做聚合,这更适合列存,于是用ClickHouse存轨迹明细;两者之间通过实时流同步打通,最终整体的性能和开发效率都让我满意。你会发现,真正称手的方案往往不是一个结构包打天下,而是用结构之间的搭配去对冲各自的短板,这才是理解存储结构后可以做出来的判断。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询