惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

The Register - Security
The Register - Security
IT之家
IT之家
阮一峰的网络日志
阮一峰的网络日志
博客园 - 叶小钗
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
S
SegmentFault 最新的问题
有赞技术团队
有赞技术团队
博客园 - 【当耐特】
The Cloudflare Blog
T
The Blog of Author Tim Ferriss
Apple Machine Learning Research
Apple Machine Learning Research
C
Check Point Blog
Microsoft Azure Blog
Microsoft Azure Blog
V
V2EX
量子位
F
Fortinet All Blogs
Martin Fowler
Martin Fowler
大猫的无限游戏
大猫的无限游戏
Last Week in AI
Last Week in AI
博客园 - 聂微东
美团技术团队
Stack Overflow Blog
Stack Overflow Blog
Google DeepMind News
Google DeepMind News
宝玉的分享
宝玉的分享
M
MIT News - Artificial intelligence
U
Unit 42
罗磊的独立博客
MyScale Blog
MyScale Blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
V
Visual Studio Blog
Jina AI
Jina AI
Recorded Future
Recorded Future
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
小众软件
小众软件
H
Help Net Security
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
MongoDB | Blog
MongoDB | Blog
N
Netflix TechBlog - Medium
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
B
Blog
J
Java Code Geeks
爱范儿
爱范儿
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
aimingoo的专栏
aimingoo的专栏
B
Blog RSS Feed
月光博客
月光博客
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
P
Privacy International News Feed
Know Your Adversary
Know Your Adversary
雷峰网
雷峰网

InfoQ - 促进软件开发领域知识与创新的传播

