
1. 为什么要在交互式查询场景下深度定制 Livy交互式查询这件事做过数据平台的人都知道最怕的不是查询慢而是“等”。用户提交一段 SQL等三五分钟才看到结果体验直接崩盘。Livy 作为连接上层应用和 Spark 集群的桥梁天生就是为交互式场景设计的但默认配置下的 Livy 只能算“能用”离“好用”还差着十万八千里。我所在的团队维护着一套面向内部数据分析师的交互式查询平台日均处理上千个会话请求。最初直接用 Livy 的默认配置上线结果问题一个接一个会话启动慢、资源回收不及时、大查询把集群拖垮、小查询又占着资源不放。后来花了将近两个月时间做深度定制才把整套链路打磨到相对满意的状态。这篇文章就把我们踩过的坑、调过的参数、改过的源码逻辑完整地分享出来。Livy 的核心价值在于它把 Spark 的交互式能力封装成了 REST 接口让上层应用不需要直接管理 SparkContext 就能提交代码片段、获取执行结果。但默认的会话管理策略是“一个会话一个 SparkContext”这在并发量上来之后资源消耗非常恐怖。我们实测过50 个并发会话就能吃掉整个集群 80% 的内存而其中大部分会话在大部分时间里都是空闲的。所以深度定制的第一个切入点就是会话生命周期管理。这篇文章适合谁看如果你正在用 Livy 做交互式查询平台或者准备用 Livy 搭建类似的数据分析服务又或者你已经在用但遇到了性能瓶颈那接下来的内容应该能帮你省下不少试错时间。我会从架构设计、参数调优、源码改造、问题排查几个维度展开尽量把“为什么这么改”讲清楚而不是只丢一堆配置让你抄。2. Livy 交互式查询的核心架构与默认行为的局限2.1 Livy 的会话模型到底是怎么运转的Livy 的交互式会话本质上是一个长期运行的 Spark 应用。当你通过 REST API 创建一个 session 时Livy 会在后台启动一个 SparkContext然后把这个 context 和一个 session ID 绑定。后续所有通过这个 session 提交的代码片段都会在这个 context 里执行。这个设计的好处是上下文复用用户不需要每次查询都重新初始化 Spark省去了大量启动开销。但问题也出在这里。SparkContext 一旦启动就会一直占着资源直到 session 被显式删除或者超时。Livy 默认的会话超时时间是 60 分钟也就是说一个用户如果创建了会话但只查了一次数据剩下的 59 分钟里这个 SparkContext 都在空转。我们统计过平均每个会话的实际活跃时间只有 4 到 5 分钟但资源占用时间却长达 30 分钟以上浪费率超过 80%。Livy 的架构分为 server 和 session 两层。Server 负责接收 REST 请求、管理 session 元数据、调度资源Session 则是实际的 Spark 应用载体。默认情况下Livy 使用 YARN 或者 Standalone 模式来管理资源每个 session 独立申请资源。这种模式在会话数量少的时候没问题但一旦并发上来资源申请和释放的开销就会成为瓶颈。2.2 默认配置下暴露出来的四个典型问题第一个问题是会话启动慢。默认配置下创建一个新 session 需要 15 到 30 秒这取决于集群规模和资源紧张程度。对于交互式查询来说用户点一下“新建查询”要等半分钟才能开始写 SQL体验非常差。我们分析过启动耗时的构成其中 SparkContext 初始化占了 60% 以上剩下的时间花在资源申请和 Livy 内部的状态同步上。第二个问题是资源隔离性差。默认模式下所有 session 共享同一个 YARN 队列一个大查询可以把整个队列的资源吃光导致其他用户的查询全部排队。我们遇到过好几次因为某个分析师写了一个全表扫描的 SQL把整个集群拖垮的情况。Livy 本身没有提供细粒度的资源隔离机制需要借助 YARN 的队列或者标签调度来实现。第三个问题是会话回收不及时。Livy 的超时机制是基于最后一次操作时间的但有些客户端会定期发送心跳来保活导致已经没人用的会话一直不释放。我们后来不得不加了一个自定义的回收策略结合 CPU 使用率和最近查询时间来综合判断。第四个问题是日志和监控缺失。默认的 Livy 只记录基本的 session 状态变化对于查询级别的性能指标、资源消耗、错误详情几乎没有采集。出了问题只能去翻 Spark 的日志效率极低。我们后来在 Livy 的 server 层加了一层拦截器把所有查询的元信息都落到了自己的监控系统里。2.3 深度定制的整体思路和取舍逻辑我们的定制思路可以概括为“三层改造”最上层是接入层负责会话路由和限流中间是管理层负责生命周期和资源调度最底层是执行层负责查询优化和结果缓存。每一层的改造重点不同但目标是一致的让交互式查询更快、更稳、更省资源。在方案选型上我们考虑过几种路线。一是完全基于 Livy 做二次开发改源码二是用 Livy 的插件机制做扩展三是自己写一个代理层在 Livy 前面做拦截。最终我们选择了“插件为主、源码为辅”的混合方案。原因是 Livy 的插件机制虽然不够完善但足以覆盖大部分定制需求只有少数几个点需要改源码。这样升级 Livy 版本的时候迁移成本可控。具体来说我们在 Livy 的SessionManager里加了一个自定义的SessionLifecycleListener用来做会话回收和资源统计。在InteractiveSession里重写了executeStatement方法加入了查询超时控制和结果缓存逻辑。在 server 层加了一个RequestInterceptor用来做限流和路由。这些改动加起来大概 800 行代码但效果非常明显。3. 会话生命周期管理的深度定制实操3.1 自定义会话回收策略让空闲会话自动释放Livy 默认的会话回收是基于livy.server.session.timeout参数的单位是毫秒默认值 3600000也就是 1 小时。这个策略的问题在于它只看“最后一次操作时间”不看会话的实际资源使用情况。一个会话可能已经 50 分钟没查询了但因为它每隔 10 分钟发一次心跳超时计时器一直被重置资源就一直占着。我们的做法是引入一个“活跃度评分”机制。每个会话维护一个评分初始值为 100。每次执行查询加 20 分每次心跳加 5 分每分钟没有操作扣 10 分。当评分低于 30 分时会话进入“待回收”状态低于 10 分时直接删除。这个逻辑我们是通过实现SessionLifecycleListener接口来完成的核心代码如下class ActivityBasedRecycler extends SessionLifecycleListener { private val scoreMap new ConcurrentHashMap[String, AtomicInteger]() override def onSessionCreated(session: Session): Unit { scoreMap.put(session.id, new AtomicInteger(100)) } override def onStatementExecuted(session: Session): Unit { scoreMap.get(session.id).addAndGet(20) } override def onHeartbeat(session: Session): Unit { scoreMap.get(session.id).addAndGet(5) } // 定时任务每分钟执行一次 def decayAndRecycle(): Unit { scoreMap.forEach { (id, score) val newScore score.addAndGet(-10) if (newScore 10) { sessionManager.delete(id) } else if (newScore 30) { sessionManager.markForRecycle(id) } } } }这个策略上线后平均会话资源占用时间从 32 分钟降到了 11 分钟集群整体资源利用率提升了将近 40%。需要注意的是评分参数需要根据实际业务场景调整。如果你们的查询间隔比较长比如用户习惯写一段 SQL 跑一次然后思考几分钟再跑下一次那扣分速度就要调慢一点否则会把正常使用的会话误杀。提示在实现自定义回收策略时一定要加一个“白名单”机制。有些会话是系统内部使用的比如定时报表任务这些会话不能被自动回收。我们是通过在 session 的 metadata 里加一个persistenttrue标记来区分的。3.2 会话预热与连接池把启动时间压到 3 秒以内会话启动慢是交互式查询的致命伤。用户点一下按钮等 20 秒这个体验没人能接受。我们的优化思路是“预热 复用”。具体来说维护一个最小会话池池子里的会话始终保持活跃状态。当用户请求新会话时直接从池子里拿一个已经启动好的而不是从零开始创建。实现上我们写了一个SessionPoolManager在 Livy server 启动时预创建 N 个会话N 根据集群规模和预期并发量来定我们设的是 10。每个预热会话都处于idle状态不执行任何查询但 SparkContext 已经初始化完毕。当有新的会话请求进来时从池子里取出一个更新它的 owner 信息然后返回给用户。用户释放会话时不直接删除而是清空状态后放回池子。这里有个关键点SparkContext 的复用需要小心处理。不同用户可能有不同的 Spark 配置需求比如 executor 数量、内存大小、自定义的 Spark 参数等。如果池子里的会话配置和用户请求的不一致就不能直接复用。我们的做法是把池子分成几组每组对应一套常见的配置模板。用户请求时根据他的配置需求匹配最接近的组然后从那个组里取会话。# 会话池的简化实现逻辑 class SessionPool: def __init__(self, size_per_group5): self.groups { small: [], # 2 executors, 4GB memory medium: [], # 4 executors, 8GB memory large: [] # 8 executors, 16GB memory } self.size_per_group size_per_group def warm_up(self): for group_name, sessions in self.groups.items(): for _ in range(self.size_per_group): session self._create_session(group_name) sessions.append(session) def acquire(self, config): group self._match_group(config) if self.groups[group]: return self.groups[group].pop() else: # 池子空了降级为实时创建 return self._create_session(group) def release(self, session): session.reset() group session.config_group if len(self.groups[group]) self.size_per_group: self.groups[group].append(session) else: session.close()预热池上线后会话获取时间从平均 18 秒降到了 2.5 秒用户几乎感觉不到等待。但这里有个坑要注意预热会话会一直占着资源如果集群资源紧张池子里的会话可能会被 YARN 抢占。我们后来加了一个保活机制定期检查池子里会话的状态如果发现被杀了就重新创建。3.3 会话隔离与资源配额防止一个大查询拖垮整个集群资源隔离是交互式查询平台必须解决的问题。Livy 本身不提供隔离能力需要借助 YARN 的队列机制。我们的做法是为每个用户组分配独立的 YARN 队列然后在 Livy 创建会话时根据用户身份把会话提交到对应的队列。具体配置上我们在livy.conf里设置了livy.spark.yarn.queue参数但这个参数是全局的不能按会话动态指定。所以我们改用了另一种方式在创建会话时通过conf参数传入spark.yarn.queue。Livy 支持在创建 session 时传递 Spark 配置这个配置会覆盖全局配置。POST /sessions { kind: spark, conf: { spark.yarn.queue: analyst_group_a, spark.executor.memory: 4g, spark.executor.cores: 2, spark.dynamicAllocation.enabled: true, spark.dynamicAllocation.maxExecutors: 10 } }除了队列隔离我们还加了查询级别的资源限制。通过 Livy 的Statement接口提交查询时可以设置超时时间。我们默认设置 300 秒超过这个时间查询会被强制杀掉。这个超时时间不是拍脑袋定的而是分析了历史查询的耗时分布后确定的。我们统计了 10 万条查询记录发现 95% 的查询在 120 秒内完成99% 在 280 秒内完成。所以 300 秒的超时阈值可以覆盖 99% 的正常查询同时避免异常查询长时间占用资源。注意设置查询超时的时候一定要和用户沟通清楚。我们最初设了 180 秒结果有几个分析师抱怨他们的报表查询被杀了。后来调到了 300 秒并且加了超时前的预警机制——查询进行到 240 秒时如果还没结束系统会给用户发一个提示让他决定是继续等待还是取消。4. 查询执行层的性能优化与结果处理4.1 查询结果缓存重复查询直接秒回交互式查询有一个特点很多查询是重复的。分析师在探索数据的时候经常会微调 SQL 然后重新执行比如改个过滤条件、换个聚合维度。这些查询之间有很高的相似度如果每次都重新跑一遍浪费很大。我们实现了一个基于 SQL 指纹的结果缓存。具体来说对用户提交的 SQL 做规范化处理去掉多余空格、统一大小写、参数化常量然后计算 MD5 作为缓存 key。如果缓存里有对应的结果并且数据版本没有变化就直接返回缓存结果不再提交到 Spark 执行。import hashlib import re def normalize_sql(sql): # 去掉注释 sql re.sub(r--.*$, , sql, flagsre.MULTILINE) # 统一空白字符 sql re.sub(r\s, , sql).strip().lower() # 参数化常量把数字和字符串替换为占位符 sql re.sub(r\b\d\b, ?, sql) sql re.sub(r[^]*, ?, sql) return sql def get_cache_key(sql, database_version): normalized normalize_sql(sql) fingerprint hashlib.md5(normalized.encode()).hexdigest() return f{fingerprint}:{database_version}缓存的有效期我们设置的是 30 分钟或者直到底层数据表发生变更。数据变更的检测是通过监听 Hive Metastore 的 DDL 事件来实现的。当有表被修改时所有涉及该表的缓存都会被标记为失效。这个缓存机制上线后查询命中率达到了 35% 左右也就是说超过三分之一的查询是直接走缓存的。对于命中缓存的查询响应时间从平均 8 秒降到了 200 毫秒以内。但这里有个坑缓存结果的大小要控制。有些查询返回几十万行数据如果直接缓存在内存里会把 Livy server 的内存撑爆。我们的做法是只缓存结果行数小于 10000 行的查询超过这个阈值的查询不缓存。4.2 查询超时与熔断让异常查询快速失败交互式查询最怕的是“慢查询拖死整个系统”。一个用户写了一个笛卡尔积的 SQL跑起来没完没了把 executor 全占了其他用户的查询全部排队。我们的解决方案是“超时 熔断”双保险。超时机制前面已经提到了通过 Livy 的 statement 超时参数来控制。但光有超时还不够因为超时是在查询执行阶段才生效的如果查询在编译阶段就卡住了比如解析一个巨大的 SQL超时机制可能来不及触发。所以我们还加了一个“查询预检”环节在提交到 Spark 之前先对 SQL 做静态分析识别出潜在的高风险查询。预检规则包括检测笛卡尔积没有 join 条件的多表查询、检测全表扫描没有分区过滤的大表查询、检测超大结果集没有 limit 的查询。对于高风险查询系统会直接拒绝并提示用户修改 SQL。这个预检逻辑我们是用 ANTLR 解析 SQL 语法树来实现的准确率还不错。// 简化的预检逻辑 public class QueryPreChecker { public CheckResult check(String sql) { ParseTree tree parse(sql); CheckResult result new CheckResult(); // 检测笛卡尔积 if (hasCartesianProduct(tree)) { result.addWarning(检测到笛卡尔积请检查 JOIN 条件); result.setRiskLevel(RiskLevel.HIGH); } // 检测全表扫描 if (hasFullTableScan(tree)) { result.addWarning(检测到全表扫描建议添加分区过滤条件); result.setRiskLevel(RiskLevel.MEDIUM); } // 检测无 LIMIT 查询 if (!hasLimit(tree) isSelectQuery(tree)) { result.addWarning(查询没有 LIMIT可能返回大量数据); result.setRiskLevel(RiskLevel.MEDIUM); } return result; } }熔断机制则是基于系统负载的。我们监控 Livy server 的查询队列长度和 Spark 集群的 executor 使用率。当队列长度超过阈值我们设的是 50或者 executor 使用率超过 90% 时新的查询请求会被快速失败返回“系统繁忙请稍后重试”。这个机制避免了系统在过载情况下雪崩。4.3 结果集分页与流式返回大结果集不再卡死交互式查询经常需要返回大量数据比如分析师想看某个维度的明细。如果一次性返回几十万行不仅网络传输慢客户端渲染也会卡死。我们的做法是强制分页默认每页 1000 行用户可以通过offset和limit参数翻页。但分页有个问题Spark 的查询结果默认是全部计算完才返回的如果查询本身很慢第一页也要等很久。所以我们实现了流式返回查询结果一边计算一边返回用户可以先看到前几页数据不用等全部算完。这个功能是通过 Livy 的Statement的output接口实现的我们重写了结果序列化逻辑把 Spark 的 iterator 包装成了一个可以分批读取的流。// 流式结果返回的简化实现 class StreamingResultIterator(iterator: Iterator[Row], pageSize: Int) { private var buffer ListBuffer[Row]() private var hasMore true def nextPage(): (List[Row], Boolean) { buffer.clear() var count 0 while (iterator.hasNext count pageSize) { buffer iterator.next() count 1 } hasMore iterator.hasNext (buffer.toList, hasMore) } }这个流式返回机制对用户体验提升很大。以前查一个大表要等 30 秒才能看到第一行数据现在 2 到 3 秒就能看到第一页。用户可以先浏览前面的数据同时后面的数据在后台继续计算。提示流式返回要注意内存管理。如果客户端读取速度慢而 Spark 计算速度快中间的数据会积压在内存里。我们的做法是加一个背压机制当缓冲区超过一定大小时暂停 Spark 的计算等客户端消费完再继续。5. 监控、日志与问题排查的实战经验5.1 关键监控指标的采集与告警配置Livy 默认的监控能力很弱只有基本的 session 状态和 JVM 指标。我们自己在 server 层加了一套埋点采集了以下几类指标第一类是会话级别指标会话创建数、活跃会话数、会话平均存活时间、会话回收数。这些指标帮助我们了解系统的整体负载和资源使用效率。第二类是查询级别指标查询提交数、查询成功率、查询平均耗时、查询 P99 耗时、查询超时数。这些指标反映了查询服务的质量。第三类是资源级别指标Spark executor 使用率、内存使用率、YARN 队列等待时间。这些指标帮助我们判断集群资源是否充足。第四类是错误级别指标各类错误的计数包括 SQL 语法错误、权限错误、资源不足错误、超时错误等。这些指标帮助我们快速定位问题类型。采集方式上我们用了 Dropwizard Metrics 库在 Livy 的关键代码路径上埋点然后通过 JMX 暴露出来最后由 Prometheus 抓取。告警规则我们设了这么几条查询失败率超过 5% 持续 5 分钟告警查询 P99 耗时超过 60 秒告警活跃会话数超过 100 告警YARN 队列等待时间超过 30 秒告警。# Prometheus 告警规则示例 groups: - name: livy_alerts rules: - alert: HighQueryFailureRate expr: rate(livy_query_failed_total[5m]) / rate(livy_query_total[5m]) 0.05 for: 5m labels: severity: warning annotations: summary: 查询失败率超过 5% - alert: HighQueryLatency expr: histogram_quantile(0.99, rate(livy_query_duration_seconds_bucket[5m])) 60 for: 5m labels: severity: warning annotations: summary: 查询 P99 耗时超过 60 秒5.2 常见故障场景与排查路径在实际运维中我们遇到过不少故障这里挑几个典型的场景分享一下排查思路。场景一会话创建失败报“资源不足”。这个问题的原因通常是 YARN 队列资源满了。排查路径是先看 YARN 队列的资源使用情况确认是哪个队列满了然后看 Livy 的 session 创建日志确认是哪个用户提交的最后看这个用户的会话是否正常回收。我们遇到过一次是因为某个用户的会话回收失败导致资源一直被占着。后来发现是自定义回收策略的一个 bug当会话处于busy状态时回收逻辑会跳过但有些会话因为 Spark 任务卡住一直处于busy状态永远不会被回收。修复方法是加了一个强制回收的超时时间超过 2 小时还在busy状态的会话强制杀掉。场景二查询执行缓慢但集群资源充足。这个问题比较隐蔽资源充足但查询慢通常是 Spark 的并行度不够。排查路径是看 Spark UI 的 stage 详情确认 task 数量是否足够看 executor 的 CPU 使用率确认是否有倾斜。我们遇到过一次是因为某个查询的 partition 数量太少只有 10 个 task而集群有 100 个 core大部分 core 都在空闲。解决方案是在 Livy 的配置里加了spark.sql.shuffle.partitions的自动调整逻辑根据数据量动态设置 partition 数量。场景三Livy server 频繁 Full GC。这个问题通常是因为结果缓存太大把堆内存撑爆了。排查路径是看 GC 日志确认 Full GC 的频率和耗时看缓存的大小和条目数看是否有大结果集被缓存。我们的解决方案是给缓存加了大小限制用 LRU 策略淘汰旧条目同时限制单个缓存条目的大小不超过 1MB。故障现象可能原因排查路径解决方案会话创建失败YARN 资源不足检查队列资源、会话回收日志优化回收策略、扩容队列查询执行缓慢并行度不足查看 Spark UI stage 详情调整 shuffle partitionsLivy Full GC缓存过大查看 GC 日志、缓存大小限制缓存大小、LRU 淘汰查询超时数据倾斜或 SQL 问题查看 Spark UI、SQL 执行计划优化 SQL、加超时熔断会话无法回收状态卡住查看会话状态、Spark 任务状态加强制回收超时5.3 日志分级与链路追踪的落地实践Livy 的日志默认是 INFO 级别信息量很大但缺乏结构。我们做了两件事一是日志分级把不同模块的日志输出到不同的文件二是链路追踪给每个请求分配一个 trace ID贯穿整个调用链。日志分级方面我们把 Livy 的日志分成了三类访问日志记录每个 REST 请求的基本信息、业务日志记录会话和查询的关键事件、错误日志记录异常和错误堆栈。访问日志和业务日志输出到不同的文件方便排查问题时快速定位。链路追踪方面我们在 REST 请求进入 Livy 时生成一个 trace ID然后把这个 ID 放到 MDCMapped Diagnostic Context里这样所有相关的日志都会带上这个 ID。当用户反馈某个查询有问题时我们只需要拿到 trace ID就能把所有相关的日志串起来看。// 链路追踪的简化实现 public class TraceFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { String traceId UUID.randomUUID().toString(); MDC.put(traceId, traceId); try { chain.doFilter(request, response); } finally { MDC.clear(); } } }这套日志和追踪机制上线后问题排查时间从平均 30 分钟降到了 5 分钟以内。以前排查一个问题要翻好几个日志文件现在只需要根据 trace ID 过滤一下所有相关信息一目了然。6. 定制过程中的踩坑记录与经验总结6.1 那些年我们改错的参数Livy 的参数有几十个但真正影响交互式查询性能的其实就那么几个。我们最初调参的时候犯了一个典型错误把livy.server.session.timeout设得太短导致用户正在写 SQL 的时候会话被回收了。后来我们把这个参数和自定义的活跃度评分机制结合起来用才解决了问题。另一个坑是livy.spark.dynamicAllocation.enabled参数。我们一开始把它设成了true想让 Spark 动态调整 executor 数量。但实际用下来发现动态分配在交互式场景下反而会拖慢查询速度因为每次查询都要等 executor 申请。后来我们改成了固定 executor 数量配合会话池的预热机制整体性能反而更好。还有一个参数是livy.server.session.state-retained控制已结束会话的状态保留时间。默认值是 1 小时我们改成了 10 分钟。因为已结束的会话状态占用的内存不多但积累多了也会影响 Livy server 的性能。缩短保留时间后Livy server 的内存使用率下降了 15% 左右。6.2 源码改造的边界与升级策略Livy 的源码改造要非常谨慎因为改得越多升级新版本时迁移成本越高。我们的原则是能用插件实现的就不改源码必须改源码的地方尽量做成可配置的开关。我们实际改源码的地方只有三处一是SessionManager里加了会话回收的钩子二是InteractiveSession里加了查询超时和结果缓存三是Statement里加了流式返回的支持。这三处改动都加了配置开关默认关闭需要的时候才打开。这样即使升级 Livy 版本只要新版本的接口没有大改我们的改动就能快速迁移。升级策略上我们每季度评估一次 Livy 的新版本看是否有重要的 bug 修复或性能改进。升级前会在测试环境跑一遍完整的回归测试确认自定义功能不受影响。升级过程中我们会把自定义的代码单独放在一个 patch 文件里方便在新版本上重新应用。6.3 给后来者的五条实用建议第一条建议不要一开始就追求完美。我们最初想做一个大而全的定制方案结果做了两个月还没上线。后来改成小步快跑先解决最痛的问题会话回收和启动慢上线后再逐步优化效果反而更好。第二条建议监控先行。在改任何代码之前先把监控埋点做好。没有监控数据你根本不知道改动的效果是好是坏。我们是在监控上线两周后才开始做定制的这两周的数据给了我们很多决策依据。第三条建议和用户保持沟通。交互式查询平台的用户就是数据分析师他们的使用习惯直接影响你的设计决策。我们定期和几个核心用户聊天了解他们的痛点很多优化点都是从聊天中得到的灵感。第四条建议做好降级方案。任何定制功能都要有降级开关出问题的时候能快速回滚。我们的每个自定义功能都有对应的配置项可以在不重启服务的情况下动态开关。第五条建议文档和注释要跟上。定制代码最怕的是后人看不懂。我们在每个自定义类和方法上都写了详细的注释说明为什么这么改、改之前是什么行为、改之后是什么行为。这些注释在后来排查问题时帮了大忙。提示如果你也在做 Livy 的深度定制建议先从会话生命周期管理入手。这是投入产出比最高的优化点改起来相对简单但效果立竿见影。查询执行层的优化虽然效果也很好但涉及的面更广需要更谨慎。最后再分享一个小技巧Livy 的 REST API 支持批量提交 statement但默认是串行执行的。如果你的场景里有大量小查询可以改造成并行提交能显著提升吞吐量。我们实测下来并行提交后小查询的吞吐量提升了 3 倍以上。不过要注意控制并发度太高了反而会因为资源竞争导致性能下降。