尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
理解slotted page:数据库存储引擎中变长记录管理的核心机制
1. 为什么会出现slotted page定长页与变长记录的矛盾先从一个反直觉的现象说起在传统的内存数据结构里修改一条记录往往比插入新记录便宜——找到位置、改掉字段、完事。但在数据库存储引擎的页page里这条直觉经常失效。我见过不少第一次写索引实现的人把记录按定长数组方式摆进页里然后被变长更新折磨到怀疑人生明明只是更新一个varchar字段为什么整页数据都要搬这就是slotted page出现的根本原因。数据库的物理存储单位是页通常4KB到16KB不等。页的大小是固定的但页里的记录长度几乎永远是可变的——一个varchar(255)可能只存了3个字节一个复合类型可能塞了十几KB。如果你把页当成定长数组用每一条记录都得按最大长度预留空间一页只能放三五条记录空间浪费直接摧毁缓存命中率如果你把记录一个接一个紧密排列删除和变长更新又会让整页数据频繁搬移性能也扛不住。slotted page的解法用一句话概括页内不直接存储记录的实际地址给外部使用而是维护一层“槽数组”slot array外部拿到的只是一个槽位编号槽位里才存这条记录在页内的真实偏移和长度。记录本身在页内怎么挪、怎么压缩、怎么整理外部感知不到。这有点像对外公布的是工位编号而不是员工的物理坐标——员工今天换到三楼还是五楼工位号不变外部不需要跟着改。当然这只是最粗的骨架。真正到了实现层面槽数组怎么排布、插入删除时如何更新槽位、碎片整理怎么做才能既高效又崩溃安全、以及不同数据库在这个机制上做了哪些变种每一层都有大量值得拆解的细节。这也是存储引擎面试里最爱从slotted page往下追问的原因它看起来简单但几乎串联了缓冲池、事务日志WAL、页面恢复、碎片整理这些核心子系统的所有关键决策。2. 页面内部解剖槽数组、记录区与那四个元数据字段2.1 一页数据到底长什么样以最经典的PostgreSQL堆页面为例其他系统的实现我会在第5部分对比一个slotted page的物理布局从低地址到高地址大致是页面头PageHeaderData固定长度存放页的元信息槽数组ItemIdData数组从页头之后向后生长每个槽4字节空闲空间free space槽数组和记录区之间的空白地带记录区item area从页尾向页头方向生长每条记录的实际数据在这里特殊空间special space可选区域索引页面用于放一些额外信息比如B树的高位键这里有个关键设计槽数组和记录区相向生长。槽数组永远从低地址往高地址扩张记录永远从高地址往低地址扩张中间的空闲空间被两侧蚕食。这么做的好处是插入新记录时只需要在记录区头部划一块空间同时把槽数组向后推一个槽位互相不干扰不需要搬动已有记录。如果两者同向生长每次插入都可能要移动一大片数据那就完全失去了slotted page的意义。页面头里最重要的四个字段字段含义pd_lower槽数组结束位置的偏移量也就是空闲空间的起始地址pd_upper记录区起始位置的偏移量也就是空闲空间的结束地址pd_special特殊空间的起始偏移量pd_linp指向槽数组首元素的指针判断一页还有多少空闲空间只需要看pd_upper - pd_lower。判断一页是否满了也很简单两者相遇就是满。这比定长数组的“剩余多少格”稍微绕一点但正好对应变长记录下“剩余多少字节”这个真实需求。2.2 一个槽里到底存了什么每个槽ItemIdData虽然只有4字节却被拆成了三部分lp_off15位记录在页内的偏移量lp_flags2位槽的状态标记lp_len15位记录的长度2位flag可以表示四种状态未使用、正常、已重定向redirection用于逻辑删除后仍保留槽位、无效。日常操作里最常看到的是“未使用”和“正常”两种——删除记录时槽被标记为未使用但记录本身的数据还在页内留给后续空间整理去回收。那么问题来了为什么槽里要同时存偏移和长度按理说有了偏移就能定位记录要长度干什么这就要说到一个平时不起眼但非常关键的场景外部传来的TID页面号槽位号可能是过期的。例如一个事务已经删除了某条记录另一个并发事务还拿着旧TID来访问。这时槽位虽然被标记为未使用但页头里还残留着旧数据如果没有长度字段系统可能把残留数据当成有效记录返回有了长度校验再加上事务可见性判断就能在物理层就拦截掉这种脏读风险。2.3 记录头为什么是“半持久”的slotted page里还有一条容易被忽略的约束记录内部的头结构里不能保存槽位号或任何依赖页面布局的信息。为什么因为记录在页内是可以被随便搬动的——碎片整理时整条记录可能从页尾搬到页中间如果记录头里存了“我的槽位是几号”这类信息搬家之后还得回头改记录头既增加复杂度又容易出错。PostgreSQL的元组头HeapTupleHeaderData很好地体现了这个原则它只保存事务信息t_xmin/t_xmax、前后版本链接t_ctid、列数t_infomask2、标志位t_infomask、头长度t_hoff唯独没有任何一项跟“我在哪个槽位”相关。t_ctid存的是“当前或更新后的版本位置”指向的也是另一个物理位置而不是槽位号这样索引扫描时通过t_ctid能找到新版本但元组被搬动时t_ctid完全不受影响。这个概念我习惯叫“半持久记录标识”记录ID里的槽位号在记录存活期间保持稳定因为正常读写都通过槽数组间接定位但记录的实际物理位置说变就变。这就是为什么所有数据库外层的索引都存TID/ROWID而不直接存物理偏移——中间必须隔一层槽数组相当于加了一个不稳定的映射关系。3. 插入、更新、删除的完整演化过程3.1 插入分配记录区、挂槽、更新页头插入一条新记录时流程看起来很简单但每一步都有值得注意的边界条件。以PostgreSQL的heap_insert为例计算需要的总空间记录实际字节数 记录头长度对齐到MAXALIGN通常是8字节边界检查空闲空间如果pd_upper - pd_lower小于所需空间先尝试碎片整理具体整理流程第4部分细说整理后还不够就说明页真满了走页分裂或行外存储从记录区头部划空间pd_upper往下减新记录的物理位置就定在减出来的区域申请一个空闲槽位优先复用之前删除留下的空闲槽如果不够就把pd_lower往上加填写槽位槽里的lp_off指向新记录位置lp_len填入长度lp_flags标为正常更新页头pd_lower和pd_upper已经随着上面的操作隐式更新了这里有个容易踩坑的细节必须先分配记录区再挂槽还是先挂槽再分配记录区在崩溃恢复的视角下顺序很重要。如果先更新槽位指向一个还不存在的记录区域此时系统崩溃恢复后页内就出现了悬空槽位如果先分配记录区再写槽位崩溃时最多浪费一点空间但不会出现指向错误数据的槽。业界实现基本都遵循“先数据后指针”的原则这也是WAL日志里insert操作的redo动作要按特定顺序重放的原因之一。3.2 更新slotted page如何应对变长记录的尴尬这是slotted page最微妙的地方。一条记录更新后可能变长也可能变短。变短好办把长度字段改小、多余空间归还给空闲区就行。变长就麻烦了分两种情况情况一页内有足够空闲空间。最简单的做法是直接在原位置把旧记录标记为无效或者拷贝一份新记录到页尾的空闲区再把槽位指向新位置。PostgreSQL在MVCC机制下实际是插入一个全新版本旧版本保留用于旧事务读取这在slotted page上表现为“新版本占据新位置旧版本槽位暂时保留但内容过期”。如果只是纯粹的物理页管理而不涉及MVCC那么更常见的做法是在原记录的区域写不下就到记录区头部重新分配一块新空间拷贝数据然后更新槽位指向旧空间留待碎片整理回收。情况二页内空间不足。这一步直接决定了为什么变长更新可能比插入还贵。Space不够时系统得先尝试整理碎片让零散空间合并整理后还不行就得启用**行迁移row migration / forwarding pointer**机制——把整条记录搬到另一个有空位的页里在原页留一个“转发指针”指向新位置。SQL Server用forwarding pointerPostgreSQL用TOAST机制直接把超长字段挪出去底层都是同一个思想物理位置可以被替换但逻辑引用不能断。从我实际的开发经验来看理解变长更新的关键不在于那几行伪代码而在于理解“页内空间是二维的困境”你需要一大块连续空间但空闲区可能是被洞穿的碎片。slotted page的槽数组并不能解决碎片问题它只是让碎片不至于立刻致病。真正把碎片变成可管理问题的是下面要说的整理机制。3.3 删除先标记后清理的两阶段策略删除记录也分两步。第一步是逻辑删除把目标槽位的flag标记为未使用/失效或者在记录头里打上删除标记取决于数据库的具体协议。这时候记录的数据还完整躺在页里只是从“索引可达”变成“可回收”。第二步是物理清理等到没有事务再需要读取该旧版本时系统把它占用的空间正式归还给空闲区。为什么要分成两步因为MVCC下其他事务可能还在读这条被删除的记录。如果删除事务一提交就物理抹掉数据读旧快照的事务就会读到不完整的页。所以PostgreSQL里VACUUM负责“物理回收”环节SQL Server的ghost cleanup做类似的事它们都必须等到没有早于删除事务开始的活跃事务时才能真正动手。删除时另一个容易被忽视的点是槽位复用。一条记录被删后槽位本身占用的4字节不会自动归还给空闲区它只是被标记为“空闲可复用”。如果系统频繁插删槽数组会不断膨胀占用页头区域最后把空闲空间挤压到零。所以碎片整理不仅要合并记录区的碎片还要压缩槽数组——这一步在很多简化实现里被漏掉导致页头被槽位撑满而实际数据很少的诡异现象。4. 碎片化的真相内存拷贝、瓦片与页面整理策略4.1 一张图理解碎片是怎么产生的假设页内现在连续存放了四条约100字节的记录A、B、C、D。此时删除B页内出现一个100字节的空洞。接着穿插插入几条新记录有的80字节、有的120字节它们被分配到尾部空闲区把原来紧凑的空间切成了一堆大小不一的小块。此时页头显示的空闲空间可能还有800字节但最大的连续可用块只有130字节。插入一条需要150字节的记录就会失败必须触发整理。这就是slotted page日常性能波动的来源——页的空闲空间总和足够大但可用连续空间不足。因为没有整体规划插入越乱碎片率越高。碎片率可以用一个粗暴但有效的公式估算碎片率 1 - (最大连续空闲块 / 总空闲空间)。如果这个值长期超过70%说明页面整理策略或者buffer pool的分配策略有问题。这个指标我建议每个存储引擎开发者都放到监控面板里。4.2 整理的本质把记录搬到尾部把槽位压缩到头部PostgreSQL的PageRepairFragmentation函数是理解整理机制最好的入口。它做三件事把所有有效记录的逻辑内容“逻辑上”搬到页面尾部重新计算它们应该占据的偏移位置把槽数组重新压缩排列清掉中间的空洞重置pd_lower和pd_upper让它们在新的边界上重新开始实际实现里不会真的先把记录拷到临时缓冲区再拷回来——那样内存带宽代价太高。常见做法是遍历槽数组把每条有效记录的目标偏移计算出来然后直接在当前页内memmove。因为搬运方向是从高地址向低地址调整不会覆盖尚未处理的记录可以原地完成。搬运结束后统一刷新所有槽位的lp_off。这里最需要留意的不是速度而是崩溃一致性。整理过程中涉及大量内存拷贝和指针修正如果操作途中系统崩溃页面会处于一个“既不是整理前也不是整理后”的状态。PostgreSQL的解法是如果页面的LSN落后于WAL记录中的LSN或者页面即将被修改但还没有full-page image整理动作必须严格遵循WAL规则。简单说整理本身会被当作一个页面修改动作写入WAL崩溃后通过redo重放恢复。Redis或内存数据库不存在这个问题但存储引擎必须面对。4.3 碎片整理不是越勤越好一个常见的初学误区是碎片整理这么好为什么不每次insert前都整理一次答案是整理做起来很贵——它要把页内可能上百条记录来回搬动每搬一条都要更新槽位和可能的二级索引引用逻辑虽然槽位本身对外不可见但整理时记录移动的瞬间如果有并发访问需要页锁保护。在Buffer Pool里执行一次全页整理的开销可能比这次插入省下的空间成本要高一个数量级。业界通用的策略是懒整理只有在空间不足且有碎片可整理时才会触发配合VACUUMPostgreSQL、页面拆分B树等周期性的全局回收机制。SQL Server更进一步有独立的页面空闲空间监控结构PFS页当页面空闲空间低于一定阈值时主动做“页面合并”或“页面拆分”来决定记录的去向。另一个优化方向是整理时保留一点缓冲不要等到pd_upper pd_lower才整理否则整理完立刻又插入很快又满。很多实现会在整理后故意让pd_lower比紧贴槽数组的位置稍微留出几个槽位的余量避免频繁整理。这种微小的“战略预留”对高并发插入场景的提升非常可观。5. slotted page的家族谱系从System R到PostgreSQL、SQL Server的关键实现5.1 System R的原创与后续30年的演化slotted page最早出现在IBM的System R项目里当时的设计目标就是解决变长记录高效存取。今天几乎所有主流关系数据库的页内管理都继承了这个基本框架但各家在具体细节上走出了完全不同的路线。用“槽数组相向生长”这个框架去看每个数据库几乎都在一个维度上做了取舍是让槽位更灵活还是让记录更稳定怎么在临界条件下保持崩溃一致。这里最值得对比的还是SQL Server和PostgreSQL因为他们分别代表了“页内原地更新为主”和“版本链式追加为主”两种完全不同的哲学。5.2 PostgreSQL元组头里的语义比物理布局更重要PostgreSQL的堆页面虽然也有标准slotted page布局但它把“物理页管理”和“行版本管理”彻底解耦了。一个数据元组tuple在页内被当作一个独立单元内部带完整的头信息。更新操作从来不尝试在页内原地修改而是在当前页或另一个页插入一个全新版本旧版本通过t_ctid字段链接到新版本。这种设计天然规避了“变长更新需要移动大量数据”的尴尬——因为大家都只是追加物理移动的只有那个小元组头。PostgreSQL的slogan“append-only heap”在slotted page上的表现就是页面本身几乎是只追加的删除和更新都没法把空间立即还回去必须等VACUUM调度。这导致PostgreSQL的页内空间利用率比原地更新的系统波动更大但换来了极低的更新延迟和优秀的并发读因为读取不需要锁。另外PostgreSQL引入了一个专门的PageHeaderData字段pd_prune_xid来做“页面裁剪”page prune允许在索引扫描过程中顺便清理可回收的元组缓解空间压力。5.3 SQL Server原地更新为主转发指针兜底SQL Server的页内结构体现了一种更“空间敏感”的路线。它的槽数组是2字节偏移数组紧跟在96字节页头后面记录从页尾向前生长外观上和slotted page一样。但SQL Server的更新策略是“能原地就在原地改”——因为原地更新不需要额外分配记录区、不需要调整槽位偏移对OLTP短事务的IO最友好。只有当原地放不下时才启用forwarding pointer把整条记录搬到新页把原位置替换成一个8字节的转发地址。这种设计的问题也很经典转发指针太长了会破坏页内空间的局部性。SQL Server对此有专门检测——如果一条记录被连续转发两次以上或者转发链太长系统会强制把整个链拉回到本页或合并到一个新页。这个“强制回拉”的规则本质上就是在slotted page的物理布局之上又加了一层逻辑稳定性保护。从存储引擎架构的角度看这两种哲学没有绝对优劣更多取决于业务模型如果系统以读为主、更新少append-only更好如果系统是短事务高并发更新为主原地更新配合转发指针通常会更顺。5.4 Oracle与InnoDB的“变种”与边界Oracle的row directory是同一套思想的不同实现每一行在数据块头部的directory里登记一个“目录项”指向行数据的偏移和长度目录项本身也像槽数组一样向后生长。但Oracle的块结构更复杂它把数据块头部分成了多个层次允许目录项被重定位以便在块分裂时保持目录的连续性。InnoDB经常被拿来和slotted page比较但它其实走的是另一条路——用“记录堆”“链表/页面目录”的组合而不是严格的槽数组。InnoDB的页面内记录通过next指针串联页目录page directory里记录每组的最大偏移用于加速查找。这种设计在B树场景下非常合适因为InnoDB的页面天然是索引页而堆表里的slotted page更强调“给定槽位直接访问”的速度。两者各有各的并发模型不能直接划等号。对比一下关键差异维度PostgreSQLSQL ServerInnoDB槽数组4字节/槽状态标记丰富2字节/槽偏移数组无传统槽数组页目录链表更新方式追加新版本原地更新为主原地更新变长字段重构碎片整理VACUUM触发PageRepairFragmentation空闲空间监控 ghost清理页合并/页分裂由B树维护崩溃恢复WAL full-page imageWAL 页校验和redo log doublewrite6. 我看过的那些踩坑现场与工程建议最后聊点纯实战的东西。我这些年看过的和slotted page相关的故障几乎都跳不出下面这三个坑。第一个坑页面大小和记录大小的匹配度。如果业务里大量记录的变长幅度超过页面大小的一半slotted page的碎片整理会被频繁触发页内实际利用率会低到吓人。我看到过一个案例某个表一行的平均值大约6KB页面大小却配了16KB导致每页最多放两条记录而且这两条记录经常需要整体搬运。这种场景正确的做法是调大页面到32KB如果数据库支持或者把大字段拆到TOAST/溢出页而不是跟页管理机制死磕。第二个坑频繁小块删除对槽数组的膨胀。前面提到过删除记录并不会立即释放槽位。如果业务模式是“批量插入-短时间大量删除-再批量插入”页头的槽数组可能被大量空槽位塞满。这个问题在PostgreSQL里表现为VACUUM反复扫描但页面利用率不见上升在SQL Server里表现为PFS页频繁报告“可用空间不足”但页内实际数据很少。解决思路是控制每个页面上的事务密度尽量让删除和插入在时间上错峰或者在应用层做批量清理后再重新填充。第三个坑整理和并发访问的冲突。碎片整理要移动记录必然要短暂持有页锁。如果页锁持得过久高并发场景下会出现严重的锁等待。我调试过一个线上问题某系统在统计报表期间跑大批量导入同时前端有大量实时读写页面整理触发的锁等待把99.9分位延迟推到了几秒。最终优化方式是降低页面的填充因子让页面提前拒绝新插入而不是频繁整理同时调整VACUUM/ghost cleanup的调度频率。我个人的工程建议其实就三条。第一务必在压测环境模拟“随机插入随机删除随机更新变长字段”的混合负载纯顺序写入测出来很难暴露碎片问题。第二监控页面空闲空间的分布而不只是总量最大连续块大小的历史曲线能帮你提前预判整理风暴。第三理解你所用数据库的“追加vs原地更新”哲学——PostgreSQL上频繁更新变长字段会导致版本堆积SQL Server上转发指针链太长则要考虑重建聚集索引这些最终的根因都可以回到slotted page的物理布局上找到答案。说穿了slotted page不是什么复杂算法它就是在“固定大小物理页”和“无限变长的逻辑记录”之间强行插入的一层间接映射。理解它本质上是在理解存储引擎如何在不可预测的数据分布下维持高效、稳定、可恢复——这个能力才是内核工程师和只停留在SQL语法的开发者之间真正的分水岭。
RELATED

