论文阅读《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 | SELECT price * quantity |
逐行计算时,每次处理一行的 price * quantity。向量化执行则会一次取出一批 price 和 quantity,用更紧凑的内存布局和 SIMD 指令批量计算。
论文中提到,在 TPC-H scale factor 1000 的测试中,native execution 在延迟和 CPU 时间上整体带来约 2 到 3 倍提升。在生产交互式 workload 中,native vectorized execution 也带来了超过 50% 的延迟改善。
Adaptive filtering:让过滤更聪明
过滤是 SQL 查询中最常见的操作。Presto 在过滤层做了多项优化。
子字段裁剪
现代数据仓库里经常有复杂类型,比如 map、array、struct。机器学习特征尤其常见,一个字段里可能包含成千上万个特征。
如果查询只需要其中一个子字段,没必要把整个复杂对象读出来。
例如:
1 | SELECT features['age_bucket'] |
如果 features 是一个巨大的 map,Presto 可以只读取 age_bucket 这个 key,而不是读取整个 map。
这就是 subfield pruning,它可以显著减少 I/O 和 CPU。
过滤条件重排序
如果一个查询有多个过滤条件,先执行哪个条件很重要。
例如:
1 | WHERE country = 'US' |
如果 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 | SELECT * |
如果 dim 表过滤后只剩下一批 user_id,Presto 可以把这些 user_id 的摘要下推到 fact 表扫描阶段,让 fact 提前跳过不可能匹配的行。
这可以显著减少 join 前的数据量。
物化视图:兼顾低延迟和新鲜度
Dashboard 通常希望又快又新。但这两者存在天然冲突:
- 想快,就要预计算。
- 想新,就要读最新原始数据。
Presto 的 materialized view 机制提供了折中方案。
它会把已经稳定的历史数据预先物化,而对仍在持续写入的 near real-time 数据,查询时再从 base table 读取。执行时,Presto 会把两部分用 UNION ALL 合起来。
可以简单理解为:
1 | SELECT * FROM materialized_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 | 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 的是什么数据结构、何时可以合并、何时可以释放内存。
执行效率优化:让优化器更懂数据
除了缓存、向量化和容错,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 依赖静态统计信息,但统计信息可能不准。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 支持用户自定义类型,例如:
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 不只是“能跑图查询”,而是试图把图语义纳入优化器,让用户用声明式语言表达复杂图逻辑。
生产效果:数据增长下保持稳定
论文最有说服力的部分之一,是它给出了 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 的十年演进,正是这一趋势的典型案例。