news 2026/9/9 19:08:05

TiDB 列池(Column Pool)设计方案:从 Chunk 粒度到列粒度的缓冲复用与内存优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TiDB 列池(Column Pool)设计方案:从 Chunk 粒度到列粒度的缓冲复用与内存优化

TiDB 列池(Column Pool)设计方案:从 Chunk 粒度到列粒度的缓冲复用与内存优化

【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb

<docs/design/2018-10-22-the-column-pool.md> 是一份由 zz-jason 于 2018-10-22 提交的 TiDB 内部设计提案,它提出将执行引擎中以 Chunk 为粒度的缓冲复用策略改为以 Column(列)为粒度,借助一个会话级(session-level)列池实现更细粒度的内存复用,从而降低查询执行阶段的总内存占用,并为面向列的(向量化)表达式求值扫清障碍。读完本文,你将理解该提案诞生的背景、列池的并发分片设计,以及它在当前 TiDB 代码库中对应的实现(pkg/util/chunk/pool.goalloc.go)与后续在向量化求值中的落地方式。

上图出自原始设计文档:一个 ColumnPool 内含 5 个规格不同的子池(Pool 4 / Pool 8 / Pool 16 / Pool 40 / Pool Var),每个子池再拆分为多个 Shard,用于并发访问时降低锁竞争。

背景:Chunk 粒度缓冲复用面临的三个问题

在 TiDB 执行引擎中,行数据按批量方式以 Chunk 为单位在算子(operator)之间流动与存储,一套成熟的 Chunk / Column 内存模型也沉淀在 pkg/util/chunk 包中。设计提案回顾了当时的内存复用现状:缓冲复用的粒度停留在 Chunk 级别,即一个 Chunk 用完后再整体放回某种缓存结构中。该策略存在三方面不足:

  1. 复用效果受限:当某些算子处于非活跃状态时,它们持有的内存无法被其他算子复用,导致执行整条查询期间的总内存峰值居高不下,内存复用策略"不生效于部分场景"。
  2. 多 goroutine 场景下的回收策略复杂:为了复用 Chunk,必须为每一个利用多 goroutine 挖掘线程级并行的算子(如 hash join)单独设计资源回收策略,代码因此复杂且难以维护。
  3. 跨查询复用缺失、GC 压力大:同一 session 内,当前查询占用的内存在下一条查询中无法复用,反复分配会显著抬高 Golang GC 压力,进而影响单台 TiDB server 上的 OLTP 性能。

这三个问题共同指向同一个方向:缓冲复用的粒度需要下沉到 Column 一级,让内存能在算子之间、查询之间更自由地流动。

提案核心:面向 Column 的细粒度缓冲复用

方案的核心思路一句话概括:把 Chunk 粒度的缓冲复用改造成 Column 粒度,并引入一个会话级列池来承载这些可复用的 Column 对象

其好处与上一节的三个痛点一一对应:

  • Column 是比 Chunk 小得多的内存单元,多个算子可以"按需借用"任意数量的列缓冲,非活跃算子持有的列可以被及时归还并转借给活跃算子,从而降低整条查询的总内存占用;
  • 资源回收逻辑从"算子内部的自定义策略"收敛为"统一的列池 Put/Get",大幅简化 hash join 这类并发算子的代码;
  • 同一 session 内的多条查询共享同一个列池,前一条查询归还的列缓冲可直接服务下一条查询,减少对象分配,降低 GC 压力,保护 OLTP 场景的稳定性。

列池设计要点:五种列规格与分片式 LIFO

考虑到当时 TiDB 仅支持有限的类型集合,提案认为列池只需覆盖 5 种列即可:

  • 4 种定长列:元素宽度分别为 4 / 8 / 16 / 40 字节;
  • 1 种变长列:元素宽度未知(如 VARCHAR、JSON),统一归入变长列。

该分类与此后落地的元素宽度映射一一对应(详见下文getFixedLen),核心观察是:TiDB 的类型系统里定长类型只需少数几种内存宽度,池化时"按宽度分类"性价比最高。

