论文阅读《Presto: A Decade of SQL Analytics at Meta》

我们曾在此前的文章中专门解读过《Presto: SQL on Everything》这篇论文,该论文从整体视角介绍了 Presto 这个分布式计算引擎的架构设计与系统实现。在 Presto 诞生十年之际,Meta 团队又发表了题为《Presto: A Decade of SQL Analytics at Meta》的论文以回顾 Presto 在 Meta 公司过去十年所面临的挑战以及架构演进。

image

本文我们将解读这篇十年回顾之作,如果说前者回答的是“Presto 为什么能用统一 SQL 查询一切”,那么后者回答的就是另一个更现实的问题,即当这个系统在 Meta 内部持续运行十年,并被推到 EB 级数据、弹性容器、长时间 ETL、机器学习、隐私合规和图分析等更复杂场景时,它是如何继续演进的。

伴随业务演进所面临的挑战

Presto 早期的设计目标非常明确,即面向交互式 SQL 查询,尽可能快地从分布式存储中读取数据,并在内存中完成计算。它的经典架构包括:

  • 一个 Coordinator 节点负责解析 SQL、生成执行计划、调度任务。
  • 多个 Worker 节点负责并行扫描数据、执行算子、通过网络 shuffle 交换数据。
  • 存储与计算分离,通过 Connector 访问 Hive、分布式文件系统等外部数据源。

这种架构非常适合“秒级到分钟级”的交互式查询,但随着 Meta 内部数据和业务的增长,问题逐渐暴露出来。

数据规模快速增长

Meta 的数据仓库横跨多个数据中心,数据量达到 EB 级别。随着业务的演进,即使同一条查询逻辑不变,底层需要扫描的数据量也可能随着时间增长数倍。论文中提到,在 2019~2022 年间,交互式和 Ad-hoc 查询的扫描数据量增长接近 5 倍,但用户依然希望查询的响应时间保持稳定。

这意味着 Presto 不能只依赖加机器来解决问题,因为:

  • 集群规模不能无限扩大。
  • 网络连接数和 shuffle 开销会成为瓶颈。
  • 机器越多,单点故障和节点失败概率越高。
  • 成本也会快速上升。

ETL 工作负载越来越重

Presto 原本不是为长时间、大规模 ETL 设计的,但由于 SQL 易用、执行效率高等特点,越来越多用户开始用 Presto 跑轻量 ETL,后来甚至希望它处理小时级、PB 级扫描任务。问题在于,原始 Presto 有几个天然限制:

  • 计算主要依赖内存,内存不足时容易失败。
  • Worker 节点缺乏细粒度容错能力。
  • Coordinator 节点是单点。
  • 流式 RPC shuffle 不适合超大规模、长时间任务。

换句话说,Presto 从“快查询引擎”被推向了“通用 SQL 计算引擎”。

基础设施变得更弹性

Meta 的资源管理逐渐转向更小、更弹性的容器。容器可以被频繁分配和回收,这有利于资源利用率,但对 Presto 提出新挑战:

  • 查询运行期间 Worker 节点可能被回收。
  • Coordinator 节点不能长期保存大量队列和调度状态。
  • 单个容器的内存变小,原有内存模型不再可靠。

对于一个长时间运行的 ETL 任务来说,任何一个 Worker 节点失败都可能导致整个查询失败,这显然是不可接受的。

分析需求超出传统 SQL

除了传统报表和 ETL,Meta 还出现了新的需求:

  • 机器学习特征工程需要灵活添加、删除候选特征。
  • 隐私合规要求支持用户数据删除、匿名化和使用追踪。
  • 社交网络和数据血缘天然是图结构,需要图分析能力。
  • 用户希望用 SQL 或类 SQL 的方式表达更复杂的数据处理逻辑。

因此,Presto 的演进方向不再只是更快,而是朝着更稳、更大、更通用方向迈进。

适配业务述求所引入的改进

论文将 Presto 的演进分成几个方向,包括执行效率、可扩展性和分析能力等,下面逐一解读。

执行效率优化:让查询在数据增长下仍然足够快

