ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

列式存储原理与OLAP实战:从Parquet到ClickHouse优化

列式存储原理与OLAP实战:从Parquet到ClickHouse优化 本文想聊的是大数据OLAP场景中绕不开的核心技术——列式存储。如果你折腾过千万级甚至亿级数据的分析任务一定遇到过同样的困惑明明数据量不算夸张MySQL或普通行存表却慢到让人怀疑人生换了ClickHouse、Doris或者把Hive表换成Parquet之后速度却像开了挂。差别就在“按行存”还是“按列存”。这篇文章从底层原理到实际项目落地把列式存储讲透适合正在做数仓设计、搞数据分析优化、或者刚接触OLAP引擎想搞懂“为什么这么快”的朋友读完可以直接用来指导技术选型和调优。1. OLAP场景为什么绕不开列式存储1.1 从一次“慢查询事故”说起先讲一个我自己的例子。两年前帮一个团队做网约车订单数据的分析项目订单表大概有1亿行用MySQL放着。业务方要查“最近一个月每个时段的订单量分布”SQL很简单group by一个小时字段然后count结果跑了将近三分钟。后来我看了下这个任务实际上要把订单表整表扫描一遍每一行无论用不用得上都得从磁盘读出来。这张表一行有几十个字段光订单金额、GPS坐标、司机ID、乘客ID这些就占了接近2KB1亿行算下来就是200GB的读取量。而group by真正用到的字段撑死也就是两三个。这就是行式存储最典型的问题IO放大。在行存格式里一行的所有字段连续放在一起哪怕你只需要其中一个字段也得把整行读进内存。OLAP查询基本都是大范围扫描加聚合计算这种“读多列但只用少列”的模式被行存无限放大。列式存储解决的就是这个根本矛盾把一列的所有值连续存放查询只读取涉及的那几列数据。同样是刚才那个订单表如果只读时段字段你只需要扫大概几GB的数据和200GB差了不止一个数量级。所以我一直跟做数据的朋友说判断一张表该不该列存别看数据量大小要看你的查询模式。如果是按主键查一行、改一行、事务性强行存没问题但只要是“扫描大批量数据、做聚合统计、分析趋势”这种操作列式存储就是必选项。1.2 列存到底改变了什么从物理布局说起列式存储的核心变化是在物理存储层把“行的集合”重新编排成了“列的集合”。一张订单表如果按行存文件里的数据大概长这样订单A的ID、金额、时间、城市、司机……全部挨在一起然后是订单B的同样字段。而列存格式会把这个表拆成独立的列文件或列块所有订单ID放一块所有订单金额放一块所有订单时间放一块。这个排列方式带来的直接好处有三个。第一查询裁剪能力大幅提升只读需要的列IO量成倍下降第二同一列的数据类型一致内容相似度更高压缩率比行存好得多第三因为同一列的数据被连续存放可以对连续值做批量计算这个特性为后面要讲的向量化执行和SIMD优化提供了基础。打个比方行存就像图书馆按“一本书一个架子”来放你想找所有书里提到某个单词的页码就得一本书一本书翻列存则是把所有书的目录页单独抽出来排在一起找关键词只需要翻目录册。做分析的人多数时候看的不是某本书全文而是从不同书里抽出来的同类信息所以目录册式的组织方式天然占优。1.3 行式存储并非“不好”只是用错了地方说实话行式存储并没有被列式存储淘汰也不能被淘汰。MySQL、PostgreSQL这类事务型数据库每天处理海量订单、用户登录、库存扣减靠的就是行存下“按主键快速定位单行”的能力。行存配合B树索引点查一条记录的时延可以压到毫秒级这是列存做不到的。列存设计理念是为吞吐量服务它的单行点查要么退化成全列扫描后筛选要么靠主键索引额外维护映射关系开销都比行存高。二者本质上是两种数据访问模式下的产物。访问模式偏“行”的用行存偏“列”的用列存。下面这个对比表可以帮你快速做判断维度行式存储列式存储数据写入单行写入、更新删除友好大块批量写入更优单行写开销大点查按主键查一条快B树索引直达慢扫描整列后过滤范围扫描与聚合慢IO大快只读相关列压缩率低字段类型混杂高同类型同语义连续存放典型代表MySQL、PostgreSQLClickHouse、Doris、Parquet/ORC适用场景OLTP交易系统OLAP分析、报表、数据湖分析把行存和列存放在“谁更好”的对立面上是新手最常见的误区。技术选型的正确问法不是我该用哪种存储而是我的查询到底在读什么、写什么。2. 列式存储背后那些“看不见”的性能机制2.1 数据压缩同类型数据连续存放带来的红利很多人以为列存的好处只是“少读列”其实列式存储的另一大杀招是超高压缩率。同一列里的值往往高度相似比如订单状态字段一共就“已完成、已取消、进行中”几种取值城市字段就那几十个城市。行存的时候这些值跟订单ID、金额等乱七八糟的类型混排在一起压缩算法很难找到规律列存把这些值归拢到一块之后压缩算法识别规律的难度大幅降低。实际项目中列存格式一般会组合使用多种编码方式。低基数列字段取值种类少比如省份、状态、星期几适合用字典编码或RLE行程长度编码。字典编码的做法是把“北京、上海、广州”映射成0、1、2这样的整数ID数据里只存IDRLE则更进一步把连续重复的“2,2,2,2,2”直接记录成“值2连续5个”。高基数列比如金额、里程数字典编码没意义一般直接上通用压缩算法比如LZ4、ZSTD。排序之后的数据还有额外红利相近的值被排在一起RLE和delta编码的效果会更好压缩率还能再上一个台阶。我自己做实验时看过一个很直观的差距同样是网约车订单数据存成没压缩的文本格式1亿行能占70GB以上转成Parquet列存并启用ZSTD后压缩到大概8GB压缩比接近9:1。这带来的好处不仅仅是省磁盘更重要的是查询扫描时的IO量也跟着缩小了磁盘读得越快查询当然越快。2.2 向量化执行与SIMD让CPU不再“等待数据”列式存储能跟向量化执行配合得这么紧密不是偶然。向量化执行的思路是不再像传统执行器那样一行一行的处理而是一次读入一批数据比如1024行的一个batch然后对这一批数据的同一列做批量运算。SIMD单指令多数据是CPU提供的一种能力一条指令可以同时对多个数据执行相同操作。现代CPU里的AVX2指令集寄存器宽度是256位一条指令能同时处理8个32位整数或者4个64位整数到了AVX-512就能一次处理16个32位整数。配合列存只要把这一批订单金额数据连续放在内存里执行器一条指令就能对16个金额值做累加然后再来处理下一批。这种“批量洗衣服”的方式比传统“一件一件洗”的效率高得多。如果数据是行存的情况就很尴尬同一批数据里这行是金额、那行是城市ID、再下一行是时间戳类型都不同SIMD没法对它们批量运算。所以行存引擎很难做向量化Column-oriented layout是向量化执行的物理前提。这也是为什么现在主流的分析型数据库像ClickHouse、Doris、StarRocks清一色是列存加向量化执行引擎的搭配。它们跑的快的秘诀一半靠低IO另一半靠CPU的高效批量计算。2.3 延迟物化与块迭代减少不必要的数据搬运列存引擎里还有个经常被提及的概念叫“延迟物化”Late Materialization。它的意思是在查询执行过程中尽量不要过早地把分散在各列的字段拼接成完整的行。比如你要算每个城市的平均订单金额执行引擎会先只读取城市列和金额列各自完成过滤和聚合最后才把结果组合成最终输出。整个过程里需要“变成完整行”的数据只有最终那一点点聚合结果。如果反着来一开始就把所有需要读的字段物化成一行行的记录中间会产生大量临时行数据内存带宽和CPU开销全被浪费在“搬运没用字段”上。块迭代则是配合延迟物化的执行方式数据以列块比如ColumnBlock为单位在算子之间流转而不是以单行为单位。这进一步降低了函数调用开销也更好地喂饱了SIMD流水线。理解这个概念对排查性能问题很有用。你在看ClickHouse的查询计划时会看到某些阶段读取列数很少查询最后阶段才物化行这其实都是延迟物化在起作用。如果你发现某个查询慢除了看扫了多少数据还要看执行计划里是不是过早物化了不必要的大字段比如把整个GPS字符串拼进中间结果。2.4 稀疏索引和ZoneMap不读数据也能跳过数据列式存储还有一个被低估的杀手锏就是统计信息级别的过滤。典型的实现方式是在列存文件内部把数据按行组Row Group或数据块划分成多个段每个段在元数据里记录这一列在这个段内的最小值、最大值有的还会记null值数量。查询的时候如果过滤条件里的值不在某个段的[min, max]范围内那整个段就可以直接跳过一个字都不用读。这套机制跟分区裁剪的逻辑有点像但粒度更细。分区裁剪是在表分区级别做的粗过滤ZoneMap是在文件内数据块级别做的精细过滤。比如按时间分区存数据查询某一天的数据分区裁剪能帮你跳过其他所有日期的文件而在某一天的文件内部如果再把数据按小时排序切块每一块的min/max可能进一步压缩扫描范围。好的列存表设计可以把一次全表扫描变成几次小块的精确读取。这里特别提醒一句ZoneMap和稀疏索引能不能生效很大程度上取决于数据的排序情况。如果数据乱序存放比如一个块里既有1月又有12月的订单那块的min1月、max12月你查3月的数据也跳不过这个块只有让数据按查询常用维度排序让相邻数据尽量落在同一范围ZoneMap才能充分发挥作用。3. 从文件格式到数据库列存的两层实现3.1 Parquet大数据生态里的“默认列存格式”聊列式存储不能不提Parquet它几乎成了大数据生态的事实标准。Hive、Spark、Flink写数据湖表时最常推荐的存储格式就是Parquet。Parquet的设计脱胎于Google的Dremel论文有个很特别的能力是处理嵌套数据结构像“用户下的多个订单、每个订单里的多个商品”这种复杂JSONParquet也能高效存储和读取。它通过Repetition Level和Definition Level两套元数据还原出每条记录嵌套层级关系不需要把整个JSON对象一步到位展开。从物理结构上看Parquet文件由多个Row Group组成每个Row Group包含这个行组内所有列的一个Column Chunk每个Column Chunk内部又划分为Page。查询引擎按Row Group读取元数据然后只读取需要的列和Page。这个结构设计得很规整既有列存的存储优势支持谓词下推也保留了行组层面的并行度方便Spark这类分布式引擎拆任务并行扫。实际用的时候我建议关注几个Parquet参数。文件大小控制如果目标文件明显小于64MB甚至32MB说明Row Group太小元数据占比高扫描效率差block size和page size的设置也会影响压缩率和随机读取的粒度开启统计信息和字典编码对低基数列效果尤其明显。Hive建表时用STORED AS PARQUETSpark写数据时可以用parquet.block.size这类参数控制文件块大小这些都是在实际项目里效果最直接的调优点。3.2 ORC与Parquet的差异与选型ORC是另一套著名的列存格式主要由Hive社区推动后来在Hive、Presto、Spark中也有广泛应用。ORC的文件结构按Stripe条带组织每个Stripe包含数据列、索引列和字典信息文件尾部有Footer存储整体信息每个Stripe内部还有独立的索引段记录每列的min/max。这个设计让ORC在Hive生态里的的表现非常稳定尤其Hive数仓里跑聚合和扫描类任务ORC往往比Parquet更省存储。两个格式的对比可以从几个维度看对比项ParquetORC出身Dremel论文Twitter/Cloudera贡献Hive社区 Hortonworks推动嵌套结构支持原生支持repetition/definition级别支持但相对轻量索引粒度Row Group级的统计信息Stripe级和文件级双层索引写入生态Spark、Flink、Hive全家桶都很成熟Hive最成熟Spark适配稍逊压缩效果高基数字段用ZSTD优秀低基数字段和字符串常优于Parquet典型场景数据湖通用格式、跨引擎分析Hive数仓、重聚合任务选型上我的经验是如果主要用Spark做分析优先Parquet生态最顺如果主要跑Hive数仓尤其表结构和查询相对传统ORC也很稳如果同时服务多个引擎Parquet的兼容性更好。另外现在Iceberg和Delta Lake这类表格式底层基本都默认用Parquet做数据文件所以学会了Parquet基本上就是拿到了湖格式的底层技能。3.3 文件列存不等于数据库列存这里必须拎清楚一个概念Hive表用Parquet格式存储确实已经是“列式存储”了但这不等于Hive就是列存数据库。Parquet解决了数据在磁盘上的组织方式问题但查询引擎是否高效利用了列存能力是另一回事。Hive默认的执行引擎在读取Parquet文件时也能做到列裁剪、谓词下推但其执行模型仍然是传统的逐行或逐批次迭代缺少向量化执行和延迟物化的深度优化所以性能天花板明显。真正的列存数据库比如ClickHouse、Doris、StarRocks、Greenplum是存储层、索引层、执行层三层统一设计的。存储上列式组织索引上内置主键稀疏索引和ZoneMap执行上用向量化引擎配合SIMD。三层互相配合才能达到单机数十亿行秒级响应的效果。你拿ClickHouse去分析1亿行订单数据和拿Hive on Parquet分析同样数据体感差异是巨大的但两者底层都用列式思想。我见过不少团队把Hive表转成Parquet之后就以为万事大吉结果查询还是慢到受不了最后才意识到问题出在执行引擎。所以做数仓设计时要想清楚你是要一个轻量的离线分析底座用Hive/Spark加Parquet就足够还是要一个能支撑高并发在线报表和即席查询的系统那必须引入真正的列存OLAP数据库。4. 项目落地从选型到建表调优4.1 不同OLAP场景下列存数据库怎么选现在的列存分析数据库选择非常多但每家的侧重点不一样选错了后期运维很痛苦。我的经验是先分场景ClickHouse适合日志分析、事件分析、大宽表聚合查询单表查询性能极强写入吞吐高但复杂多表join能力偏弱高并发点查也不是强项。Doris和StarRocks走的是MPP路线SQL兼容性好支持标准MySQL协议适合做实时数仓、统一OLAP分析平台既能跑大宽表聚合也能做多表join还能直接支撑报表和即席查询部署运维上比ClickHouse要重一些。Greenplum这类传统MPP数据库则更偏向大规模并行处理的企业级数仓适合海量数据离线分析但组件多、部署维护成本高。选型建议可以套用一个粗暴的判断标准查询以单表大扫描为主追求极致的性能上ClickHouse需要标准SQL、实时写入、多表join、还要统一支撑报表和即席分析上Doris或StarRocks已经在Hadoop体系里沉淀了大量任务只想做数仓加速考虑用Parquet/ORC优化存储加Spark SQL即可不一定要上线新数据库。结合前阵子网上很火的“网约车大数据综合项目”这类场景如果需求是做离线数据清洗加可视化用Hive/Spark加Parquet完全够如果想做成一个能实时更新、后端报表秒级响应的分析平台那就值得引入Doris或ClickHouse把清洗后的明细数据导进去。4.2 列存表设计的几个核心规范建列存表的逻辑跟建MySQL表差别很大。一个常见的坑是把所有索引字段都塞进主键或者把排序键顺序搞反导致过滤条件走不了稀疏索引。以ClickHouse为例表的ORDER BY字段不仅决定数据排序还直接构建稀疏索引。设计排序键时优先把查询中最常见的等值过滤字段放前面比如城市、日期这些低基数字段再放需要范围过滤的字段。给一个直观的建表示例-- ClickHouse 建表网约车订单明细表简化 CREATE TABLE dongche_order_dwd ( order_id String, city_id UInt32, driver_id String, passenger_id String, order_amount Decimal(10, 2), order_status UInt8, order_time DateTime, pickup_lat Float64, pickup_lng Float64 ) ENGINE MergeTree PARTITION BY toYYYYMMDD(order_time) ORDER BY (city_id, order_time, order_id);这里分区字段选了日期排序键第一顺位是city_id。为什么city_id放最前面因为查询很可能是“某城市某几天”的分析先按城市裁剪再按时间范围过滤效率最高。如果把order_id这种高基数字段放第一顺位每次查询都很难利用稀疏索引裁剪等于给每个查询都加了全列扫描的负担。另外ORDER BY字段也影响压缩率排序规整后低基数列的相邻重复值多了RLE效果会好很多。4.3 列存写入慢批量导入才是正确姿势列存数据库普遍有“写入放大”的问题。数据写进来时要排序、要生成索引、要做压缩单条写入效率远低于行存。如果拿ClickHouse当MySQL用一条条insert很快就会被写入性能折磨。正确的姿势是批量导入微批写入攒够一批再统一提交。ClickHouse的实际情况是每次insert都会生成一个data part大批小part产生后后台线程会做合并merge。如果持续高频小批量写入part数量爆炸merge跟不上查询就要读取越来越多的part性能直线下降。这就是为什么社区一直在强调“大批少次”的写入策略比如每分钟攒几万行批量插一次。Doris在这方面的体验好一些因为它的写入模型像数据库支持行级实时导入但在高峰期也需要控制并发度和批次大小。还有一个容易被忽略的点列存表对更新删除支持不好。你如果试图用Update语句高频修改历史数据在MergeTree这类引擎上会非常痛苦每次修改实际是把符合条件的整个part重写一遍。所以列存表适合“一次写入、多次读取”的数据流数据清洗和修正尽量在上游完成别把脏活留给OLAP引擎。5. 实操复盘一次网约车订单分析任务的列存优化5.1 场景与初始方案为了更具体地说明列存优化的效果拿一个典型的“网约车大数据综合项目”场景来复盘。假设业务表是某城市一个月的网约车订单明细大约1亿条记录几十个字段包括订单时间、城市、司机ID、乘客ID、起点经纬度、终点经纬度、订单金额、订单状态等。初始方案是存成Hive文本表按天分区直接用HiveSQL跑每日订单量和热门区域分析。查询长这样SELECT from_unixtime(CAST(order_time / 1000 AS BIGINT), yyyy-MM-dd HH:00) AS hour_slot, city_id, COUNT(*) AS order_cnt, SUM(order_amount) AS total_amount FROM ods_taxi_order WHERE order_time 2024-06-01 AND order_time 2024-06-08 AND order_status 1 GROUP BY hour_slot, city_id;这个任务其实只用到order_time、city_id、order_amount、order_status几个字段但文本表没有列裁剪能力每个Map任务都得把整行读进来解析。1亿行乘每行几千字节扫描量直接到了几个TB级别。我当时的实测是查一周的数据跑了大概110秒对业务方来说太慢了。5.2 改造过程与效果对比改造分三步走。第一步把数据重新写入Parquet列存分区表按天分区并且写数据时用sortBy指定了order_time排序第二步查询入口保持不变HiveSQL的读取引擎自动走Parquet的列裁剪和谓词下推第三步把hive.exec.orc.split.strategy和Parquet相关参数调了一下确保每个文件大小合理避免小文件过多导致NameNode压力大和扫描任务碎片化。改造完以后的效果非常明显。数据量从70GB左右压到了8GB上下压缩比接近9:1查询耗时从110秒降到12秒左右而且这个提升几乎不需要改业务SQL。提升的核心原因就是第一章和第二章讲的原理只读了四个字段而不是整行IO量降了一个数量级Parquet的Row Group统计信息帮引擎跳过了大量不满足order_time和order_status条件的数据块ZSTD压缩进一步缩小了读取量。后来看Spark的执行计划Streaming聚合阶段读取的字节数从原先的几百GB降到了二十几GB这就解释了为什么查询快那么多。5.3 排序键和分区键的真实影响在另一个验证里我把同样的数据导入了ClickHouse测试了不同ORDER BY设计的查询差异。第一版排序键是(order_time, city_id)查询一周数据加指定城市时ClickHouse虽然也用上了分区裁剪但每个分区内还要扫描相对多的数据块因为时间范围覆盖了多个分区城市过滤在分区内起的作用有限。改成(city_id, order_time)之后同一个城市的全部数据在物理上排到一起查询城市维度时稀疏索引直接命中少量granule扫描数据量进一步下降查询耗时有30%左右的提升。这说明一个设计规律分区键解决“从所有文件里选出哪些文件”的问题排序键解决“从选中文件里读出哪些数据块”的问题。两者是串联关系都把好钢用在刀刃上查询才会真正快。如果分区键和排序键都选不好那列存的底层能力就浪费了七八成你只是在用行存的心态用列存。6. 常见问题与排查技巧实录6.1 建表看起来没问题但查询还是慢查列存数据库慢查询我一般按下面几个步骤来排查。第一步看读取量ClickHouse用profile事件看ReadRows和ReadBytes如果发现明明只查几列ReadBytes却很大说明列裁剪没生效或者数据压缩率差。第二步看过滤有没有下推日志里如果扫描了所有分区说明分区键没被查询条件覆盖检查SQL里的过滤字段和表的分区键是否对齐。第三步看有没有走稀疏索引查询过滤字段如果不在排序键里ClickHouse只能全量扫描该字段构建过滤结果io成本翻倍。一个很典型的坑是在ClickHouse建表时把ORDER BY和PRIMARY KEY搞混以为PRIMARY KEY才是索引。实际上ClickHouse的稀疏索引由ORDER BY决定PRIMARY KEY只是去重和数据跳跃的辅助工具。如果你的过滤字段没出现在ORDER BY里性能基本靠运气。6.2 列存写入慢到底慢在哪写入慢的常见情形是高频小批量insert。ClickHouse每次insert生成独立partpart太多时select并行度受限于part数merge线程长期繁忙写入和查询互相抢资源。解决办法有两个方向一是上游Kafka或日志系统攒批按分钟级别刷数据二是改用buffer引擎或者直接用分布式表批量摄入把单次写入行数抬上去。Doris和StarRocks这类MPP数据库写入体验稍好支持小批量实时写入但如果并发写过多也会出现版本合并压力。实践里可以观察BE日志的compaction耗时如果很长就要降低写入频率或增加副本数分担压力。总体思路就是列存数据库不是用来承接高频单行写入的你要在前面加一道缓冲层。6.3 压缩率忽高忽低是怎么回事压缩率与数据分布和排序情况强相关。同一张表如果数据按时间乱序写入状态字段的RLE效果会很差如果把状态和城市这类低基数字段排到排序键前部压缩率会明显提升。另一个影响因素是字段本身的基数高基数的订单ID、GPS坐标用什么编码也压不下去但可以靠ZSTD这类通用算法做二次压缩效果也不差。还有一个经验是字符串字段尽量用定长或字典化处理。比如城市名“北京市”直接存UTF-8字符串在Parquet里走字典编码效果还行在ClickHouse里则建议转成枚举类型或者UInt32编码既降低存储又提升过滤和聚合速度。表设计阶段多花半小时做的字段类型优化可能在查询性能上是几个小时调优都换不来的收益。我个人的体会是列式存储不是银弹它是对数据访问模式的一种精准回应。做数仓设计时想清楚你的查询到底读哪几列、过滤哪些字段、按什么维度聚合要比纠结“谁家列存更强”更先一步。把这一层想明白无论你最后选Parquet、ORC、ClickHouse还是Doris都能把列存的红利吃到最大而不是迷信某一个组件本身。
返回列表