内存管理与资源调度
摘要
Trino 的内存管理是其最复杂也最容易引发生产故障的模块之一。与 Apache Spark 通过 RDD 血缘(Lineage)支持重算容错不同,Trino 的执行状态完全在内存中,内存管理不当会直接导致查询 OOM 失败或集群雪崩。本文系统解析 Trino 的三级内存配额体系(集群级、查询级、Operator 级),深入分析内存池(Memory Pool) 的设计与演进(Trino 400+ 废弃了 Reserved Pool,简化为单一 General Pool),以及资源组(Resource Group) 如何实现多租户下的查询队列管理、并发控制与优先级调度。理解这些机制是进行 Trino 集群调优和故障定位的前提——绝大多数”查询莫名失败”问题都根源于内存配置不合理或资源组规则设计不当。
第 1 章 为什么内存管理在 Trino 中特别重要
1.1 Trino 的内存使用特点
Trino 的执行模型决定了它是一个内存密集型系统:
- 中间结果不落盘(标准模式):所有 Stage 的中间数据在 Worker 内存中流式传递,不写磁盘(除非触发 Spill)。一个扫描 1TB 数据的聚合查询,其内存使用峰值可能达到 GB 级(取决于聚合的 key 基数)
- 多查询并发:生产集群通常有数十到数百个查询并发运行,每个查询都在消耗 Worker 内存
- 内存释放依赖 GC(Java):Java 的 GC 引入了内存使用的不确定性——对象可能在被 GC 之前仍然占用内存,实际可用内存比理论值更少
若没有严格的内存管理,一个”大查询”可能耗尽某个 Worker 的内存,导致:
- 该 Worker 的 JVM OOM:Worker 进程崩溃,Coordinator 检测到 Worker 下线,将其上的 Task 标记为失败,整个查询失败
- 其他查询受牵连:同一 Worker 上其他查询的 Task 也因 Worker OOM 而失败
- 集群雪崩:若 Worker 频繁 OOM 重启,Coordinator 持续尝试重调度失败的 Task,形成正反馈循环,整个集群陷入不可用状态
内存管理的目标是在多查询并发的情况下,保证没有任何单个查询能够耗尽 Worker 的全部内存,同时最大化集群整体的内存利用率。
1.2 内存管理的三个层次
Trino 的内存管理分三个层次,从粗到细:
| 层次 | 控制范围 | 超限后果 |
|---|---|---|
| 集群级(General Pool) | 单个 Worker 上所有查询的总内存 | 触发查询抢占或 Spill |
| 查询级(Query Memory Limit) | 单个查询的总内存(跨所有 Worker) | 查询被 Kill,报 OOM 错误 |
| Operator 级(Revocable Memory) | 单个 Operator(如 HashAggregation)的内存 | 触发 Spill 到磁盘 |
第 2 章 Trino 的内存池设计
2.1 内存池的基本概念
每个 Worker 的 JVM 堆内存被分为两部分:
- 系统内存(System Memory):JVM 本身、Netty 网络缓冲区、Connector 读取缓冲区等不可被 Trino 直接控制的内存
- 用户内存池(General Pool):由 Trino 管理的内存池,所有查询的数据(Hash Table、Sort Buffer、Aggregation State 等)从这里分配
General Pool 的大小由 memory.heap-headroom-per-node(JVM 堆内存中为系统预留的空间,默认 0.3 × 堆大小)决定:
General Pool 大小 = JVM 堆大小 × (1 - heap-headroom-per-node 比例)
例:Worker JVM 堆 = 64GB,heap-headroom-per-node = 0.3
General Pool = 64 × 0.7 = 44.8 GB
2.2 Reserved Pool 的废弃——历史演进
在 Trino 400 之前,内存池分为两个:General Pool 和 Reserved Pool(默认 25% 的 General Pool 大小)。Reserved Pool 用于保证在集群内存压力极大时,仍能有一个”最消耗内存的查询”在 Reserved Pool 中继续执行(防止所有查询都因 OOM 被 Kill 而集群处于饥饿状态)。
这种设计引发了反直觉的行为:集群内存紧张时,“最大”的查询获得了优先保护,而”较小”的查询反而被 Kill——与用户预期相反。加之 Reserved Pool 的分配逻辑极为复杂,引发了大量生产问题。
Trino 400+ 彻底废弃了 Reserved Pool,改为统一的 General Pool + Spill 机制:内存不足时先触发 Spill(将部分 Operator 的状态写到磁盘),Spill 也不够时再 Kill 最大的查询。这个模型更简单、行为更可预期。
2.3 内存分配的追踪机制
Trino 通过 MemoryPool 类(Java 对象)追踪每个查询在每个 Worker 上的内存使用:
// 每个 Operator 申请内存时通过 MemoryContext 追踪
public class HashAggregationOperator implements Operator {
private final LocalMemoryContext userMemoryContext;
// 构建 Hash Table 时申请内存
private void ensureCapacity(int newCapacity) {
long newSize = newCapacity * ENTRY_SIZE;
// 向 MemoryContext 申请内存(向上报告使用量)
userMemoryContext.setBytes(newSize);
// 若申请失败(超出查询内存限制),抛出异常触发 Spill 或 Query Kill
}
}内存使用追踪是**簿记式(Accounting)**的,而非真正的内存分配控制——Trino 不能阻止 Java 对象的实际分配,而是在应用层追踪”声称使用”了多少内存,并在超过限制时主动采取措施(Spill 或 Kill)。这意味着实际使用内存可能短暂超过配置的限制,但最终会被控制住。
第 3 章 查询内存限制
3.1 核心配置参数
Trino 的查询内存限制通过以下参数控制:
# config.properties(协调节点和工作节点共用)
# 单个查询在单个 Worker 上的最大内存(用户数据)
query.max-memory-per-node=8GB
# 单个查询在整个集群上的最大总内存(跨所有 Worker 的总和)
query.max-memory=50GB
# 单个查询在单个 Worker 上的最大总内存(用户数据 + 系统数据,如 Exchange Buffer)
query.max-total-memory-per-node=16GB
# 单个查询在整个集群上的最大总内存(含系统内存)
query.max-total-memory=100GB内存限制的层次关系:
query.max-memory-per-node控制单节点的用户内存上限query.max-memory控制全集群的用户内存总量上限- 对于均匀分布的查询,
query.max-memory通常应约等于query.max-memory-per-node × Worker 数量
3.2 为什么要区分”用户内存”和”系统内存”
用户内存(User Memory):Operator 直接管理的数据——Hash Table、Sort Buffer、Aggregation State、窗口函数的分区缓冲区。这是查询的”主要”内存消耗,可以通过 Spill 释放。
系统内存(System Memory):Trino 框架层面为支持查询执行分配的内存——Exchange Client 的网络接收缓冲区、数据序列化/反序列化的临时 Buffer、Connector 读取缓冲区。这部分内存无法 Spill,只能通过减少并发查询数量来降低。
将两者分开追踪的目的是更精确的内存控制:若只追踪用户内存,可能低估了实际的总内存使用;若用总内存限制,则对 Spill 触发时机的判断不准确(Spill 只能释放用户内存)。
3.3 内存不足时的处理策略
当某个 Worker 上的 General Pool 使用率超过阈值时,Trino 按以下优先级处理:
优先级 1:触发 Revocable Memory 的 Spill
部分 Operator(如 HashAggregationOperator、OrderByOperator)的内存被标记为 Revocable(可撤销),意味着 Trino 可以强制该 Operator 将其内存中的数据 Spill 到磁盘,释放内存供其他查询使用。
触发条件:当 Worker 的 General Pool 剩余内存 < query.low-memory-killer.policy 阈值
Spill 触发流程:
1. MemoryPool 检测到剩余内存不足
2. 通知 QueryContext:需要撤销 X GB 的 Revocable Memory
3. QueryContext 找到最大的 Revocable Operator,触发其 Spill
4. Operator 将内存中的数据(如 Hash Table 的分桶数据)序列化写入 spill-path 指定的目录
5. 内存被释放,其他查询可以继续分配
优先级 2:Kill 最占内存的查询
若 Spill 后内存仍不足(所有 Revocable Operator 都已 Spill,或 Spill 磁盘已满),Trino 会 Kill 当前内存使用量最大的查询,释放其所有内存。被 Kill 的查询收到 EXCEEDED_GLOBAL_MEMORY_LIMIT 错误。
生产避坑
配置
query.max-memory时,不要设置过高(如等于集群总内存)。应该预留 20%~30% 的缓冲空间:
query.max-memory-per-node≤General Pool 大小 × 70%query.max-memory≤query.max-memory-per-node × Worker 数量 × 80%过高的设置会导致在多查询并发时,各查询的内存使用叠加超过物理内存,触发频繁 GC 甚至 OOM。
第 4 章 资源组——多租户下的查询调度
4.1 为什么需要资源组
在多租户 Trino 集群(如同时服务数据分析师、ETL 工程师、BI 报表、机器学习特征查询)中,不同类型的查询有不同的优先级和资源需求:
- BI 报表查询需要快速响应(低延迟优先)
- ad-hoc 数据探索查询可以稍慢(不影响重要业务)
- ETL 查询数据量大,可以在低峰期执行
- 某个分析师提交了一个”炸弹查询”(如误扫描了全量历史数据),不应影响其他用户
资源组(Resource Group) 是 Trino 的多租户查询管理机制,提供:
- 并发控制(Concurrency Limiting):限制每个租户/队列的最大并发查询数
- 排队管理(Queuing):超过并发限制的查询进入等待队列
- 优先级调度(Priority Scheduling):不同资源组有不同的调度优先级
- 内存配额(Memory Limit Per Group):限制某个资源组内所有查询的总内存使用
4.2 资源组的层次结构
资源组是树形层次结构,父组的限制约束所有子组的总体行为:
root(根组)
maxQueued=1000, hardConcurrencyLimit=100
├── critical(关键业务)
│ maxQueued=100, hardConcurrencyLimit=30, schedulingWeight=50
│
├── production(生产 ETL)
│ maxQueued=200, hardConcurrencyLimit=30, schedulingWeight=20
│ ├── etl_daily
│ └── etl_hourly
│
├── interactive(交互式分析)
│ maxQueued=500, hardConcurrencyLimit=30, schedulingWeight=10
│ ├── analyst_team_a
│ └── analyst_team_b
│
└── dev(开发测试)
maxQueued=100, hardConcurrencyLimit=10, schedulingWeight=1
关键参数说明:
| 参数 | 含义 |
|---|---|
hardConcurrencyLimit | 该组同时运行的查询数上限(硬限制,超过则排队) |
maxQueued | 排队等待的查询数上限(超过则拒绝新查询) |
softMemoryLimit | 软内存限制(超过后降低调度优先级,但不 Kill 查询) |
hardMemoryLimit | 硬内存限制(超过则拒绝新查询进入该组) |
schedulingWeight | 同级组之间的 CPU 调度权重(Weight-Fair Queuing) |
schedulingPolicy | 组内查询的调度策略(FAIR / WEIGHTED / WEIGHTED_FAIR / QUERY_PRIORITY) |
4.3 资源组的配置文件
资源组通过 JSON 文件配置(resource-groups.json):
{
"rootGroups": [
{
"name": "root",
"softMemoryLimit": "80%",
"hardConcurrencyLimit": 200,
"maxQueued": 2000,
"schedulingPolicy": "weighted",
"subGroups": [
{
"name": "critical",
"softMemoryLimit": "40%",
"hardConcurrencyLimit": 50,
"maxQueued": 100,
"schedulingWeight": 50,
"schedulingPolicy": "query_priority"
},
{
"name": "production",
"softMemoryLimit": "30%",
"hardConcurrencyLimit": 40,
"maxQueued": 200,
"schedulingWeight": 20,
"schedulingPolicy": "weighted_fair",
"subGroups": [
{
"name": "etl_${USER}",
"softMemoryLimit": "10%",
"hardConcurrencyLimit": 5,
"maxQueued": 50
}
]
},
{
"name": "interactive",
"softMemoryLimit": "20%",
"hardConcurrencyLimit": 80,
"maxQueued": 500,
"schedulingWeight": 10
},
{
"name": "dev",
"softMemoryLimit": "5%",
"hardConcurrencyLimit": 10,
"maxQueued": 100,
"schedulingWeight": 1
}
]
}
],
"selectors": [
{
"group": "critical",
"userRegex": "etl_system|reporting_bot"
},
{
"group": "production.etl_${USER}",
"source": "etl_pipeline.*",
"userRegex": ".*"
},
{
"group": "interactive",
"userRegex": "analyst_.*"
},
{
"group": "dev",
"userRegex": ".*"
}
]
}动态子组(Template SubGroup):etl_${USER} 中的 ${USER} 是一个模板变量,Trino 会根据提交查询的用户名动态创建子组(如 etl_alice、etl_bob)。这使得每个用户的 ETL 查询被隔离在自己的子组中,互不干扰,也无法互相占用资源。
4.4 查询路由(Selector)
查询提交时,Trino 根据 Selector 规则(selectors 配置)决定将查询路由到哪个资源组。Selector 按照配置顺序匹配,第一个匹配成功的 Selector 决定路由结果。
Selector 可以匹配的维度:
userRegex:提交查询的用户名(正则表达式)sourceRegex:查询来源(JDBC 连接的ApplicationName属性,正则表达式)queryType:查询类型(SELECT、INSERT、CREATE TABLE AS SELECT等)clientTags:客户端在提交查询时附带的标签(通过X-Trino-Client-TagsHTTP Header 传递)
-- 客户端通过 ClientTag 控制资源组路由
-- JDBC URL 中设置:
jdbc:trino://host:8080/hive?sessionProperties=resource-group=critical
-- 或通过 HTTP Header:
X-Trino-Client-Tags: resource-group=critical,priority=high4.5 调度策略对比
| 调度策略 | 适用场景 | 行为 |
|---|---|---|
FAIR | 默认,平等对待所有查询 | 轮询执行,无优先级区分 |
WEIGHTED | 不同查询有不同权重 | 根据 queryPriority 属性加权调度 |
WEIGHTED_FAIR | 子组间公平,子组内加权 | 各子组按 weight 分配资源,组内 FAIR |
QUERY_PRIORITY | 紧急查询需要插队 | 完全按 queryPriority 数值排序,高优先级查询先执行 |
QUERY_PRIORITY 注意事项:使用此策略时,若始终有高优先级查询,低优先级查询可能永远得不到执行(饥饿问题)。建议只在 critical 这类专用资源组中使用,不要在顶级组使用。
第 5 章 内存调优实战
5.1 常见 OOM 场景的分析
场景一:大规模 DISTINCT 聚合 OOM
-- 这个查询可能 OOM:统计每个事件的唯一用户数
SELECT event_type, count(DISTINCT user_id)
FROM behavior_log
WHERE dt = '2024-01-15'
GROUP BY event_type;原因:count(DISTINCT user_id) 需要为每个 event_type 维护一个 user_id 集合(HashSet),若 event_type 基数低但 user_id 基数高(如亿级),每个 event_type 的 HashSet 可能占用 GB 级内存。
优化方案:
-- 方案 1:改用 approx_distinct()(HyperLogLog 近似计算,误差 ~2%)
SELECT event_type, approx_distinct(user_id)
FROM behavior_log
WHERE dt = '2024-01-15'
GROUP BY event_type;
-- 方案 2:先去重,再聚合(两阶段)
SELECT event_type, count(*)
FROM (
SELECT DISTINCT event_type, user_id
FROM behavior_log WHERE dt = '2024-01-15'
)
GROUP BY event_type;场景二:大表 Cross Join / Cartesian Product OOM
-- 这个查询会产生笛卡尔积(危险!)
SELECT * FROM table_a, table_b; -- 忘记写 JOIN 条件
-- 或者
SELECT * FROM table_a CROSS JOIN table_b;若两个表各有 100 万行,笛卡尔积产生 10^12 行,内存瞬间爆炸。Trino 通过 query.max-memory 的检查来拦截这类查询,但往往是在内存已被大量消耗时才触发。
优化:在资源组配置中设置 hardConcurrencyLimit 限制并发,并通过 query.max-execution-time 限制查询最大执行时间(如 10 分钟),及时 Kill 明显异常的查询。
场景三:ORDER BY 大数据集 OOM
-- 没有 LIMIT 的 ORDER BY 需要全量排序,可能 OOM
SELECT * FROM behavior_log ORDER BY event_time;优化:Trino 的 experimental.max-spill-per-node 配置可以限制 Spill 总量,防止 ORDER BY 将磁盘也耗尽。建议要求用户的 ORDER BY 必须带 LIMIT。
5.2 内存使用监控
Trino 提供了完整的内存使用监控接口:
# 查询集群整体内存状态
curl http://coordinator:8080/v1/cluster | jq '.memoryInfo'
# 查询当前所有查询的内存使用情况
curl http://coordinator:8080/v1/query | jq '.[] | {queryId, state, memoryPool, totalMemoryReservation}'
# 查询特定查询的详细内存使用
curl "http://coordinator:8080/v1/query/{queryId}" | jq '.queryStats.totalMemoryReservation'
# 通过 Trino UI 查看内存使用(图形界面)
# http://coordinator:8080/ui/ → 查询列表 → 点击查询 ID → Memory 标签页关键监控指标:
| 指标 | 含义 | 告警建议 |
|---|---|---|
cluster.memory.non_revocable_memory_bytes | 不可 Spill 的内存使用 | > 80% 总内存时告警 |
cluster.memory.revocable_memory_bytes | 可 Spill 的内存使用 | 持续增长时警惕 |
query.memory_pool_peak_bytes | 某个查询的内存峰值 | 超过 query.max-memory 的 70% 时告警 |
resource_group.queued_queries | 资源组排队查询数 | 持续 > 0 时说明并发设置过低 |
第 6 章 CPU 调度与查询优先级
6.1 CPU 分配的公平性问题
在 Trino 中,CPU 资源(Worker 的线程池时间)由 TaskExecutor 以时间片方式在所有 Driver 之间公平分配(见 02 查询执行引擎——Stage、Task 与 Pipeline)。但资源组层面,不同资源组之间的 CPU 分配并非完全公平——schedulingWeight 决定了各组相对获得的 CPU 时间比例。
Weight-Fair Queuing(WFQ):在同一个父组下,子组按 schedulingWeight 的比例分配 CPU。例如:
critical组 weight=50,interactive组 weight=10,dev组 weight=1- 在三组同时有查询运行时,CPU 分配比例约为 50:10:1
critical组的查询完成速度约是dev组的 50 倍(在相同复杂度的查询下)
6.2 查询优先级(Query Priority)
通过 Session 参数设置查询优先级(query_priority),影响在 QUERY_PRIORITY 或 WEIGHTED 策略的资源组中的调度顺序:
-- 设置查询优先级(1 最低,默认 1;数字越大优先级越高)
SET SESSION query_priority = 10;
SELECT ... FROM large_table ...; -- 这个查询会在 priority=1 的查询之前执行设计哲学
资源组的 CPU 调度是”最尽力(Best-Effort)“的,而非硬实时保证。Trino 的设计哲学是:在 Coordinator 层面通过资源组管理查询的准入(Admission Control)和优先级,在 Worker 层面尽量公平地分配 CPU。不要期望资源组的 CPU 保证能精确到毫秒级——对于需要严格 SLA 的场景,最终手段还是为关键业务分配独立的 Trino 集群(物理隔离)。
第 7 章 小结
7.1 内存管理的核心要点
Trino 的内存管理体系的核心设计思路是:
- 多层配额(集群 → 查询 → Operator)逐层限制,粒度从粗到细
- 簿记式追踪而非真正的内存分配控制,依靠应用层主动 Spill/Kill 保护集群
- Spill 是保命机制,不是性能优化手段——频繁 Spill 说明配置不合理,需要增加内存或降低并发
7.2 资源组配置的最佳实践
一个生产环境资源组配置的参考原则:
- 按业务重要性分层:关键业务(SLA 严格)→ 生产 ETL(可稍慢)→ 交互分析(可排队)→ 开发测试(最低优先级)
- 动态子组隔离用户:使用
${USER}模板,防止某个用户提交大量查询占用所有资源 - 合理设置 maxQueued:队列过小会导致查询被拒绝,过大会导致查询等待时间过长、用户体验差
- 设置 softMemoryLimit:为每个资源组设置内存软限制,防止某个组的查询消耗了不成比例的内存
7.3 后续章节导引
- 05 查询优化——CBO、动态过滤与索引下推:深入 Trino 的基于代价的优化器(CBO),理解统计信息如何影响执行计划选择,以及动态过滤如何在运行时进一步减少数据扫描量
- 06 Trino 运维——集群部署、慢查询分析与调优:生产集群的部署配置、慢查询分析方法和常见调优场景
思考题
- Trino 的内存管理分为 User Memory(查询数据处理)和 System Memory(缓冲区、网络传输)。
query.max-memory-per-node限制单个查询在单个节点上的最大内存。如果设置过小,大查询会失败;过大,少量大查询可能耗尽集群内存。在一个 10 节点集群中如何合理设置这个参数?- CBO(Cost-Based Optimizer)使用表的统计信息(行数、列基数、直方图)来选择最优 JOIN 顺序和 JOIN 策略。
ANALYZE命令收集统计——但在数据湖场景中(数据频繁更新),统计可能很快过期。你如何在统计收集的成本和查询优化的收益之间取得平衡?- Trino 的 Spill-to-Disk 功能允许在内存不足时将中间数据溢写到磁盘——但这会显著降低查询性能。在什么场景下你会启用 Spill-to-Disk(如宁可慢也不能失败的报表查询)?与增加集群内存相比,Spill-to-Disk 的性价比如何?