对于交互式查询,用户最关心的是延迟,尤其是 Dashboard 场景,用户希望点击筛选条件后能快速看到结果,但在存储计算分离架构下,Presto 每次查询都要从远端存储读取数据,I/O 成本很高,因此论文中介绍了多类优化手段。

这里的 Dashboard 指的是 Meta 内部大量用于产品决策和业务观察的指标看板,例如 A/B 实验结果分析、广告与推荐效果观察、用户增长和互动指标追踪、数据质量或系统状态监控等。Meta 员工会通过这些看板反复查看核心指标,并按时间、地区、产品形态、实验分组等维度切片分析,因此同一批热门数据和相似查询会被高频访问,对低延迟、数据新鲜度和稳定性都有很高要求。

多级缓存:减少远端 I/O 和重复计算

Presto 引入了多层缓存机制。

第一类是 Raw Data Cache,即把远端读取的数据块缓存在 Worker 节点本地 Flash 存储上。例如,某个 Worker 节点读取了远端文件的 [2MB, 5MB) 范围,Presto 会把这些数据块缓存在本地,下次如果查询再次读取重叠范围就可以直接从本地读取,而不是访问远端存储。

这对 Dashboard 非常有效,因为 Dashboard 往往存在重复访问:

  • 同一张表经常被查。
  • 同一批热门分区经常被查。
  • 用户可能不断调整过滤条件,但底层数据范围重叠。

第二类是 Fragment Result Cache,即缓存部分查询片段的中间结果。论文举了一个很直观的例子:第一次用户查询过去 1 天的聚合结果,Presto 可以缓存这一天的部分聚合结果,之后用户把时间范围扩大到过去 3 天,Presto 不必重新计算前 1 天,只需要计算新增的 2 天,再与缓存结果合并。这相当于把“重复扫描 + 重复计算”变成“增量计算”。

Cache Locality:让查询尽量命中缓存

缓存有效的前提是同一份数据下次还要尽量调度到有缓存的 Worker 节点上。所以 Presto 在调度层加入了 Cache Locality 策略:

  • 根据文件路径或数据块做 hash。
  • 尽量让同一个文件的读取请求落到同一个 Worker 节点。
  • 如果某个 Worker 节点过热,再 fallback 到其他 Worker 节点,避免热点。

这说明缓存并不只是存储层需要考虑的功能,它需要调度器配合,否则缓存命中率会大幅下降。

向量化执行:从 Java 走向 C++ 执行引擎

Presto 最初采用纯 java 实现,这带来了良好的工程效率和生态,但在高性能计算上存在一些限制:

  • 对内存布局控制不够精细。
  • 难以充分利用 SIMD。
  • GC 和对象模型可能带来额外开销。

Meta 围绕 Presto 孵化了 Velox 项目,一个 C++ 向量化执行引擎。Presto 可以将执行计划下发给 C++ Worker 节点,由 Velox 执行表达式、函数、I/O 和算子。

向量化执行的核心思想是“不再一行一行处理数据,而是一批一批处理数据,让 CPU 更高效地执行相同操作”。例如,传统执行可能类似:

1
SELECT price * quantity FROM orders;

逐行计算时,每次处理一行的 price * quantity。向量化执行则会一次取出一批 pricequantity,用更紧凑的内存布局和 SIMD 指令批量计算。

论文中提到,在 1TB TPC-H 的测试中,向量化执行在延迟和 CPU 时间上整体带来约 2~3 倍的性能提升。在生产交互式 workload 中,向量化执行也带来了超过 50% 的延迟改善。

Adaptive Filtering:让过滤更聪明

过滤是 SQL 查询中最常见的操作,Presto 在过滤层做了多项优化。

子字段裁剪

现代数据仓库里经常有复杂类型,比如 map、array、struct,这在机器学习特征尤其常见,一个字段里可能包含成千上万个特征。如果查询只需要其中一个子字段,没必要把整个复杂对象读出来。例如:

1
SELECT features['age_bucket'] FROM user_features;

如果 features 是一个巨大的 map,Presto 可以只读取 age_bucket 这个 key,而不是读取整个 map。这就是 subfield pruning,它可以显著减少 I/O 和 CPU。

过滤条件重排序

如果一个查询有多个过滤条件,先执行哪个条件很重要。例如:

1
WHERE country = 'US' AND expensive_udf(text) = true

