论文阅读《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 公司过去十年所面临的挑战以及架构演进。
本文我们将解读这篇十年回顾之作,如果说前者回答的是“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。向量化执行则会一次取出一批 price 和 quantity,用更紧凑的内存布局和 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 | SELECT * |
如果 dim 表过滤后只剩下一批 user_id,Presto 可以把这些 user_id 的摘要下推到 fact 表扫描阶段,让 fact 提前跳过不可能匹配的行,从而显著减少 join 前的数据量。
物化视图:兼顾低延迟和新鲜度
Dashboard 通常希望又快又新,但这两者存在天然冲突:1)想要快,就要预计算;2)想要新,就要读最新原始数据。Presto 的物化视图机制提供了折中方案。它会把已经稳定的历史数据预先物化,而对仍在持续写入的近实时数据,查询时再从 base 表读取。执行时,Presto 会把两部分用 UNION ALL 合起来。
可以简单理解为:
1 | SELECT * FROM materialized_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 | SELECT * |
如果 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 | SELECT col1, COUNT(*) |
如果分区键和聚合键一致,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 支持用户自定义类型,例如:
ProfileIdUserIdPageIdEmail
这些类型不仅是底层 Long 或 String 的别名,还可以绑定额外语义:
- 数据质量约束。
- 隐私策略。
- 删除规则。
- 匿名化规则。
例如,可以定义 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 | SELECT vertices(path) |
这段查询表达的是:从指定起点出发找长度 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 的十年演进,正是这一趋势的典型案例。