在并发设计上,列池需支持多个 goroutine 并发访问。提案给出的方案是:

  • 分片(shard):把列池拆成多个分片,每次 Put / Get 时随机选择目标分片,从而把并发竞争分摊到不同分片上,减少锁冲突;
  • 每片使用 LIFO 栈:每个分片内部实现为"后进先出"栈。这样设计是因为大多数情况下新申请的列长度与上一次放回池中的列长度相等——LIFO 恰好把"最近归还、容量最可能匹配"的列最先复用,从而最大化命中率,也让长期闲置的列能尽快被淘汰、释放。

从提案到实现:列池在当前代码库中的落地

提案之后,列池机制被正式实现并持续演进。下面结合当前仓库源码逐层还原其真实形态。

Pool 结构:按列宽拆分的五个sync.Pool

列池的本体位于 pkg/util/chunk/pool.go。注释明确写道"Pool is the Column pool",其结构体正是把提案中 5 种列逐一映射为独立的sync.Pool

// Pool is the Column pool. // NOTE: Pool is non-copyable. type Pool struct { initCap int varLenColPool *sync.Pool fixLenColPool4 *sync.Pool fixLenColPool8 *sync.Pool fixLenColPool16 *sync.Pool fixLenColPool40 *sync.Pool }

NewPool(initCap)为每种列都注册了各自的构造函数:变长列用newVarLenColumn(initCap),4/8/16/40 定长列分别用newFixedLenColumn(4/8/16/40, initCap)。这里可以看到实现上的一个演进:提案设想的"分片 + LIFO 栈 + 手动锁"最终以 Go 标准库sync.Pool落地sync.Pool本身就是分片式的(每个 P 维护本地私有缓冲,私有缓冲未命中才触及共享池,并以随机化降低竞争),天然等价于"按 goroutine 分片 + LIFO 近似复用"的目标,把并发安全交给运行时,大幅降低了维护成本。

GetChunk 与 PutChunk:按字段类型路由到对应子池

Pool提供两个核心方法。GetChunk(fields)依据每个字段的类型计算定长宽度,然后从对应子池中取出一个已复用的 Column 组装成 Chunk;PutChunk(fields, chk)则反向把 Chunk 的各列reset()后归还到对应子池,并置空chk.columns释放列引用:

func (p *Pool) GetChunk(fields []*types.FieldType) *Chunk { chk := new(Chunk) chk.capacity = p.initCap chk.requiredRows = p.initCap chk.columns = make([]*Column, len(fields)) for i, f := range fields { switch elemLen := getFixedLen(f); elemLen { case VarElemLen: chk.columns[i] = p.varLenColPool.Get().(*Column) case 4: chk.columns[i] = p.fixLenColPool4.Get().(*Column) case 8: chk.columns[i] = p.fixLenColPool8.Get().(*Column) case 16: chk.columns[i] = p.fixLenColPool16.Get().(*Column) case 40: chk.columns[i] = p.fixLenColPool40.Get().(*Column) } } return chk } func (p *Pool) PutChunk(fields []*types.FieldType, chk *Chunk) { for i, f := range fields { c := chk.columns[i] c.reset() switch elemLen := getFixedLen(f); elemLen { case VarElemLen: p.varLenColPool.Put(c) case 4: p.fixLenColPool4.Put(c) // ... 8 / 16 / 40 同理 } } chk.columns = nil // release the Column references. }

注意两点工程细节:一是PutChunk前必须逐列调用reset()清空lengthdatanullBitmap等状态(变长列还需保留 offsets 的首个 0 元素,方便后续切片操作),保证下次取出的是"干净"的列;二是Get / Put 必须传入与分配时一致的fields,否则列会被错误路由到宽度不匹配的子池,破坏数据布局——这也是该 API 的一个使用前提。

类型到列宽的映射:getFixedLen

"5 种列"的分类依据落在 pkg/util/chunk/codec.go 的getFixedLen函数上。它是一个类型级的查表逻辑:

返回宽度覆盖的 MySQL/TiDB 类型说明
4TypeFloat单精度浮点
8TypeTiny/Short/Int24/Long/Longlong/Double/Year/Duration各类整数、双精度与时间间隔
16(sizeTimeTypeDate/Datetime/Timestamp时间类,sizeTime = int(unsafe.Sizeof(types.ZeroTime))(常见 64 位平台下即 16 字节)
40(MyDecimalStructSizeTypeNewDecimal十进制定点数,常量定义于 pkg/types/mydecimal.go
-1(VarElemLen其余类型(字符串、JSON 等)变长列

VarElemLen = -1MyDecimalStructSize = 40这些常量分别定义在 pkg/util/chunk/codec.go 与 pkg/types/mydecimal.go 中,恰好一一对应提案里"4/8/16/40 + 变长"的五类划分。与之配套的 Column 存储布局在 pkg/util/chunk/column.go 中实现:定长列由连续data缓冲区 + 等宽elemBuf构成,初次分配的data容量为elemLen * capacity;变长列则由data+offsets前缀和数组构成(offsets 初始容量capacity + 1,首个元素恒为 0,便于数据切片),初次 data 容量按经验值estimatedElemLen = 8估算,即"varchar 等变长列首次执行时用 8 字节/行做预估,宁可后续 growslice 也不一次性多占内存"。两类列都携带nullBitmap(容量约(capacity+7)/8),用于位图方式标记 NULL。

全局列池:以初始容量为键

提案将其定位为"会话级列池",而当前代码进一步演化为一个进程级全局列池、按初始容量分组的结构。同在 pkg/util/chunk/pool.go 中:

var ( globalChunkPoolMutex syncutil.RWMutex // globalChunkPool is a chunk pool, the key is the init capacity. globalChunkPool = make(map[int]*Pool) )

globalChunkPool以 Chunk 的初始化容量(initCap)为键缓存多个*Pool。由于 initCap 在直方图分桶(bucket)场景下取值有限(见源码注释"initCap is the size of the bucket in the histogram, so it will not have too many difference value"),所以不会出现键过多、内存碎片化的问题。对外暴露的getChunkFromPool(initCap, fields)/putChunkFromPool(initCap, fields, chk)两个包内函数采用"先读锁查表、未命中再写锁建池"的懒加载模式。

面向执行器的进一步封装:Allocator 接口

Pool只能按固定 initCap 分配,不足以满足执行器多样化的容量需求。为此 pkg/util/chunk/alloc.go 定义了Allocator接口:

type Allocator interface { Alloc(fields []*types.FieldType, capacity, maxChunkSize int) *Chunk CheckReuseAllocSize() bool Reset() }

其默认实现allocator维护两块缓存:freeChunks列表缓存整块 Chunk;poolColumnAllocator则把用完的 Chunk 在Reset()重新拆解成一个个 Column,按宽度分门别类放入columnList的空闲链表,等待下一次Alloc直接复用(复用前会用newColumn检查容量,cap(col.data) < count时才新建)。这就是提案设想的"面向 Column 的复用"在算子生命周期上的落点:一个算子通过Allocator.Alloc领取 Chunk、Reset()归还,归还动作本质上是把列归还给列池。

alloc.go同时提供几个内存水位控制与包装器,值得工程参考:

  • maxFreeChunks = 64maxFreeColumnsPerType = 256:每类空闲列的最大缓存数量,超过即丢弃,防止缓存无限膨胀;
  • MaxCachedLen = 16 * 1024:变长列若cap(data)超过该值(单列占用过大内存)则不进入空闲队列(见checkColumnTypepush),避免大缓冲长期滞留池中;
  • syncAllocator:用 mutex 包一层,供多 goroutine 共享一个 Allocator;
  • reuseHookAllocator:当第一次真正命中复用对象时触发回调(sync.Once保证只触发一次),可用于埋点统计"复用是否真正发生";
  • NewEmptyAllocator():一个"空池",总是直接New新建 Chunk,用于需要禁用复用的场景;
  • 全局开关InitChunkAllocSize(maxFreeChunks, maxFreeColumnsPerType)可动态调整两类水位。

这套接口正是提案实现步骤中"替换NewChunkWithCapacity()"的最终形态——执行器不再关心 Chunk 来自哪里、回收到哪里,只依赖统一的 Allocator 抽象。

向量化表达式求值中的列池:localColumnPool

列池的第二个收益"支撑面向列的表达式求值"也在代码中可见:向量化求值需要一个随时可取的列缓冲区。在 pkg/expression/builtin_vectorized.go 中定义了columnBufferAllocator接口与基于sync.PoollocalColumnPool

// localColumnPool implements columnBufferAllocator interface. // It works like a concurrency-safe deque which is implemented by lock-free sync.Pool. type localColumnPool struct { sync.Pool }

包级单例globalColumnAllocator通过GetColumn/PutColumn对外提供服务;pkg/expression/builtin.go 中向量化函数(vectorized builtin function)在运行时用b.bufAllocator = newLocalColumnPool()建立自己的缓冲分配器,向量化求值时临时申请 Column、用完即还。也就是说,内置函数的向量化求值本身就建立在一个轻量级列池之上——这直接呼应了提案 Abstract 中"支持面向列的表达式求值并发挥向量化执行能力"的第二个目标。对应地,pkg/expression/builtin_vectorized_test.go 与 pkg/expression/bench_test.go(其中包含BenchmarkColumnPoolGetBenchmarkColumnPoolGetPutParallel等基准用例)覆盖了该池的并发与吞吐验证。

落地步骤回顾与实现演进

提案原文给出的实施路线是三步走:

  1. 先实现列池(column pool)本身;
  2. 移除各算子内部复杂、易错的资源回收策略
  3. NewChunkWithCapacity()逐一替换为pool.GetChunk()

对照当前代码库,这三步均已落地,且形态上发生了有意义的演进:

  • 第一步产出即 pkg/util/chunk/pool.go 的Pool,5 类子池与提案一一对应;
  • 第二步收敛为 pkg/util/chunk/alloc.go 的统一Allocator,算子只需面向接口,hash join、sort 等并发算子的回收逻辑由此大幅简化;
  • 第三步中,GetChunk的职责被更通用的Alloc取代:执行器层(例如 executor、distsql、join、sort 等模块,大量文件已引用这套 chunk 池化/分配工具)在领取与归还 Chunk 时统一走分配器,配合Reset()完成跨查询的列级复用;
  • 作用域上,会话级列池最终以"全局按 initCap 分组的Pool缓存 + 执行器内Allocator"的混合形态落地,sync.Pool承担了提案中"分片 + LIFO + 减少锁竞争"的全部并发诉求,而maxFreeChunks/maxFreeColumnsPerType/MaxCachedLen则充当池子的"水位闸门"。

测试与验证

pkg/util/chunk/pool_test.go 为列池提供了较完整的单元测试与基准:

  • TestNewPool:校验NewPool(1024)后五个子池均非空、initCap正确;
  • TestPoolGetChunk:用 varchar/json/float/decimal/double/longlong 等字段构造 Chunk,验证变长列(varchar、json)elemBuf为空、定长列的elemBuf长度与getFixedLen返回值一致,且data容量恰好为initCap * 元素宽度,从侧面印证了预分配内存是按列宽精确计算的;
  • TestPoolPutChunk:验证归还后chk.columns被置空,列引用被正确释放;
  • BenchmarkPoolChunkOperationRunParallel并发执行PutChunk(GetChunk(...)),验证高并发下的取还吞吐。

这些用例连同上文提到的表达式侧基准,共同保证了列池在并发正确性与复用效率上满足设计目标。

使用注意与延伸阅读

从实现中可以提炼出几条使用列池时的注意点:

  • Get/Put 必须使用同一组 fields:归还时按字段类型路由到对应子池,字段列表不一致会把定长列误放进变长池,破坏后续复用;
  • Pool不可拷贝:结构体注释明确NOTE: Pool is non-copyable,应当以指针形式共享;
  • 归还前需 resetPutChunk内部会执行c.reset(),业务侧不应在归还后继续持有该 Chunk 的列引用(PutChunk会主动置空chk.columns);
  • 关注缓存水位:超大列(变长 data 超过MaxCachedLen)不会进入空闲队列,这是刻意为之的防 OOM 设计,而非缺陷。

想深入这一机制,建议按以下路径阅读源码:

  • 设计提案原文:docs/design/2018-10-22-the-column-pool.md;
  • 列池本体与全局池:pkg/util/chunk/pool.go;
  • 执行器分配器与水位控制:pkg/util/chunk/alloc.go;
  • 列宽映射与类型常量:pkg/util/chunk/codec.go、pkg/types/mydecimal.go;
  • Column 存储布局与 reset 语义:pkg/util/chunk/column.go;
  • 向量化求值列缓冲池:pkg/expression/builtin_vectorized.go;
  • 测试与基准:pkg/util/chunk/pool_test.go。

总结而言,这份提案定义了 TiDB 执行引擎内存管理的一个关键转向:把复用单元从"整块 Chunk"下沉到"单根 Column",并通过按列宽分类、分片并发、LIFO 复用的池化设计,同时服务了内存节约、代码简化、GC 压力缓解与向量化执行四重目标;而当前仓库中的PoolAllocatorlocalColumnPool三层结构,正是这一设计思想历经演进后的完整实现证据。

【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 19:08:03

2025前端技术盘点:AI编程、性能治理与工程化新范式

2025年是我做前端这么多年以来&#xff0c;感觉最“不无聊”的一年。年初还在讨论AI辅助编程是不是炒作&#xff0c;年末发现身边的团队已经默认用AI写第一版代码&#xff1b;上半年还在为构建速度发愁&#xff0c;下半年Rolldown已经让冷启动快到让人觉得是不是没跑起来。作为…

作者头像 李华
网站建设 2026/9/9 19:05:36

光伏储能并网仿真模型:VSG虚拟同步发电机与Buck-Boost变换器控制详解

做光伏储能并网仿真这几年&#xff0c;我最深的一个体会是&#xff1a;控制策略的差异&#xff0c;比硬件拓扑的差异更容易决定一套系统的成败。同样是光伏板、电池、逆变器组成的并网系统&#xff0c;用传统的PQ控制去做&#xff0c;电网弱一点、阻抗大一点&#xff0c;波形就…

作者头像 李华
网站建设 2026/9/9 19:05:22

COMSOL激光通孔仿真实战:热源、相变与生死单元材料去除全解析

激光打孔仿真在工业界和学术界都是个高频需求&#xff0c;尤其是通孔加工&#xff0c;从PCB微孔到航空叶片气膜冷却孔都在用。但真正用COMSOL做出来一个能用的激光通孔模型&#xff0c;很多人卡在了“原理清楚、实操不会”这一步&#xff1a;热源怎么加载、材料怎么去除、相变怎…

作者头像 李华
网站建设 2026/9/9 19:02:09

JAVA毕设项目:基于SpringBoot的乡村农业信息统计管理系统的设计与实现 基于SpringBoot的农业资源信息智能化管理系统的设计与实现 (源码+文档,讲解、调试运行,定制等)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

作者头像 李华
网站建设 2026/9/9 18:59:40

如何给AI Agent接入微信通知?企业微信Webhook推送服务实践

1. 为什么需要给 Agent 加一个“通知”能力&#xff1f;如果你最近在折腾 AI Agent&#xff0c;大概率会遇到一个非常真实的尴尬场景&#xff1a;你精心设计了一个 Agent&#xff0c;给它配好了工具&#xff0c;输入了任务&#xff0c;然后它开始吭哧吭哧地跑。跑批任务、循环调…

作者头像 李华