相关推荐

多媒体应用46-928(超极简的大点兵!!!)

多媒体应用46-928(超极简的大点兵!!!)

MIDI:16 通道,10 通道打击乐;3 种物理接口;OUT 接口行程编码 KKKKKBBBBTEEEEEEEEEOOOOOOOO:字符分组:K (5),B (4),T (1),E (9),O (8);每组占 2 字节 (1 字节长度 1 字节字符),共 5 …

📅 2026/10/19 2:05:28
Vue 3 深入学习笔记 07:v-model 与受控组件设计

Vue 3 深入学习笔记 07:v-model 与受控组件设计

Vue 3 深入学习笔记 07:v-model 与受控组件设计 一、为什么要学习 v-model? 在前面的学习中,我们已经掌握了组件设计与职责边界,知道了: 父组件通过 props 向子组件传递数据。子组件通过 emit 通知父组件发生了什么。共…

📅 2026/10/19 2:05:28
零基础秒转AMR,3大神器全解析

零基础秒转AMR,3大神器全解析

AMR这个格式, 它是专门针对移动通信和语音录音这块儿设计的轻量化音频方案。它就是为了把那些人声通话的场景、语音消息的场景, 还有设备录音的场景给优化好而存在的。它的文件体积是最小最小的, 压缩率也是高得出奇的。而且在兼容性方面, 它覆盖了从前老式的手机、功能机这些设…

