数据库执行引擎的内存仲裁模式:从 OOM 保护到资源管控

数据库执行引擎的内存管理,真正难点不在于统计单条 SQL 用了多少内存,而在于当多个 SQL、后台任务和运行时系统共同竞争实例内存时,如何在 OOM 之前做出可解释的全局决策。传统“先分配、后统计、超限再杀 SQL”的方式处理得太晚,难以表达业务优先级,也无法覆盖逻辑内存与真实进程内存之间的偏差。本文围绕 TiDB 执行引擎的全局内存仲裁机制,讨论稳定性、利用率、公平性和优先级保护之间的设计取舍。

本文要解决的问题

数据库执行引擎中,内存管理面临的难题是:

  • 当多个 SQL 同时增长内存时,系统应该让谁继续执行?
  • 当实例内存已经紧张时,新来的内存申请是等待、失败,还是抢占其他 SQL?
  • 当进程接近 OOM 时,应该取消哪个 SQL,还是强制终止正在运行的 SQL?
  • 如何让 OLTP、OLAP、后台任务在同一个 TiDB 实例上有不同的资源保障?

传统方式有什么问题

传统做法大致是:

1
2
3
4
5
6
SQL 执行
-> 分配内存
-> 追踪统计内存使用
-> 实例内存超过阈值
-> 找到内存使用最高的 SQL
-> 强制终止这个 SQL

这种方式有优点:简单、侵入小、容易落地。但它有一个根本问题:处理得太晚。

等实例内存已经超过阈值时,通常已经发生了下面这些事情:

  • Go 堆内存已经很高,GC 压力已经出现。
  • SQL 已经分配了大量对象,强制终止后也不一定马上释放。
  • 系统只能看到“谁用得多”,但不知道“谁应该被保护”。
  • 所有 SQL 在全局内存风险面前几乎是同一类,业务优先级很难体现。

所以,仅靠事后强制终止内存使用最多的 SQL,不能解决执行引擎的资源竞争问题。我们需要在 SQL 继续扩大内存占用之前,就进行全局判断。

核心思路:在可控路径上先申请额度

内存仲裁的核心变化是:

1
2
3
4
5
6
7
8
9
Before:

先分配内存 -> 再统计 -> 超限后强制终止

After:

预估或报告内存增长
-> 检查并补充逻辑额度
-> 获得额度后继续执行

这里的“额度”可以理解为逻辑内存预算,“先申请”是一项执行代码需要主动遵守的逻辑协议,不是对所有物理内存分配的统一拦截。SQL 不一定真的马上分配这么多物理内存,但它需要先向系统声明:我接下来可能需要更多内存,以此来获得较强的事前控制。

如果代码在对象创建后才报告内存,或者相关内存没有接入追踪系统,仲裁器就无法阻止这次物理分配。它只能在后续额度申请或运行时内存压力升高时进行补救。

因此,这套机制管理的是数据库内部发放的逻辑额度。它没有侵入 Go 内存分配器,也不能自动覆盖运行时、第三方库和所有后台任务的内存。

操作系统层面的“申请成功”也不等于物理内存已经安全可用。以 Linux 为例,mallocmmap 可能只获得虚拟地址空间;只有程序实际访问相应页面时,内核才需要建立物理映射。在启用 Overcommit 时,系统接受的虚拟内存申请还可能超过当前物理内存能力。

所以,逻辑额度、runtime 统计和操作系统物理内存是三个不同层次。数据库仲裁器主要控制第一个层次,并通过运行时指标观察第二个层次,最终仍需要为操作系统层面的偏差保留安全空间。

全局仲裁器收到申请后,会根据当前实例状态做决定:

  • 内存充足:允许申请。
  • 内存紧张:让申请等待,或者取消申请。
  • 有优先级差异:保护高优先级 SQL,回收低优先级 SQL。
  • 已经接近 OOM 风险:强制终止部分正在运行的 SQL,优先保护 TiDB 进程。

这个设计的关键不是“限制每个 SQL”,而是“在实例级别做资源决策”。

