关于列式存储(Column-base Storage)的几个要点解读

〇、前言

上一篇文章中,博主对*行式存储和列示存储*进行了简单的介绍和对比。

了解完它们的基本信息后,还有疑点,那么本文就针对几个常见的疑点进行详细的解读下,供参考。

一、数据是按照列来分别存储的,那么如何写入?

列式存储数据库通过追加写入(Append-Only)机制结合 LSM-Tree 架构实现高效数据写入。

核心在于将数据按列组织并批量处理,特别适合分析型场景的批量加载需求,但对单行更新和删除操作支持较弱。

1.1 列式存储数据库的写入过程的三个阶段

1)内存缓冲阶段

数据首先写入内存缓冲区(MemTable) ,并记录**预写日志(WAL)**以保证数据持久性。

在内存中对数据进行列式转换,将行数据拆分为各列的独立数据流。

批量提交是关键,单次写入多条记录(通常 1000-150000 行)比单行写入效率高 10 倍以上。

2)磁盘落盘阶段

内存缓冲区达到阈值,数据被压缩并转换为列式结构。

作为不可变数据段(Part/SSTable)刷写到磁盘,每个数据段包含单一列的部分数据。

100% 填满设计:列式存储通常将数据块填满,不留空闲空间,最大化 I/O 效率。

3)后台合并阶段

定期执行 Compaction 任务,将零散的小数据块合并为大数据块

优化压缩率和查询效率,同时清理标记为删除的数据。

采用分层结构 (如 LSM-Tree【Log-Structured Merge-Tree】:更适合写密集型场景)管理数据,减少合并开销。

1.2 列式写入与行式写入的本质区别

特征 列式存储 行式存储
数据组织 按列连续存储(ID 列、Name 列、Age 列分开) 按行连续存储(1:Tom:20→2:Jerry:22)
写入方式 追加写入,数据文件不可变 原地修改,支持 UPDATE/DELETE
写入单位 批量写入,单次写入多行数据 支持单行写入,适合高频事务
更新机制 生成新版本记录,旧数据标记为"已废弃" 直接修改原数据位置
删除机制 生成墓碑标记(Tombstone) 直接删除或标记为删除

注意:列式存储将所有写入操作(INSERT、UPDATE、DELETE)统一转化为****追加写入 行为,避免了行式存储中的原地修改。

1.3 列式存储的写入限制与应对策略

1)不支持单行更新:更新操作需要重写整个列,效率低下

应对策略:批量加载优先:使用批量工具(如 DataX、yasldr)替代单行插入。

2)删除操作复杂:需生成墓碑标记并在查询时过滤

应对策略:通过墓碑标记后台定期清理。

3)写入放大问题:频繁更新会导致存储空间浪费

应对策略:避免频繁更新,设计数据模型时考虑"写一次、读多次"特性。

4)事务支持弱:通常不支持 ACID 事务,仅支持批量写入

应对策略:采用混合存储架构,如 YashanDB 采用可变列存支持原地更新,实现毫秒级实时写入。

二、主键和列值是如何关联对应的?

列式存储里不会存储一个显式的"顺序索引"(比如存一个 Row_ID: 1),位置即索引(位置指的就是"物理行号")。

一个数据对象有几列,就会分别保存到几个不同的文件中。通过让所有列文件的第 N 条数据在物理上指向同一行,从而省去了维护复杂索引的开销,并利用同列数据相似的特点进行了极致压缩。

例如,当插入一条包含 5 个字段的记录时:

  • 行式存储:一次性写入 1 个数据块(如:1、张三、25、男、郑州),不同的数据类型仍然存储在连续的物理地址上。
  • 列式存储:需要分别写入 5 个独立的列文件(如:ID.bin→1、姓名.bin→张三、年龄.bin→25、性别.bin→男、地区.bin→郑州),每个文件存储在不同的物理块。

那么如何保证 ID 为 1 的"张三"和 ID 为 1 的"25岁"是属于同一行的呢?

答案就是靠物理偏移量(Offset)或行号对齐。

  • ID.bin 的第 1 个数据,天然对应 Name.bin 的第 1 个数据。
  • ID.bin 的第 100 个数据,天然对应 Name.bin 的第 100 个数据。