📅 2026/10/19 2:05:28
MORE NEWS

更多资讯

📰

开源代码审查增强工具 open-code-review:从规则引擎到团队工程实践

做代码审查最难受的是什么?不是看不懂代码,而是你根本不知道从哪里看起。我记得早年间团队刚立 code review 规矩的时候,每次 PR 上来,审查人对着几百行 diff 从头翻到尾,翻到后面已经忘了前面看过啥;写代码…

📰

open-code-review:用AST和复杂度指标重塑代码评审流程

做代码评审这行当久了,你会碰到一种特别无奈的局面:代码评审明明挂在流程里,写着“必须通过”,可每到临近上线,大家基本都在赶时间,PR 一打开,评论几条低级问题,就算 reviews approv…

📰

安卓蓝牙示波器实战:从蓝牙SPP数据传输到ADC波形绘制的完整链路

简介:安卓蓝牙示波器资源包面向电子爱好者、嵌入式开发者和移动端工程师,提供一套将手机当作便携测量终端的完整参考方案,适合低成本信号检测与分析场景。硬件部分包含信号采集电路、ADC与蓝牙模块,压缩包内附电路原理图及dsPIC固…

📰

从零掌握Makefile:依赖规则、增量构建与多目录实战避坑指南

第一次接触Makefile的时候,我其实是拒绝的。当时手头的模拟项目X已经开始膨胀,二十几个源文件分了好几个目录,每次改完代码,我都是靠一条手打的gcc命令去重新编译,直到某次不小心把路径写错、把旧的.o文件和新的头文件…

📰

pv+tar:Linux命令行进度条与实时解压测速实战

你有没有遇到过这种场景:一条tar命令敲下去,终端安安静静,光标一闪一闪,你不知道它是在正常干重活,还是已经卡死。如果是几十 GB 的压缩包解压,或者把一整个项目目录打包上传备份,那种“眼看它起…

📰

开源代码审查方法论:CLI+Git+本地LLM实战指南

1. 项目概述:这不是一个“工具”,而是一套可落地的开源代码审查方法论open-code-review 这个名字乍看像某个 GitHub 仓库,但实际它代表的是一类正在快速演进的工程实践——用开源、透明、可审计的方式,把大语言模型(LL…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