Meta 收购 Manus 这事儿泡汤了 5.5万 Star 开源项目 Ghostty 被迫出走,GitHub 正在终结一代技术人的乌托邦 Slack 长时运行多智能体系统的上下文管理方案 从 T+1 到分钟级:金城银行基于 Apache Doris 构建高可靠、强一致的实时数据平台 谷歌云推出 Agents CLI,简化 AI 智能体开发全流程 Claude官方击穿高薪、高学历的安全防线!Anthropic点名10大高危职业,但有群人暂时稳了 亚马逊云科技终止 WorkMail 服务,并将 App Runner 转入维护模式 OPPO小布记忆:全模态碎片化内容的理解与智能整理实践|AICon上海 模力工场038周AI应用周榜:工具在消失,工作流在出现 Akamai CEO Tom Leighton:Agent 时代来临,云基础设施正从“中心化”转向“分布式边缘” 日均数百亿入库背后:从“人肉调度”到K8s弹性架构,度小满金融基于OceanBase重构入库架构实践 百度文库网盘发布GenFlow 4.0:月活用户超1亿,要把网盘变成全端AI工作台 Altman 投的 Agent 终端 Warp 开源了!斩获3.5万star 哪些客户需要拒, 敢让龙虾决定吗?_AI&大模型_InfoQ 中文站_InfoQ精选视频 从开发到生产:为什么越来越多的机器学习团队纷纷迁移到 Snowflake | BUILD 2025_AI&大模型_王玮_InfoQ精选视频 探索多智能体工作流:LangGraph Snowflake Cortex AI | BUILD 2025_AI&大模型_王玮_InfoQ精选视频 腾讯云分布式缓存数据库:AI Agent - 从提示词工程到 Harness 工程 | 腾讯云数据库 DBTalk_腾讯_凌敏_InfoQ精选视频 基于 Streamlit 为 CSV 数据构建分析智能体 | BUILD 2025_AI&大模型_王玮_InfoQ精选视频 AI 智能体:告别文档缺漏 | BUILD 2025_AI&大模型_王玮_InfoQ精选视频 构建 AI 驱动的数据管道:深度探讨 Snowflake Openflow 与非结构化数据 | BUILD 2025_AI&大模型_王玮_InfoQ精选视频 云端太贵、本地不够聪明,英特尔押注“端云混合AI”:智能体PC会替人完成工作 不到10%的存储投入,可能拖垮90%的GPU投资!IBM把AI Agent塞进存储系统,算清企业最容易忽略的一笔账 Snowpark 上手实战 | BUILD 2025_大数据_王玮_InfoQ精选视频 ClickHouse + Langfuse,构建 Agent 可观测基石 腾讯云分布式缓存数据库:Cluster Proxy 共享连接架构深度解析 | 腾讯云数据库 DBTalk_腾讯_凌敏_InfoQ精选视频 AI 写代码太烧钱了:Copilot、Claude 一起涨价,不如把程序员请回来? 英特尔发布至强600系列工作站处理器与锐炫Pro B70 GPU,全新AI工作站来了 腾讯云分布式缓存数据库:从 Redis 到 Valkey - 开源社区如何快速创新 | 腾讯云数据库 DBTalk_腾讯_凌敏_InfoQ精选视频 印奇这次要“从0重做”智驾模型!首谈阶跃和千里双公司布局:中国AI商业闭环要靠车跑出来 从Cursor返聘归来,90后华裔女高管带Claude开启日更模式:token成本比工程师工资低多了! 从 Coding 到 Agent:QCon 北京 2026 全景复盘,优秀出品人 & 明星讲师名单揭晓 全链路支撑大模型国产化“Day 0适配”,商汤大装置构建全栈能力底座 凌晨,OpenAI 与亚马逊云科技史上最大联合发布来了 HashiCorp Vault 2.0 发布:引入新身份联邦机制,迈入 IBM 生命周期体系 Yelp 实现超 1,000 个 Cassandra 节点零停机升级 写了 17 年开源代码,我为什么认为 Coding Agents 堆功能是在瞎折腾? 基于 Apache Camel 编排智能体与多模态 AI 管道 面向智能体与人类用户的AI记忆系统:架构设计与核心场景实践|AICon上海 Anthropic 推出 Managed Agents,简化 AI 代理部署流程 阿里HappyHorse开启灰测,720P视频生成低至0.44元/秒 讯飞联合清华团队押注量子AI:不看营收、不设KPI,一群“无人区”科学家,抢夺下代AI算力入口 小米万亿模型全面开源:MIT 协议、1M 上下文,但还是打不过 DeepSeek Cortex Code 入门指南:面向数据工程师的实践路径 | 技术实践 openJiuwen社区首发Team Skills,定义Coordination Engineering新范式 用 Snowflake Cortex Agents 释放结构化数据的最大价值 | 技术实践 Grafana 利用 Kafka 对 Loki 进行了架构重构,并发布了一款命令行工具,旨在将可观测性引入编码代理 ClickHouse重构全文索引:对象存储上跑出高性能 Full-Text Search 可观测性和遥测技术如何提升软件工程实践 Dropbox 与 GitHub 合作,将单体库大小从 87GB 缩减至 20GB Agent 的下一站:基于长期记忆系统 EverOS 的自我演进|AICon上海 同一赛道,四种收费:Agent 控制层(Harness)开始分裂 Cloudflare Sandboxes 正式发布,为 AI 代理提供持久化隔离环境 Agent 的“记忆断片”困局,该怎么破?_AI&大模型_AICon 全球人工智能开发与应用大会_InfoQ精选视频 数据分析师如何快速建立在 AI 时代最值钱的能力:一份可落地的行动路线图 摩尔线程最新财报:研发占比超86%,万卡级大规模智算集群落地 当云区域失效:地缘动荡环境下的高可用重构 Slack 重构通知系统,设置参与度提升 5 倍 智能体工程的隐性技术债务 “我把所有模型都换成了DeepSeek V4”:月账单将降 90%,效果还更好 阿里云智能集团高级技术专家刘少伟已确认出席AICon上海站,并分享如何构建企业 Agent 的自动化行动架构 构建生产就绪的 tRPC API:Apollo Federation 的 TypeScript 替代方案 Anthropic推出面向Claude Code的基于智能体的代码审查功能 北京车展直击:斑马智能甩出车载Agent短剧,比亚迪率先落地,AI让智能座舱又热起来了 Snowflake 作为智能体运行时:从静态管道迈向自主数据系统 | 技术实践 Snowflake 上的本体体系:基于 Cortex Code 能力实现从架构到部署 | 技术实践 Cloudflare 公布 MCP 架构方案,应对企业面临的安全与治理风险 复杂的项目管理怎么做到「AI 友好」?飞书项目用「开放」给出答案 Snowflake Cortex Code 的规范驱动开发:将 SDLC 方法论引入 AI 辅助工作流 | 技术实践 Copilot 不让注册了:从“随便用”到“全面限”,agent 把原有订价模型顶穿了 当互联网用AI卷效率时,这家公司先问了一连串“能不能” Meta 开始记录员工每一次点击:AI 要接管工作,先监控会工作的人 Meta“Token榜”逼疯打工人,一夜烧掉公司几万刀!AI时代Token焦虑越来越离谱 智源FlagOS完成DeepSeek-V4-Flash在八款芯片Day0适配,实现三重技术突破 DeepSeek V4 重磅开源!首次打通华为Ascend,也没丢掉英伟达,百万上下文夺回国产模型话语权 李志飞的“新实验”:当超级个体撞上真实组织 GPT-5.5 登顶时刻,Anthropic 亲口承认 Claude 变笨了!网友群嘲:太敷衍 那些没空写的小需求,龙虾真能做吗?_AI&大模型_InfoQ 中文站_InfoQ精选视频 从 Pandas 到生产:使用任意 IDE 进行可扩展的 ML 数据管道与分布式处理 | BUILD 2025_AI&大模型_王玮_InfoQ精选视频 pnpm 11 候选版本发布,带来 ESM 分发、供应链默认设置以及新的存储格式 银行业PDF表格提取方案重构:基于Java的分层方案 GPT-5.5 赢了 Opus 4.7 和 Mythos?奥特曼晒黄仁勋内部信:英伟达全员用上 Codex! Cloudflare 推出 Think:一款面向 AI 代理的持久化运行时 1850亿美元天价支出、75%代码由AI生成!谷歌正式宣告:全面转向智能体工作流 xAI落后太多,马斯克“开大”重金求购Cursor,100亿美金“分手费”都敢签! Pulumi 新增对 Bun 运行时的全面支持 姚顺雨腾讯模型首秀!不卷参数只做 “听话打工人”,Hy3 preview登场 | 附实测 老板让你“忽悠”投资人,你敢发给龙虾吗?_AI&大模型_InfoQ 中文站_InfoQ精选视频 Gemini CLI 引入子代理机制,实现任务委派与并行代理工作流 清华系团队星工聚将完成数千万天使轮融资,轮式机器人拿下头部制造企业亿级大单 Pretext.js 绕过 DOM 布局重排,实现 120 FPS 的高级交互体验 靠“AI 云”爆红的 Vercel,栽在一个第三方AI工具手里!IPO前夕遭黑,200万美元赎金谈崩? 高能研讨会|端侧 AI 正在重写实时感知效率上限_AI&大模型_王玮_InfoQ精选视频 2050大会看这篇就够了|报名、交通食宿指引大全 Java 近期资讯:OpenJDK JEP、Jakarta EE 12、Spring Framework、Micrometer、Camel、JBang 金融智能的架构编排:基于 Snowflake Cortex Agents 实现结构化与非结构化数据统一分析 | 技术实践 在AK大神爆火的任务里,摸清国产AI真实水平 百灵Ling-2.6-flash 正式发布:高 Token 效率,以 1/10 消耗实现 SOTA 级 Agent 能力 当 PM 懂AI,当技术懂产品:AI 时代产品力的双向进化|PM x AI产品力领航者大会即将开幕 为 AI 智能体设计记忆机制:揭秘 LinkedIn 的认知记忆智能体 获奖名单公布|2026主题征文第一期|分享你最有价值的龙虾场景与核心 Skill_热门活动_InfoQ写作社区官方_InfoQ写作社区
ClickHouse性能优化:Index Pruning助你告别慢查询!
ClickHouse凌敏张琰梓 · 2026-06-03 · via InfoQ - 促进软件开发领域知识与创新的传播