实际存储结构更像这样:

  • ID.bin: [1, 2, 3, ...] (第N个位置的值)
  • Name.bin: ["张三", "李四", "王五", ...] (第N个位置的值)

数据库不需要查索引,它只需要读取"第 N 行",直接去各个文件的第 N 个位置抓取数据即可。这就像班级中排列整齐的课桌,每一列的第几位属于一排,都是固定的。

三、数据如何编码(Encoding)来优化存储?

在列式存储中,编码是核心"杀手锏"。它的作用是在通用压缩(如 Snappy、ZSTD)之前,先对数据进行预处理,利用列数据类型相同、数值相似的特点,大幅降低数据的熵,从而让后续的压缩效果达到极致

现代列式数据库(如 ClickHouse, Parquet, OceanBase)通常非常智能,它们会根据数据的特征自动选择 最佳的编码组合。

如下简单列举:

数据特征 推荐编码组合 典型例子
重复值多 (低基数) 字典编码 省份、性别、状态
连续重复 (已排序) 游程编码 排序后的类别、状态流
数值递增/波动小 差值编码 + 位打包 时间戳、自增ID、温度
长字符串/前缀同 前缀编码 URL、文件路径
完全随机/无规律 直接存储 + 通用压缩 UUID、哈希值

下面是对各个编码方式的详解。

3.1 字典编码

这是应用最广泛、效果最显著的编码方式,特别适合低基数(即重复值多、唯一值少)的列

字典编码就是,建立一个"字典"映射表,将原始数据映射为整数 ID

  • 字典:{0: "北京", 1: "上海", 2: "深圳"}
  • 实际存储:0, 1, 0, 2...(原本存字符串,现在存整数)

适用于一些字符串类型的列,如:城市、国家、性别、状态(成功/失败)、枚举值。

只要一列数据的唯一值数量远小于总行数,字典编码就能发挥奇效。

通过字典编码,实现了极高的压缩率 ,把几十字节的字符串变成了 1 或 2 字节的整数。也会很大程度提升查询速度,比较整数(id=1)比字符串(city='北京')要快得多。

另外需要注意,如果列值的重复度不高(即"高基数"场景),强行进行字典编码不仅得不偿失,甚至会导致性能下降和内存溢出。在数据库领域,我们通常把这种情况称为**"字典编码的陷阱"** 。重复度不高的字段,数据库通常不会使用字典编码,而是直接存储原始值并依赖 LZ4 或 ZSTD 等通用压缩算法来处理。

3.2 游程编码

这是一种非常古老但极其有效的编码方式,特别适合排序后的数据或重复值连续出现的场景

游程编码就是将原本要存储的一系列相同值,转换为"值 + 连续出现的次数"的形式。

  • 原始数据:A, A, A, A, B, B, C
  • 编码后:(A, 4), (B, 2), (C, 1)

这样进行编码后,查询时就会按照数字来确认次序。

主要适用于数据经过排序的列。例如,按"时间"排序的日志,或者按"部门"排序的员工表。也适用于状态类数据,可能连续几千条记录都是"在线"状态。

因此,对于连续重复数据,压缩比极高。在聚合查询(如 SUM、COUNT)时,可以直接利用次数进行计算,无需解压所有数据。

在实际应用中,列式数据库(如Parquet、ORC)通常会组合使用这些编码。例如,先对"姓名"列进行字典编码,如果编码后的ID序列恰好是连续的(如1, 1, 1),再对其进行游程编码,实现双重压缩。

3.3 差值编码

这是处理数值型数据的利器,特别适合单调递增或变化幅度小的数据。

差值编码就是不存储原始值,而是存储当前值与前一个值的差值

  • 原始数据:100, 102, 105, 109
  • 编码后:100, 2, 3, 4(第一个存原值,后面存差值)

其他类型:FOR:存储与最小值的差值,适合乱序但范围小的数据;Delta-of-Delta:对差值再求差值,常用于时间戳数据。

适用场景:

  • **时间戳:**时间通常是递增的,差值很小且固定。
  • **自增主键 ID:**差值通常很小。
  • **传感器数据:**温度、电压等数值通常在一定范围内波动。

通过保存差值,将大的数值(如 64 位时间戳)变成极小的数值(如 8 位差值),极大地节省了空间