如果 country = 'US' 能过滤掉大量数据,而且计算很便宜,就应该先执行它,这样昂贵的 expensive_udf 只需要作用在少量剩余行上即可。因此,Presto 会在运行时统计每个过滤条件的选择率和 CPU 成本,并动态调整过滤顺序。

延迟物化

Presto 还支持基于过滤的延迟物化。例如:

1
WHERE col1 > 10 AND col2 = 5

执行时可以先读取并判断 col1 > 10,只有通过这个条件的行才需要读取 col2 列,否则无需物化 col2 列。这对宽表尤其重要,因为很多列最终可能并不需要真正读取。

Dynamic Join Filtering

对于 join,Presto 可以从 build 侧生成 bloom filter、range 或 distinct values,然后下推到 probe 侧的扫描阶段。例如:

1
2
3
4
5
SELECT *
FROM fact f
JOIN dim d
ON f.user_id = d.user_id
WHERE d.country = 'US';

如果 dim 表过滤后只剩下一批 user_id,Presto 可以把这些 user_id 的摘要下推到 fact 表扫描阶段,让 fact 提前跳过不可能匹配的行,从而显著减少 join 前的数据量。

物化视图:兼顾低延迟和新鲜度

Dashboard 通常希望又快又新,但这两者存在天然冲突:1)想要快,就要预计算;2)想要新,就要读最新原始数据。Presto 的物化视图机制提供了折中方案。它会把已经稳定的历史数据预先物化,而对仍在持续写入的近实时数据,查询时再从 base 表读取。执行时,Presto 会把两部分用 UNION ALL 合起来。

可以简单理解为:

1
2
3
SELECT * FROM materialized_part
UNION ALL
SELECT * FROM fresh_part;

这样既能利用历史数据的预计算结果,又能看到最新数据。论文中提到,在 Meta 一个超大 NRT 表场景中,通过 5 个物化视图覆盖常见子查询,P90 下 CPU、扫描行数和延迟都降低超过 2 倍。

查询优化器:让执行计划更懂数据

除了缓存、向量化和过滤下推,Presto 还在优化器层面持续改进。

Cost-Based Optimizer:基于统计信息选计划

Presto 的 CBO 会使用表和分区级统计信息估算成本,包括:

  • 行数。
  • distinct value count。
  • null count。
  • min/max。
  • histogram。
  • 数据大小。

这些统计信息用于决策:

  • join 顺序。
  • broadcast join 还是 repartition join。
  • filter 后的基数估计。
  • join 后的数据规模。
  • 内存和 CPU 成本权衡。

例如,对于一个小维表和大事实表 join:

1
2
3
4
SELECT *
FROM fact_orders f
JOIN dim_country d
ON f.country_id = d.id;

如果 dim_country 很小,broadcast join 可能更快;如果两边都很大,repartition join 更合适,所以 CBO 的作用就是避免引擎盲目选择。

论文中提到,在 Meta 生产 ETL join 查询中,启用 CBO 后,约 60% 的查询计划发生变化并降低 CPU 使用。即使部分查询 CPU 增加,其中 83% 也降低了内存使用,说明优化目标并不只是 CPU,而是整体资源平衡。

History-Based Optimizer:利用历史运行结果优化重复任务

ETL 查询往往高度重复。例如每天跑一次同样逻辑,只是日期不同:

1
WHERE ds = '2026-05-23'

明天可能变成:

1
WHERE ds = '2026-05-24'

传统 CBO 依赖静态统计信息,但统计信息可能不准。HBO 的思路是,既然昨天已经跑过类似查询,为什么不直接利用昨天的真实执行统计?因此,Presto 会先对查询计划做一次归一化处理,把语义等价但写法不同的计划改写成统一的标准形态。例如统一变量名、对可交换表达式排序、剪掉冗余谓词,再把日期、ID 等具体常量替换成占位符,得到一份只保留结构信息的符号化计划(Symbolic Plan)。这样,日期不同但结构相同的查询就会得到同一份符号化计划,可以直接命中之前积累下的执行统计。例如:

1
WHERE ds = ?