最快的分析查询,总是那些读取数据量最少的查询。我们深知这一点的重要性,因为事实确实如此!

ClickHouse 提供了多种方法来实现这一目标。在本文中,我们将以英国房地产销售数据集为例,深入探讨三种基于索引的剪枝技术——助您精准掌握其适用场景与时机。

剪枝技术 1:主索引 (Primary Index)

首项剪枝技术是主键 (Primary Key),这也是创建数据表时首先需要掌握的概念之一。数据表的主键决定了数据分片 (Data Part) 内数据的排序方式。

数据分片由数据粒 (Granule) 组成,每个数据粒默认包含 8,192 行。ClickHouse 的主索引 (Primary Index) 会存储 每个数据粒中首行的主键列值。

如下图所示,数据按主键 C1 排序,行被组织成多个数据粒(从 g1 到 g4)。为便于理解,图中每个数据粒仅包含 3 行。主索引存储每个数据粒的首个值,例如 g1 为 10,g2 为 20,依此类推。

图片

主索引能够根据主键上的过滤条件,在数据读取之前跳过整个数据粒。例如,对于包含 WHERE C1 > 60 的查询,数据粒 g1 和 g2 将通过主索引进行剪枝,从而仅读取剩余的数据。

剪枝技术 2:轻量级投影 (Lightweight Projections)

我们的下一项剪枝技术是 轻量级投影,该功能最初在 ClickHouse 25.6 版本中引入,并在 ClickHouse 26.1 版本中获得了更易用的语法。

ClickHouse 中的投影 (Projection) 是自动维护的隐藏表副本,它们以不同的排序方式存储,因此拥有不同的主索引。这些替代的布局能够加速那些受益于特定排序的查询。但其缺点在于,投影会在磁盘上复制基础表的数据。

轻量级投影 (Lightweight projections) 的行为类似于二级索引,但无需复制完整的行。它们不存储完整的数据副本,而是仅存储其排序键以及一个指向基础表的 _part_offset 指针。这大大减少了存储开销,但意味着任何其他需要返回的列都必须从基础表读取。

我们可以通过更新图表来了解其工作原理,即在 C2 上添加一个轻量级投影:

图片

对于不属于主键 (primary key) 的列上的筛选条件,例如 WHERE C2 > 900,ClickHouse 可以使用轻量级投影。这种投影存储排序后的投影键 (C2) 值和 _part_offset 值,并提供其自身的主索引 (primary index) (②),该索引允许根据投影键上的筛选条件对数据粒度 (granule) 进行剪枝 (pruning)。

剪枝技术 3:跳过索引

我们介绍的最后一个技术是跳过索引 (Skip indexes)。其中一种跳过索引是 minmax 索引 (minmax index),它记录了每个数据粒度 (granule) 中某一列的最小值和最大值。

ClickHouse 支持 minmax 索引已有五年多,但我们最近新增了支持,可以自动为表中特定类型的每个列创建这些索引。

minmax 索引相对于轻量级投影 (Lightweight projection) 的优势在于,它不会在磁盘上重复存储列值。然而,需要注意的是,应用 minmax 索引的列需要与主键 (primary key) 具有一定的相关性,否则该索引将无法有效地剪枝数据。

在下图中,minmax 索引 (③) 记录了每个数据粒度 (granule) 中 C3 列的最小值和最大值。

图片

对于 WHERE C3 > 600 这样的筛选条件,由于 g1 到 g3 这些数据粒度 (granule) 的最大值都低于 600,因此可以跳过它们,只需读取 g4

剪枝实践:英国房地产数据集

既然我们已经对每种剪枝技术有了宏观的了解,接下来让我们学习如何在真实数据集上将它们付诸实践。我们将使用 英国房地产价格数据集,其中包含英国房地产销售的详细信息。

我们将在配备 64GB 内存的 Apple Mac M2 Max 上运行所有查询。

导入英国房地产数据集

让我们首先创建表:

CREATE OR REPLACE TABLE uk_price_paid(    price UInt32,    date Date,    postcode1 LowCardinality(String),    postcode2 LowCardinality(String),    type Enum8('terraced' = 1, 'semi-detached' = 2, 'detached' = 3, 'flat' = 4, 'other' = 0),    is_new UInt8,    duration Enum8('freehold' = 1, 'leasehold' = 2, 'unknown' = 0),    addr1 String,    addr2 String,    street LowCardinality(String),    locality LowCardinality(String),    town LowCardinality(String),    district LowCardinality(String),    county LowCardinality(String))ENGINE = MergeTreeORDER BY (postcode1, postcode2, addr1, addr2);

复制代码

主键(除非另有说明,它与 ORDER BY 语句的定义一致)是 (postcode1, postcode2, addr1, addr2)

表创建完成后,我们将导入数据:

INSERT INTO uk_price_paidSELECT *FROM file('uk_all.parquet');