3.4 位打包

这通常作为上述编码(特别是差值编码)的"后续步骤"来使用。

如果一组数据的最大值很小(比如差值编码后最大只有 10),那么每个数据只需要 4 个比特位就能存下。

位打包会将这些数据紧密地塞在一起,不留空隙。

  • 常规存储:一个整数占 32 位(4字节),哪怕数值只是 1。
  • 位打包:如果数值都很小,可以 8 个数值塞进一个 32 位的空间里。

适用场景:可以配合差值编码使用;也可以在存储布尔值、极小的整数列时。

可以达到存储利用率最高化,榨干每一个比特的存储空间,实现极致的紧凑存储。

3.5 前缀编码

专门针对字符串的优化。

前缀编码主要是利用字符串排序后的公共前缀。

原始数据为三个网址:www.example.com/home、www.example.com/login、www.example.com/profile。

前缀编码过程:

第1行:完整存储 www.example.com/home。

第2行:与上一行对比,前缀 www.example.com/ 共 17 个字符相同。

存储为:[长度:17] + login

第3行:与上一行对比,前缀 www.example.com/ 共 17 个字符相同。

存储为:[长度:17] + profile

原本每行都要存冗长的 www.example.com/,现在这部分巨大的开销被完全消除了。

适用场景:长字符串且前缀重复度高,如 URL、文件路径、长名称。

3.6 编码完成后还需要进行通用压缩

经过第一阶段的编码处理后,数据已经变得非常"规整"和"紧凑"。第二阶段会在此基础上,应用通用的无损压缩算法进行最后的"打包"。

常用算法:

LZ4:追求极致的解压速度 ,CPU 开销低,适合对实时性要求高的热数据

Snappy:在压缩率和速度之间取得良好平衡,是许多大数据框架(如Spark、Kafka)的默认选择

ZSTD(Zstandard):由 Facebook 开发,提供了极高的压缩率和不错的解压速度 ,并且可以灵活调整压缩级别,是目前非常流行的选择。

GZIP:压缩率很高,但速度相对较慢,常用于对存储成本敏感、访问频率不高的冷数据归档。

整个压缩流程可以概括为:按列拆分 → 选择编码 → 通用压缩 → 写入磁盘。查询时则反向操作:读取 → 解压缩 → 解码 → 返回结果。

现代列式数据库(如 ClickHouse、Parquet、HBase)都非常智能,能够根据数据的特征自动选择最佳的"编码+压缩"组合。

数据特征 推荐的"编码+压缩"组合 典型例子
重复值多 (低基数) 字典编码 + ZSTD/Snappy 省份、性别、状态
连续重复 (已排序) 游程编码 (RLE) + LZ4 排序后的类别、状态流
数值递增/波动小 差值编码 + 位打包 + ZSTD 时间戳、自增ID、温度
完全随机/无规律 直接进行通用压缩 (如 LZ4) UUID、哈希值

通过这种两阶段的协同工作,列式存储不仅实现了高达 5-10 倍的压缩比,更关键的是,它通过减少磁盘 I/O、提高内存利用率和 CPU 缓存命中率,最终将节省下来的资源全部转化为了查询性能的飞跃。

四、列示存储为什么对数据的操作(增删改)较慢,查询非常快?

操作 行式存储 (OLTP) 列式存储 (OLAP) 核心原因
插入 快 (追加写) 慢 (需拆解到多列,IO次数多) 物理布局差异
修改 快 (定位行,直接覆盖) 极慢 (需解压-修改-重压缩) 压缩机制差异
删除 快 (标记或物理移除) 慢 (通常仅标记,物理删除需合并) 数据结构差异
查询(单行) 快 (一次IO读完所有字段) 慢 (需从多列文件拼凑数据) 数据连续性差异
查询(聚合) 慢 (读取大量无用数据) 极快 (只读相关列,向量化计算) 列裁剪 + SIMD

4.1 数据的操作(增删改)较慢

在列式存储中,修改数据不仅仅是"改一个值",而是一场"牵一发而动全身"的搬运工。

慢的原因有以下三点。

1)写入放大(Write Amplification)