如果历史运行结果表明某个 join 顺序、shuffle fanout 或聚合策略更优,后续查询就可以直接复用这些经验。论文中提到,HBO 让约 25% 的查询计划发生变化,整体 CPU 改善约 10%。

Adaptive Execution:运行时重新优化

CBO 和 HBO 都是在执行前做优化,但真实数据可能依然和预估的不同,例如:

  • 数据分布不均匀。
  • 列之间存在相关性。
  • 某些 key 严重倾斜。
  • 多表 join 复杂度太高,估算误差累积。

Adaptive execution 的做法是在上游任务执行完成后,把真实统计信息反馈给 Coordinator 节点,然后重新优化下游计划,类似“边跑边修正”。

不过,论文指出 Adaptive Execution 主要适用于 Presto on Spark,因为它需要分阶段执行和 Disaggregated Shuffle,原始 Presto 的 Streaming Shuffle 模式不适合在中途重规划

可扩展性优化:从交互式查询走向大规模 ETL

执行效率优化解决“跑的快、跑的省”的问题,而可扩展性优化解决“跑的大、跑的稳”的问题。

多 Coordinator 节点:消除单点瓶颈

原始 Presto 中 Coordinator 节点是单点,负责查询排队、SQL 解析和优化、任务调度,以及集群状态管理等角色,这在交互式查询时代问题不大,但在长时间 ETL 和弹性容器环境中风险很高。

为了解决单点问题,Presto 的改进是引入多个 Coordinator 和 Resource Manager:

  • Coordinator 只负责 query lifecycle。
  • Resource Manager 负责队列和资源状态。
  • Resource Manager 之间通过类似 Raft 的协议复制状态。
  • Coordinator 节点之间相互独立,不需要彼此通信。

这样做有两个好处:1) Coordinator 节点崩溃不会导致全局队列状态丢失;2)查询调度和资源管理可以横向扩展。

Recoverable Grouped Execution:降低内存峰值并支持恢复

Presto 原始执行模式倾向于并行扫描所有数据,再通过 shuffle 做聚合或 join,这在大查询下会产生巨大内存压力。论文介绍了 Grouped Execution 机制。假设有一张表按 col1 分区,查询是:

1
2
3
SELECT col1, COUNT(*)
FROM table1
GROUP BY col1;

如果分区键和聚合键一致,Presto 就没必要一次扫描所有分区,而是可以一次处理一个分区:

  • 先处理 partition 1,聚合后输出。
  • 再处理 partition 2。
  • 最后处理 partition 3。

这样峰值内存从“容纳所有分区的聚合状态”变成“只容纳单个分区的聚合状态”。

更进一步,Presto 会在 shuffle 边界把中间数据物化下来。如果 Worker 节点崩溃,可以从已经物化的中间结果恢复,而不是从源数据重新开始。这对于 PB 级扫描和小时级 ETL 非常关键。

Presto on Spark:借用 Spark 的容错和调度能力

Recoverable Grouped Execution 是 Presto 自身架构上的增强,但 Meta 后来发现它仍然不够通用和稳定,于是出现了 Presto on Spark。它的思路非常巧妙,即 保留 Presto 的 SQL 语义、解析器、优化器和执行算子,但把底层调度、shuffle、容错交给 Spark 来处理

也就是说,Presto 不再作为一个完整集群运行,而是作为 library 嵌入 Spark 引擎:

  • Spark Driver 中运行简化版 Presto Coordinator 节点。
  • Spark Executor 中运行简化版 Presto Worker 节点。
  • Presto 负责解析 SQL 和生成执行计划。
  • Spark 负责 RDD 调度、任务重试、资源隔离和容错。

这样 Meta 可以统一 SQL 入口,同时复用 Spark 成熟的容错机制。

论文中特别强调,这里不是使用 SparkSQL,而是使用 Spark RDD 及以下的能力。原因是 Meta 希望保持 Presto SQL 语义一致,降低用户迁移成本。这也是 Presto 演进中的一个重要信号,它不再执着于所有能力都自己实现,而是把自己定位成统一 SQL 计算层,底层 runtime 可以根据 workload 选择。

Spilling:应对局部内存不足和数据倾斜