复制代码

我首先从 pp-complete.csv 导入数据(相关文档),然后将其导出为 Parquet 格式,从而创建了 uk_all.parquet 文件。

运行插入查询的输出如下所示:

30452463 rows in set. Elapsed: 5.366 sec. Processed 30.45 million rows, 170.44 MB (5.68 million rows/s., 31.76 MB/s.)Peak memory usage: 774.00 MiB.

复制代码

该数据集包含 3000 万行,按照 ClickHouse 的标准来看相对较小。我们可以通过多次导入 Parquet 文件来增加数据量,但还有一种更快的方法,即使用 ATTACH PARTITION 命令。

以下命令会复制表中的所有数据块 (parts),使数据量翻倍:

ALTER TABLE uk_price_paid ATTACH PARTITION ID 'all' FROM uk_price_paid;

复制代码

我运行了几次该命令,以便我们有足够的数据进行操作。作为参考,以下是运行查询三次的输出:

0 rows in set. Elapsed: 0.167 sec.0 rows in set. Elapsed: 0.458 sec.0 rows in set. Elapsed: 0.412 sec.

复制代码

我们可以编写以下查询来返回表中记录的数量:

SELECT count()FROM uk_price_paid;

复制代码

┌───count()─┐243619704-- 243.62 million└───────────┘1 row in set. Elapsed: 0.001 sec.

复制代码

通过主索引过滤

让我们从编写一个基于主键过滤的查询开始。以下查询返回 Croydon(伦敦的一个郊区)出售的房产数量以及平均销售价格:

SELECT postcode1, count(), avg(price)FROM uk_price_paidWHERE postcode1 LIKE 'CR%'GROUP BY ALLORDER BY count() DESCSETTINGS   output_format_pretty_single_large_number_tip_threshold=0,  use_query_condition_cache=0;

复制代码

运行此查询的输出如下所示:

┌─postcode1─┬─count()─┬─────────avg(price)─┐│ CR0       │  573952 │  264860.4016363738 ││ CR2       │  219464 │ 287568.45715014765 ││ CR4       │  192912 │ 218234.12212822426 ││ CR3       │  155304 │  306863.8307319837 ││ CR8       │  147880 │  373809.7425480119 ││ CR7       │  141152 │  211355.8734413965 ││ CR5       │  123112 │ 355812.51777243486 ││ CR6       │   47920 │  384279.0923205342 ││ CR9       │     352 │ 12324871.113636363 ││ CR24      │      16 │              25000 │└───────────┴─────────┴────────────────────┘

复制代码

10 rows in set. Elapsed: 0.030 sec.10 rows in set. Elapsed: 0.015 sec.10 rows in set. Elapsed: 0.021 sec.

复制代码

最好的查询时间为 15 毫秒,对于一个包含超过 2 亿条记录的表的查询来说,这个表现相当不错。

如果我们使用 EXPLAIN indexes=1, pretty=1, compact=1 命令作为查询前缀,就可以查看其查询计划:

┌─explain─────────────────────────────────────────────┐ 1. │ Output: postcode1, count(), avg(price)              │ 2. │                                                     │ 3. │ Sorting (Sorting for ORDER BY)                      │ 4. │ └──Aggregating                                      │ 5. │    └──ReadFromMergeTree (default.uk_price_paid)     │ 6. │          Indexes:                                   │ 7. │            PrimaryKey                               │ 8. │              Keys:                                  │ 9. │                postcode1                            │10. │              Condition: (postcode1 in ['CR', 'CS')) │11. │              Parts: 36/36                           │12. │              Granules: 235/29751                    │13. │              Search Algorithm: binary search        │14. │            Ranges: 36                               │    └─────────────────────────────────────────────────────┘

复制代码

在第 12 行,我们可以看到查询引擎只需处理 29,751 个数据粒度 (granule) 中的 235 个(不到 1%),即可运行此查询。

我们可以通过查询 system.query_log 表来查看处理了多少行数据:

SELECT event_time, query, read_rowsFROM system.query_logWHERE type = 'QueryFinish' AND query NOT LIKE '%query_log%'ORDER BY event_time DESC LIMIT 1FORMAT Vertical;

复制代码

Row 1:──────event_time: 2026-04-09 10:37:22query:      SELECT postcode1, count(), avg(price)...read_rows:  1687552 -- 1.69 million

复制代码

我们的查询从总计 2.43 亿行数据中读取了 160 万行,因此可以肯定地说,主索引在减少所需读取数据量方面发挥了出色作用。

当过滤属于主键的多个列时,只要这些列构成整个主键的前缀,主索引就会非常有效。

我们的主键是 (postcode1, postcode2, addr1, addr2),因此,例如,仅对 postcode1 和 postcode2 进行过滤将是高效的。

SELECT postcode1, postcode2, count(), avg(price)FROM uk_price_paidWHERE postcode1 LIKE 'CR%' AND postcode2 LIKE '4%'GROUP BY ALLORDER BY count() DESC LIMIT 10SETTINGS  output_format_pretty_single_large_number_tip_threshold=0,  use_query_condition_cache=0;

复制代码

┌─postcode1─┬─postcode2─┬─count()─┬─────────avg(price)─┐│ CR4       │ 4FD       │    2496 │ 136439.84935897434 ││ CR4       │ 4FF       │    2056 │ 111415.15953307394 ││ CR4       │ 4FE       │    1376 │  98730.37790697675 ││ CR4       │ 4LT       │    1320 │ 104595.98787878788 ││ CR0       │ 4UX       │    1240 │ 118912.51612903226 ││ CR8       │ 4DZ       │    1200 │             103860 ││ CR0       │ 4TX       │    1184 │ 110415.50675675676 ││ CR0       │ 4HB       │    1152 │ 162919.75694444444 ││ CR0       │ 4FG       │    1144 │  230394.2097902098 ││ CR0       │ 4GA       │    1032 │ 211535.29457364342 │└───────────┴───────────┴─────────┴────────────────────┘10 rows in set. Elapsed: 0.015 sec. Processed 638.98 thousand rows, 3.30 MB (42.27 million rows/s., 218.59 MB/s.)Peak memory usage: 3.92 MiB.

