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 里加一个
persistent=true标记来区分的。
3.2 会话预热与连接池:把启动时间压到 3 秒以内
会话启动慢是交互式查询的致命伤。用户点一下按钮等 20 秒,这个体验没人能接受。我们的优化思路是“预热 + 复用”。具体来说,维护一个最小会话池,池子里的会话始终保持活跃状态。当用户请求新会话时,直接从池子里拿一个已经启动好的,而不是从零开始创建。
实现上,我们写了一个SessionPoolManager,在 Livy server 启动时预创建 N 个会话(N 根据集群规模和预期并发量来定,我们设的是 10)。每个预热会话都处于idle状态,不执行任何查询,但 SparkContext 已经初始化完毕。当有新的会话请求进来时,从池子里取出一个,更新它的 owner 信息,然后返回给用户。用户释放会话时,不直接删除,而是清空状态后放回池子。
这里有个关键点:SparkContext 的复用需要小心处理。不同用户可能有不同的 Spark 配置需求,比如 executor 数量、内存大小、自定义的 Spark 参数等。如果池子里的会话配置和用户请求的不一致,就不能直接复用。我们的做法是把池子分成几组,每组对应一套常见的配置模板。用户请求时,根据他的配置需求匹配最接近的组,然后从那个组里取会话。
# 会话池的简化实现逻辑 class SessionPool: def __init__(self, size_per_group=5): 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, flags=re.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 partitions |
| Livy Full GC | 缓存过大 | 查看 GC 日志、缓存大小 | 限制缓存大小、LRU 淘汰 |
| 查询超时 | 数据倾斜或 SQL 问题 | 查看 Spark UI、SQL 执行计划 | 优化 SQL、加超时熔断 |
| 会话无法回收 | 状态卡住 | 查看会话状态、Spark 任务状态 | 加强制回收超时 |
5.3 日志分级与链路追踪的落地实践
Livy 的日志默认是 INFO 级别,信息量很大但缺乏结构。我们做了两件事:一是日志分级,把不同模块的日志输出到不同的文件;二是链路追踪,给每个请求分配一个 trace ID,贯穿整个调用链。
日志分级方面,我们把 Livy 的日志分成了三类:访问日志(记录每个 REST 请求的基本信息)、业务日志(记录会话和查询的关键事件)、错误日志(记录异常和错误堆栈)。访问日志和业务日志输出到不同的文件,方便排查问题时快速定位。
链路追踪方面,我们在 REST 请求进入 Livy 时生成一个 trace ID,然后把这个 ID 放到 MDC(Mapped 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 倍以上。不过要注意控制并发度,太高了反而会因为资源竞争导致性能下降。