列式存储如何实现高效的数据查询与分析?

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