为什么不能只靠单 SQL 内存上限

单 SQL 内存上限只能回答“这个 SQL 有没有超过自己的限制”,不能回答“整个 TiDB 实例是否还安全”。

极端情况下,每个 SQL 都没有超过自己的内存上限,但并发叠加后,实例堆内存仍然可能进入危险区。反过来,有些 SQL 的逻辑额度不高,但由于未追踪对象、临时对象或 GC 滞后,也可能造成真实内存压力。

这就是为什么需要实例级仲裁器,而不是只依赖 SQL 自己的内存上限。

总体架构

内存仲裁可以分成三层。

1
2
3
4
5
6
7
8
9
10
11
12
13
SQL / 执行器 / 计划器 / 编译器
|
v
内存追踪器(Memory Tracker)
|
v
内存资源池(Memory Resource Pool)
|
v
全局内存仲裁器(Global Memory Arbitrator)
|
v
运行时内存 / GC / 实例内存上限

下图表示 TiDB 内存仲裁模式的总体架构

Memory arbitrator architecture

内存追踪器(Memory Tracker):逻辑计量入口

执行阶段的算子通过 Memory Tracker 报告内存增长和释放。解析、编译阶段则主要使用基于 SQL 复杂度的额度估算,当前还不能精确追踪所有临时对象。

Memory Tracker 提供任务归属和使用量,但它不是物理内存分配器。一次计量调用发生在对象创建之前还是之后,取决于具体代码路径。

因此,Memory Tracker 同时承担两种作用:

  • 对已经发生的内存变化进行计量。
  • 在正确接入的路径上,触发额度检查和全局仲裁。

这套设计可以复用已有的 SQL 内存统计能力,但事前控制的覆盖范围取决于各执行模块的接入方式。

内存资源池(Memory Resource Pool):给 SQL 建资源账户

内存资源池可以理解为 SQL 或任务的内存账户。

它记录这个任务已经获得了多少额度、用了多少额度、还能不能继续扩容。当 SQL 的内存使用变大时,它不是直接无限增长,而是通过自己的内存账户向上申请更多额度。

这一层解决的是“内存归谁”的问题。它内部可以分成两类路径:

  • root pool(执行内存账户):用于执行阶段持续增长、可能需要等待或回收的内存。
  • await-free pool(快速额度池):用于短生命周期内存和小额度增长。

await-free pool:短生命周期内存的快速路径

await-free pool 主要服务两类场景:

  • 解析、编译等短生命周期阶段,根据 SQL 复杂度预估一段临时额度。
  • 执行阶段尚未超过小额度阈值的内存增长。

这两类场景的控制强度并不完全相同。

解析和编译阶段会预估额度,并在实例进入内存风险时等待或终止;但 await-free pool 补充额度失败,并不一定直接中断当前解析或编译。因此,它更接近轻量计量和风险入口,不应理解成严格的内存准入。

执行阶段接入 Memory Tracker 后,先使用 await-free pool 提供的小额度。当使用量超过阈值(例如全局内存上限的1/1000),或者快速额度无法继续补充时,SQL 会升级到 root pool,进入完整仲裁。

await-free pool 仍然计入实例的逻辑额度,但它避免了每次小额增长都触发完整仲裁。

全局内存仲裁器(Global Memory Arbitrator):做实例级决策

全局内存仲裁器是实例级决策者。每个 TiDB 实例有一个仲裁器。

它负责回答:

  • 当前实例还剩多少可分配内存额度?
  • 哪些 SQL 正在等待额度?
  • 哪些 SQL 是高优先级,哪些是低优先级?
  • 当前运行时内存是否已经危险?
  • 如果资源不够,应该取消、等待、回收还是强制终止?

这一层解决的是“系统还能不能继续分配”的问题。

核心设计难点:逻辑额度和真实内存不一致

内存仲裁最难的地方,不是维护一个全局计数器,而是判断“还有多少内存可以安全分配”。