复制代码

此查询处理了 2.43 亿行中的略多于 63 万行。

如果仅根据 postcode2 进行过滤,尽管它是主键的一部分,但并非第一个键列,效率将不会那么高:

SELECT postcode1, postcode2, count(), avg(price)FROM uk_price_paidWHERE postcode2 LIKE '4%'GROUP BY ALLORDER BY count() DESC LIMIT 10SETTINGS  output_format_pretty_single_large_number_tip_threshold=0,  use_query_condition_cache=0;

复制代码

┌─postcode1─┬─postcode2─┬─count()─┬─────────avg(price)─┐│ TR8       │ 4LX       │    3328 │  67047.70913461539 ││ CR4       │ 4FD       │    2496 │ 136439.84935897434 ││ SS16      │ 4TY       │    2328 │  85003.52233676976 ││ NR29      │ 4NW       │    2328 │ 36411.996563573884 ││ SS16      │ 4TQ       │    2184 │  88534.72161172162 ││ SS16      │ 4TD       │    2160 │  67603.75925925926 ││ BS4       │ 4EY       │    2104 │ 100474.69201520912 ││ RG22      │ 4UR       │    2096 │  143119.3893129771 ││ BB11      │ 4JZ       │    2096 │  29956.74427480916 ││ LS1       │ 4ES       │    2088 │  256009.9655172414 │└───────────┴───────────┴─────────┴────────────────────┘10 rows in set. Elapsed: 0.787 sec. Processed 138.82 million rows, 572.89 MB (176.47 million rows/s., 728.26 MB/s.)Peak memory usage: 146.53 MiB.

复制代码

它现在扫描了 1.38 亿行,返回结果所需时间是之前的 50 倍。如果我们解释这个查询,将看到以下输出:

