内存管理与资源调度

摘要

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 的内存,导致:

  1. 该 Worker 的 JVM OOM:Worker 进程崩溃,Coordinator 检测到 Worker 下线,将其上的 Task 标记为失败,整个查询失败
  2. 其他查询受牵连:同一 Worker 上其他查询的 Task 也因 Worker OOM 而失败
  3. 集群雪崩:若 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 PoolReserved 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-nodeGeneral Pool 大小 × 70%
  • query.max-memoryquery.max-memory-per-node × Worker 数量 × 80%

过高的设置会导致在多查询并发时,各查询的内存使用叠加超过物理内存,触发频繁 GC 甚至 OOM。


第 4 章 资源组——多租户下的查询调度

4.1 为什么需要资源组

在多租户 Trino 集群(如同时服务数据分析师、ETL 工程师、BI 报表、机器学习特征查询)中,不同类型的查询有不同的优先级和资源需求:

  • BI 报表查询需要快速响应(低延迟优先)
  • ad-hoc 数据探索查询可以稍慢(不影响重要业务)
  • ETL 查询数据量大,可以在低峰期执行
  • 某个分析师提交了一个”炸弹查询”(如误扫描了全量历史数据),不应影响其他用户

资源组(Resource Group) 是 Trino 的多租户查询管理机制,提供:

  1. 并发控制(Concurrency Limiting):限制每个租户/队列的最大并发查询数
  2. 排队管理(Queuing):超过并发限制的查询进入等待队列
  3. 优先级调度(Priority Scheduling):不同资源组有不同的调度优先级
  4. 内存配额(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_aliceetl_bob)。这使得每个用户的 ETL 查询被隔离在自己的子组中,互不干扰,也无法互相占用资源。

4.4 查询路由(Selector)

查询提交时,Trino 根据 Selector 规则selectors 配置)决定将查询路由到哪个资源组。Selector 按照配置顺序匹配,第一个匹配成功的 Selector 决定路由结果。

Selector 可以匹配的维度:

  • userRegex:提交查询的用户名(正则表达式)
  • sourceRegex:查询来源(JDBC 连接的 ApplicationName 属性,正则表达式)
  • queryType:查询类型(SELECTINSERTCREATE TABLE AS SELECT 等)
  • clientTags:客户端在提交查询时附带的标签(通过 X-Trino-Client-Tags HTTP Header 传递)
-- 客户端通过 ClientTag 控制资源组路由
-- JDBC URL 中设置:
jdbc:trino://host:8080/hive?sessionProperties=resource-group=critical
 
-- 或通过 HTTP Header:
X-Trino-Client-Tags: resource-group=critical,priority=high

4.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_PRIORITYWEIGHTED 策略的资源组中的调度顺序:

-- 设置查询优先级(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 的内存管理体系的核心设计思路是:

  1. 多层配额(集群 → 查询 → Operator)逐层限制,粒度从粗到细
  2. 簿记式追踪而非真正的内存分配控制,依靠应用层主动 Spill/Kill 保护集群
  3. Spill 是保命机制,不是性能优化手段——频繁 Spill 说明配置不合理,需要增加内存或降低并发

7.2 资源组配置的最佳实践

一个生产环境资源组配置的参考原则:

  1. 按业务重要性分层:关键业务(SLA 严格)→ 生产 ETL(可稍慢)→ 交互分析(可排队)→ 开发测试(最低优先级)
  2. 动态子组隔离用户:使用 ${USER} 模板,防止某个用户提交大量查询占用所有资源
  3. 合理设置 maxQueued:队列过小会导致查询被拒绝,过大会导致查询等待时间过长、用户体验差
  4. 设置 softMemoryLimit:为每个资源组设置内存软限制,防止某个组的查询消耗了不成比例的内存

7.3 后续章节导引


思考题

  1. Trino 的内存管理分为 User Memory(查询数据处理)和 System Memory(缓冲区、网络传输)。query.max-memory-per-node 限制单个查询在单个节点上的最大内存。如果设置过小,大查询会失败;过大,少量大查询可能耗尽集群内存。在一个 10 节点集群中如何合理设置这个参数?
  2. CBO(Cost-Based Optimizer)使用表的统计信息(行数、列基数、直方图)来选择最优 JOIN 顺序和 JOIN 策略。ANALYZE 命令收集统计——但在数据湖场景中(数据频繁更新),统计可能很快过期。你如何在统计收集的成本和查询优化的收益之间取得平衡?
  3. Trino 的 Spill-to-Disk 功能允许在内存不足时将中间数据溢写到磁盘——但这会显著降低查询性能。在什么场景下你会启用 Spill-to-Disk(如宁可慢也不能失败的报表查询)?与增加集群内存相比,Spill-to-Disk 的性价比如何?