SQL 内存追踪器记录的是逻辑内存使用,Go 运行时暴露的是进程真实内存状态。两者经常不一致:

  • SQL 内存追踪器不一定覆盖所有临时对象。
  • SQL 释放对象后,Go 堆内存不一定马上下降。
  • 运行时、goroutine 栈、堆外内存、缓存、后台任务都会占用内存。
  • 有些内存很难准确归属到某个 SQL。
  • 同样的逻辑额度,在不同负载下可能对应不同的堆内存压力。

因此,全局仲裁器不能简单地用下面这个公式做决策:

1
可分配额度 = 实例内存上限 - 已分配额度

这个公式看起来合理,但会漏掉一大块风险:已经在进程里、但不在 SQL 逻辑额度里的内存。它们可能来自 GC 滞后,也可能来自未追踪对象,还可能来自系统后台任务。

这意味着系统需要为“无法安全继续分配的风险空间”建模。这个风险空间在后文称为 out-of-control(失控风险空间)。

一条 SQL 的执行流程

简化后的流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
SQL 开始执行
|
v
解析 / 编译阶段按 SQL 复杂度估算额度
|
v
通过 await-free pool 临时占用,阶段结束后释放
|
v
进入执行阶段
|
v
把 SQL 的内存追踪器接到内存仲裁上下文
|
v
小额度增长使用 await-free pool
|
v
增长超过小额度阈值,或 await-free pool 补额度失败
|
v
SQL 升级到独立 root pool
|
v
root pool 需要扩容时,向全局仲裁器申请额度

这里的关键取舍不是“要不要追踪小对象”,而是“小额增长是否需要立即触发完整仲裁”。

Memory Tracker 仍然可以记录小额增长,但任务在已有额度内只需要更新本地或分片计数。只有额度不足、超过小额度阈值或需要创建 root pool 时,才发生更重的全局协调。

这种方式把全局仲裁的粒度从单次对象变化提高到额度补充事件,在控制能力和执行开销之间取得平衡。

两种工作模式

内存不足时,不同业务希望的行为不一样。有些系统希望尽快失败并重试;有些系统希望保护关键 SQL。因此仲裁器提供不同模式。

STANDARD 模式

STANDARD 模式不区分业务优先级,额度申请按照 FIFO 顺序处理。申请方会同步等待本次仲裁结果;如果当前资源无法满足申请,仲裁器会取消尚未满足的额度申请,而不是保证 SQL 一直等待到资源自然释放。

这种模式的主要特点是规则简单。它适合由上层负责重试的场景,但不适合表达关键业务保护关系。

适合场景:

  • 希望行为简单、可预测。
  • 不需要区分业务优先级。
  • 可以接受 SQL 在资源不足时失败,并由上层重试。

取舍:

  • 好处:规则简单,同类申请之间容易解释。
  • 代价:不能保护关键业务;资源不足时,等待中的多个申请可能一起被取消。

PRIORITY 模式:保护高优先级 SQL

PRIORITY 模式下,SQL 会带有内存优先级。优先级可以来自 Resource Group(资源组)。

这里需要注意:资源组可以有多个,但当前内存仲裁消费的是资源组上的三档优先级:LOW、MEDIUM、HIGH。也就是说,它表达的是资源紧张时的保护顺序,不是完整的内存配额、保底或隔离模型。

当内存不足时,系统会优先保护高优先级 SQL,并尝试取消或回收低优先级 SQL。

适合场景:

  • OLTP 和 OLAP 混合部署。
  • 关键业务需要更高保障。
  • 可以接受低优先级查询在资源紧张时更容易失败。

取舍:

  • 好处:可以表达业务重要性。
  • 代价:优先级粒度较粗,低优先级 SQL 可能被频繁取消;如果大量资源组都配置成 HIGH,模式会退化。

除了 STANDARD 和 PRIORITY,系统还可以支持更细的等待策略。例如低延迟请求可以选择不在当前 TiDB 节点等待内存释放,而是快速取消,交给上层重试。这类策略不是新的仲裁模型,而是对“资源不足时是否等待”的补充。

配置入口:表达策略,不替代架构

