我们在 Cloudflare 内部大量使用 ClickHouse,这是一个开源的联机分析处理 (OLAP) 数据库。每天,我们会向 ClickHouse 发出数百万次调用,以确定应该向使用 Cloudflare 产品的用户收取多少费用。如果我们不及时完成这些任务,将很难进行发票对账。
此管道为数亿美元的使用收入、反欺诈系统等提供支持,因此其延迟会对后续流程产生重大影响。
正因如此,当 ClickHouse 中负责确保 Cloudflare 账单正常发出的每日汇总任务处理速度在迁移后显著减缓时,我们才意识到这是一个严重问题。所有常见检查指标看起来都正常:I/O、内存、扫描的行数、读取的片段。这些我们通常会在 ClickHouse 查询速度变慢时检查的所有指标似乎都正常。
本文将概述我们如何发现 ClickHouse 内部深处隐藏的瓶颈,以及我们编写了哪三个补丁来修复。
我们使用 ClickHouse 在数十个集群中存储超过一百 PB 的数据。为了简化众多内部团队的引导流程,我们在 2022 年初构建了一个名为“Ready-Analytics”的系统。
前提很简单:团队无需设计新表,即可将数据流式传输到单张超大表。数据集通过 namespace 来消除歧义,每条记录使用标准模式(例如,20 个浮点字段、20 个字符串字段、一个时间戳,以及一个 indexID)。
在 ClickHouse 中,数据排序方式对于查询性能至关重要。indexID 正好可以发挥这方面的作用。它是一个字符串字段,构成主键的一部分,也就是说,每个命名空间可以按照其所有者预期的运行情况,优化查询数据的排序方式。最终得到的主键如下所示:(namespace、indexID、timestamp)。
这个系统广受欢迎,数百个应用程序都使用它。截至 2024 年 12 月,其数据量已超过 2PiB,数据摄取速率高达每秒数百万行。但它有一个严重缺陷:数据保留策略。
Cloudflare 多年来一直使用 ClickHouse,甚至在其具有原生生存时间 (TTL) 功能之前就已开始使用。因此,我们基于数据分区机制构建了自己的保留系统。Ready-Analytics 表按 day 分区,因此,我们的保留作业会简单地删除超过 31 天的分区。
这种“一刀切”的 31 天保留期是一个重大限制。有些团队由于法律或合同义务需要存储数据多年,而另一些团队只需存储数据几天。这种限制意味着这些用例无法使用 Ready-Analytics,而而不得不选择传统的部署方案,其引导流程也复杂得多。
我们需要一个支持按命名空间保留数据的新系统。
我们考虑了两种主要方法:
每个命名空间一张表:这自然可以解决数据保留问题,但需要大量新的自动化功能来按需管理数千张表。
新的分区键:我们可以将分区键从简单的 (day) 修改为 (namespace, day)。
我们选择了第二个选项。这样一来,现有的保留系统可以继续管理分区,但如今我们还可以按命名空间进行精细化管理。
我们知道这将增加表中片段的总数量,但我们做了一个关键假设:由于按特定命名空间过滤每个查询,任何单个查询读取的片段数量不应改变。我们认为,这意味着性能不会受到影响。
这显示了我们是如何更改分区方式,从而能够经济实惠地删除单个命名空间的数据
这个新系统也让我们能够构建一个复杂的存储管理层。利用最大最小公平算法,我们可以设置目标磁盘利用率(例如 90%),并自动“共享”可用空间。使用率低于其公平份额的命名空间,可以将其未使用的容量让给那些需要更多容量的命名空间。这样一来,我们能够自信地将集群的运行利用率提高到 90%。
2025 年 1 月,我们开始了迁移。我们使用 ClickHouse 的 Merge 表功能合并了新旧表,从而将所有新数据写入新的分区表,旧数据逐渐超过保存期限。
两个月后,也就是 2025 年 3 月下旬,我们的计费团队报告说每日汇总任务处理速度变慢了。这些任务对时间要求很高;如果不完成,便无法发出账单。任务的处理速度越来越慢,而我们不断接近最后期限。
我们进行了调查,但所有常见检查指标看起来都很正常。I/O 正常。内存正常。单个查询的指标显示,其读取的数据和片段数量并不比以前更多。我们的初步假设似乎是正确的,但系统却开始缓慢运行。
我们花了好几天时间才找到一套理论来解释这个问题。最终,我们绘制了查询持续时间与总片段数的关系图。结果显示,两者之间存在明显的关联。
Ready Analytics ClickHouse 集群上的平均 SELECT 查询持续时间,显示性能逐渐下降。
采用新的 (namespace, day) 分区方案后,每表副本的数据片段总数呈线性增长。
但是,为什么会这样呢?如果没有读取额外的片段,为什么它们的存在会减缓运行?
我们转为利用 ClickHouse 内置的 trace_log 来生成火焰图。这是一个内置表,用于记录正在运行的 ClickHouse 服务器的跟踪信息。它不仅包含正在执行的代码跟踪信息,还将这些信息与特定用户、查询 ID 和其他元数据关联,这意味着可以根据需要筛选出相当精确的事件集。在我们的用例,我们专门查看了末端节点 SELECT 查询。由于表中提供了元数据,因此很容易做到这一点。
第一个基于 CPU 的火焰图迅速证实了我们的怀疑:查询规划耗费了大量时间。这是执行之前的阶段,此时 ClickHouse 会决定要读取哪些片段。
火焰图,显示末端节点查询 45% 的 CPU 时间用于根据分区 ID 来过滤片段向量
火焰图清晰地表明:45% 采样的 CPU 时间用于执行一个名为 filterPartsByPartition 的函数。
我们最初尝试的修复方法是对这个确切的代码路径打一个小补丁。规划器评估启发式算法以减少片段,但我们认为没有按最优顺序来进行评估。我们的补丁调整了顺序,从而提升了 5% 的性能。我们找到了正确的方向,但忽略了真正的问题。
我们一直生成的是“CPU”跟踪信息,这些跟踪信息只对活动线程进行采样。后来我们切换为“真实”跟踪信息,这些跟踪信息会对所有线程进行采样,包括那些处于非活动状态或等待状态的线程。新的火焰图给我们很大的启示。
火焰图,显示超过一半的末端节点查询持续时间用于等待保护活动片段列表的互斥锁
问题不在于 CPU 密集型任务;而是大规模锁争用。超过一半的查询持续时间都花在等待获取保护表片段列表的单个互斥锁 (MergeTreeData)。若要规划查询,每个线程都必须:
获取此互斥锁的独占锁。
完整复制表中所有片段列表。
释放该锁。
过滤列表,将其缩小到相关片段。
由于有数万个片段和数百个并发查询,它们就像排成一列一样,等待执行。
这一发现帮助我们规划了一系列优化措施来缓解这些性能瓶颈。与针对 ClickHouse 所做的所有补丁一样,我们力求使这些措施具有通用性,并最终将其贡献到上游代码库。这让我们可以更轻松地更容易维护自己的分支,意味着社群也能从我们所做的改变中受益!
查询规划器不会修改片段列表;它只是读取。因此,没有理由使用独占锁。
解决方法:我们修改了代码,以获取共享锁 (std::shared_lock)。这使得所有查询规划器可以同时进入临界区。
结果:查询持续时间立即显著下降。锁争用问题消失了。
使用共享锁优化(优化措施 1)对平均 SELECT 查询持续时间的即时影响,表明解决了锁争用问题。
性能虽已显著改善,但仍未恢复到基准水平。我们重新查看了跟踪日志,并绘制了另一个“真实”的火焰图。
火焰图,显示四分之一的末端节点查询持续时间用于复制所有片段向量,另外四分之一的末端节点查询持续时间用于过滤(再次复制)。
新的火焰图显示,瓶颈只是发生了转移。现在,即使使用了共享锁,仍然会花费时间复制庞大的片段向量。凭直觉,复制向量似乎很省时,但当它包含成千上万个元素且每秒执行数百次时,时间就会累加。
解决方法:我们完全推迟了复制操作。我们创建了一个片段列表的“共享副本”。只读操作(例如查询规划)直接从该副本读取数据。任何修改片段集合(例如插入新片段)的操作都会重新生成缓存。现在,规划器只复制其真正需要的已过滤片段列表。
结果:又一次显著的性能提升。
推出向量复制优化(优化措施 2)后,进一步提升了性能。
在内部看到了这些显著的性能提升后,我们决定将这些更改拓展到社群。经过与 ClickHouse Inc. 维护人员进行多次微小的设计迭代后,我们在 PR #85535 中合并了这些更改。这些更改自 ClickHouse 25.11 版本以来一直可用。
事情还没有结束。随着片段数量的增加,性能仍然会下降,只是下降速度变得更慢。与片段数量的关联仍然存在。几个月后,我们重新审视了这个问题,新的火焰图(与图 3 相同)显示了过滤代码路径(我们首先尝试修复的路径)所花费的时间。该代码对所有片段执行线性扫描,从而针对每个片段评估谓词。在几个月之后,我们又回到了优化前的 SELECT 持续时间。
但我们知道,这个片段列表是按分区键排序。请记住,分区键的第一列是命名空间,绝大多数查询会根据它来过滤,因为它会标识“租户”。我们如何利用这一点?
解决方法:我们根据分区 ID 的 namespace 片段,实施了二分搜索。之所以有效,是因为向量已排序,用户无需实际查看即可过滤许多条目。因为 namespace 是该排序键的第一个片段,所以这种方法特别有效。经过第一轮二分搜索后,我们需要检查的片段范围显著缩小,对于这些片段,我们仍然会逐个检查,应用与之前相同的逻辑,根据其他条件排除某些片段。
结果:在 2026 年 3 月部署此补丁后,查询持续时间减少了 50%(参见图 8)。更重要的是,这最终打破了查询持续时间与片段数量之间的关联。但遗憾的是,这个解决方法对于任意查询条件(例如,namespace in (5,10) 等条件)的通用性并不强。我们正在研究更通用的方法,例如扩展查询条件缓存来涵盖片段过滤。
实施二分搜索以减少片段后,延迟持续降低(优化措施 3)。
这些优化措施暂时解决了计费系统的问题。但这次经历也暴露了我们分区方案选择带来的深远且不易察觉的代价。
依然存在其他问题。在这篇博客文章中,我们只描述了片段数量增加对 SELECT 持续时间产生的影响,但它也导致了 ZooKeeper 问题,ZooKeeper 负责跟踪 ClickHouse 中所有片段的元数据。或许有一天,我们会概述 100 GB ZooKeeper 集群的情况。
虽然我们获得了足够的喘息空间,但根本问题仍然存在:这种分区方案是否是正确的长期选择?或者,我们最终需要硬着头皮,迁移到不同的架构?目前,我们的补丁暂时有效,但这次经历清楚地表明,即使是精心策划的变更也可能因为错误的假设而失败。
计费团队首次报告该问题时,我们每个副本有 30,000 个片段。片段数量持续增长,一年后每个副本的片段数量达到了 16 万个,但由于我们现阶段的优化措施,查询持续时间一直保持稳定。
Cloudflare 致力于解决复杂的大规模工程难题。如果您认为本文描述的调试和优化正是您寻求的挑战,请查看空缺职位,了解我们的一些招聘信息。