ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

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/tidbdocs/design/2018-10-22-the-column-pool.md 是一份由 zz-jason 于 2018-10-22 提交的 TiDB 内部设计提案它提出将执行引擎中以 Chunk 为粒度的缓冲复用策略改为以 Column列为粒度借助一个会话级session-level列池实现更细粒度的内存复用从而降低查询执行阶段的总内存占用并为面向列的向量化表达式求值扫清障碍。读完本文你将理解该提案诞生的背景、列池的并发分片设计以及它在当前 TiDB 代码库中对应的实现pkg/util/chunk/pool.go、alloc.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 用完后再整体放回某种缓存结构中。该策略存在三方面不足复用效果受限当某些算子处于非活跃状态时它们持有的内存无法被其他算子复用导致执行整条查询期间的总内存峰值居高不下内存复用策略不生效于部分场景。多 goroutine 场景下的回收策略复杂为了复用 Chunk必须为每一个利用多 goroutine 挖掘线程级并行的算子如 hash join单独设计资源回收策略代码因此复杂且难以维护。跨查询复用缺失、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 组装成 ChunkPutChunk(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()清空length、data、nullBitmap等状态变长列还需保留 offsets 的首个 0 元素方便后续切片操作保证下次取出的是干净的列二是Get / Put 必须传入与分配时一致的fields否则列会被错误路由到宽度不匹配的子池破坏数据布局——这也是该 API 的一个使用前提。类型到列宽的映射getFixedLen5 种列的分类依据落在 pkg/util/chunk/codec.go 的getFixedLen函数上。它是一个类型级的查表逻辑返回宽度覆盖的 MySQL/TiDB 类型说明4TypeFloat单精度浮点8TypeTiny/Short/Int24/Long/Longlong/Double/Year/Duration各类整数、双精度与时间间隔16sizeTimeTypeDate/Datetime/Timestamp时间类sizeTime int(unsafe.Sizeof(types.ZeroTime))常见 64 位平台下即 16 字节40MyDecimalStructSizeTypeNewDecimal十进制定点数常量定义于 pkg/types/mydecimal.go-1VarElemLen其余类型字符串、JSON 等变长列VarElemLen -1与MyDecimalStructSize 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变长列则由dataoffsets前缀和数组构成offsets 初始容量capacity 1首个元素恒为 0便于数据切片初次 data 容量按经验值estimatedElemLen 8估算即varchar 等变长列首次执行时用 8 字节/行做预估宁可后续 growslice 也不一次性多占内存。两类列都携带nullBitmap容量约(capacity7)/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列表缓存整块 ChunkpoolColumnAllocator则把用完的 Chunk 在Reset()时重新拆解成一个个 Column按宽度分门别类放入columnList的空闲链表等待下一次Alloc直接复用复用前会用newColumn检查容量cap(col.data) count时才新建。这就是提案设想的面向 Column 的复用在算子生命周期上的落点一个算子通过Allocator.Alloc领取 Chunk、Reset()归还归还动作本质上是把列归还给列池。alloc.go同时提供几个内存水位控制与包装器值得工程参考maxFreeChunks 64、maxFreeColumnsPerType 256每类空闲列的最大缓存数量超过即丢弃防止缓存无限膨胀MaxCachedLen 16 * 1024变长列若cap(data)超过该值单列占用过大内存则不进入空闲队列见checkColumnType与push避免大缓冲长期滞留池中syncAllocator用 mutex 包一层供多 goroutine 共享一个 AllocatorreuseHookAllocator当第一次真正命中复用对象时触发回调sync.Once保证只触发一次可用于埋点统计复用是否真正发生NewEmptyAllocator()一个空池总是直接New新建 Chunk用于需要禁用复用的场景全局开关InitChunkAllocSize(maxFreeChunks, maxFreeColumnsPerType)可动态调整两类水位。这套接口正是提案实现步骤中替换NewChunkWithCapacity()的最终形态——执行器不再关心 Chunk 来自哪里、回收到哪里只依赖统一的 Allocator 抽象。向量化表达式求值中的列池localColumnPool列池的第二个收益支撑面向列的表达式求值也在代码中可见向量化求值需要一个随时可取的列缓冲区。在 pkg/expression/builtin_vectorized.go 中定义了columnBufferAllocator接口与基于sync.Pool的localColumnPool// 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其中包含BenchmarkColumnPoolGet、BenchmarkColumnPoolGetPutParallel等基准用例覆盖了该池的并发与吞吐验证。落地步骤回顾与实现演进提案原文给出的实施路线是三步走先实现列池column pool本身移除各算子内部复杂、易错的资源回收策略把NewChunkWithCapacity()逐一替换为pool.GetChunk()。对照当前代码库这三步均已落地且形态上发生了有意义的演进第一步产出即 pkg/util/chunk/pool.go 的Pool5 类子池与提案一一对应第二步收敛为 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、jsonelemBuf为空、定长列的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.goColumn 存储布局与 reset 语义pkg/util/chunk/column.go向量化求值列缓冲池pkg/expression/builtin_vectorized.go测试与基准pkg/util/chunk/pool_test.go。总结而言这份提案定义了 TiDB 执行引擎内存管理的一个关键转向把复用单元从整块 Chunk下沉到单根 Column并通过按列宽分类、分片并发、LIFO 复用的池化设计同时服务了内存节约、代码简化、GC 压力缓解与向量化执行四重目标而当前仓库中的Pool、Allocator与localColumnPool三层结构正是这一设计思想历经演进后的完整实现证据。【免费下载链接】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),仅供参考
返回列表