SQL 列存数据库(ClickHouse / Druid / Pinot)的列式存储与聚合性能优势

4次阅读

列存数据库GROUP BY更快因同字段值连续存储,聚合时仅读取相关列,大幅减少I/O与内存压力;行存需读取整行。实操需注意引擎特性、谓词下推、编码选择等细节。

SQL 列存数据库(ClickHouse / Druid / Pinot)的列式存储与聚合性能优势

为什么列存数据库做 GROUP BY 比行存快得多

核心原因是:列存把同一字段的所有值连续存储,聚合时只需读取涉及的几列、跳过无关字段,大幅减少 I/O 和内存带宽压力。行存(如 postgresql)哪怕只查 user_idamount 两列,也得把整行(比如含 created_atip_addressuser_agent 等)全读出来。

实操中要注意:

  • clickhouseReplacingMergeTree 在未 MERGE 前可能返回重复聚合结果,别直接信实时 GROUP BY 结果
  • Druid 对高基数维度(如 user_id)做 GROUP BY 时,内存消耗陡增,容易触发 QueryTimeoutExceptionTooManySegmentsException
  • Pinot 默认对 GROUP BY 字段建倒排索引,但若字段值稀疏(如 is_premium 只有 true/false),倒排反而拖慢——这时该关掉 invertedIndex 配置

WHERE 条件下推到列存扫描层的关键条件

列存快的前提是谓词能下推到存储层过滤,否则就退化成“先读全量列再 CPU 过滤”,优势归零。

常见失效场景:

  • ClickHouse 中用 toYYYYMMDD(toDateTime(timestamp_str)) = 20240501字符串转时间函数无法下推;应改用原生时间类型 + partition by toYYYYMM(timestamp),让分区裁剪和谓词下推同时生效
  • Druid 的 WHERE 不支持嵌套 json 字段路径表达式(如 Event.payload.user.id = '123'),必须提前展平为扁平列或用 json_extract_scalar(但后者不走索引)
  • Pinot 要求 WHERE 字段必须出现在 segment.column.indexing.enabled 白名单里,否则直接报错 Column not indexed for Filtering

聚合函数在列存里的执行差异:不是所有 sum() 都一样

列存引擎常对聚合函数做向量化优化,但不同函数的加速程度天差地别。

典型表现:

  • ClickHouse 的 sum()count()min()/max() 直接走 SIMD 批处理,百万行聚合通常在毫秒级;但 uniqCombined()(近似去重)会触发哈希表构建,内存占用翻倍,且无法跳过 NULL
  • Druid 的 longSumdoubleSum 是列级预聚合,快;但 filtered 聚合器(如 {"type":"filtered","filter":{"type":"selector","dimension":"status","value":"success"},"aggregator":{"type":"count"}})需逐行判断,性能接近行存
  • Pinot 的 COUNT(*) 走元数据直接返回 segment 行数,极快;但 COUNT(col) 必须扫描该列非 NULL 值,若列稀疏(大量 NULL),实际耗时可能比 COUNT(*) 高 5–10 倍

写入时列存格式选择直接影响查询性能

列存不是“存了就快”,压缩格式、编码方式、分块粒度都得匹配查询模式。

几个硬约束:

  • ClickHouse 推荐用 Delta 编码存单调递增 ID,比 DoubleDelta 更省空间;但若字段频繁乱序(如 session_id 字符串哈希值),强行用 Delta 反而膨胀 20%+
  • Druid 的 stringDictionary 编码对低基数维度(如 country)极高效,但若列唯一值超 100 万,字典构建失败,自动降级为 compressed,查询变慢且不可预测
  • Pinot 要求 sorted 列(如时间戳)必须按升序写入,否则 range 查询无法利用排序跳过块;而乱序写入后调用 SortByTime 工具重建,耗时可能超过原始导入

列存的性能红利藏在细节里:一个没设对的编码、一次没绕开的 NULL 扫描、一个没压住的高基数维度,都可能让查询从 200ms 拉长到 2s。这些地方不报错,但慢得毫无征兆。

text=ZqhQzanResources