1. 先搞清楚:列式存储到底解决了什么问题
1.1 行式存储的“历史惯性”是怎么来的
在聊列式存储之前,有必要先把行式存储(Row-based Storage)的家底翻一翻。传统关系型数据库,比如 MySQL、Oracle、SQL Server,数据在磁盘上都是按行来存放的。你可以把一张表想象成一本“花名册”,每一行就是一个人,行式存储就是先把张三的姓名、年龄、电话、住址都写完,再换下一行去写李四的信息。这种组织方式有一个天然的好处:处理单条记录时,一次性就能把这行所有的列都读出来,不需要再做拼接。传统的OLTP(联机事务处理)场景请求模式高度集中在增删改查,读写往往落到某一条或少数几条记录上,行式存储的工作方式跟这种业务模型天然匹配,所以这么多年一直在统治着数据库世界。
但问题在于,当数据量从GB级膨胀到TB甚至PB级时,业务方开始频繁跑“统计某个时间段内、按某个维度分组、对某个指标求和”这种分析型查询,行式存储的短板就完全暴露出来了。这类查询往往需要把整张表大部分列扫一遍,但真正参与计算的列可能就三五个。以一张用户行为表为例,假设有三十个字段:用户ID、设备型号、操作系统、网络类型、页面URL、事件类型、事件时间、停留时长……你在做SELECT 事件类型, COUNT(*) FROM 行为表 WHERE 事件时间 BETWEEN ... GROUP BY 事件类型这种统计时,数据库引擎为了找到“事件类型”和“事件时间”这两列,需要把每一行的三十个字段全部从磁盘拖上来,然后在内存里丢掉不需要的字段。磁盘I/O是白花花地烧掉了,但绝大多数数据根本没参与任何计算。这种查询在数据量小的时候没感觉,一旦表里有几亿行,延迟立刻飙升到秒级甚至分钟级,业务部门的投诉也就跟着来了。
也就是说,行式存储的“历史惯性”,本质上是围绕OLTP场景设计的优化方向,而大数据分析场景的工作负载和访问模式完全不同。它就是列式存储出现的根本原因:让数据按照列来组织存储,从物理结构上避开“读了很多不需要的列”这个问题。
1.2 列式存储到底是怎么存数据的
列式存储(Columnar Storage)的核心,是把同一列的数据连续存放在一起。还是拿那张用户行为表打比方,如果换成列式存储,磁盘上的物理布局就变成了:所有用户ID挨在一起、所有设备型号挨在一起、所有事件类型挨在一起……查询的时候,引擎只需要把“事件时间”和“事件类型”这两列的存储块读上来,其余28列的存储块完全不用碰。你别小看这个差别,在一个列数很多、行数很大的分析表里,I/O量能减少一个数量级以上,查询提速十倍上百倍都是纸面数据。
列式存储能在大数据领域站稳脚跟,不只是靠省I/O,它还有几个行式存储很难做到的“武器”:
高压缩率:同一列的数据类型一样、语义相近,甚至很多列的直接基数值很低。比如“操作系统”这一列,来来去去就是iOS、Android、Windows、macOS、Linux这么几种,去重之后枚举值很少。压缩算法对这类数据特别有效,可以用字典编码、位图编码等方式把体积压得很小。实际项目中,列式文件的压缩比做到5:1到10:1很常见,极端情况下几十倍的压缩我都见过。数据文件变小了,存储成本下降是次要的,更关键的是查询时从磁盘搬到内存的数据量也变小了,这条链路又省了一道I/O。
数据跳过(Predicate Pushdown + 统计信息过滤):列式存储格式通常会在文件里维护每列的最小值、最大值等元数据。查询带了过滤条件时,先在这些统计信息上做一次粗筛,跳过根本不可能存在符合条件数据的文件块。这个机制在时间范围过滤上效果极其明显。比如你按天分区存了一百天的数据,但只查最近三天,直接就能跳掉九十七个分区的文件,写法和效果都比行式存储优雅太多。
向量化执行:列式布局天然契合现代CPU的SIMD(单指令多数据)能力。数据在内存里连续排列,可以直接按批量处理,一批处理几千几万行,CPU的流水线利用率高很多。这个优势是行式存储想做也做不出来的,因为行式布局的数据在内存里是离散错落的,没法高效批量喂给CPU。列式存储+向量化执行引擎,是当前大数据分析性能的重要来源。
1.3 什么样的企业场景适合上列式存储
列式存储不是银弹,别指望它能把所有性能问题都治好。从业界经验看,满足这几条的场景值得认真考虑:
- 读多写少、且读取以大规模扫描和聚合为主。最典型的就是BI报表、用户行为分析、流量日志分析、财务对账分析。
- 表结构宽,但每次查询只关心少数列。列数十几列以上,查询用到两三列,这就非常适合列式。如果表一共就五六个字段,每次还都是
SELECT *,那列式的优势会被明显削弱。 - 数据按时间批量进入,很少做单行更新。列式存储在单行级更新上天生弱势,像“改某一行的一个值”这种操作在列式存储里往往要重写整个文件或者做一个标记合并。所以它不是用来做OLTP事务系统的。
- 数据量到了一定规模。多少算“一定规模”?我个人的经验是,单表数据量至少上亿行,或者说分析查询单次要扫的数据量超过几个GB,这时候列式存储的优势才能拉开差距。几十万行的小表,行式存储可能更快,没必要为了“先进”强行上列式。
反过来讲,如果你的系统主要做高并发点查,查询条件总能命中唯一索引,或者需要频繁更新单行、依赖强事务约束,那老老实实用行式数据库才是正解。列式存储解决的是“大数据量下的复杂分析”这个特定问题,这一点先看清楚了,后面的技术选型才不容易跑偏。
2. 技术选型:从文件格式到列式数据库
2.1 文件层面的列式:Parquet与ORC到底怎么选
确定了要上列式存储,第一步要决定的是数据在文件层面用什么格式落盘。在大数据生态里,Parquet和ORC是普及度最高的两种列式存储文件格式,Hadoop生态里的绝大多数组件都原生支持它们。
Parquet源于Twitter和Cloudera的合作项目,后来捐给了Apache基金会。它的设计目标跟Dremel那套嵌套数据模型有关系,支持非常复杂、带有嵌套结构的schema。如果你手上的数据是JSON风格的带嵌套结构的日志,Parquet能原样表达嵌套关系而不需要展平。Parquet在跨生态兼容性上做得也很扎实,几乎整个大数据圈子——Spark、Hive、Flink、Impala、Presto/Trino、Drill——都对它有良好的读写支持。
ORC是Hortonworks牵头搞的格式,最初是Hive的配套存储格式,后来也被其他引擎支持了。ORC对Hive的适配最顺畅,内部内置了轻量级索引(Row Group Index、Bloom Filter Index),在某些过滤查询下能省掉大量I/O。ORC的压缩效率在多数场景下略微优于Parquet,尤其对字符串列的压缩表现更好。
怎么选?我的建议是三个维度:
- 如果团队已经深度绑定Spark或者Presto/Trino,优先Parquet,生态兼容性最好,遇到问题能在社区快速找到答案;
- 如果技术栈以Hive为核心,且主要跑MapReduce或Tez作业,ORC的Hive集成度更高、性能更好;
- 如果数据有大量嵌套结构,需要灵活表达复杂类型,选Parquet更顺手。
表格放这里,方便直观对比:
| 对比维度 | Parquet | ORC |
|---|---|---|
| 开发团队 | Twitter/Cloudera | Hortonworks |
| 嵌套数据支持 | 原生支持,能力强 | 支持有限 |
| Hive集成 | 好 | 最佳 |
| 轻量级索引 | 有限 | ACID、布隆过滤器等 |
| 跨引擎兼容性 | 极佳 | 良好 |
| 综合压缩率 | 良好 | 多数场景略优 |
2.2 数据库层面的列式:ClickHouse与Doris等
文件格式解决的是“数据怎么落盘”,但真要给分析师提供一个可交互的查询平台,光有文件还不够——需要一个能直接对外提供服务的查询引擎。市面上主流的列式数据库产品我挑几个聊一下。
ClickHouse给我的感觉是“快得简单粗暴”。它是一款真正的列式数据库管理系统,索引机制以稀疏主键索引为主,用跳数索引做辅助,配合极致的向量化执行和异步多线程IO,单表聚合查询的性能非常惊人。国内很多互联网公司拿它做用户行为分析、监控指标存储、A/B实验数据分析,单机就能扛起很高的QPS,集群扩容也相对容易。它的短板在于:高并发点查和复杂join能力弱于传统MPP数据库,更新和删除需要通过轻量级突变(Mutation)机制,语法限制也比较多。ClickHouse是一个偏“分析型”的单表扫描利器,你要有心理准备。
Apache Doris是源自百度广告报表场景的开源项目,后来捐给了Apache基金会。它的定位更偏MPP分析型数据库,支持标准的MySQL协议,对BI工具的兼容性好。Doris在“明细表+聚合模型”的处理上做得比较顺手,支持Unique模型、Aggregate模型和Duplicate模型三种数据模型。如果你的团队有较强的MySQL背景,上Doris的学习曲线会比ClickHouse低不少,因为这货的SQL方言非常接近MySQL,分析师几乎不需要重新学习。
Apache Druid在时序分析和OLAP切片查询上有自己的优势,跟ClickHouse有部分场景重叠。它自带时间分区和段(Segment)管理,特别适合时序指标查询这类场景,比如监控曲线、实时大屏。不过它的上手复杂度相对高一些,导入链路比较重。
选择哪款,建议从四个维度评估:
- 现有技术栈是偏向Hadoop生态还是偏向MPP数据库,这决定了团队的学习成本和运维难度;
- 业务查询模型是单表大宽表聚合多,还是多表join多、更新频繁;
- 数据实时性要求高到什么程度,秒级还是分钟级导入;
- 服务可用性和并发控制要求,ClickHouse的并发能力相对有限,Doris在多用户并发场景下更从容。
2.3 选型前必须想清楚的两件事
第一件事:列式存储解决的是“分析变快”,不是“所有问题变快”。我之前见过有团队把在线订单系统直接切到ClickHouse,结果发现单行更新、事务一致性完全搞不定,又花了大把时间迁移回去。列式数据库不适合当业务系统的主库,更合理的方式是:业务主库保留在MySQL/PG这类行式数据库里,通过数据同步工具把数据同步到列式存储的仓库中,在分析侧发挥它的价值。
第二件事:数据模型设计要跟着查询走,不要跟着习惯走。行式数据库建模讲究三范式,尽量消除冗余。但列式存储不同,它的最佳实践恰恰是“宽表化”——把分析要用的维度字段都冗余到一个大表里,用空间换查询效率。你要转变建模思路,从“这张表该怎么存”变成“这个查询该怎么读”。这个思想转变,往往是项目从行式迁移到列式时团队最难适应的一关。
3. 企业落地案例拆解
3.1 案例一:用户行为分析平台从MySQL集群迁到ClickHouse
之前帮一家电商公司做过一次用户行为分析平台的改造。他们的业务部门要看的核心指标是:每天各渠道的独立访客数(UV)、页面浏览量(PV)、人均浏览次数、转化漏斗等。原始数据链路是前端埋点上报到Kafka,然后消费写入MySQL集群。前期数据量小的时候MySQL还能扛,后面随着推广渠道变多、埋点事件越来越细,行为日志单日量涨到了3亿多条,MySQL的统计查询开始频繁超时,分析师跑一个跨三十天的漏斗报表,经常要等十几分钟甚至报错。
改造方案比较直接:日志链路保持不变,写入端增加一层流式ETL,把Kafka里的JSON日志解析成结构化数据,直接写入ClickHouse的分布式表。ClickHouse集群采用3分片x2副本的部署方式,每台机器64GB内存,2TB NVMe SSD。行为日志表按天分区,以事件时间作为分区键,以用户ID作为分片键保证同一用户的数据落在一个分片上。在数据建模时,把维度字段(渠道、页面、设备、地域)全部冗余成列,不做多表join。
上线后的效果:单个查询从原来几十秒到几分钟,压到了几百毫秒到两三秒。最直观的收益是分析师的自助探索粒度变细了——以前只能看日报周报,现在可以直接按小时去下钻,业务响应速度完全不是一个级别。
这个案例里最值得借鉴的不是“换了个数据库”,而是数据接入链路的搭建方式。很多团队在迁移时容易忽略“历史数据怎么回填”这个问题。当时我们是先把存量三个月的MySQL历史数据用一条Sqoop/ DataX 同步任务导到ClickHouse,然后才接Kafka实时链路。回填和实时同步之间有一个时间重叠窗口,因为Hive/HDFS上的历史行为日志文件还在,直接通过本地文件导入补了一版,再从Kafka消费增量数据,通过去重逻辑保证不重复统计。
3.2 案例二:BI报表提速——Presto + Parquet做数据湖分析
另外一个场景是制造业集团的经营分析报表。他们之前的数据仓库是建立在Hive上的,底层文件格式是TextFile和SequenceFile,Hive跑月报任务动辄跑几个小时,BI前端展示的数据延迟一天以上。业务部门想看到当天的经营数据,技术部门只能眼巴巴等着离线任务跑完。
改造核心是把Hive默认存储格式从TextFile/FewSequenceFile切换为Parquet,同时引入Presto作为交互式查询引擎。数据文件格式切换之后,存储占用直接降了近七成——同样一份历史数据,TextFile占12TB,Parquet压缩后只有不到4TB。查询性能提升更明显:一张核心销售明细表有接近二十亿行、四十多个字段,老Hive跑一次按月汇总需要四十多分钟,同一份数据用Presto在Parquet文件上跑,只需不到五十秒。
有人可能觉得,换掉Hive的存储格式而已,怎么提升这么大?这里有两个底层原因:
- 第一,列式跳过机制帮助过滤了无关数据。Hive跑MR任务时,即使只用到两三个字段,也要把整行数据解析出来,TextFile的每条记录都要做一次反序列化;换成Parquet后,Hive的InputFormat可以只读取需要的列,字段少的时候效率提升非常显著。
- 第二,Presto本身就是一个分布式内存计算引擎,它自己管理内存,不依赖MapReduce的落盘调度,加上Parquet文件自带丰富的统计信息,过滤条件下推到文件扫描层,Cloudera早年的性能测试也验证了类似结论。
这里还有一个实战细节:Parquet文件在做小文件合并时,可以用Spark的repartition或coalesce控制输出文件数量。小文件太多是列式存储布局下最容易踩的坑之一,后面我单独开一章节详细说。
3.3 案例三:日志分析平台——宽表加位图索引的极致组合
第三个案例是一家在线教育公司,他们的业务要查询“过去三十天内,学过某门课的活跃用户中有多少同时学过另一门课”,这类场景天然适合先物化成用户-课程关系的宽表,再做快速集合分析。
这个场景用到了Doris的Bitmap(位图)索引能力。Doris可以使用BITMAP类型存储用户ID集合,比如把“学过课程A的用户ID列表”存储为一个Bitmap,把“学过课程B的用户ID列表”也存储为一个Bitmap,然后通过BITMAP_INTERSECT做集合交集计算,如果配合聚合模型预先物化,按照课程维度和日期维度提前聚合,性能会非常强悍。
当时他们的解决方式是对用户行为明细表做了三层建模:
- 明细层(Duplicate模型):保留所有埋点事件原始数据,按天分区,存储全部字段。
- 聚合层(Aggregate模型):按课程、用户ID、学习时长做预聚合,按照Doris的聚合模型自动合并相同键的值,剔除重复上报。
- 应用层(Bitmap模型):把活跃用户ID列表物化为Bitmap类型,专门服务“共同学习人数”“DAU交集”这类集合计算。
最终这套系统支撑了实时课程推荐、教学运营每周复盘、投放ROI分析等好几条业务线。这个案例的核心收获是:列式存储擅长把“按行扫描”转化为“按列批量计算”,而BitMap进一步把“按列批量计算”转化为“集合运算”,这就是大数据分析越来越“算子化”的趋势。
3.4 回顾:这些案例背后共同的数据治理逻辑
把三个案例放一起看,能总结出几条共通的规律:
- 入口统一到Kafka或文件落地,中间做流批一体处理,出口给多套引擎。列式文件(Parquet/ORC)在HDFS或对象存储上是“数据湖”的事实标准,列式数据库(ClickHouse/Doris)则是“数据服务层”的加速引擎。两者不是替代关系,而是上下游配合。
- 宽表化建模。反复出现的建模手段是“把维度展平、把指标冗余、把粒度定义清楚”。列式存储不怕宽表,怕的是宽表没有清晰的分区和有序性设计。
- 压缩策略前置决策。用什么压缩算法、什么排序键,会影响查询性能的50%以上,必须在一开始就确定,而不是等数据上了量再回头调。
4. 实操过程:从行式到列式迁移的核心细节
4.1 迁移前的建模准备
很多团队在一开始就把迁移做歪了,原因是上来就考虑“怎么搬数据”,却没先考虑“搬过去之后长什么样”。迁移前必须把这几件事定下来,最好是写进设计文档里:
第一,明确表的粒度和主键语义。列式存储的更新模型比较弱,最好一开始就把行的唯一性定清楚。例如用户行为明细表用“事件ID”或“时间+会话+事件序号”作为唯一标识;订单事实表用“订单ID”。不要等到数据进去之后再发现重复或歧义。
第二,确定分区键和排序键。分区键决定数据按什么维度物理切分,排序键决定同一分区内数据按什么顺序排列。分区键通常选时间(天/小时),排序键通常选高频过滤字段。拿ClickHouse举例,一张订单明细表的建表语句可能长这样:
CREATE TABLE order_detail ( order_id String, user_id UInt64, product_id UInt64, channel LowCardinality(String), status LowCardinality(String), amount Decimal(18, 2), order_time DateTime ) ENGINE = MergeTree() PARTITION BY toYYYYMMDD(order_time) ORDER BY (order_time, channel, user_id);分区键选了天,排序键选了“时间 + 渠道 + 用户ID”。这样的话,“查某天的某个渠道”可以在分区裁剪和主键前缀过滤的双重作用下,只扫极少的数据块,查询速度会非常理想。
第三,给列选择合适的数据类型。能用数字的不要用字符串,能用Int8的不要用Int64,能声明LowCardinality的尽量声明。列式存储对数据类型的敏感度极高,同样的数据用Int32存和用String存,磁盘占用和查询性能能差出一大截。
4.2 两条主流落地路径:离线批量 vs 实时增量
从实战看,行式到列式的迁移路径一般分成两条:
路径A:离线全量 + 增量追平。白天业务低峰期,把源库全量数据导出一份到列式存储。之后,再通过监听业务库的binlog(如MySQL的binlog)或消息队列,将增量变更数据投递到列式存储。这套方案比较平稳,适合从MySQL/PostgreSQL迁移到ClickHouse/Doris的常规场景。
具体操作时,可以先用DataX或者Sqoop做全量初始化,脚本大致是这样的思路:
# DataX将MySQL订单表全量导入临时表 datax.py -p "-Dtable=order_detail" job/mysql_to_clickhouse.json全量任务跑完后,再把binlog同步链路启动,从全量快照的时间点开始消费增量的变更事件。这样能保证最终一致性。注意:全量导入期间,源业务库仍然在继续产生新数据,所以binlog同步的起点必须包含全量快照时间之后的所有变更,否则数据会有空洞。
路径B:数据湖格式 + 流批一体。先把数据落到Parquet/ORC文件,再通过引擎做批量导入。适合Hive数仓迁移或已经有数据湖底座的公司。用Spark批量将历史数据转换成Parquet并做压缩、合并小文件,然后用INSERT INTO ... SELECT把Parquet数据灌入列式数据库。
// Spark读取Hive表,按分区写成Parquet,控制输出文件大小 spark.sql("SELECT * FROM ods.order_detail_daily") .repartition(col("dt"), col("channel")) .write .partitionBy("dt") .option("compression", "snappy") .mode("overwrite") .parquet("/data/warehouse/ods/order_detail_daily")4.3 数据迁移期间的常见坑
坑一:历史数据量过大,一次性导入占满磁盘或IO。建议分批迁移,比如按月份逐月迁移,或者按业务线分开迁移。迁移期间要监控目标库的磁盘使用率和CPU负载,不要追求一天搬完,稳比快重要。
坑二:时间字段时区问题。这是迁移中非常容易出错的地方。源库存的是UTC时间还是北京时间?导入到列式存储后是否统一转换?一定要在开始之前约好。之前见过一个项目把所有历史数据导完之后,发现报表上每天的“凌晨”流量都少了一块——因为导入脚本用了本地时区做分区,把部分UTC时间算到了前一天。
坑三:数据校验缺失。迁移完之后,一定要做全量校验或者抽样校验。最简单的方法是分别对源库和目标库跑COUNT(*)以及关键指标的SUM,对比结果是否一致。但COUNT和SUM只能发现数据多或少,发现不了“错”。更稳妥的做法是抽样若干条记录,逐一比对字段值;如果数据量特别大,可以在Checksum字段上做一次哈希比对。
5. 查询性能调优:压缩、索引与数据布局
5.1 压缩算法:省空间与快查询之间的取舍
列式存储支持的压缩算法五花八门,Snappy、LZ4、Zstd、Gzip、Zlib各有侧重。我的经验判断是:
- Snappy和LZ4:解压速度快,CPU开销低,但压缩率一般。适合查询延迟敏感、存储成本不敏感的场景。LZ4在ClickHouse里的Raw模式解压速度极高,适合高频查询的明细表。
- Zstd:Facebook开源的压缩算法,压缩率和解压速度平衡得非常好。如果业务对压缩比有要求,又不希望查询性能明显下降,选Zstd基本不会错。Parquet和ORC都支持Zstd,ClickHouse的默认推荐压缩算法之一也是Zstd。
- Gzip/Zlib:压缩率高但解压慢,一般只适合存那些极少被在线查询、仅作归档用的数据。
我见过很多企业把全库的数据都统一用Gzip压缩,导致查询性能很差。比较合理的方式是按冷热分层:热数据用LZ4或Zstd,冷数据或归档数据用Gzip或Zstd高压缩级别。这个决策可以在表级别、甚至分区级别完成。
5.2 主键索引、稀疏索引和布隆过滤器
列式数据库的索引跟MySQL那种B+树不太一样。ClickHouse用的是稀疏主键索引——它不会给每一行都建索引,而是每隔N行记录一次主键值。这样索引体积非常小,可以完全加载到内存中。查询时,先快速定位到可能的行组(Granule),再在行组内部做线性扫描。这个设计对“大数据量+高吞吐扫描”非常友好,代价是单行精确查询的性能不如B+树。
Doris则支持在列上单独创建Bitmap索引和Bloom Filter索引。对于低基数列(如“渠道”枚举值只有几个),BitMap索引非常高效;对于类似“用户访问过的页面URL”这种高基数且等值查询的概率不高的情况下,Bloom Filter在IN和=过滤中也有帮助。但注意:高基数列建BitMap索引会大幅增加存储开销,所以要评估字段的实际基数和查询频率,不是给所有列都上索引就是好事。
5.3 数据倾斜:分桶键选择不当的连锁反应
列式存储数据倾斜的后果,比行式存储严重得多。拿ClickHouse的分布式表来说,数据写在分布式表上会自动路由到每个分片节点。如果你的分片键选得不好,比如订单表的分片键选了“有大量NULL的渠道字段”,那么大量NULL值会被路由到同一个分片,某个节点的磁盘和CPU被瞬间打满,其他节点却很闲。整体查询“木桶效应”明显——最慢的那个分片决定了全链路耗时。
解决办法是分片键尽量选高基数且分布均匀的字段,最常用的就是用户ID或订单ID。如果用户ID也存在分布不均,比如某个大客户贡献了全表50%的数据量,那就需要在建模时做一些打散处理,比如给用户ID加上一个随机后缀再取模,让数据更均匀地散布到各个分片上。这会稍微增加查询侧的扫描量,但能解决严重的数据倾斜问题,总体性价比是值得的。
5.4 更进阶:预聚合与物化视图
列式存储不是万能的,即使它能扫得很快,面对几十亿行的明细数据做COUNT(DISTINCT user_id)这种精确去重统计,也依然要付出高昂的计算成本。真正高效的方式是“让数据在导入时就算好”。
以Doris为例,Aggregate模型天然支持预聚合。你在建表时指定AGGREGATE KEY,数据在导入时,相同KEY的多条记录会自动按照聚合函数合并。而ClickHouse则提供了物化视图(Materialized View),数据写入MergeTree表时,物化视图的聚合结果会同步增量更新,实现导入即聚合的效果。
建议在架构设计阶段就识别出哪些统计指标是高频且固定的,把这些指标通过预聚合提前算好,明细数据仍然保留在底层,这样既保留了下钻分析的能力,又能给常规报表提供秒级响应的性能。这就是“多层加速”的核心打法:明细层用列式存储保证灵活性,聚合层用预聚合保证极致响应速度。
6. 常见问题与排查技巧实录
6.1 为什么列式存储查询反而变慢了
有个朋友前段时间很困惑地对我说,他们的查询从MySQL迁到ClickHouse后,某些SQL反而慢了一倍。我让他把执行计划打出来看一下,很快发现了问题:SQL里包含了对非分区键字段的高频过滤,但建表时没有为这个字段设置任何索引,导致ClickHouse只能做全表扫描。很多人的思维惯性是从MySQL带过来的,觉得“查询快的重点是建索引”,但列式存储的真正优势不在索引,而在于“尽量少读数据”。如果你的表设计不能帮助引擎在文件层面跳过无关数据,那性能打折扣是必然的。
排查思路一般是这样:先看查询是否命中了分区裁剪,再看是否命中了排序键前缀,再看过滤条件是否被合理下推到文件扫描层,最后看有没有进行全表扫描和高基数序列化。逐层排查,问题通常出在其中某一环。
另外还有一个容易被忽视的问题:一次查询如果同时使用了多个复杂的聚合函数,比如同时算多个COUNT(DISTINCT),内存开销会指数上升。遇到这种情况,不妨拆成多条查询或者预先做精确去重物化。
6.2 写入性能差:小文件是万恶之源
小文件问题可以说是大数据场景下的头号敌人。列式存储虽然查询时有优势,但如果一个表里塞了成千上万个很小的文件,光是打开文件的元数据开销就能拖垮整个查询。数据导入时,如果上游Spark作业的并行度设得过大,每个并行任务输出一个很小的Parquet文件,而且每个文件只有几百KB,那么后续所有查询都会很痛苦。
ClickHouse对这种问题的处理方式是后台自动做分区合并(Merge),但Merge本身也消耗I/O资源。Doris同样有compaction机制,但吞吐量大的写入场景仍然需要控制批次大小。最佳实践是:导入数据时合理控制批次和并行度,让每个生成的文件大小至少落在128MB到1GB区间范围内。
6.3 更新数据总是“不生效”
这个问题几乎每个列式数据库的新手都会踩。ClickHouse的UPDATE和DELETE是异步的轻量级突变操作,执行后不会立刻生效,你需要通过SELECT ... FINAL或者在等待后台异步执行完成后再查询才能看到结果。更关键的是,频繁执行突变操作会产生大量临时数据,影响后续查询和磁盘空间回收。
Doris的Unique模型可以通过REPLACE语义实现行级更新,但也要求你正确设定分桶键和主键,乱更新一样会出现大量版本合并开销。
所以列式数据库的最佳实践不是频繁“改数据”,而是“直接写新版本的数据”。用分区或版本号来区分新旧数据,查询时只读取最新版本,改造的思路是把“原地更新”变成“追加写入+视图切换”。这个方法刚开始团队会觉得不习惯,但一旦跑通,后续的维护成本和查询性能都会非常理想。
6.4 列式存储的运行时运维要点
最后补充几个日常运维的要点,都是亲身踩过坑才总结出来的:
- 监控磁盘I/O和后台Merge/Compaction状态,不要让后台合并任务和前台查询抢资源,尤其在业务高峰期。
- 定期检查表的健康状况,比如碎片率、文件大小分布、冷热数据是否合理分层。
- 数据保留策略要尽早规划。列式数据库存储文件体积相对较大,如果保留30天数据和保留90天数据对成本影响是线性的,一定要让业务方签字确认留存周期,再通过TTL或定时清理任务把过期数据清掉。
- 权限管理不能因为“临时上线”就跳过。很多公司把数据仓库搭好之后才发现内部人员可以访问全库明细数据,这在大数据环境里非常危险。把角色权限、列级权限、行级权限提前设计到位,比事后修补省心得多。
7. 最后聊点实在的心得
列式存储这件事,技术原理书上写得再清楚,也不如自己动手跑一遍数据来得深刻。我这些年带过的数据平台项目,凡是一开始就把“分区键、排序键、压缩算法、数据模型”这四件事想明白的,后面几乎都走得很顺;凡是上来就急着导数据、库表设计随便拍脑袋的,后面全都付出了几倍的时间做返工,无一例外。
另外,始终要记得一点:列式存储只是手段,不是目的。它是为了让企业的数据能够更快、更稳定地服务于业务决策,而不是为了让技术团队在架构评审会上“秀肌肉”。选型的时候多问问业务方真正关心什么指标、什么样的查询频率、能容忍多长的查询等待时间,然后再倒推回来选引擎、建模型,这条路才是最务实的。
我个人实操下来,还有一个比较深的体会:列式存储与数据湖底座结合,是未来几年中小企业逐步发力的一条主线。先让数据落到对象存储或HDFS上的Parquet/ORC文件,再用ClickHouse/Doris这类引擎在上面做加速服务,既能兼顾成本与灵活性,又不会过早锁死技术栈。如果你们公司当前还在犹豫是不是要上列式存储,我的建议很简单:找一个数据量最大的分析场景,用一个月时间跑一个最小可行的POC,用真实的业务数据和查询SQL验证效果,别的都是纸上谈兵。数据不会骗人。