文章摘要
本文介绍了一种从单个Parquet文件构建快速钻取仪表盘的方法,通过将数据汇总为Parquet数据立方体,利用对象存储的HTTP范围请求来高效查询聚合行,无需额外供应商即可实现客户分析仪表盘功能。
文章总结
好的,这是根据您的要求,对原文进行中文重述和精简后的版本:
从单个 Parquet 文件构建快速下钻仪表盘
一个40MB的Parquet数据立方体,一个18KB的读取器,一个R2存储桶,以及一些不起眼的HTTP范围请求。
每月都有关于对象存储巧妙应用的新案例涌现。最近的一个例子是Cursor Origin使用S3和WAL来管理大规模Git仓库。受此启发,我开始思考另一种可能完全适用对象存储的场景:面向客户的分析仪表盘。我的一位朋友在R2上的Iceberg表中存有客户使用数据,他想为用户提供带有筛选功能的基础图表,但不想引入新的供应商,这排除了我所在公司MotherDuck(一个云托管DuckDB数据库)的方案。
在分析领域,当你只有对象存储时,一切看起来都像是一个范围请求。我们可以将这类数据汇总成一个Parquet数据立方体,然后使用范围查询来获取仪表盘所需的聚合行。通常我会使用DuckDB-Wasm来处理这类BI工作负载,但我发现Hyparquet(一个在浏览器中运行的JavaScript Parquet读取器)能以18KB的体积完成相同的范围扫描,而无需多个MB的库和专用工作线程。这样,你无需数据库或查询引擎,就能提供一个真正的下钻仪表盘。 数据立方体甚至可以达到几十或几百MB,因为一个布局合理的文件意味着你每次只需读取其中一小部分。你只需要一个数据管道来生成这些立方体——事实证明,当你拥有一个真正的分析数据库时,大部分成本也恰恰花在了这里。
为了测试,我使用了著名的纽约市311服务请求数据集(约3400万行),将其汇总成一个40MB的Parquet立方体,包含城市机构、投诉类型、提交方式和行政区等筛选维度,以及一个用于时间序列的创建时间列。然后将其放在R2上。40MB的大小足以让人感受到下载整个文件的压力。
下面的演示仪表盘直接使用Hyparquet从该文件读取数据。数据通过一个Cloudflare Worker中转,因为免费的r2.dev URL有速率限制。老实说,鉴于它既没有真正的数据库也没有强大的查询引擎,新数据加载的速度让我惊讶。UI负责所有实际的读取工作,并且足够轻量,可以直接嵌入本文而不影响页面加载。真正的复杂性几乎完全转移到了数据立方体的布局上。
那么,这个仪表盘是如何工作的?
这类仪表盘旨在回答一组有限的分析问题,例如每日请求数、某个机构的每日请求数、按行政区划分的历史总数。每个问题都可以通过GROUP BY查询来回答,因此我们可以提前预计算所有结果,并将每个结果保存为一个小表,称为分组集。将所有分组集堆叠在一个Parquet文件中,每个分组集一个部分,就构成了一个数据立方体。一个分组集只有在能够回答问题或减少数据拉取延迟时才有用。这个文件包含了两种类型的分组集:历史总数用于排行榜,而每个筛选组合的每日分组集则为折线图提供数据。周和年的分组集减少了因刷选图表而扫描的行数。
文件现在包含了渲染仪表盘所需的分组集,但浏览器仍需从中提取所需行。Parquet格式的两个特性使之成为可能。Parquet文件被划分为行组(每个约几万行),文件末尾包含一个页脚,其中包含行组的字节范围以及每列的最小/最大值等元数据。客户端读取一次页脚。然后,每个查询利用最小/最大值来选择可能匹配的行组,获取这些字节范围,并在浏览器中进行聚合。
仪表盘请求的低延迟归功于Parquet文件中行的排序和扫描方式。如果文件行是随机排序的,每个行组的最小/最大值将几乎覆盖每列的整个范围,那么一个查询为了获取一小部分行,就必须读取大部分文件。相反,每个分组集的行都按其查询筛选的列进行排序。因此,匹配的行通常构成文件中的一个连续区域,最小/最大值统计信息使读取器可以忽略其余行组。这就是为什么点击机构排行榜中的NYPD,只会从40MB文件中读取约260KB,而不是整个文件。
这种设置适用于两个条件: 图表和筛选器的组合数量必须保持较小;你的数据管道必须足够快地重建每个客户的文件,以满足更新频率。大多数使用量和计费页面都满足这两个条件。它们包含一组固定的图表(如随时间变化的事件、按小时或按天的计数或总和、几个筛选器或排行榜),数据按粗略计划而非实时更新。从延迟角度看,立方体大小无关紧要,但你还是希望它相对较小,因为你需要按计划为每个客户重新生成一个。
在我的例子中,时间粒度明显占主导地位,因为每日部分占据了文件的大部分字节。基数(cardinality)是另一个倍增因素——投诉类型有485个不同的值,图中每个大的部分都包含它。实际上,为折线图选择每日粒度使文件大小约为每周粒度(5.6MB)的7倍。尽管如此,每日粒度并未显著影响范围请求的延迟,因为任何交互都只读取几个行组。
上述仪表盘适用于分布式和代数式聚合(如求和、计数、最大值、平均值),这些聚合可以分块计算,然后在可视化前合并。对于整体式聚合(需要了解分布情况才能得到最终过滤后的聚合结果),有精确和近似两种解决方案。
通过精心布局的文件进行范围请求已有不少先例。PMTiles将瓦片集打包到一个文件中,客户端通过HTTP范围请求读取。SQLite-over-HTTP的案例也证明了这种机制甚至适用于B树。唯一的要求是一个支持HTTP字节范围服务的主机,如R2、S3、GitHub Pages或普通的nginx服务器。
我最喜欢的一点是,这种方法将复杂性“左移”到了数据管道。布局是预先决定的,因此当用户点击排行榜或刷选时间序列图表时,客户端只需获取正确的行并进行求和。对于大多数面向客户的仪表盘,一个10MB的客户立方体可以通过DuckDB的GROUP BY GROUPING SETS语句轻松生成。当然,我可能有偏见,但如果我来构建这个管道,我会选择MotherDuck和Flights(一个MotherDuck功能,可以为此类任务调度Python作业)。无论你如何实现,对于我那位数据工程师朋友来说,将复杂性推给管道是再合适不过的“职业病”了。
每个客户一个文件也使身份验证变得异常简单。访问控制归结为该客户文件的签名URL,或一个检查会话的小型Worker。
鉴于R2提供免费出站流量,管道才是真正花钱的地方。写入操作的成本是读取的12.5倍,无论文件大小如何,每次重建每个客户只需支付一次写入费用。假设有10,000个客户。每次重建都会替换文件,因此存储成本是固定的:10MB的立方体产生100GB,每月约1.50美元;40MB的立方体产生400GB,每月约6美元。每天重建所有文件一次,每月30万次写入,约1.35美元;每小时重建一次,每月720万次写入,约32美元;每五分钟重建一次,每月8600万次写入,约389美元。幸运的是,Iceberg快照差异可以精确告诉你哪些客户有新数据,因此很容易只重建有活动的立方体。
即使每5分钟更新一次且每个客户都有活动(这不太可能),对于这样一个单一用例,10,000个客户每月389美元的成本可能比搭建新基础设施更便宜,而且几乎肯定更简单。经济性和用户体验最近都发生了巨大变化:出站费用虽然不会完全扼杀这个想法,但可能阻碍了人们进行此类实验。同样的设置在S3上总体成本仅高出约20%——在100万次查询中,出站费用约为每月20美元,而100万次查询的流量已经超过了大多数客户仪表盘。
我另一个最喜欢的部分是它极其精简的实现。一个18KB的JavaScript读取器,加上一个替你完成数据库工作的字节布局。多么奇妙的世界!
评论总结
根据评论内容,主要观点如下:
观点一:使用Cloudflare Worker解决免费r2.dev URL的速率限制问题
- 关键引用:"The bytes pass through a small Cloudflare Worker on the way, because the free r2.dev URL is rate-limited."
- 该观点指出,通过Cloudflare Worker中转数据可规避免费r2.dev URL的速率限制。
观点二:建议直接使用GitHub Pages托管40MB文件,无需Cloudflare Worker
- 关键引用:"For a 40MB file I suggest hosting it directly on GitHub Pages - that's effectively a free CORS-enabled CDN and supports HTTP range requests"
- 该观点强调GitHub Pages作为免费CDN的优势,支持CORS和HTTP范围请求,可简化演示实现。
平衡性总结:评论呈现两种技术方案——Cloudflare Worker用于解决速率限制,而GitHub Pages作为更直接的替代方案。后者因免费、支持CORS和范围请求,被推荐用于40MB文件的托管。