数据库执行引擎的内存仲裁模式:从 OOM 保护到资源管控
数据库执行引擎不仅要统计单条 SQL 用了多少内存,还要协调多个 SQL、后台任务和运行时系统之间的内存竞争。当资源不足时,谁应该继续执行,谁应该等待,谁应该退出?内存过载可能从单条 SQL 变慢扩大为实例级故障,影响同一实例上的其他业务。
控制内存竞争、优先保护关键负载,是数据库保障可用性和支撑业务 SLA 的一部分。本文围绕 TiDB 执行引擎的全局内存仲裁机制,介绍它如何协调 SQL 的内存申请,以及如何兼顾实例稳定性、内存利用率和业务优先级。
内存回收有多种方式。Go runtime 通过 GC 回收不再使用的对象,执行引擎也可以通过淘汰缓存、清理对象池、重置 arena,或将临时数据溢写到磁盘(spill)来释放内存。不同系统采用的方式不同,本文主要讨论 TiDB 的内存额度管理及其与 Go runtime 的配合。
本文要解决的问题
数据库执行引擎中,内存管理面临的难题是:
- 当多条 SQL 的内存占用同时增加时,系统应该让谁继续执行?
- 当实例内存已经紧张时,新的内存申请应该等待、被拒绝,还是通过回收其他 SQL 的内存来满足?
- 当进程接近 OOM 时,应该取消哪些 SQL?何时需要强制终止?
- 如何让 OLTP、OLAP、后台任务在同一个 TiDB 实例上有不同的资源保障?
TiDB 原有 OOM 保护方式的局限
在引入全局内存仲裁之前,TiDB 的实例级 OOM 保护主要依靠内存统计和阈值检查,流程大致如下:
1 | SQL 执行 |
实例阈值保护的优点是简单、接入成本低,但等到检查发现超限时,导致压力的内存已经分配出去了。
此时可能已经出现以下情况:
- Go 堆内存已经很高,GC 压力已经出现。
- SQL 已经分配了大量对象,强制终止后也不一定马上释放。
- 选择取消对象时主要依据 SQL 内存用量,难以体现业务优先级。
- 一条 SQL 被取消时,其他 SQL 的内存占用仍可能继续增加。
所以,仅靠事后强制终止内存使用最多的 SQL,不能解决执行引擎的资源竞争问题。我们需要在 SQL 继续扩大内存占用之前,就进行全局判断。
核心思路:在可控路径上先申请额度
内存仲裁的核心变化是:
1 | Before: |
这里的“额度”可以理解为逻辑内存预算,“先申请”是一项执行代码需要主动遵守的逻辑协议,不是对所有物理内存分配的统一拦截。SQL 不一定真的马上分配这么多物理内存,但它需要先向系统声明:我接下来可能需要更多内存,以此来获得较强的事前控制。
如果代码在对象创建后才报告内存,或者相关内存没有接入追踪系统,仲裁器就无法阻止这次物理分配。它只能在后续额度申请或运行时内存压力升高时进行补救。
因此,这套机制管理的是数据库内部发放的逻辑额度。它没有侵入 Go 内存分配器,也不能自动覆盖运行时、第三方库和所有后台任务的内存。
操作系统层面的“申请成功”也不等于物理内存已经安全可用。以 Linux 为例,malloc 或 mmap 可能只获得虚拟地址空间;只有程序实际访问相应页面时,内核才需要建立物理映射。在启用 Overcommit 时,系统接受的虚拟内存申请还可能超过当前物理内存能力。
因此,逻辑额度、Go 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 主要服务的场景:
- 执行阶段尚未超过小额度阈值(实例内存上限的 1/1000)的内存增长。
- 生命周期短的堆内存分配:例如临时缓冲区、临时对象等,使用完毕后会立即释放。
解析 SQL、编译 plan 等阶段,可以根据 SQL 复杂度预估所需额度。任务执行阶段先接入 await-free pool,当使用量超过阈值或者 await-free pool 无法补充额度时,SQL 会升级到 root pool,进入完整仲裁。
全局内存仲裁器(Global Memory Arbitrator):做实例级决策
全局内存仲裁器是实例级决策者。每个 TiDB 实例有一个仲裁器。
它负责回答:
- 当前实例还剩多少可分配内存额度?
- 哪些 SQL 正在等待额度?
- 哪些 SQL 是高优先级,哪些是低优先级?
- 当前运行时是否已经出现内存风险?
- 如果资源不够,应该取消、等待、回收还是强制终止?
这一层解决的是“系统还能不能继续分配”的问题。
核心设计难点:逻辑额度和真实内存不一致
内存仲裁最难的地方,不是维护一个全局计数器,而是判断“还有多少内存可以安全分配”。
- SQL 内存追踪器不一定覆盖所有临时对象。
- SQL 释放对象后,Go 堆内存不一定马上下降。
- 运行时、goroutine 栈、堆外内存、缓存、后台任务都会占用内存。
- 有些内存很难准确归属到某个 SQL。
- 同样的逻辑额度,在不同负载下可能对应不同的堆内存压力。
以 Go 的 runtime.MemStats 为例,HeapAlloc 统计已分配堆对象的大小,其中包括尚未被 GC 回收的不可达对象;HeapInuse 统计正在使用的堆 span,还包含这些 span 内的空余空间。它们都不能直接当作整个进程的 RSS,也不等于容器的内存用量。
因此,全局仲裁器不能简单地用下面这个公式做决策:
1 | 可分配额度 = 实例内存上限 - 已分配额度 |
这个公式看起来合理,但会漏掉已经占用内存、却没有计入 SQL 账本的部分。它们可能来自 GC 滞后、未追踪对象、allocator 保留的内存或后台任务。反过来,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 的部分,可以参考下面的简化模型:
1 | 可安全分配额度 ~= 实例内存上限 - 已分配额度 - 缓冲空间 buffer - out-of-control |
out-of-control 需要综合多种风险,计算公式为:
1 | out-of-control ≈ |
每个内存池都需要追踪堆内存对象的使用量,仲裁器用实例的内存使用量减去追踪到的,就能得到未追踪的。如果内存池漏掉一部分,导致算出来的 out-of-control 偏大,但对于全局内存安全性来说是可接受的。如果内存池统计多了,只要追踪的值小于内存池的额度上限,那么 out-of-control 就不会被低估。理论上而言,采样频率越高,out-of-control 估算越准确,全局内存资源利用率越高。
详细流程如下图所示:

