〇、前言
上一篇文章中,博主对*行式存储和列示存储*进行了简单的介绍和对比。
了解完它们的基本信息后,还有疑点,那么本文就针对几个常见的疑点进行详细的解读下,供参考。
一、数据是按照列来分别存储的,那么如何写入?
列式存储数据库通过追加写入(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 效率和业务场景匹配度。
在实际应用中,列式存储并非"更好",而是"更适合特定场景"------当面对海量数据分析需求时,其优势才能充分发挥。