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

之前,我们曾解读过《Presto: SQL on Everything》这篇论文,该论文从整体视角介绍了 Presto 这个分布式计算引擎的架构设计与系统实现。在 Presto 诞生十年之际,Meta 团队又发表了题为《Presto: A Decade of SQL Analytics at Meta》的论文。如果说前者回答的是“Presto 为什么能用统一 SQL 查询一切”,那么后者回答的就是另一个更现实的问题,即当这个系统在 Meta 内部持续运行十年,并被推到 EB 级数据、弹性容器、长时间 ETL、机器学习、隐私合规和图分析等更复杂场景时,它是如何继续演进的。

Presto 面临的挑战

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

它的经典架构包括:

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

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

数据规模快速增长

Meta 的数据仓库横跨多个数据中心,数据量达到 EB 级别。即使同一条 dashboard 查询逻辑不变,底层需要扫描的数据也可能随着时间增长数倍。

论文中提到,在 2019 到 2022 年间,交互式和 Ad-hoc 查询的扫描数据量增长接近 5 倍,但用户依然希望 dashboard 的响应时间保持稳定。

这意味着 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 的关键改进

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

延迟优化:让查询在数据增长下仍然足够快

对于交互式查询,用户最关心的是延迟。尤其是 dashboard 场景,用户希望点击筛选条件后能快速看到结果。

但在存储计算分离架构下,Presto 每次查询都要从远端存储读取数据,I/O 成本很高。论文中介绍了多类优化手段。

多层缓存:减少远端 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,避免热点。

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

Native vectorized execution:从 Java 走向 C++ 向量化执行

Presto 最初是 Java 实现。Java 带来了良好的工程效率和生态,但在高性能计算上有一些限制:

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

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

向量化执行的核心思想是:

不再一行一行处理数据,而是一批一批处理数据,让 CPU 更高效地执行相同操作。

例如,传统执行可能类似:

1
2
SELECT price * quantity
FROM orders;

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

论文中提到,在 TPC-H scale factor 1000 的测试中,native execution 在延迟和 CPU 时间上整体带来约 2 到 3 倍提升。在生产交互式 workload 中,native vectorized execution 也带来了超过 50% 的延迟改善。

Adaptive filtering:让过滤更聪明

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

子字段裁剪

现代数据仓库里经常有复杂类型,比如 map、array、struct。机器学习特征尤其常见,一个字段里可能包含成千上万个特征。

如果查询只需要其中一个子字段,没必要把整个复杂对象读出来。

例如:

1
2
SELECT features['age_bucket']
FROM user_features;

如果 features 是一个巨大的 map,Presto 可以只读取 age_bucket 这个 key,而不是读取整个 map。

这就是 subfield pruning,它可以显著减少 I/O 和 CPU。

过滤条件重排序

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

例如:

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

如果 country = 'US' 能过滤掉大量数据,而且计算很便宜,就应该先执行它。这样昂贵的 expensive_udf 只需要作用在少量剩余行上。

Presto 会在运行时统计每个过滤条件的选择率和 CPU 成本,并动态调整过滤顺序。

Lazy materialization

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

例如:

1
WHERE col1 > 10 AND col2 = 5

执行时可以先读取并判断 col1 > 10。只有通过这个条件的行,才需要读取 col2。没有通过第一关的行,连 col2 都不用物化。

这对宽表尤其重要,因为很多列最终可能并不需要真正读取。

Dynamic join filtering

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

例如:

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 通常希望又快又新。但这两者存在天然冲突:

  • 想快,就要预计算。
  • 想新,就要读最新原始数据。

Presto 的 materialized view 机制提供了折中方案。

它会把已经稳定的历史数据预先物化,而对仍在持续写入的 near real-time 数据,查询时再从 base table 读取。执行时,Presto 会把两部分用 UNION ALL 合起来。

可以简单理解为:

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

这样既能利用历史数据的预计算结果,又能看到最新数据。

论文中提到,在 Meta 一个超大 NRT 表场景中,通过 5 个物化视图覆盖常见子查询,P90 下 CPU、扫描行数和延迟都降低超过 2 倍。

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

延迟优化解决“快”的问题,可扩展性优化解决“跑得大、跑得稳”的问题。

多 coordinator:消除单点瓶颈

原始 Presto 中 coordinator 是单点。它负责:

  • 查询排队。
  • SQL 解析和优化。
  • 任务调度。
  • 集群状态管理。

这在交互式查询时代问题不大,但在长时间 ETL 和弹性容器环境中风险很高。

Presto 的改进是引入多个 coordinator 和 resource manager:

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

这样做有两个好处:

  • coordinator 崩溃不会导致全局队列状态丢失。
  • 查询调度和资源管理可以横向扩展。

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 的是什么数据结构、何时可以合并、何时可以释放内存。

执行效率优化:让优化器更懂数据

除了缓存、向量化和容错,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 依赖静态统计信息,但统计信息可能不准。History-Based Optimizer 的思路是:既然昨天已经跑过类似查询,为什么不直接利用昨天的真实执行统计?

Presto 会对查询计划做 canonicalization,把常量替换成符号,形成 symbolic plan。这样日期不同但结构相同的查询可以匹配到同一个历史计划。

例如:

1
WHERE ds = ?

如果历史运行结果表明某个 join 顺序、shuffle fanout 或聚合策略更优,后续查询就可以直接复用这些经验。