buffer 和死锁规避
动态内存仲裁还有一个容易被忽略的问题:所有 root pool 都在等待额度时,系统不能让仲裁过程本身也被阻塞。否则会出现“大家都在等别人释放内存,但没有人能够继续推进到释放点”的风险。
因此,仲裁器需要保留一部分 buffer,让取消、回收和退出流程有内存可用。在优先级模式下,privileged budget(特权额度)用于让一个 root pool 在极端情况下继续推进,直到它释放资源或完成退出。
这个设计的收益是避免全局卡死;代价是极端压力下并发度可能下降,甚至退化成接近单并发推进。它不是为了提高性能,而是为了在资源已经紧张时保证系统仍然能完成回收动作。
安全余量的大小会直接影响实例风险和查询执行:
| 估算偏高 | 估算偏低 |
|---|---|
| 更安全 | 内存利用率更高 |
| SQL 更早等待或取消 | SQL 更容易继续执行 |
| 可能浪费可用内存 | 更容易触发 GC 抖动或 OOM |
| 用户感知是“太保守” | 用户感知是“不稳定” |
Auto Tune:把反馈变成下一轮决策
Auto Tune 根据运行时反馈和历史记录调整额度。在 soft limit 自动模式下,系统可以随内存压力收紧或放宽额度,让安全余量随负载变化。它改变的是额度边界,STANDARD 和 PRIORITY 决定的则是资源不足时的处理顺序。
TiDB 当前主要使用 Go runtime 统计和 SQL 内存追踪数据来判断压力,并利用运行时状态记录和历史画像辅助决策。历史画像可以为后续额度预估提供参考,但同一 SQL 在数据量、参数或并发条件变化后,仍可能需要不同的内存。
如果把这套框架用于其他执行引擎,还可以将缓存淘汰量、内存池回收量和 spill 效果纳入反馈,帮助判断回收是否有效。
可观测性
内存仲裁必须可解释。否则用户只会看到 SQL 被取消或强制终止,却不知道为什么。
至少需要观察这些信息:
- 当前仲裁模式。
- 已分配额度、可用额度、风险空间。
- 等待额度的 SQL 数量。
- root pool 数量。
- 取消、强制终止、GC 次数。
- SQL 等待内存仲裁的耗时。
- 实际内存和逻辑额度的对比(压力水平)。
- soft limit 与实例内存上限的关系。
这些信息可以帮助判断:
- 是整体内存不足,还是某类 SQL 消耗异常?
- 是 soft limit 太保守,还是负载本身太重?
- 是低优先级 SQL 按策略被取消,还是关键 SQL 也受影响?
- 是应该调大资源、降低并发,还是调整 Resource Group?
实测案例:8C16G TiDB 高负载下未发生 OOM
部署一台 8 核、16 GiB 内存的 TiDB 实例,数据规模为 TPC-C 1000 warehouses,用 4000 个并发线程进行压测。