从用户接口看,内存仲裁需要把几个关键策略暴露出来:是否启用仲裁、采用哪种仲裁模式、soft limit 如何设置、单个 SQL 是否显式预留内存资源、资源不足时是否倾向快速失败。

这些配置不应该被理解成新的架构层。它们只是把前面的设计选择变成可操作的入口:

  • 仲裁模式决定资源不足时按公平顺序处理,还是按业务优先级处理。
  • soft limit 决定全局内存额度边界,是安全性和利用率之间的调节旋钮。
  • 手动预先订阅足量份额可以提高单个 SQL 的隔离性,减少执行过程中多次申请额度的开销。
  • 快速失败策略用于低延迟业务,让 SQL 不在当前 TiDB 节点长时间等待,而是交给上层重试。

这部分在架构上最重要的不是变量名字,而是用户能把业务意图表达给执行引擎。

CANCEL 和 KILL 的区别

内存仲裁必须区分两件事:

  • 只是没有足够额度。
  • TiDB 进程已经接近 OOM。

这里的 KILL 指强制终止 SQL 执行,不是终止 TiDB 进程。

对应的处理也不同:

1
2
额度不足        -> CANCEL
实例 OOM 风险 -> KILL

CANCEL

CANCEL 是普通资源不足时的处理方式。它说明 SQL 没有拿到足够额度,或者系统希望它让出资源。

CANCEL 是调度行为,不一定表示实例已经危险。

KILL

KILL 是最后保护手段。只有当运行时内存已经进入危险区,并且 GC 或自然释放无法及时降低压力时,才应该强制终止正在运行的 SQL。

这个区分很重要。否则所有内存不足都会变成强制终止,用户体验会很差,也很难解释。

out-of-control:风险空间的建模

out-of-control 指的是仲裁器不应该继续分配出去的那部分内存空间。它不一定都是“未追踪内存”,也包括因为 soft limit(软上限)、GC 滞后、运行时开销等原因需要保留的安全空间。

全局仲裁不能只看 SQL 内存追踪器统计出来的内存。真实进程内存还包括:

  • Go 堆内存中等待 GC 的对象。
  • 未被内存追踪器精确统计的临时对象。
  • goroutine 栈。
  • 运行时和堆外内存开销。
  • 缓存和后台任务。

系统需要在硬限制下面留出安全空间,这个安全空间就是 out-of-control 估算的主要结果。

换成仲裁器的分配模型,就是:

1
可安全分配额度 ~= 实例内存上限 - 已分配额度 - buffer - out-of-control

可以把实例内存理解成:

1
2
3
4
5
实例内存上限
├── 已分配额度
├── 还能继续分配的额度
├── 调度和回收需要的 buffer
└── 不适合继续分配的 out-of-control

可以把 out-of-control 理解成几类风险的最大值:

1
2
3
4
5
6
7
out-of-control =
max(
未追踪或难归属的内存,
运行时堆内存与逻辑额度的偏差,
soft limit 要求保留的空间,
历史风险状态给出的保守余量
)

soft limit 是其中一个重要来源。它用来控制“最多分配多少额度给 SQL”,确保系统不会把硬限制全部暴露给执行层。

下图展示了 TiDB 内存仲裁模式中 out-of-control、soft limit、未追踪内存和历史画像之间的关系。

Memory arbitrator auto tune

buffer 和死锁规避

动态内存仲裁还有一个容易被忽略的问题:所有 root pool 都在等待额度时,系统不能让仲裁过程本身也被阻塞。否则会出现“大家都在等别人释放内存,但没有人能够继续推进到释放点”的风险。

因此,仲裁器需要保留一部分 buffer,用来支持取消、回收、状态推进等必要动作。在优先级模式下,还可以引入类似 privileged budget(特权额度)的机制:极端情况下,只允许一个 root pool 继续向前推进,直到它释放资源或完成退出。

这个设计的收益是避免全局卡死;代价是极端压力下并发度可能下降,甚至退化成接近单并发推进。它不是为了提高性能,而是为了在资源已经紧张时保证系统仍然能完成回收动作。