┌─explain───────────────────────────────────────────────────────────┐ 1. │ Expression (Project names)                                        │ 2. │   Limit (preliminary LIMIT)                                       │ 3. │     Sorting (Sorting for ORDER BY)                                │ 4. │       Expression ((Before ORDER BY + Projection))                 │ 5. │         Aggregating                                               │ 6. │           Expression (Before GROUP BY)                            │ 7. │             Expression ((WHERE + Change column names to column id⋯│ 8. │               ReadFromMergeTree (default.uk_price_paid)           │ 9. │               Indexes:                                            │10. │                 PrimaryKey                                        │11. │                   Keys:                                           │12. │                     postcode2                                     │13. │                   Condition: (postcode2 in ['4', '5'))            │14. │                   Parts: 11/11                                    │15. │                   Granules: 16950/29744                           │16. │                   Search Algorithm: generic exclusion search      │17. │                 Ranges: 7066                                      │    └───────────────────────────────────────────────────────────────────┘

复制代码

查询引擎可以使用主键排除近一半的 granules(第 15 行),但在第 16 行,我们看到它正在使用通用排除搜索算法。该算法的效率取决于 postcode2 列与其前继键列 postcode1 之间的基数差异。您可以在文档中查看一个分步示例,但核心要点是:当前继列具有较低基数时,算法效率较高;而当前继列具有较高基数时,效率则不那么高。

在下一节中,我们将探讨如何通过完全不属于主键的列进行更高效的过滤。

通过轻量级投影过滤

通过主索引过滤是最佳技术,在设计表时,确保数据按您最可能进行过滤的列排序,是应牢记的重要一点。

但通常,我们也希望通过其他列进行查询。例如,假设我们想查找当 district = ‘BURNLEY’ 时,按城镇划分的已售房产数量:

SELECT town, count(), round(avg(price)) AS avgPrice, argAndMax(date, price)FROM uk_price_paidWHERE district = 'BURNLEY'GROUP BY ALLORDER BY count() DESC LIMIT 10SETTINGS    use_query_condition_cache = 0;

复制代码

多次运行此查询的输出如下所示:

10 rows in set. Elapsed: 0.428 sec. Processed 240.96 million rows, 480.72 MB (562.98 million rows/s., 1.12 GB/s.)Peak memory usage: 841.37 KiB.10 rows in set. Elapsed: 0.466 sec. Processed 219.79 million rows, 438.38 MB (471.16 million rows/s., 939.77 MB/s.)Peak memory usage: 852.86 KiB.10 rows in set. Elapsed: 0.481 sec. Processed 207.87 million rows, 414.50 MB (432.32 million rows/s., 862.05 MB/s.)Peak memory usage: 844.83 KiB.

复制代码

查询引擎必须处理数据集中的几乎所有行才能响应此查询。

让我们看看是否可以通过在 district 列上添加轻量级投影来提升性能:

ALTER TABLE uk_price_paidADD PROJECTION by_district INDEX district TYPE basic;

复制代码

我们将物化该投影,以便能够立即使用:

ALTER TABLE uk_price_paidMATERIALIZE PROJECTION by_districtSETTINGS mutations_sync=1;

复制代码

0 rows in set. Elapsed: 14.480 sec.

复制代码

现在,让我们使用投影运行查询:

SELECT town, count(), round(avg(price)) AS avgPrice, argAndMax(date, price)FROM uk_price_paidWHERE district = 'BURNLEY'GROUP BY ALLORDER BY count() DESC LIMIT 10SETTINGS    use_query_condition_cache = 0,    optimize_use_projections = 1;

复制代码

optimize_use_projections 默认是启用的,但我们将其包含在此处为求完整。如果您关闭它,投影将不会被使用。这对于检查您的投影是否确实生效非常有用!

运行上述查询的耗时如下所示:

10 rows in set. Elapsed: 0.023 sec.10 rows in set. Elapsed: 0.056 sec.10 rows in set. Elapsed: 0.046 sec.

复制代码

对于 BURNLEY 查询,在 district 列上使用轻量级投影将查询时间从 428 毫秒减少到 23 毫秒,性能提升了 94%。

现在,让我们深入了解其内部机制。我首先在查询前加上了之前用过的 explain 子句:

EXPLAIN indexes=1, pretty=1, compact= 1 SELECT town, count(), round(avg(price)) AS avgPrice, argAndMax(date, price)FROM uk_price_paidWHERE district = 'BURNLEY'GROUP BY ALLORDER BY count() DESC LIMIT 10SETTINGS    use_query_condition_cache = 0,    optimize_use_projections = 1;

复制代码

┌─explain─────────────────────────────────────────────────┐│ Output: town, count(), avgPrice, argAndMax(date, price) ││                                                         ││ Limit (preliminary LIMIT)                               ││ └──Sorting (Sorting for ORDER BY)                       ││    └──Aggregating                                       ││       └──ReadFromMergeTree (default.uk_price_paid)      ││             Indexes:                                    ││               PrimaryKey                                ││                 Condition: true                         ││                 Parts: 6/6                              ││                 Granules: 29741/29741                   ││               Ranges: 6                                 │└─────────────────────────────────────────────────────────┘

复制代码

这个输出无法提供有效信息,因为它只包含了带有主键索引信息的基础查询计划。我们需要额外添加 projections=1,以便在输出中包含投影(projection)分析:

EXPLAIN indexes=1, projections=1, pretty=1, compact= 1 SELECT town, count(), round(avg(price)) AS avgPrice, argAndMax(date, price)FROM uk_price_paidWHERE district = 'BURNLEY'GROUP BY ALLORDER BY count() DESC LIMIT 10SETTINGS    use_query_condition_cache = 0,    optimize_use_projections = 1,    output_format_pretty_max_value_width=65,    output_format_pretty_row_numbers=1;

复制代码

┌─explain───────────────────────────────────────────────────────────┐ 1. │ Output: town, count(), avgPrice, argAndMax(date, price)           │ 2. │                                                                   │ 3. │ Limit (preliminary LIMIT)                                         │ 4. │ └──Sorting (Sorting for ORDER BY)                                 │ 5. │    └──Aggregating                                                 │ 6. │       └──ReadFromMergeTree (default.uk_price_paid)                │ 7. │             Indexes:                                              │ 8. │               PrimaryKey                                          │ 9. │                 Condition: true                                   │10. │                 Parts: 11/11                                      │11. │                 Granules: 29744/29744                             │12. │               Ranges: 11                                          13. │             Projections:                                          │14. │               Name: by_district                                   │15. │                 Description: Projection has been analyzed and wil⋯│16. │                 Condition: (district in ['BURNLEY', 'BURNLEY'])   │17. │                 Search Algorithm: binary search                   │18. │                 Parts: 11                                         │19. │                 Marks: 72                                         │20. │                 Ranges: 11                                        │21. │                 Rows: 589824                                      │22. │                 Filtered Parts: 0                                 │    └───────────────────────────────────────────────────────────────────┘

复制代码

让我们逐一分析这里发生的情况,首先从索引部分开始:

主键索引未能对该查询发挥作用 —— 第 9 行的 Condition: true 表示它没有应用任何过滤条件,因此所有 11 个分片(part,第 10 行)和 29,744 个数据粒(granule,第 11 行)都必须被处理。

接下来是投影部分:

  • 第 22 行的 Filtered Parts: 0 表明没有任何分片(part)被完全排除,这意味着 BURNLEY 出现在所有 11 个分片中;

  • 第 19 行将搜索范围缩小到 72 个数据粒(granule)(或标记);

  • 默认情况下,每个数据粒(granule)包含 8,192 行,由此得出了第 21 行的计数 (72 * 8,192=589,824);

  • 这 72 个数据粒(granule)分布在 11 个分片(part)(第 18 行)中;由于 BURNLEY 的数据行在每个分片内部都是连续存储的,因此每个分片都有一个连续的范围,总计 11 个范围(第 20 行)。

下表展示了与 Burnley 相比,售出房产数量更多和更少的地区,在使用和不使用投影(projection)时的耗时:

我在使用投影(projection)和不使用投影的情况下,分别运行了三次查询,并取其最低耗时。

我们可以看到,当使用投影(projection)时,所有这些地区的查询耗时都实现了至少 75% 的提升。

轻量级投影(lightweight projection)的一个值得关注的特性是,我们可以将它们组合起来,从而在多个独立的排序顺序上实现行级过滤。例如,我们可以在 date 列上添加另一个轻量级投影:

ALTER TABLE uk_price_paidADD PROJECTION by_date INDEX date TYPE basic;ALTER TABLE uk_price_paidMATERIALIZE PROJECTION by_dateSETTINGS mutations_sync=1;

复制代码

一个同时按 district 和 date 进行过滤的查询(例如,查找 2023 年 1 月在 Manchester 售出的房产),将会用到这两个轻量级投影:

SELECT town,        count(),       round(avg(price)) AS avgPrice,       argAndMax(date, price)FROM uk_price_paidWHERE (district = 'MANCHESTER') AND (date BETWEEN '2023-01-01' AND '2023-01-31'GROUP BY ALLORDER BY count() DESCLIMIT 10SETTINGS    use_query_condition_cache = 0,    optimize_use_projections = 1;

复制代码

┌─town───────┬─count()─┬─avgPrice─┬─argAndMax(date, price)─┐│ MANCHESTER │    3656 │   266818 │ ('2023-01-13',3000000) ││ SALFORD    │      24 │   341667 │ ('2023-01-31',670000)  │└────────────┴─────────┴──────────┴────────────────────────┘

复制代码

该查询的 explain 输出如下所示:

    ┌─explain───────────────────────────────────────────────────────────┐ 1.  │ Output: town, count(), avgPrice, argAndMax(date, price)           │ 2.  │                                                                   │ 3.  │ Limit (preliminary LIMIT)                                         │4.  │ └──Sorting (Sorting for ORDER BY)                                 │ 5.  │    └──Aggregating                                                 │6.  │       └──ReadFromMergeTree (default.uk_price_paid)                │ 7.  │             Indexes:                                              │ 8.  │               PrimaryKey                                          │ 9.  │                 Condition: true                                   │10. │                 Parts: 11/11                                      │11. │                 Granules: 29744/29744                             │12. │               Ranges: 11                                          │13. │             Projections:                                          │14. │               Name: by_district                                   │15. │                Description: Projection has been analyzed and wil⋯│16. │                Condition: (district in ['MANCHESTER', 'MANCHESTE⋯│17. │                 Search Algorithm: binary search                   │18. │                 Parts: 11                                         │19. │                 Marks: 247                                        │20. │                 Ranges: 11                                        │21. │                 Rows: 2023424                                     │22. │                 Filtered Parts: 0                                 │23. │               Name: by_date                                       │24. │                Description: Projection has been analyzed and wil⋯│25. │                Condition: and((date in (-Inf, 19388]), (date in ⋯│26. │                 Search Algorithm: binary search                   │27. │                 Parts: 11                                         │28. │                 Marks: 71                                         │29. │                 Ranges: 11                                        │30. │                 Rows: 581632                                      │31. │                 Filtered Parts: 0                                 │         └───────────────────────────────────────────────────────────────────┘

复制代码

每个投影都会独立计算出哪些部分和数据颗粒符合其过滤条件,而从基表读取的行集则是二者的交集。

为了分别以及共同考察这些轻量级投影的效果,我们将创建两个额外的表,每个表包含一个轻量级投影。

首先,uk_price_paid_by_date 表将仅包含 by_date 轻量级投影:

CREATE TABLE uk_price_paid_by_dateCLONE AS uk_price_paid;ALTER TABLE uk_price_paid_by_date DROP PROJECTION by_district;

复制代码

而 uk_price_paid_by_district 表将仅包含 by_district 轻量级投影:

CREATE TABLE uk_price_paid_by_district CLONE AS uk_price_paid;ALTER TABLE uk_price_paid_by_district DROP PROJECTION by_date;

复制代码

现在,我们将针对每个表运行之前查询 2023 年 1 月在曼彻斯特售出房产的语句。我们将对每个表运行三次,并记录最短耗时和处理的行数:

仅凭 by_date 轻量级投影,我们并未观察到显著的性能提升。尽管该投影已将匹配行数缩小到约 50 万行(即 2023 年 1 月售出的房产),但这些行分散在基表全部 2.43 亿行数据中。因此,查询引擎仍然不得不遍历大部分数据才能检索到它们,然后才能最终过滤出曼彻斯特区域的数据。

我们可以通过编写以下查询来验证上述观察结果,该查询用于计算需要扫描多少数据颗粒才能找到 2023 年 1 月售出房产的所有匹配行:

SELECT    uniqExact((_part, intDiv(_part_offset, 8192))) AS granulesWithMatchingRows,    (        SELECT sum(marks)        FROM system.parts        WHERE (\`table\` = 'uk_price_paid_by_date') AND active    ) AS totalGranules,    round((granulesWithMatchingRows / totalGranules) * 100, 2) AS pctFROM uk_price_paid_by_dateWHERE (date >= '2023-01-01') AND (date <= '2023-01-31');

复制代码

┌─granulesWithMatchingRows─┬─totalGranules─┬──pct─┐│                    29724 │         29755 │ 99.9 │└──────────────────────────┴───────────────┴──────┘

复制代码

我们正在扫描 29,724 个数据颗粒,即大约 243,499,008 行(29,724*8,192),以找到匹配行。

让我们看另一个例子,在该例子中我们在日期过滤上更精确,在区域过滤上更宽松。以下查询将找出 2023 年 2 月 1 日在曼彻斯特、伯明翰、萨顿和威拉尔售出的房产数据:

SELECT town,       count(),       round(avg(price)) AS avgPrice,       argAndMax(date, price)FROM uk_price_paidWHERE (district IN ('MANCHESTER', 'BIRMINGHAM', 'SUTTON', 'WIRRAL'))AND (date = '2023-02-01')GROUP BY ALLORDER BY count() DESCLIMIT 10SETTINGS    use_query_condition_cache = 0,    optimize_use_projections = 1;

复制代码

同样,我们将对每个表运行三次,并记录最短耗时和处理的行数:

这次,by_date 在数据过滤方面的表现优于 by_district,但当这两个轻量级投影共同作用时,我们能获得最佳性能。

按 minmax 索引过滤

我们最后一种剪枝技术是 minmax 索引(minmax index),它是一种跳跃索引(skip index)。需要快速提醒的是,任何添加了跳跃索引的列都必须与主键(primary key)相关联;否则,该索引将无法生效。

我们将为 price 列添加一个 minmax 索引,以便更高效地过滤价格。价格与邮政编码(postcode)之间存在一定关联,因为同一地理区域的房产通常会在相似的价格区间内出售:例如,伦敦高价邮政编码(如 SW1、W1)的房产价格普遍偏高,而农村地区的邮政编码则普遍偏低。这意味着查询引擎(query engine)在按价格搜索时应该能够跳过数据粒度(granules)。

我们可以通过以下查询添加 minmax 索引:

ALTER TABLE uk_price_paidADD INDEX price_minmax price TYPE minmax GRANULARITY 1;

复制代码

我们将物化(materialize)该索引,从而能立即投入使用:

ALTER TABLE uk_price_paidMATERIALIZE INDEX price_minmaxSETTINGS mutations_sync=1;

复制代码

接下来,我们将编写一个查询,以找出销售价格超过 10,000,000 英镑的房产最多的行政区:

SELECT    district,    count(),    formatReadableQuantity(avg(price)) AS avgPriceFROM uk_price_paidWHERE price > 10000000GROUP BY ALLORDER BY count() DESCLIMIT 10SETTINGS use_query_condition_cache = 0,         use_skip_indexes = 1;

复制代码

use_skip_indexes 设置默认启用,但我们可以将其关闭,以评估跳跃索引带来的影响。

运行查询的结果如下所示:

┌─district───────────────┬─count()─┬─avgPrice──────┐CITY OF WESTMINSTER    │   1388032.61 million ││ KENSINGTON AND CHELSEA │    7120 │ 19.22 million │CAMDEN                 │    361634.78 million │CITY OF LONDON         │    244850.09 million ││ TOWER HAMLETS          │    2392 │ 43.70 million ││ MANCHESTER             │    1752 │ 24.71 million ││ SOUTHWARK              │    1712 │ 37.31 million ││ BIRMINGHAM             │    1520 │ 27.66 million ││ ISLINGTON              │    1504 │ 35.81 million ││ LEEDS                  │    1328 │ 24.94 million │└────────────────────────┴─────────┴───────────────┘

复制代码

我在未启用跳跃索引(即 use_skip_indexes=0)的情况下运行了三次此查询:

10 rows in set. Elapsed: 0.347 sec. Processed 243.62 million rows, 1.09 GB (701.77 million rows/s., 3.13 GB/s.)Peak memory usage: 6.21 MiB.10 rows in set. Elapsed: 0.506 sec. Processed 243.62 million rows, 1.09 GB (481.02 million rows/s., 2.15 GB/s.)Peak memory usage: 6.21 MiB.10 rows in set. Elapsed: 0.390 sec. Processed 243.62 million rows, 1.09 GB (624.22 million rows/s., 2.78 GB/s.)Peak memory usage: 6.21 MiB.

复制代码

随后,在启用跳跃索引(即 use_skip_indexes=1)的情况下又运行了三次:

10 rows in set. Elapsed: 0.312 sec. Processed 116.41 million rows, 578.67 MB (373.50 million rows/s., 1.86 GB/s.)Peak memory usage: 5.46 MiB.10 rows in set. Elapsed: 0.306 sec. Processed 116.41 million rows, 578.67 MB (380.51 million rows/s., 1.89 GB/s.)Peak memory usage: 5.46 MiB.10 rows in set. Elapsed: 0.304 sec. Processed 116.41 million rows, 578.67 MB (382.48 million rows/s., 1.90 GB/s.)Peak memory usage: 5.49 MiB.

复制代码

未启用跳跃索引(skip index)时的最优耗时为 304 毫秒,而启用跳跃索引后的最优耗时为 234 毫秒,性能提升约 23%。

启用跳跃索引的查询处理的行数减少了约一半。我们可以通过解释查询(explaining the query)来查看被忽略的数据:

EXPLAIN indexes=1, projections=1, pretty=1, compact=1

复制代码

输出结果如下所示:

┌─explain────────────────────────────────────────────────┐ 1. │ Output: district, count(), avgPrice                    │ 2. │                                                        │ 3. │ Limit (preliminary LIMIT)                              │ 4. │ └──Sorting (Sorting for ORDER BY)                      │ 5. │    └──Aggregating                                      │ 6. │       └──ReadFromMergeTree (default.uk_price_paid)     │ 7. │             Indexes:                                   │ 8. │               PrimaryKey                               │ 9. │                 Condition: true                        │10. │                 Parts: 11/11                           │11. │                 Granules: 29744/29744                  │12. │               Skip                                     │13. │                 Name: price_minmax                     │14. │                 Description: minmax GRANULARITY 1      │15. │                 Condition: (price in [10000001, +Inf)) │16. │                 Parts: 11/11                           │17. │                 Granules: 14214/29744                  │18. │               Ranges: 6034                             │    └────────────────────────────────────────────────────────┘

复制代码

从第 17 行可以看出,跳跃索引排除了略高于 15,000 个数据粒度。

结论

这篇博文向我们介绍了 ClickHouse 提供的三种基于索引的剪枝技术:主索引(primary index)、轻量级投影(lightweight projections)和跳跃索引。

其中,主索引的功能最为强大。在设计 ORDER BY 子句时,应围绕最常用的过滤列进行。

轻量级投影是针对非主键列进行过滤的有效选择。通过结合多个投影,ClickHouse 可以对结果进行交集运算,从而实现更高效的剪枝。

最后,像 minmax 这样的跳跃索引,仅当目标列与主键相关联时最能发挥作用;如果缺乏这种关联,它们的效果将大打折扣。

/END/

征稿启示

面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出 &图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com。