论文中提到,history-based optimizer 让约 25% 的查询计划发生变化,整体 CPU 改善约 10%。

Adaptive Execution:运行时重新优化

CBO 和 HBO 都是在执行前做计划,但真实数据可能依然和预估不同:

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

Adaptive execution 的做法是:上游任务执行完成后,把真实统计信息反馈给 coordinator,然后重新优化下游计划。

这类似“边跑边修正”。

不过,论文指出 adaptive execution 主要适用于 Presto on Spark,因为它需要分阶段执行和 disaggregated shuffle。原始 Presto 的 streaming shuffle 模式不适合在中途重规划。

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

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

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

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

第一个是机器学习特征工程。

机器学习工程师会不断尝试新特征。某些候选特征可能最终进入主表,某些会被删除。如果每次都修改主表 schema,成本非常高,也容易影响其他用户。

第二个是隐私合规。

用户可能要求删除或停止使用个人数据。对于 EB 级别表来说,频繁重写整张表显然不可行。

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

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

可以把它理解为给不可变大文件加了一层“变更日志”。

这既支持机器学习候选特征的灵活增删,也支持行级删除满足隐私要求。

代价是读取时需要合并 delta。论文中提到,如果删除 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 不只是“能跑图查询”,而是试图把图语义纳入优化器,让用户用声明式语言表达复杂图逻辑。

生产效果:数据增长下保持稳定

论文最有说服力的部分之一,是它给出了 Meta 生产环境中的效果。

几个关键结果包括:

  • 在 3 年内,交互式和 Ad-hoc 查询扫描数据量增长接近 5 倍,但 P75 和 P90 延迟基本保持稳定。
  • 同期交互式集群核心数只增长约 82%,说明性能提升并非单纯依赖堆机器。
  • 对生产交互式 workload,缓存相比原始架构带来约 60% 延迟改善。
  • Adaptive filtering 在缓存基础上额外带来约 10% 到 20% 改善。
  • Native vectorized execution 再额外带来超过 50% 改善。
  • 对 NRT 大表,物化视图让 P90 下 CPU、扫描行数和延迟均降低超过 2 倍。
  • Presto on Spark 推出后,Meta 开始将 SparkSQL workload 迁移到 Presto on Spark,以统一 SQL 入口。

这些数字背后的核心结论是:

Presto 的演进不是单点优化,而是系统性重构。它同时改进了 I/O、CPU、内存、调度、容错、优化器和语言能力。

未来展望

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

非 SQL API

GraphSQL 解决了图查询问题,但仍属于 SQL 扩展。Meta 还在探索类似 Snowpark 或 PySpark 的非 SQL API,让用户可以用 Python 等语言表达控制流,同时把数据流交给 Presto worker 执行。

这意味着未来 Presto 可能不只是 SQL engine,而是一个更通用的数据处理后端。

分布式缓存

当前缓存策略依赖 worker 本地 flash,但不是所有计算机器都有本地磁盘。Meta 正在探索将远程 flash cache 嵌入分布式文件系统。

如果成功,缓存能力就可以从 Presto 内部抽象出来,变成数据基础设施的通用能力,其他计算系统也能受益。

统一容器调度

Presto on Spark、流计算系统和其他计算任务都需要容器调度。如果每个系统都有自己的调度器,会导致重复建设和资源碎片化。

Meta 正在探索更统一、更轻量的调度模型,类似在 Kubernetes/Tupperware 这类容器平台上统一管理不同计算引擎。

统一 UDF

当前 UDF 主要服务 Presto,而机器学习训练和推理系统可能需要另一套 UDF 实现。这会导致用户重复开发同一逻辑。

随着 Presto 和机器学习服务都逐渐迁移到 Velox,未来可以实现“一次编写,多处执行”的统一 UDF 体系。

更强的隐私能力

隐私仍是巨大挑战。论文提到两个方向:

  • 查询重写,例如通过差分隐私展示统计结果,但不暴露敏感个体信息。
  • 数据血缘,追踪敏感数据如何流入、流出和被使用。

这很难,因为复杂 SQL、自定义 UDF、数据下载等行为都会破坏完整追踪。未来 Presto 如果成为统一入口,就更有机会在引擎层统一实现隐私控制。

总结

这篇论文的价值,不只是介绍 Presto 做了哪些优化,而是展示了一个大规模数据系统如何在真实业务压力下持续演进。

Presto 的十年演进可以概括为四条主线:

  • 延迟优化:通过多层缓存、向量化执行、adaptive filtering 和物化视图,让交互式查询在数据规模数倍增长下仍保持稳定体验。
  • 可扩展性优化:通过多 coordinator、recoverable grouped execution、Presto on Spark 和 spilling,让 Presto 能承载长时间、大规模、易失败的 ETL workload。
  • 执行效率优化:通过 CBO、HBO 和 adaptive execution,让优化器从“基于规则”逐渐走向“基于统计、历史和运行时反馈”。
  • 分析能力扩展:通过 delta、用户自定义类型、UDF 和图查询扩展,让 Presto 从传统 SQL 分析引擎走向统一数据湖计算入口。

从工程角度看,Presto 的成功不在于某一个技术点特别新,而在于它把许多成熟技术在 Meta 规模下系统性落地,并围绕统一 SQL 入口持续整合原本分散的计算引擎。

最终,Meta 希望用 Presto 承担过去多个系统分别负责的工作:

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

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