取舍:

  • out-of-control 估算越高,系统越安全,但 SQL 更容易等待或取消。
  • out-of-control 估算越低,内存利用率越高,但更容易触发 GC 抖动或 OOM。
  • soft limit 可以采用自动计算方式,但它只是风险边界的调节方式,不是独立的仲裁模式。

估算没有完美答案:

估算偏高 估算偏低
更安全 内存利用率更高
SQL 更早等待或取消 SQL 更容易继续执行
可能浪费可用内存 更容易触发 GC 抖动或 OOM
用户感知是“太保守” 用户感知是“不稳定”

因此,仲裁器的目标不是算出一个绝对准确的 out-of-control,而是在安全性和利用率之间找到一个能动态调整的边界。

runtime feedback:逻辑额度之外的真实世界

逻辑额度和真实内存不是一回事。

一个 SQL 可能只报告了一部分内存,但 Go 运行时里还有未释放对象;也可能 SQL 已经退出,但堆内存还没被 GC 回收。因此,仲裁器不能只看 SQL 的逻辑额度,还要看运行时内存。

runtime feedback 主要用于回答:

  • 当前堆内存是否已经很高?
  • 是否有大量未追踪内存?
  • GC 是否能及时释放内存?
  • 逻辑额度和真实堆内存之间是否长期不匹配?

也就是说,out-of-control 给出风险边界,runtime feedback 负责根据运行状态修正这个边界。

它的作用是把仲裁器从固定阈值判断变成闭环控制:

  1. 先根据逻辑额度做分配。
  2. 再观察真实堆内存是否按预期变化。
  3. 如果堆内存压力明显高于额度预期,就扩大 out-of-control。
  4. 如果风险解除,再逐步释放保守余量。

这类控制一定不是完全精确的,但比固定阈值更能适应不同负载。

引入 runtime feedback 的取舍是:

  • 好处:更接近真实进程状态,能减少 OOM。
  • 代价:系统行为会受运行时波动影响,解释成本更高。

Auto Tune:把反馈变成下一轮决策

Auto Tune 是内存仲裁的自适应校正机制,用 runtime feedback 和历史画像修正额度决策。

短期看,它根据运行时内存压力调整 out-of-control,让系统在风险升高时更保守,在风险解除后逐步释放保守余量。长期看,它可以记录 SQL 或 root pool 的历史内存画像,用于改善下一次执行的额度预估,减少反复扩容和仲裁。

这不是新的仲裁模式,而是让仲裁器从静态阈值走向自适应决策的能力。写这部分时需要区分当前实现和演进方向:运行时状态记录、历史画像和 soft limit 自动模式提供了框架,但不能把它描述成已经能完美预测所有 SQL 内存使用。

推广到执行引擎资源管控

内存仲裁所使用的“业务策略、资源账户、全局决策、反馈校正和回收动作”也可以推广到 CPU、IO、网络和临时磁盘等资源。

不过,不同资源的回收方式并不相同:CPU 可以限速和让出执行,IO 可以排队,内存则必须等待对象释放或终止任务。统一资源管控应复用决策框架,而不是假设所有资源都能使用同一种仲裁算法。

可观测性

内存仲裁必须可解释。否则用户只会看到 SQL 被取消或强制终止,却不知道为什么。

至少需要观察这些信息:

  • 当前仲裁模式。
  • 已分配额度、可用额度、风险空间。
  • 等待额度的 SQL 数量。
  • root pool 数量。
  • 取消、强制终止、GC 次数。
  • SQL 等待内存仲裁的耗时。
  • 运行时堆内存和逻辑额度之间的比例。
  • soft limit 与实例内存上限的关系。

这些信息可以帮助判断:

  • 是整体内存不足,还是某类 SQL 消耗异常?
  • 是 soft limit 太保守,还是负载本身太重?
  • 是低优先级 SQL 被正常回收,还是关键 SQL 也受影响?
  • 是应该调大资源、降低并发,还是调整 Resource Group?

主要取舍总结

稳定性 vs 利用率