当插入一行数据(包含 10 个字段)时,列式存储系统必须把这行数据拆解,分别去写10 个不同的文件(ID列、姓名列、年龄列...)。这意味着:插入 1 条数据,磁盘要进行 10 次 IO 操作。如果是机械硬盘,磁头需要在不同文件之间来回跳跃寻址,效率极低。

2)修改成本极高(Read-Modify-Write)

假设要修改第 500 万行用户的"年龄"。行式存储是找到那一行,直接覆盖原来的数据。

但列式存储的步骤就比较复杂了。先读取"年龄列"的数据块;解压数据(因为列存通常压缩率很高);找到第 500 万个值;修改;重新压缩整个数据块;写回磁盘。

这就造成了,因为要改一个数字,要重写几千个数字。

3)删除的"标记"机制

列式存储通常不直接物理删除数据(因为要把后面的数据往前挪,代价太大)。这也是"空间换时间"的策略

它通常使用标记删除(比如生成一个 .del 文件记录哪些行被删了)。真正的删除要等到后台进行数据合并时才处理,这又增加了后台的负载。

4.2 数据查询较快

列式存储利用了分析型查询(OLAP)的特点:"读很多行,但只读很少列"

快的原因有以下四点。

1)极致的列裁剪

假设一个有 100 列的人员信息表,如果要通过年龄列,来算"平均年龄"。

  • 行式存储,必须把每一行的 100 个字段都从磁盘读入内存,然后丢弃 99 个不需要的字段,只留年龄。这浪费了 99% 的 IO 带宽。
  • 列式存储,只读取"年龄"这一列的文件。

如果表有 100 列,IO 量直接减少 99%,这是查询快的最核心原因。

2)向量化执行

行式存储,CPU 处理数据像"单线程工人",一次处理一行(取出一行 -> 处理 -> 取下一行)。

列式存储,数据在内存中是连续排列的(比如 1000 个年龄紧挨着)。CPU 可以使用 SIMD 指令(单指令多数据流),一次性把 1000 个年龄加载到寄存器,并行计算求和。

这就像用高铁将旅客送往目的地,而不是用自行车。

3)压缩带来的"隐身加速"

前边讲到过,列存压缩率极高(比如 10:1)。

行式存储:读取 10GB 数据,磁盘就要读 10GB,内存也要存 10GB。

列式存储:读取 10GB 数据,磁盘只需读 1GB(压缩后),内存只需解压 1GB。

虽然 CPU 多花了一点点时间解压,但磁盘 IO 和内存带宽节省了 90%,整体速度反而快了几十倍。

4)谓词下推

列式存储文件(如 Parquet)会在文件头记录统计信息(比如这一块数据的最小值是 2023-01-01,最大值是 2023-01-31)。

如果查询条件是 WHERE date = '2024-01-01'。数据库先比对统计信息,大于最大值,就会直接跳过。

五、小小的总结

列式存储的核心难点在于,以列为单位的数据组织方式带来的写入复杂性、查询执行机制、数据存储的组织关系等等。

理解这些点需要跳出传统行式存储的思维框架,关注数据局部性、I/O 效率和业务场景匹配度。

在实际应用中,列式存储并非"更好",而是"更适合特定场景"------当面对海量数据分析需求时,其优势才能充分发挥。

相关推荐
٩( 'ω' )و2602 小时前
MySQL基础
数据库·mysql
生命不息战斗不止(王子晗)2 小时前
mysql基础语法面试题
java·数据库·mysql
知识分享小能手2 小时前
MongoDB入门学习教程,从入门到精通,MongoDB应用程序设计知识点梳理(9)
数据库·学习·mongodb
一直都在5723 小时前
Redis (一)
数据库·redis·缓存
字符串str3 小时前
sql的基本技术栈
数据库·sql·oracle
秦jh_3 小时前
【Redis】客户端使用
数据库·redis·缓存
剑之所向3 小时前
DataEase 做大屏,只认 2 种 SQL 格式
数据库·sql·正则表达式
我真会写代码3 小时前
Redis核心特性详解:事务、发布订阅与数据删除淘汰策略
java·数据库·redis
TDengine (老段)3 小时前
TDengine IDMP 工业数据建模 —— 数据标准化
大数据·数据库·物联网·ai·时序数据库·tdengine·涛思数据