数据库执行引擎的内存仲裁模式:从 OOM 保护到资源管控
数据库执行引擎的内存管理,真正难点不在于统计单条 SQL 用了多少内存,而在于当多个 SQL、后台任务和运行时系统共同竞争实例内存时,如何在 OOM 之前做出可解释的全局决策。传统“先分配、后统计、超限再杀 SQL”的方式处理得太晚,难以表达业务优先级,也无法覆盖逻辑内存与真实进程内存之间的偏差。本文围绕 TiDB 执行引擎的全局内存仲裁机制,讨论稳定性、利用率、公平性和优先级保护之间的设计取舍。
本文要解决的问题
数据库执行引擎中,内存管理面临的难题是:
- 当多个 SQL 同时增长内存时,系统应该让谁继续执行?
- 当实例内存已经紧张时,新来的内存申请是等待、失败,还是抢占其他 SQL?
- 当进程接近 OOM 时,应该取消哪个 SQL,还是强制终止正在运行的 SQL?
- 如何让 OLTP、OLAP、后台任务在同一个 TiDB 实例上有不同的资源保障?
传统方式有什么问题
传统做法大致是:
1 | SQL 执行 |
这种方式有优点:简单、侵入小、容易落地。但它有一个根本问题:处理得太晚。
等实例内存已经超过阈值时,通常已经发生了下面这些事情:
- Go 堆内存已经很高,GC 压力已经出现。
- SQL 已经分配了大量对象,强制终止后也不一定马上释放。
- 系统只能看到“谁用得多”,但不知道“谁应该被保护”。
- 所有 SQL 在全局内存风险面前几乎是同一类,业务优先级很难体现。
所以,仅靠事后强制终止内存使用最多的 SQL,不能解决执行引擎的资源竞争问题。我们需要在 SQL 继续扩大内存占用之前,就进行全局判断。
核心思路:在可控路径上先申请额度
内存仲裁的核心变化是:
1 | Before: |
这里的“额度”可以理解为逻辑内存预算,“先申请”是一项执行代码需要主动遵守的逻辑协议,不是对所有物理内存分配的统一拦截。SQL 不一定真的马上分配这么多物理内存,但它需要先向系统声明:我接下来可能需要更多内存,以此来获得较强的事前控制。
如果代码在对象创建后才报告内存,或者相关内存没有接入追踪系统,仲裁器就无法阻止这次物理分配。它只能在后续额度申请或运行时内存压力升高时进行补救。
因此,这套机制管理的是数据库内部发放的逻辑额度。它没有侵入 Go 内存分配器,也不能自动覆盖运行时、第三方库和所有后台任务的内存。
操作系统层面的“申请成功”也不等于物理内存已经安全可用。以 Linux 为例,malloc 或 mmap 可能只获得虚拟地址空间;只有程序实际访问相应页面时,内核才需要建立物理映射。在启用 Overcommit 时,系统接受的虚拟内存申请还可能超过当前物理内存能力。
所以,逻辑额度、runtime 统计和操作系统物理内存是三个不同层次。数据库仲裁器主要控制第一个层次,并通过运行时指标观察第二个层次,最终仍需要为操作系统层面的偏差保留安全空间。
全局仲裁器收到申请后,会根据当前实例状态做决定:
- 内存充足:允许申请。
- 内存紧张:让申请等待,或者取消申请。
- 有优先级差异:保护高优先级 SQL,回收低优先级 SQL。
- 已经接近 OOM 风险:强制终止部分正在运行的 SQL,优先保护 TiDB 进程。
这个设计的关键不是“限制每个 SQL”,而是“在实例级别做资源决策”。
为什么不能只靠单 SQL 内存上限
单 SQL 内存上限只能回答“这个 SQL 有没有超过自己的限制”,不能回答“整个 TiDB 实例是否还安全”。
极端情况下,每个 SQL 都没有超过自己的内存上限,但并发叠加后,实例堆内存仍然可能进入危险区。反过来,有些 SQL 的逻辑额度不高,但由于未追踪对象、临时对象或 GC 滞后,也可能造成真实内存压力。
这就是为什么需要实例级仲裁器,而不是只依赖 SQL 自己的内存上限。
总体架构
内存仲裁可以分成三层。
1 | SQL / 执行器 / 计划器 / 编译器 |
下图表示 TiDB 内存仲裁模式的总体架构

内存追踪器(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 | SQL 开始执行 |
这里的关键取舍不是“要不要追踪小对象”,而是“小额增长是否需要立即触发完整仲裁”。
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 | 额度不足 -> CANCEL |
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 | 实例内存上限 |
可以把 out-of-control 理解成几类风险的最大值:
1 | out-of-control = |
soft limit 是其中一个重要来源。它用来控制“最多分配多少额度给 SQL”,确保系统不会把硬限制全部暴露给执行层。
下图展示了 TiDB 内存仲裁模式中 out-of-control、soft limit、未追踪内存和历史画像之间的关系。

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 负责根据运行状态修正这个边界。
它的作用是把仲裁器从固定阈值判断变成闭环控制:
- 先根据逻辑额度做分配。
- 再观察真实堆内存是否按预期变化。
- 如果堆内存压力明显高于额度预期,就扩大 out-of-control。
- 如果风险解除,再逐步释放保守余量。
这类控制一定不是完全精确的,但比固定阈值更能适应不同负载。
引入 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 | 系统还能不能继续分配? |
这就是执行引擎资源管控的核心。
正常情况下,内存仲裁应该尽量少打扰 SQL 执行;资源紧张时,它必须足够强硬,保护 TiDB 实例不被单个或一组 SQL 拖垮。
这套机制中最关键的难点,是在不精确知道所有内存归属的情况下,仍然做出足够保守但不过度保守的分配决策。out-of-control 的估算,就是这个难点的集中体现。
最终目标是形成一套统一的执行资源管控平面:业务策略决定资源优先级,执行引擎负责准入和调度,runtime feedback 负责校正逻辑模型,可观测性负责解释系统决策。