系统会预留安全空间,不会把全部内存都分给 SQL。

  • 收益:降低 OOM 和 GC 风险。
  • 代价:极限内存利用率下降。

快速失败 vs 成功率

快速失败策略会让 SQL 更早失败。

  • 收益:避免无界等待,降低尾延迟。
  • 代价:更依赖上层重试。

优先级保护 vs 公平性

PRIORITY 模式保护高优先级业务。

  • 收益:关键 SQL 更稳定。
  • 代价:低优先级 SQL 可能更容易失败。

三档优先级 vs 细粒度隔离

当前 Resource Group 优先级只有 LOW、MEDIUM、HIGH 三档。

  • 收益:配置简单,用户容易理解,仲裁器也容易做出稳定决策。
  • 代价:同档内仍然会竞争;如果 HIGH 被滥用,优先级保护会退化;如果 LOW 长期承压,需要依赖等待策略、重试策略和可观测性来解释。

全局控制 vs 热路径开销

全局仲裁能做更强的控制,但不能参与每一次小内存分配。

  • 收益:实例级资源调度能力更强。
  • 代价:需要轻量路径来控制开销,因此精度不是绝对的。

await-free pool vs root pool

await-free pool 适合解析、编译和执行阶段的小额度增长,root pool 适合执行阶段持续增长、需要等待或回收的内存。

  • 收益:短生命周期内存和小额度增长也能纳入全局额度视野,同时避免过早进入普通等待队列。
  • 代价:需要判断什么时候从 await-free pool 升级到 root pool;升级太早会增加调度开销,升级太晚会增加 out-of-control 压力。

buffer vs 并发度

buffer 和 privileged budget 用于避免所有 root pool 同时等待导致系统无法回收。

  • 收益:极端压力下仍能推进取消、回收和退出流程,降低全局卡死风险。
  • 代价:会保留一部分不可分配空间;压力过大时并发度可能下降。

runtime feedback vs 可预测性

引入运行时内存可以更好地保护进程。

  • 收益:能处理未追踪内存和 GC 滞后。
  • 代价:决策会受到历史状态和运行时波动影响。

Auto Tune vs 可解释性

Auto Tune 用 runtime feedback 和历史画像修正额度决策。

  • 收益:额度边界可以随负载变化自适应,减少固定阈值带来的误判。
  • 代价:用户需要更多可观测性才能理解为什么同一类 SQL 在不同时间拿到的额度不同。

out-of-control 估算 vs 决策准确性

out-of-control 是仲裁器对风险空间的估算,不是一个可以精确计算的物理量。

  • 收益:把未追踪内存、GC 滞后和 soft limit 都纳入统一安全边界。
  • 代价:估算偏差会直接影响 SQL 成功率和实例稳定性。

风险

  • 如果所有 SQL 都配置成高优先级,优先级调度会失效。
  • 如果 soft limit 太低,SQL 会过早失败,资源利用不足。
  • 如果 soft limit 太高,仍可能出现 GC 抖动或 OOM 风险。
  • 如果 SQL 收到取消或强制终止后不能及时退出,内存释放会滞后。
  • 如果未追踪内存长期偏高,逻辑额度的调度效果会下降。

结论

内存仲裁的本质,不是让系统更会强制终止 SQL,而是让执行引擎在资源紧张时有明确的决策顺序。

它把问题从:

1
谁用了最多内存?

推进到:

1
2
3
系统还能不能继续分配?
应该优先保护谁?
什么时候应该等待、取消、回收或强制终止?

这就是执行引擎资源管控的核心。

正常情况下,内存仲裁应该尽量少打扰 SQL 执行;资源紧张时,它必须足够强硬,保护 TiDB 实例不被单个或一组 SQL 拖垮。

这套机制中最关键的难点,是在不精确知道所有内存归属的情况下,仍然做出足够保守但不过度保守的分配决策。out-of-control 的估算,就是这个难点的集中体现。

最终目标是形成一套统一的执行资源管控平面:业务策略决定资源优先级,执行引擎负责准入和调度,runtime feedback 负责校正逻辑模型,可观测性负责解释系统决策。