两轮负载开始后,CPU 使用量大多维持在 6~7 核,RSS 上升并维持在约 14.5 GiB。在这个负载下,TiDB 持续完成 SQL 命令,没有出现实例 OOM。由于负载压力较大,偶发事务超时失败是预期现象。
| 监控指标 | STANDARD | PRIORITY |
|---|---|---|
| CPU 使用量 | 6.79 核 | 6.70 核 |
| TiDB RSS | 14.46 GiB | 14.52 GiB |
| Query OK | 4021 次/秒 | 3966 次/秒 |
| Query Error | 0.03 次/秒 | 0.03 次/秒 |
| Query Duration P95 | 0.244 秒 | 0.253 秒 |
| Query Duration P99 | 8.19 秒 | 8.44 秒 |
这次测试展示了一个具体能力:在 CPU 和内存持续承压时,实例没有 OOM,仍能继续处理请求。这是保障可用性的基础,也是 SLA 达成的前提。
主要取舍总结
稳定性与内存利用率
预留安全空间可以降低 OOM 和 GC 风险,但会减少可分配给 SQL 的额度。Auto Tune 根据运行时状态调整余量,目标是在实例风险和内存利用率之间取得平衡。要判断调整是否合理,需要同时观察内存压力、等待时间和查询取消情况。
优先级保护与公平性
PRIORITY 模式在资源紧张时优先保护关键业务,代价是低优先级 SQL 更容易被取消。三档优先级配置简单,但同档任务仍然会竞争;如果大部分任务都设为 HIGH,优先级就难以体现业务差异。
全局控制与执行开销
await-free pool 和分批补充额度减少了全局协调次数,代价是控制粒度较粗。buffer 和特权额度让任务在内存紧张时仍有机会执行回收和退出操作,但极端情况下可能需要牺牲部分并发度。这些设计都在回答同一个问题:如何控制内存增长,又不让仲裁本身阻碍执行和回收。
快速失败与查询成功率
快速失败可以减少请求在当前节点上的等待,但会增加查询失败和重试。它能否降低业务延迟,取决于其他节点是否还有容量,以及上层是否能够合理重试。
风险
- 如果所有 SQL 都配置成高优先级,仲裁器就无法按业务重要性区分它们。
- 如果 soft limit 太低,SQL 会过早失败,资源利用不足。
- 如果 soft limit 太高,仍可能出现 GC 抖动或 OOM 风险。
- 如果 SQL 收到取消或强制终止后不能及时退出,内存释放会滞后。
- 如果未追踪内存长期偏高,逻辑额度的调度效果会下降。
结论
内存仲裁的本质,不是让系统更会强制终止 SQL,而是让执行引擎在资源紧张时有明确的决策顺序。
它把问题从:
1 | 谁用了最多内存? |
推进到:
1 | 系统还能不能继续分配? |
这就是执行引擎资源管控的核心。
正常情况下,内存仲裁应该尽量少打扰 SQL 执行;资源紧张时,它必须足够强硬,保护 TiDB 实例不被单个或一组 SQL 拖垮。
这套机制中最关键的难点,是在无法精确掌握所有内存归属的情况下,仍然做出足够保守但不过度保守的分配决策。out-of-control 的估算,就是这个难点的集中体现。
沿着这个思路,还可以进一步协调其他执行资源:由业务策略决定保护顺序,由执行引擎控制资源申请和调度,再通过运行时反馈调整决策。内存仲裁为这种扩展提供了基础。