如果你做过数据平台、数仓,或者只是把 Spark 当作日常作业引擎用过一阵子,那“Databricks”这个名字大概率绕不开。我接触这个平台也是从一次事故开始的:线上数仓作业莫名其妙从十分钟涨到三小时,老板盯着看板,我盯着一堆看 VNC 都懒得开的 Spark Executor,最后顺着那张表的管理日志,被一步步带到了 Delta Lake 和 Databricks 的文档里。那晚之后我突然想明白了一件事:很多自以为是的优化,其实只是在给错误的架构缺陷打补丁。
后来我把团队里不少设计迁移到 Databricks 上,从最初只会点点 Notebook 跑 SQL,到慢慢啃透它的全过程架构、底层执行引擎逻辑,再到真正掌握文件、分区、Join 这些优化命脉,中间踩坑无数。这篇文章就是把我这段经历里最有价值的东西整理出来,从架构设计到引擎原理,再到一套能直接套用的优化策略,分五部分讲清楚。如果你正在评估这个平台、刚上手不久,或者被 Spark 作业性能折腾得头大,这篇应该能帮你省下不少翻文档、搜报错的时间。
1. 为什么我劝你先别急着抄配置:先看懂 Databricks 在解决什么问题
1.1 自建 Spark 集群的“三座大山”
网上关于 Spark 的教程多如牛毛,教你写 DataFrame、调参数、配 Executor 的都有,但真正把一个 Spark 平台用出生产力的人,都会逐渐发现自己被三件事卡住。
第一是资源管理和运维。自己搭集群意味着你得管理机器、处理安全组、操心网络拓扑,还要自己在 Kubernetes 或者 YARN 上设计伸缩策略。半夜三点告警说 OOM,爬起来看了一通日志,最后发现只是某个节点的临时磁盘满了。这一整套事情,跟数据处理本身完全无关。
第二是数据湖的事务能力。传统湖上你直接读写 Parquet,做同步任务的时候,哪怕一个分区文件有人覆盖到一半,就会导致下游读出来脏数据。没有事务、没有版本回滚,数据质量问题永远是悬在头上的刀。
第三是数据做的速度跟管理它的成本越来越不成正比。你调了一天的 Executor 内存,作业快了 10%,但算上调试时间,整体产出其实是亏的。
Databricks 作为统一数据分析平台,恰恰是把这三座大山用一套工程方案帮你移开。它不是一个 SQL 工具,也不是简单的“云上 Spark”,而是一个把计算、存储、管线、治理、协作全包掉的工作平台。最核心的定位,是让你专注于业务的查询和建模逻辑,而不是花时间去分辨到底是哪个 Worker 把数据搞坏了。
1.2 一个平台替你吃掉了哪些复杂度
用大白话讲,Databricks 试图帮你做到三件事。
第一件事是计算与存储分离的极致实践。Storage 放在你的云账号下的对象存储里,Compute 按作业需求随时拉起、用完释放。你不再需要维护一堆常年空转的虚拟机,而是按查询量花钱。这个概念现在听着不稀奇,但真正落地到“伸缩在秒级完成、集群元数据全部托管”的细节里,做得干净舒服的不多。
第二件事是让数据具备数据库般的事务能力。靠的是它旁边的 Delta Lake 存储层。一张表可以在湖上像数据库表一样 ACID 操作,支持时间旅行,读旧版本,写失败自动回滚。这对数据工程师来说是质变:以前做数仓任务最怕跑了一半失败需要手工清理垃圾文件,现在一个 MERGE 搞定,失败重跑也是一条命令的事。
第三件事是平台级的协作环境。Notebook、SQL Editor、Dashboard、Workflow 编排、权限管理,全在一个工作区里。数据工程师写好增量任务,分析师直接在同一个 Catalog 上写查询,权限到行级别列级别,靠 Unity Catalog 统一治理。大家操作的是同一份数据、同一套元数据,而不是反复邮件传 CSV。
一句话总结:Databricks 本质上是把 Spark、数仓、数据工程和 AI 配套的复杂工程能力打包成 PaaS,让你从“伺候平台”回到“使用平台”。
1.3 什么人最适合读这篇文章
我写这篇的预设读者有三类。第一类是把 Databricks 当成黑盒在用的人,会用 Notebook 跑 SQL、开集群,但说不清楚 Control Plane 和 Data Plane 是什么,性能出了问题不知道从哪排查。第二类是刚从自建 Spark 迁过来的人,还在用以前那套思维——比如强行调 Executor 内存、到处 repartition,结果发现平台机制变了,很多旧经验不但没用,还会坏事。第三类是正在做技术选型的人,想理解平台的能力边界和成本结构,避免上了之后发现跟预期不符。
不管你是哪类,我从架构讲起,再到引擎原理,最后落到优化实操。前面两章像地基,决定了你对后面优化策略的理解深度,建议不要跳过。
2. 架构拆解:先记住“控制面”和“数据面”两条线
2.1 Control Plane 与 Data Plane:一图记住 Databricks 的骨架
很多初学者第一次接触 Databricks 架构图,容易被花花绿绿的组件搞晕。其实记住两个词就够了:Control Plane(控制面)和 Data Plane(数据面)。
控制面可以理解为平台的管理大脑。你在浏览器里打开的工作区环境、Notebook 界面、作业编排配置、集群配置页面、权限控制、元数据管理,都跑在这里。它由 Databricks 自身账号体系托管,用户不需要也不能进去看机器细节。你在 Studio 上点击“创建集群”时,请求先到控制面,由控制面帮你规划任务、下发指令。
数据面则是真正算数据的地方。这里又分两层:一个是你云账号里的计算集群,可以是虚拟机集群,也可以是 Serverless 托管算力;另一个是你的数据文件所存放的对象存储和网络环境。数据面跟控制面最大的区别,是它完全活在客户自己的云账号里。所以你的数据从物理上讲始终留在你自己的云环境中,Databricks 只负责下发代码、拉取日志回去。
这个分离设计带来的好处很实际:平台升级不会影响你的的数据链路,你删掉集群、释放全部计算资源,数据还安静躺在存储桶里;做等保、网络策略、存储加密,所有的合规动作都在你自己的账号体系内可控。
2.2 经典工作区与 Serverless 模式,怎么选
既然分成了控制面和数据面,接下来一个绕不开的问题是:计算到底跑在哪儿。Databricks 目前主流有经典数据面模式和 Serverless 数据面模式,名称在不同版本里略有出入,但逻辑是一致的。
经典模式,也叫虚拟机网络模式,计算集群会真实创建在你自己云账号下的一个 VPC/VNet 里面。你可以在自己的云控制台上看到一台台机器、磁盘、安全组,甚至可以直接登上节点看环境细节。这个模式的好处是网络隔离完全由你控制,适合有严格安全合规要求的客户,也适合需要装载自定义库、自定义挂载点、联合查询本地数据的场景。
Serverless 模式则完全相反,计算集群创建在 Databricks 托管的计算平面里,你不需要管理任何云资源,不用关心实例规格、弹性策略,后台自动调度,冷启动也要快得多。代价是:数据的网络通路是由 Databricks 通过安全方式接入你的对象存储和元数据服务。它能做到的是按实际查询时间计费,闲置零成本。
我的经验是:日常 SQL 分析、小规模 ETL、需要快速起停的探索性任务,优先用 Serverless。而需要固定资源池、运行超稳定调度的生产作业,用经典模式在独立 VPC 里建好固定集群,配合池(Pool)机制预留资源,冷启动也能压到秒级。
有几个明显的坑得提醒一下。第一,Serverless 模式下你不能安装需要 root 权限的第三方包;第二,你的对象存储访问必须通过它托管的网络路径打通,如果你们安全团队管控很严,前期光打通权限可能就要两三天;第三,Serverless 是按时计价,跑一个重查询如果写得烂、扫描了全表几 TB,账单会很酸爽。 所以要不要上 Serverless,得先把自己的查询质量管好。
2.3 控制面里容易被忽视的组件:Catalog、Cluster、Job
再梳理一遍你日常会接触的控制面核心组件。
Catalog 在最新版本里已经统一到 Unity Catalog 体系之下。它把数据目录、权限、审计、血缘全部集中化。以前的 Hive Metastore 你可能要在数据面里自扛一堆元数据库,现在控制面统一管理,跨工作区共享同一个 Catalog 是标准做法。权限不再是“你在这个 Notebook 里能读”,而是“你对这张表的 SELECT 权限是 Catalog 级别授予的”。
Cluster 管理上,Databricks 集群分为交互式集群和作业集群。交互式集群适合人挂在里面跑实验;作业集群是每次作业任务启动时自动拉起、跑完自动释放。很多团队图省事把交互式集群挂七天,让作业往里提交,成本高且容易互相踩内存。正确姿势是作业集群按需拉起,优先级高再配 Pool。
Job 调度功能也值得单独提一提。你可以把 Notebook、Python 脚本、SQL 查询、Delta Live Tables 管线全部编排成一个工作流,支持依赖关系、重试策略、报警。过去跑批任务要自己搭 Airflow、要设计重启恢复点,现在原生编排完成了八九成需求。对中小团队来说,省下的运维精力非常可观。
3. 引擎层深挖:Spark 只是“基座”,真正拉开差距的都在外围
3.1 Catalyst、Tungsten 与 AQE:Spark 性能的“老底”
聊 Databricks 就绕不开 Spark,但可能很多人没真正搞明白 Spark 到底快在哪儿。这里必须提到两个执行层机制:Catalyst 优化器和 Tungsten 执行引擎。
Catalyst 做的事简单说就是“把你自己写的乱七八糟的代码翻译成高效的物理执行计划”。你在 DataFrame 里写过滤、Join、聚合,本想起码三个阶段跑,但 Catalyst 会帮你做列裁剪,把没用的列在扫描阶段就扔掉;做谓词下推,把过滤条件下推到数据扫描层;还做常量折叠、逻辑重排。很多 SQL 看着一样的写法,执行效率千差万别,正是 Catalyst 帮你在背后抹平了一部分差异。
Tungsten 则是内存和计算层的优化。它把 JVM 对象转换成紧凑的二进制格式,减少内存占用和 GC 压力;并引入了代码生成技术,把某些算子直接编译成高效 JVM 字节码。JVM 里频繁创建对象的开销有多夸张,写过 Java 的人都懂,Tungsten 省掉的正是这部分成本。
另一个在最新版里彻底改变实践的是 Adaptive Query Execution(AQE)。以前 Spark 的优化是“静态推断”,执行计划定死了。AQE 在运行时根据中间结果的实际大小动态调整:Join 策略可以在 SortMergeJoin 和 BroadcastHashJoin 之间智能切换;Shuffle 分区数可以自动合并;遇到数据倾斜,它还能自动把大分区分裂重试。这就是后来很多 Databricks 跑作业“无脑也能快”的底层原因之一。
但注意,AQE 毕竟是启发式的,它只能修正一部分明显次优的方案。更进一步,如果你想自己干预 join,还是可以显式加.hint("skew")或者.hint("shuffle_key"),这样在运行时优化器会额外考虑你的建议。特别是对几十 GB 级别的倾斜大表,手动加 hint 往往比依赖自动探测要来得更稳。
3.2 Delta Lake 的事务魔法:ACID、时间旅行、Schema 保护
如果只选一个理由向别人推荐 Databricks,我会选 Delta Lake。它不是一种独立数据库,而是一个基于 Parquet 文件之上的存储层,给湖上文件加上了“事务日志”。
每次写入、更新、删除,Delta Lake 都会在_delta_log目录下追加一个 JSON 格式的原子操作记录。这个记录里描述了这次操作新增、删除了哪些文件。读取时不是直接从目录列表去猜,而是从日志重建表版本快照。这就是 ACID 事务的来源。如果任务写到一半跑挂,日志里没有提交记录,旧版本数据依然完整。这个机制极其朴素,但极其好用。
时间旅行是它另一个让我觉得钱花得值的功能。表里每次事务都会递增版本号,你可以用TIMESTAMP AS OF或者VERSION AS OF直接查询历史某个时间点的数据状态。这个功能解决的最大痛点是“报表数据被任务覆盖了,上游改坏了,根本找不回来”。现在只需要一条 SQL 就能读到昨天甚至上周的数据,及时口径对账,不用找 binlog,不用跑备份恢复。
Schema Enforcement 和 Schema Evolution 也省了很多事。写入的新数据如果字段类型对不上,默认会直接报错,而不是悄悄塞进去;确认需要加列,用ALTER TABLE ... ADD COLUMNS或者mergeSchema选项即可。以前在纯 Parquet 湖上加列,简直是灾难级的操作,现在只是几分钟的问题。
不过 Delta 也不是没有陷阱。它的事务日志是单写者设计的,多个并发写同一张表,冲突会导致任务重试或失败。生产中尤其要避免两个任务同时频繁 MERGE 同一张事实表。减少这种冲突的办法,通常是调整分区粒度或者干脆对写入任务做调度锁,这也是我实际报错ConcurrentAppendException才体会到的。
3.3 Photon:用向量化执行引擎重新定义“快”
Databricks 对 Spark 引擎最大的“魔改”,要数 Photon。这是它用 C++ 从头实现的一套向量化执行引擎,兼容 Spark API,但是数据以更连续的列式格式批量处理,配合 CPU 缓存友好的向量化原语,把原来基于 JVM 的一行一行处理方式替换成批量操作。
初看之下,Photon 不是玄学,它的提速主要体现在三个方面。第一,原本由 JVM 生成的执行代码改为原生码,不再受 GC 和动态分派拖累。第二,算子间数据以列式批处理,SIMD 指令能发挥更大威力。第三,它针对常用的过滤、聚合、Join、窗口函数做了深度编译优化。我自己用 TPC-DS 风格查询测过,在纯 SQL 负载下,开启 Photon 后整体跑批提速普遍在 1.5 至 3 倍之间,部分聚合密集的查询能到 4 倍以上。
但 Photon 有适用边界。它主要覆盖 SQL、DataFrame 和 ETL 的通用算子;如果作业里的核心逻辑是纯 Python UDF,或者大量自定义 Java/Scala 代码,Photon 几乎是帮不上什么忙的,因为自定义代码绕不开 JVM 和序列化开销。另外一个实际问题是,开启 Photon 后账单也会高一些。所以建议按作业粒度开启,而不是全局默认开。很多团队全开之后发现成本暴涨,又不得不一个个关掉,反而折腾。
3.4 引擎周边的两个重要组件:Delta Live Tables 与 Unity Catalog
Delta Live Tables(DLT)是引擎层最值得一提的“管线上层建筑”。它用声明式的方式定义数据表,比如定义一张表依赖上游哪几张表、按什么规则清洗、目标粒度是什么。平台解释这份声明,自动编排执行顺序、自动维护表依赖、自动管理一致性检查。
我一开始觉得 DLT 不过是套了一层 YAML 的外壳,后来在团队里用它重写了一批 SCD(渐变维度)任务才发现厉害之处。原来我要自己写启动顺序、失败恢复、依赖判断,DLT 直接把这些全部内置了。你要做的只是定义声明规则,然后让平台去跑。它的失败跳过、数据质量检查(Expectations)功能,能在一张表写入前卡住异常数据。生产环境数据异常时的排查成本能降到原来的三分之一。
Unity Catalog 在前面提过,这里再强调它的一个特性:跨工作区跨引擎的权限管理。你可以给一个表设置列级别权限,明细字段只有特定角色可见,而不会影响其他查询。审计日志也是默认开启的,谁在什么时间查了哪张表,全都有记录。这在给分析师开权限时尤其有用,不用再“我给你一坨脱敏数据”这么粗暴,而是直接共享同一份基础表,权限精细控制。
4. 优化策略实战:文件、分区、排序、Join 四条主线
4.1 文件大小是第一生产力:先治小文件病
在 Databricks 上做性能优化,我个人的第一原则永远是“先看文件”。十次慢查询里,有七次是小文件太多,而不是什么深奥的执行计划问题。
小文件导致慢的原理不难理解:Spark 每个文件扫描都要拉一次文件系统元数据、打开文件流、处理 footer 信息。文件数量到几十万级别时,光规划任务(Planning)阶段就能卡你几分钟。加上对象存储的弱一致性(部分云厂商)问题,元数据重试更是雪上加霜。
常规做法有三个层次。第一层:写数据时控制目标大小。使用 Spark 的maxRecordsPerFile选项(写在 DataFrame 写入配置里)或spark.sql.files.maxPartitionBytes,让写入产生大概 256 MB 或 512 MB 的大文件。经验值设定在 256-512 MB 之间比较合理,太大了不利于并行扫描,太小了元数据不堪重负。
第二层:用OPTIMIZE命令合并。OPTIMIZE my_table;会做一次 Bin-Packing 压缩,把一堆小文件合并成理想大小。这个操作是增量的,只重写必要文件,不是全表重写。建议在生产上把它挂成每天或每周的维护任务。OPTIMIZE跑完,表的 Delta 日志会有一次新版本,原来的文件会标记删除,但不会立即物理删除,会留给VACUUM去清理。
第三层:开启 Auto Optimize。在表属性上设置delta.autoOptimize.optimizeWrite = true和delta.autoOptimize.autoCompact = true,写入时会自动在分区级别执行合并。最大的价值是它免除了写后手动维护任务。代价是写入吞吐会有轻微耗损。我的选择是给高频写入的明细表开 autoOptimize,给批量 ETL 输出表跑定时 OPTIMIZE,两种方式看负载来。
顺带说一个排查技巧:判断是不是小文件问题,最简单的方法是看 Spark UI 里 SQL 计划的 Scan 阶段,如果发现“numFiles”达到数万且“fileSize”分布极不均匀,几乎可以肯定是小文件病。另外,直接跑一下DESCRIBE DETAIL my_table;查看numFiles和sizeInBytes,也可以快速确认。
4.2 分区策略的“两个不要”和一个必杀技
分区是湖上表最常见的管理手段,但实际用崩的情况也比比皆是。我总结出的两个“不要”。
第一个不要,是不要用高基数列做分区。比如按user_id分区、按order_id分区,一张表动辄几百万个分区。Spark 规划任务时要翻遍所有分区元数据,光 Planning 就会卡死。而且每个小分区里可能也没几个文件,直接退化为另一种“小文件”。第二个不要,是不要过度嵌套分区。比如按年/月/日/小时四层分区,前端分区路径过长,扫描性能并不会因为多套了一层而更快;相反,元数据路径膨胀、目录列举成本飙升,很多时候得不偿失。
我的默认建议是:用你查询中过滤频率最高、基数适中(通常几百到几千)的列做分区键,一般按日期分区到今天/月级别就够。如果某张表需要按业务维度频繁聚合,再额外用 Z-Order 或者 Bucketing 来优化,而不是堆分区。
再讲一个必杀技:动态分区裁剪(Dynamic Partition Pruning,简称 DPP)。这是 Spark 3.0 引入的特性,在 Databricks 上默认开启。它的作用是:Join 两边表时,如果一边是事实表、一边是维表,执行引擎会先算维表的过滤结果,再用结果去裁剪事实表要扫描的分区。这样一来,事实表不必全量扫描。效果有多明显?我曾经优化过一条 10 亿行的订单明细表 Join 用户的 SQL,开启 DPP 后直接从 7 分钟降到 40 秒。所以排查慢查询时,第一件事不是改代码,而是打开执行计划,看有没有该裁剪却没裁剪的扫描。
要确保 DPP 生效,有三个先决条件:第一,被裁剪的表一定要有合适分区键;第二,维表过滤条件要足够“早”被算出来,不要依赖子查询里嵌套了重计算;第三,需要用 Spark 3.0 以上版本(Databricks Runtime 基本都满足)。如果条件全满足而 DPP 没生效,多半是统计信息缺失,跑一下ANALYZE TABLE ... COMPUTE STATISTICS准没错。
4.3 Z-Order 与 Bucketing:多维过滤和 Join 加速的取舍
分区只能管住一个维度。比如按日期分区,那按地域维度的查询就只能全分区扫描了。这时 Z-Order 排序就有用武之地。Z-Order 是一种把多维数据映射到一维空间的排序方式,让“空间上离得近的数据在文件里也放得近”。对经常做多维过滤的列执行 Z-ORDER 后,查询可以跳过大量不相关的数据块,效果接近对多个维度同时做裁剪。
实操中,它跟分区的定位很清晰:分区管“粗粒度”裁剪,适合基数低但绝对高频的列;Z-Order 管“细粒度”修剪,适合基数高、无法作为分区的多个过滤列。比如一张用户行为表,按日期分区,用户经常按channel_id+region过滤,那么就把这两个列做 Z-ORDER。跑OPTIMIZE my_table Z-ORDER BY (channel_id, region);即可,非常简单。
不过 Z-Order 也有明显代价:OPTIMIZE 过程会全量重写文件,时间可能很长;而且过度 Z-Order 会让某些原本顺序读的场景反而变慢。所以不要把所有列都 Z-Order,一般挑两到四个经常组合过滤的列即可。另外,Z-Order 对低基数枚举列效果有限,因为本身压缩率就高、裁剪效果不明显。
再说 Bucketing(分桶)。它按指定列的哈希值把数据分配到固定数量的桶文件里。好处是相同键的数据物理上放在一起,在做 Join 或聚合时,如果两边表桶数相同或成倍数,Spark 可以直接用 SortMergeJoin 甚至 BucketJoin,跳过昂贵的 Shuffle。代价是写数据时必须严格按桶数写,错配会导致任务失败或退化。我的建议是:只有当两张表高频按相同键 Join,且这个键本身数据分布均匀时,才用 Bucketing。否则优先 Z-Order。
4.4 Join 与 Shuffle 的优化节奏:先看 Plan,再下药
优化任何一条慢查询,我推荐一个标准流程,而不是一上来就堆资源。第一步,打开 Spark UI 看这条查询的 DAG,定位哪个 Stage 耗时最长。第二步,点进那个 Stage,看它的 Shuffle Read/Write 大小和分区数。第三步,把 SQL 的物理计划拉出来看 Join 类型,确认是不是应该用 Broadcast 却用了 SortMergeJoin。
如果你的查询里有一张小维表(比如几万行的分类表)Join 大事实表,Spark 默认没指定 broadcast 的话,它可能会用 SortMergeJoin。SortMergeJoin 两边都必须按 Join Key 做全量 Shuffle,成本比 BroadcastHashJoin(把维表复制到每个 Executor 的内存里)高一个数量级。修复方式很简单:在 SQL 里给维表加一个/*+ BROADCAST(dim) */提示,或者用 DataFrame 的.hint("broadcast")。
另一个常见问题是 Shuffle 分区数不合适。如果spark.sql.shuffle.partitions默认是 200,但你处理的中间结果只有几 GB,那每个分区只有十几 MB,任务调度开销远大于计算开销。反过来,如果你在跑一个 10 TB 的大聚合,200 个分区又不够,每个 Executor 上要拖几 GB 的数据,OOM 风险暴涨。我的建议是不要全局统一调,而是对单个作业在运行时配置(Runtime Configuration)里调整。默认推荐值先用spark.sql.shuffle.partitions=500来跑,然后根据 DAG 里每个 Stage 的 Shuffle Read 大小,把目标分区大小压在 128 MB 左右,反过来推算出分区数。比如一个 Shuffle Read 是 200 GB,那就设置成 1600 左右,保证每个分区 128 MB 上下,性能最稳。
数据倾斜是 Join 优化里最让人头大的问题。典型症状是:某个 Stage 有 199 个任务秒完,最后一个任务跑半小时还不结束。原因无非是某个 Key 的值特别大,比如城市维度里“上海”占了 80% 数据。解决方案有三板斧:一是用 AQE 的自动倾斜处理,开启后平台会把大分区自动拆成多个小任务并行处理;二是手动加盐,给大 Key 拼接随机后缀,把它拆散到多个分区去 Join,完事再聚合;三是改业务逻辑,能先过滤就先过滤,别让无效的大 Key 混进来。我最常用的是第二招加盐,因为稳定可控。具体做法是:复制一份维表,给每个维表 Key 扩展 10 个加盐后缀;事实表按 Key 加随机 0-9 后缀后 Join,最后按原始 Key 去重聚合。这样 Shuffle 就均匀了。
最后补充一点关于“不要过早优化”的体会。很多时候跑得慢不是算子问题,而是资源没喂饱。比如 Executor 内存配太小,GC 频繁导致整条链路慢半拍。我的顺序固定是:先查文件数量、分区策略、Join 类型,这几类问题占了绝大多数;再查 Shuffle 大小和倾斜;最后才考虑加资源或换 Photon。绝大多数情况下,改完前两类,查询性能已经够用了。
5. 常见问题排查与避坑实录
5.1 作业越跑越慢?八成是小文件在作祟
症状:同一个 SQL 任务,昨天跑 20 分钟,今天跑了 1 小时。查看 Spark UI,Shuffle Read 没有明显变大,但 Stage 数量暴涨,Executor 大量时间花在“列出目录”“获取文件状态”。
排查步骤:先DESCRIBE DETAIL your_table;看 numFiles。如果一张表只有几十 GB 数据、文件数却有十几万,恭喜你找到原因。再用OPTIMIZE your_table;合并文件,观察时间是否回落。如果这是周期性现象,说明写入任务产生了大量小文件,需要检查写入前是否做了不必要的repartition(1000),或者上游数据源天生文件细碎。我通常会顺手在写入端设置spark.sql.files.maxPartitionBytes为 256 MB,再开maxRecordsPerFile兜底,双管齐下。
这里有个容易被忽略的操作坑:不要频繁用VACUUM来“维护表”。VACUUM只负责清理旧版本文件,对当前查询性能几乎没帮助,而且如果清理得过于激进,可能连时间旅行需要的旧版本都被删光。正确的维护顺序永远是:先 OPTIMIZE,后 VACUUM,且 VACUUM 的保留期设置要跟你的时间旅行需求匹配。
5.2 明明加了资源还是 OOM,可能根本不是内存问题
症状:作业反复报ExecutorLostFailure或java.lang.OutOfMemoryError,你给 Executor 加了内存、加了 Driver 内存,还是照旧。
我在排查这类问题时发现,真正的元凶往往是三个:第一是数据倾斜,前面说过,少数大 Key 把一个 Executor 的堆挤爆;第二是排序或聚合用到大量状态,比如groupBy后collect_list这种,本质是内存爆炸型算子;第三是对象存储 IO 超时被误判为 OOM——某次大规模读取文件时,大量并发连接导致某个节点无响应,Spark 会把这个节点杀掉,里层的错误其实是Connection timed out。
对策不能盲目加spark.executor.memoryOverhead。正确做法是:先看 DAG 里 OOM 出现在哪个算子。如果出现在聚合节点,优化聚合键分布,或改用两阶段聚合;如果出现在 Join 节点,大概率是倾斜,用加盐;如果出现在扫描节点,优先检查存储侧的访问配置,比如对象存储的并发上限和超时设置。加内存虽然偶尔救急,但治标不治本。而我个人更推荐一个排查习惯:先切到 SQL 计划,看 OOM 算子的子节点是不是一个Scan,是的话先确认 Scan 的文件数量。文件太多的时候,每个 Executor 上缓存的元数据结构也是不小的内存开销,这时候光加大内存,不如先合并文件见效快。
5.3 Delta 事务日志狂涨,读越来越慢为哪般
症状:表本身不大,但查询起来越来越慢,跑DESCRIBE HISTORY能看到几百上千条记录。这个问题的本质:Delta 的_delta_log目录下堆积了大量 JSON 事务日志,而查询在读取表状态时,需要按时间顺序演算这些日志文件才能拿到当前快照。事务越多、日志越碎,这个演算过程就越慢。
触发这个问题的典型场景是高频 MERGE。某些任务每隔五分钟就跑一次 MERGE,每次哪怕只更新几行也会新增一段日志和若干文件。解决办法有两个维度:一是把表分区粒度加大或调整调度策略,减少写入频率;二是定期跑OPTIMIZE且用VACUUM清理旧日志。给个参考:VACUUM your_table RETAIN 168 HOURS;是保留 7 天版本,既兼顾时间旅行的窗口,也能把陈年日志清干净。
还有个我们踩过最深的坑:不要在大事务量场景下为 Delta 表设置过于激进的事务隔离级别。你确实需要 ACID,但不一定每秒都需要最高的隔离级别。如果业务允许最终一致,适度拉长批处理间隔,不仅能降低并发冲突报错,也能显著减少元数据负担。要记得,事务能力不是免费的,你为 ACID 支付的,除了每次写入多写的日志,还有读取时做快照演算的 CPU。
5.4 Photon 开了没提速,先查是不是 IO 瓶颈
症状:集群开了 Photon,跑了一遍基准 SQL,结果提速不明显,甚至有些查询还慢了。查了配置确认 Photon 已生效,但 Spark UI 上算子的耗时没有明显下降。
按照我的经验,这个时候刻意盯着 CPU 是没用的,真正的瓶颈在 IO。Photon 只是加速了“计算”部分。如果你的查询瓶颈在扫描对象存储大文件、或者网络拉取 Shuffle 数据,那无论引擎多快,数据来不及搬进来一切都是空谈。具体判断标准:看 Spark UI 的某个 Stage,“Task Time” 中的 GC 时间和序列化时间占比低、而“Input”读取时间占比高,那瓶颈就在 IO。
对策主要有四条:第一,检查文件是否“Z-Order 优化过”,没用过的话扫描量偏大很正常;第二,关掉无关的列扫描,尤其是SELECT *这种情况,白白给 IO 增加负担;第三,确认是否真的需要全量读,能用过滤条件裁剪的坚决裁剪;第四,对高并发查询,开启 Delta 的本地缓存功能,让热数据落到 SSD 上,能显著降低远端对象存储的压力。当然,如果你全表读取本身就几十 TB,那 Photon 也不是救世主,优化查询逻辑、做预聚合才是正路。
5.5 常见问题速查表
| 症状 | 可能原因 | 优先处理动作 |
|---|---|---|
| 作业越跑越慢,Stage 特别多 | 小文件过多 | OPTIMIZE,重设写入文件大小上限 |
| 单个任务 OOM,其他任务很快 | 数据倾斜 | 加盐 Join、开启 AQE 倾斜处理 |
| 表不大但读取很慢 | Delta 日志过多 | 调低 MERGE 频率,VACUUM 旧日志 |
| Join 两个表,Shuffle 巨大 | 缺 broadcast | 显式加BROADCASThint |
| Shuffle Read 大,分区数量不合适 | 分区数不匹配数据量 | 按目标分区大小反推shuffle.partitions |
| Photon 开了没提速 | IO 瓶颈 | 优化扫描列、裁剪分区、开本地缓存 |
| 并发写同一张 Delta 表总报冲突 | 单写者事务限制 | 调整调度周期,减少写频率 |
这张表不复杂,但每一行都是真实报错和排查的血泪汇总。我建议刚上手 Databricks 的团队,把这几个问题打印出来贴墙上。遇到性能问题先照表排查一轮,大概率比直接去搜索引擎翻报错要快得多。
优化这件事做久了,我最大的体会是:真正高级的优化,不是把一个慢引擎调到极致的快,而是从一开始就别写会变慢的东西。文件大小选得合理、分区键想得清楚、Join 类型心里有数,后面就不需要一次次在深夜对着 Spark UI 发呆。Databricks 能做的是把 Spark 的复杂度收敛到一个更高的抽象层,但它不会替你思考数据分布和查询模式。你依然需要回到第一性原理,去看数据是怎么放的、作业到底做了什么。平台的工程水平决定了你的起点,而你自己的基本功决定了最终能走到哪一步。