即使有 Presto on Spark,大查询仍可能遇到数据倾斜。例如某个 key 特别热,导致某个 Worker 节点内存爆掉。因此,Presto 引入 spilling 能力,把内存中的 hash table 暂时写到磁盘或远端存储。现阶段支持的算子包括:

  • aggregation
  • join
  • window function
  • topN

对于交互式查询 spill 到本地 Flash 存储,优先保证延迟。对于 ETL 查询 spill 到远端存储,优先保证可扩展性。这比依赖操作系统 swap 更可控,因为查询引擎知道自己 spill 的是什么数据结构、何时可以合并、何时可以释放内存。

分析能力扩展:从 SQL 查询引擎到数据湖计算入口

Presto 的另一个重要变化是,它开始支持传统 SQL 分析之外的复杂需求。

可变数据:支持特征工程和隐私删除

传统数据仓库假设数据不可变,写入后不再修改,但 Meta 有两个重要场景需要可变性:

  • 第一个是机器学习特征工程。机器学习工程师会不断尝试新特征,某些候选特征可能最终进入主表,某些会被删除。如果每次都修改主表 schema,成本非常高,也容易影响其他用户。
  • 第二个是隐私合规。用户可能要求删除或停止使用个人数据,对于 EB 级别表来说,频繁重写整张表显然不可行。

Presto 使用 Meta 内部的 delta 机制解决这个问题:

  • 主文件保持不变。
  • 新增、删除列或行通过 delta file 表达。
  • 查询时读取主文件,并合并对应 delta file。
  • 后台定期 compact,最终删除物理数据。

可以把它理解为给不可变大文件加了一层“变更日志”,这既支持机器学习候选特征的灵活增删,也支持行级删除满足隐私要求,代价是读取时需要合并 delta。

Meta 内部的 delta 机制是一种针对数据湖大文件的“增量变更层”。Meta 数据仓库底层主要由不可变的列存大文件(ORC/DWRF)组成,这类文件一旦写入就不允许就地修改,整体重写又非常昂贵。Delta 机制在主文件之上额外维护一组小型增量文件,专门记录后续发生的列级和行级变更,例如:新增的候选特征列、被删除的行、被匿名化或重写的字段等。读取时由引擎按文件路径关联主文件和对应的 delta 文件,在执行层做合并;后台再周期性地把累积的 delta 与主文件 compact 成新的主文件,并把不再需要的物理数据真正删除。这样既绕开了对大文件就地写的限制,也避免了为一次小变更而重写整张表。

论文中提到,如果删除 1% 行,额外 scan CPU 成本约 6%;如果删除 60% 行,成本可能增加到 170%。这说明 delta 适合高效表达变更,但仍需要定期 compaction 控制读放大。

User-Defined Types:让数据有业务语义

Presto 支持用户自定义类型,例如:

  • ProfileId
  • UserId
  • PageId
  • Email

这些类型不仅是底层 LongString 的别名,还可以绑定额外语义:

  • 数据质量约束。
  • 隐私策略。
  • 删除规则。
  • 匿名化规则。

例如,可以定义 Email 类型,并声明:

  • 数据落地后立即匿名化。
  • 7 天后删除。

这样隐私策略不再散落在各个应用逻辑里,而是成为数据类型的一部分。

User-Defined Functions:支持自定义逻辑

Presto 支持多种 UDF 形式:

  • In-process UDF:函数作为库加载到 Presto 进程中执行,性能好,但安全隔离较弱。
  • UDF service:函数运行在远程服务中,Presto 通过 RPC 调用,适合多租户和多语言。
  • SQL function:用 SQL 定义函数,便于审计、推理和隐私分析。

SQL function 的价值在于它不是黑盒,相比任意代码 UDF,SQL function 更容易被优化器理解,也更容易做安全和隐私审计。

图分析扩展:用 SQL 表达图遍历

Meta 的很多数据天然是图,例如社交关系、用户互动、内容传播、数据血缘,以及系统依赖等。传统 SQL 可以通过多次 join 表达图遍历,但非常笨重。例如找 5 跳关系,可能需要写 5 次 self join,不直观也难优化。因此,Presto 扩展了 SQL,引入类似图查询语言的语法:

1
2
3
4
5
6
SELECT vertices(path)
FROM GRAPH g
MATCH (src:Vertex)-/ path:Edge{1,5}/ -> (dst:Vertex)
WHERE g.date = '2022-09-22'
AND src.id IN (1,2,3)
AND all_match(edges(path), e -> e.property = TRUE);

这段查询表达的是:从指定起点出发找长度 1 到 5 的路径,并要求路径上所有边满足某个条件。

Presto 会把图查询解析成图逻辑计划,再优化成关系执行计划,优化包括:

  • 多阶段执行,避免一次性生成巨大 join。
  • 路径扩展复用,减少重复计算。
  • 子图计算优化,避免反复扫描边表。
  • 图语义下的复杂过滤下推。

这说明 Presto 不只是“能跑图查询”,而是试图把图语义纳入优化器,让用户用声明式语言表达复杂图逻辑。

未来展望

论文最后也提到了一些尚未完全解决的问题,这些方向很值得关注,主要包括:

  • 非 SQL API:GraphSQL 解决了图查询问题,但仍属于 SQL 扩展。Meta 还在探索类似 Snowpark 或 PySpark 的非 SQL API,让用户可以用 Python 等语言表达控制流,同时把数据流交给 Presto Worker 节点执行。这意味着未来 Presto 可能不只是 SQL Engine,而是一个更通用的数据处理后端。
  • 分布式缓存:当前缓存策略依赖 Worker 节点的本地 Flash 存储,但不是所有计算机器都有本地磁盘。Meta 正在探索将远程 Flash 存储缓存嵌入分布式文件系统。如果成功,缓存能力就可以从 Presto 内部抽象出来变成数据基础设施的通用能力,其他计算系统也能受益。
  • 统一容器调度:Presto on Spark、流计算系统和其他计算任务都需要容器调度。如果每个系统都有自己的调度器,会导致重复建设和资源碎片化。Meta 正在探索更统一、更轻量的调度模型,类似在 Kubernetes/Tupperware 这类容器平台上统一管理不同计算引擎。
  • 统一 UDF:当前 UDF 主要服务 Presto,而机器学习训练和推理系统可能需要另一套 UDF 实现,这会导致用户重复开发同一逻辑。随着 Presto 和机器学习服务都逐渐迁移到 Velox,未来可以实现“一次编写,多处执行”的统一 UDF 体系。
  • 更强的隐私能力:隐私仍是巨大挑战。论文提到两个方向:1)查询重写,例如通过差分隐私展示统计结果,但不暴露敏感个体信息;2)数据血缘,追踪敏感数据如何流入、流出和被使用。这很难,因为复杂 SQL、自定义 UDF、数据下载等行为都会破坏完整追踪。未来 Presto 如果成为统一入口,就更有机会在引擎层统一实现隐私控制。

总结

这篇论文的价值,不只是介绍 Presto 做了哪些优化,而是展示了一个大规模数据系统如何在真实业务压力下持续演进。Presto 的十年演进可以概括为三条主线:

  • 执行效率优化:通过多层缓存、向量化执行、Adaptive Filtering、物化视图、CBO、HBO 和 Adaptive Execution,让 Presto 同时降低交互式查询延迟和整体资源消耗。
  • 可扩展性优化:通过多 Coordinator 节点、Recoverable Grouped Execution、Presto on Spark 和 Spilling,让 Presto 能承载长时间、大规模、易失败的 ETL workload。
  • 分析能力扩展:通过 delta、用户自定义类型、UDF 和图查询扩展,让 Presto 从传统 SQL 分析引擎走向统一数据湖计算入口。

从工程角度看,Presto 的成功不在于某一个技术点的技术创新,而在于它把许多成熟技术在 Meta 规模下系统性落地,并围绕统一 SQL 入口持续整合原本分散的计算引擎。最终,Meta 希望用 Presto 承担过去多个系统分别负责的工作:

  • SparkSQL 负责的 ETL。
  • Presto 负责的 Ad-hoc 分析。
  • Raptor 或 Cubrick 负责的低延迟 serving。
  • Giraph 负责的部分图分析。

这背后的长期趋势,是数据平台从“多个专用引擎并存”走向“统一语义层 + 多 runtime 执行”。Presto 在 Meta 的十年演进,正是这一趋势的典型案例。