Google BigQuery 处理几十TB、几百TB甚至PB级数据时,查询成本往往不是由“SQL写得长不长”决定,而是由一次查询到底读取了多少数据决定。对于按扫描量计费的查询模型,用户最应该关注的并不是服务器有多少CPU、查询用了多少计算节点,而是扫描的数据量是否可以通过分区裁剪、聚簇以及查询重写尽可能缩小。
分区表就是 BigQuery 控制扫描范围最重要的机制之一。它的基本思想并不复杂:把一张巨大的逻辑表按照某个合适的维度划分成多个分区,查询时如果能够明确限定目标分区,BigQuery 就没有必要读取整个表。假设一张日志表保存了五年的数据,而业务每天只分析最近一天,那么最合理的设计不是让每一次查询都面对五年的数据,而是让查询可以直接把扫描范围限制在目标日期对应的分区。
但分区并不是“数据越多越应该分区”,也不是“分区字段越细越快”。BigQuery 的分区设计必须和实际查询模式结合,否则很容易出现表已经分区,查询成本却没有明显下降的情况。
BigQuery 分区到底解决了什么问题
BigQuery 的分区表本质上是对大型表建立可裁剪的数据范围。最常见的是时间分区,例如按照 DATE、TIMESTAMP 或 DATETIME 类型的字段进行日、月等粒度的划分,也可以使用整数范围分区等方式。BigQuery 还支持 ingestion-time partitioning,也就是根据数据写入 BigQuery 的时间进行分区。
这里有一个很容易产生误解的地方:BigQuery 并不是传统关系型数据库意义上的“分区表+磁盘文件夹”。用户通常不需要也不能像管理本地数据库文件那样直接管理每个分区对应的物理文件。
同样,BigQuery 并不是要求每个分区独立保存成一个 Parquet 文件,然后查询时打开相应文件。BigQuery 的底层存储和执行引擎由 Google 管理,用户应该从逻辑分区、列式存储、分区裁剪以及扫描数据量的角度理解它,而不是把 BigQuery 简化成“云端 Parquet 文件查询器”。
对于绝大多数日志、订单、交易、监控、事件流和业务行为数据,时间分区通常是最自然的选择。例如:
CREATE TABLE `project.dataset.logs`
PARTITION BY DATE(event_time)
AS
SELECT *
FROM `project.dataset.raw_logs`;
这样建立以后,如果查询只需要某一天的数据:
SELECT user_id, event_type, event_time
FROM `project.dataset.logs`
WHERE event_time >= TIMESTAMP('2026-08-30')
AND event_time < TIMESTAMP('2026-08-31');
BigQuery 可以利用分区条件缩小需要读取的数据范围。
如果业务经常按照日期查询,这种设计的价值非常明显。五年的日志和一天的日志,在逻辑上虽然属于同一张表,但一次查询并不需要把五年的数据全部读进执行过程。
分区字段不能简单理解成“最常查询的字段”
原稿中把 user_id 作为一种典型分区字段,这个说法需要特别谨慎。
BigQuery 的分区不是传统数据库里的普通索引。对于大量不同用户的数据,如果 user_id 的基数非常高,把 user_id 当作分区键通常不是一个理想方案。BigQuery 的分区数量和分区管理本身存在约束,而且大量高基数字段并不适合拿来建立大量分区。
如果数据首先按照时间访问,而用户又经常作为过滤条件,那么更常见的设计是:
时间分区
+
CLUSTER BY user_id
例如:
CREATE TABLE `project.dataset.user_activity`
PARTITION BY DATE(event_time)
CLUSTER BY user_id
AS
SELECT *
FROM `project.dataset.raw_activity`;
这里的逻辑非常重要。
分区负责把“几年的数据”缩小到“某一天或某几天的数据”,而 clustering 再帮助 BigQuery 在这些目标分区内部减少需要读取的数据。
因此,对于大量用户行为数据,一个很典型的查询路径可能是:
整个数据集
↓
按日期进行 Partition Pruning
↓
只进入目标日期分区
↓
利用 Clustering 缩小 user_id 等字段的读取范围
↓
执行过滤、JOIN、聚合
这通常比试图建立“user_id 分区+时间分区”的所谓多级分区更加符合 BigQuery 的实际设计方式。
BigQuery 的表并不存在传统意义上的任意多级分区。更准确的说法是:可以使用一个 partitioning column,再结合 clustering columns 对分区内部的数据组织进行优化。
Partition Pruning 才是降低扫描成本的核心
分区表真正有价值的地方,是查询能够触发 Partition Pruning,也就是分区裁剪。
例如:
SELECT COUNT(*)
FROM `project.dataset.logs`
WHERE event_date >= '2026-08-01'
AND event_date < '2026-08-08';
如果 event_date 是分区字段,BigQuery 可以只读取对应范围的分区。
但如果开发人员写成:
SELECT COUNT(*)
FROM `project.dataset.logs`;
那么即使这张表已经建立了分区,查询本身也没有告诉 BigQuery 哪些分区是不需要的。
因此,“建立分区表”并不等于“以后所有查询都会自动变便宜”。
查询是否能够有效利用分区条件,必须结合实际执行计划和查询统计信息判断。
对于特别重要的生产数据集,可以考虑要求查询必须包含分区过滤条件。这样做的意义不是让查询变快,而是防止开发人员或者报表工具一不小心提交一个扫描数十TB甚至数百TB的数据查询。
不要把 SELECT * 当成大型数据查询的默认写法
BigQuery 是列式分析数据库,因此查询只读取需要的列非常重要。
例如:
SELECT *
FROM `project.dataset.logs`
WHERE event_date = '2026-08-30';
如果日志表有几十甚至上百个字段,而报表实际上只需要:
user_id
event_time
event_type
country
那么更合理的是:
SELECT
user_id,
event_time,
event_type,
country
FROM `project.dataset.logs`
WHERE event_date = '2026-08-30';
分区解决的是“读取多少行、多少数据范围”的问题,而选择必要字段又可以进一步减少实际读取的数据量。
这也是为什么大型 BigQuery 系统优化时不能只盯着 partitioning。分区、clustering、列裁剪、JOIN、聚合、数据模型以及查询写法往往共同决定最终成本。
Clustering 是分区之后的第二层优化
当一个分区内部仍然非常庞大时,可以使用 clustering。
例如订单数据按照日期分区:
CREATE TABLE `project.dataset.orders`
PARTITION BY DATE(order_time)
CLUSTER BY customer_id, product_id
AS
SELECT *
FROM `project.dataset.raw_orders`;
如果查询通常是:
SELECT
SUM(amount)
FROM `project.dataset.orders`
WHERE order_time >= TIMESTAMP('2026-08-01')
AND order_time < TIMESTAMP('2026-08-02')
AND customer_id = 123456;
分区首先把查询范围限制在8月1日的数据,然后 clustering 可以进一步帮助 BigQuery 缩小需要读取的数据范围。
但 clustering 不是传统意义上的 B-tree index,也不能理解成“给 customer_id 建了一个索引,所以查询一定只读取这一行”。
它更接近于让大型列式数据在存储层面保持更有利于过滤的组织方式,从而帮助执行引擎减少不必要的数据读取。
因此,选择 clustering 字段时应该看真实的查询模式,而不是简单把所有经常出现的字段全部塞进去。
CTE 不会神奇地让 BigQuery 少扫描数据
原稿提出通过子查询或者 CTE 限制分区范围,这个方向容易造成误解。
例如:
WITH recent_data AS (
SELECT *
FROM `project.dataset.logs`
WHERE event_date >= '2026-08-01'
AND event_date < '2026-08-08'
)
SELECT COUNT(*)
FROM recent_data;
这里真正起作用的是 CTE 内部的分区过滤条件,而不是“使用了 CTE”。
如果把同样的过滤条件写成普通查询:
SELECT COUNT(*)
FROM `project.dataset.logs`
WHERE event_date >= '2026-08-01'
AND event_date < '2026-08-08';
一样可以利用分区裁剪。
所以优化重点应该放在数据访问范围和执行计划,而不是为了优化成本而机械增加 CTE、子查询或者 SQL 层级。
分区生命周期管理不能写成普通 SET expiration SQL
对于日志、监控和事件数据,很多历史数据并没有永久保存的必要。
例如业务只需要保存最近180天:
今天
↓
180天
↓
自动淘汰旧数据
这种情况下可以利用 BigQuery 的分区过期策略进行生命周期管理。
它的价值不仅是减少查询范围,也可以避免无意义的历史数据持续占用存储空间。
不过这里需要区分两个概念:减少查询成本和减少存储成本不是同一件事。
分区裁剪主要影响查询扫描范围;数据生命周期管理主要影响长期存储规模。一个历史数据很多但从不查询的表,可以通过生命周期策略降低存储成本;一个每天查询大量历史数据的表,则必须从分区和查询设计入手降低计算成本。
不要用一个固定的“每TB多少钱”来计算 BigQuery 查询费用
原稿中的“10TB=$500、3TB=$150、1TB=$50”这种计算方式不能作为通用规则。
BigQuery 的实际成本取决于所采用的计费模式、区域、查询方式、容量管理和其他服务配置。对于按需查询,扫描数据量确实是非常重要的成本因素,但不能简单给所有环境套用一个固定的美元/TB数字。
因此,工程团队更应该关注查询实际显示的:
Bytes processed
Bytes billed
Execution time
Slot usage
Shuffle
Query stages
在开发阶段可以先查看预计扫描量,再决定是否执行查询。
对于大型生产项目,还应该建立查询成本监控。例如某个报表过去每天只扫描50GB,突然变成8TB,这往往比单次查询慢几秒更加值得排查。
EXPLAIN 不是成本分析的唯一工具
EXPLAIN 可以帮助理解查询执行计划,但不能把它简单理解成“执行 EXPLAIN 就能告诉你分区裁剪是否成功”。
实际调优时应该结合 BigQuery 的查询执行详情和作业统计信息观察:
扫描了多少数据
哪些 Stage 最耗时
是否发生大量 Shuffle
JOIN 是否成为瓶颈
聚合是否产生大量中间数据
是否存在不必要的数据读取
如果查询已经通过分区裁剪把扫描范围从几十TB降低到几百GB,但最终仍然很慢,那么继续折腾 partitioning 可能已经没有意义。
这时候真正的瓶颈可能已经转移到了 JOIN、Shuffle、排序、聚合或者数据倾斜。
这也是大型 BigQuery 优化中经常被忽略的一点:减少扫描量并不意味着查询一定会线性变快。
JOIN 往往比你想象的更贵
例如:
SELECT
a.customer_id,
SUM(b.amount)
FROM large_events a
JOIN large_orders b
ON a.customer_id = b.customer_id
WHERE a.event_date >= '2026-08-01'
GROUP BY a.customer_id;
即使 large_events 已经进行了时间分区,如果 JOIN 之前没有尽可能缩小数据范围,执行引擎仍然可能需要处理大量数据并进行 Shuffle。
因此大型查询的优化顺序通常应该是:
先限制分区范围
再减少无用列
再尽可能提前过滤
再控制 JOIN 数据量
最后观察聚合和 Shuffle
如果两个大型表都参与 JOIN,还需要根据实际数据模型判断是否可以预聚合、使用更合理的事实表和维度表结构,或者改变查询方式。
Materialized View 不是简单的“分区表缓存”
对于反复执行的聚合查询,Materialized View 确实可能降低计算量,但不能把它理解成“自动保存一份分区表,然后查询直接读取缓存”。
Materialized View 的价值在于预计算部分查询结果,使系统能够复用已经计算的数据。BigQuery 还可以在适用条件下进行查询重写,让查询利用物化结果。
但是否有效取决于具体查询模式、SQL结构、底层数据变化以及 Materialized View 支持的能力。
因此,如果一个报表每天执行数千次相似的聚合查询,Materialized View 值得考虑;如果查询模式变化非常大,每次都查询完全不同的条件,那么单纯增加 Materialized View 并不一定带来理想收益。
一个大型日志系统应该怎样设计
假设公司每天产生2TB日志,一年下来已经超过700TB。
如果所有日志放在一张没有合理分区设计的表里,报表团队很容易出现这样的查询:
SELECT *
FROM `project.dataset.logs`
WHERE user_id = 123456;
用户虽然只需要一个人的数据,查询却可能面对巨大的数据范围。
更合理的架构可以是:
logs
│
├── Partition
│ event_date
│
└── Clustering
user_id
event_type
查询首先利用日期缩小数据范围,然后利用 user_id、event_type 等 clustering 字段进一步减少读取。
如果业务只允许查询最近90天,还可以配合分区生命周期策略。
如果某些统计每天都会执行数百次,则进一步考虑预聚合表或者 Materialized View。
最终形成:
数据模型
↓
时间分区
↓
分区裁剪
↓
Clustering
↓
列裁剪
↓
过滤与JOIN优化
↓
预聚合 / Materialized View
↓
成本监控
这比简单地“给表增加一个分区字段”完整得多。
BigQuery 分区最容易犯的几个错误
最常见的错误之一,是为了追求查询速度而选择一个高基数字段作为分区字段。分区数量不是越多越好,user_id 这种高基数字段很多时候更适合作为 clustering 字段。
第二个错误,是认为“表已经分区,所以查询自然便宜”。如果查询没有有效利用分区过滤,分区本身不会自动把所有查询变成低成本查询。
第三个错误,是把 clustering 当成传统数据库索引。Clustering 能帮助 BigQuery 减少读取,但不是传统 OLTP 数据库里的 B-tree 索引,也不能保证每个条件都只读取极少数据。
第四个错误,是只看查询时间,不看扫描量。一次查询跑了10秒并不意味着成本低;如果它扫描了几十TB,速度和成本可能都是问题。
第五个错误,是为了优化而盲目增加 CTE、子查询、Materialized View。优化应该建立在实际查询统计和执行阶段分析上,而不是不断增加架构复杂度。
第六个错误,是把缓存当成数据治理。查询缓存、Materialized View、预聚合表和分区分别解决不同问题,不能混为一谈。
对于真正达到TB、PB级规模的 BigQuery 项目,最值得建立的习惯其实非常简单:每次发现一个昂贵查询,不要先猜“是不是服务器不够快”,而是先看它究竟读取了多少数据、哪些列被读取、分区有没有被裁剪、JOIN产生了多少Shuffle、哪个执行阶段占用了主要资源。
BigQuery 的优势就在于它不要求用户自己维护传统数据库服务器、磁盘阵列和分片集群,但这并不意味着数据架构可以随意设计。数据量达到几十TB之后,一个错误的分区字段、一个无意间出现的全表扫描,或者一个没有提前过滤的大型JOIN,都可能把一次普通报表查询变成高成本任务。
所以,BigQuery 分区表的核心不是“把数据切成很多块”,而是让查询引擎能够尽早知道哪些数据根本不需要读取。分区负责缩小数据范围,clustering负责改善分区内部的数据访问,列裁剪负责减少无用字段读取,而查询重写、JOIN优化、预聚合和Materialized View则进一步减少计算工作量。只有把这些机制放在同一个数据访问链路中考虑,海量数据查询的性能和成本才有可能同时得到控制。
BigQuery 分区表怎么使用,如何减少大型数据查询成本
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP