首页 / 资讯中心 / 文章详情

openGauss存储引擎源码解析:从数据页到元组,INSERT的完整内核旅程

openGauss存储引擎源码解析:从数据页到元组,INSERT的完整内核旅程 ★ FEATURED ARTICLE
一条INSERT语句从客户端发到openGauss到那一行数据真正落到磁盘上的数据页里中间到底发生了什么如果只看执行计划你会以为这只是SQL层的事可一旦把视角沉到存储引擎源码你会发现这里才是数据库最“物理”的地方——页、元组、行指针、事务号每一个概念都直接对应磁盘上的一串字节。这篇是本系列的存储引擎源码解析第二篇顺着上一篇的脉络我们把视线集中在行存表的数据组织方式和插入路径上把“一个元组从出生到被回收”这条主线彻底读透。适合正在啃openGauss源码的开发者也适合想搞明白数据库底层到底怎么存数据的同学。存储引擎源码解析这件事一旦开始就不是看热闹而是要回答一个很本质的问题那一行数据凭什么能被你查到1. 从一条INSERT语句到数据页存储引擎的完整链路入口1.1 源码边界在哪儿执行器与存储引擎的分工先说清楚一个很多人读源码时会犯迷糊的点SQL语句进来之后代码路径到底是怎么划分的客户端发来一条INSERTopenGauss先做词法分析、语法分析、生成执行计划然后进入执行器。执行器里的节点负责处理“怎么算”比如过滤条件、投影列、连接方式。但一旦碰到底层的“怎么存”它就撒手不管了把活儿交给存储引擎。存储引擎做的事情非常纯粹把一行记录放进一个数据页或者从数据页里把一行记录捞出来。它不关心你的WHERE条件是怎么优化的不关心你的JOIN是hash还是nestloop它只关心页、块、元组、事务号这些物理概念。在openGauss的源码里最常用、也最古老的那套行存实现被叫作Heap表对应的访问方法叫heap。你在代码里看到heap_insert、heap_update、heap_delete这些函数就是存储引擎对外暴露的核心接口。上篇我们聊过buffer pool、WAL日志和page/block的关系这篇就从heap_insert这个入口进去看看一条记录是怎么从执行器手中接过来最终变成页里的一个元组。1.2 为什么从行存表Heap源码进入最高效openGauss的存储引擎不止行存还有列存、内存表等。但如果你只打算花时间认真读透一部分源码我强烈建议从行存的Heap实现开始。原因有三第一行存表是OLTP场景下的主路径绝大部分线上业务的核心流量都打在它身上代码被踩得最狠边界情况处理得也最完善。第二Heap这套设计是从PostgreSQL内核演化过来的openGauss在建库之初就继承了整套数据结构。也就是说你一旦读懂了Heap再去读其他模块、甚至读其他同类数据库的内核代码都会有一种“似曾相识”的熟悉感。第三Heap的代码量相对集中核心链路就那么几条插入、读取、更新、删除、清理。把这几条链读通存储引擎的骨架就搭起来了。所以这一篇我们锁定的问题非常具体insert一条记录数据是怎么进入数据页的搞清楚这个问题之后后面读更新、删除、VACUUM都会轻松很多因为它们全都在围绕同一套数据结构做文章。2. 页是存储的第一公民PageHeaderData这些字段到底在管什么2.1 字段拆解一个页头都记录了哪些信息在openGauss里默认数据页大小通常是8KB或者16KB编译时决定。无论多大一个页从磁盘读进buffer pool之后最先映入眼帘的是一段固定长度的页头源码里叫PageHeaderData。整个存储引擎的一切操作本质上都在跟这个结构打交道。为便于阅读这里按字段语义整理成简化示意typedef struct PageHeaderData { PageXLogRecPtr pd_lsn; // 本页最后一次变更对应的WAL日志位点 short pd_checksum; // 页校验和用于检测页损坏 int pd_flags; // 页级标志位比如是否全页可见 LocationIndex pd_lower; // 行指针数组的结束偏移指向页内空闲区起点 LocationIndex pd_upper; // 元组数据的起始偏移指向空闲区终点 LocationIndex pd_special; // 特殊空间起始位置索引页会用到 uint16 pd_pagesize_version; // 页大小和版本信息 TransactionId pd_prune_xid; // 页内最早可能被清除的事务号 } PageHeaderData;这个结构体里最核心的就是pd_lower和pd_upper。它们决定了这个页还能塞多少数据也决定了页满时系统该做什么。pd_lsn同样重要。数据库崩溃恢复时会拿数据页上的pd_lsn跟WAL日志里的记录做对比如果日志位点已经覆盖了页上的改动说明这个页是干净的不需要回放如果页落后于日志就要从WAL里把这个页缺失的修改补回去。一句话pd_lsn是连接数据页和WAL日志的桥梁。2.2 pd_lower与pd_upper的双向挤压设计理解pd_lower和pd_upper最好的方式是把它想象成一个停车场的双向入口。页的空间被分成两段一段从页头往页尾方向增长存放的是行指针每个4字节另一段从页尾往页头方向增长存放的是真正的元组数据。空闲空间就是这两段之间的间隙也就是pd_upper - pd_lower。为什么这么设计如果所有数据都从一头往另一头顺序塞那么一旦中间出现删除导致的空洞想要腾出连续空间就得来回搬移数据。而双向挤压的设计让行指针和数据各自从两端生长中间留出的空闲区域是连续的。删除元组后清理进程可以很方便地把后面的数据往前挪把空间“压缩”回来。PageGetFreeSpace这个宏干的事情很简单就是用pd_upper减去pd_lower再减去一些对齐和预留的空间算出还能塞下多大的元组。读到这儿你可能想问行指针和数据为什么非要分开存答案在于更新的效率。后续VACUUM回收空间时只需要把元组数据移动行指针数组本身可以保持不变或仅做少量调整这样索引里记录的tid行ID大部分情况下不用跟着改。2.3 页初始化与空闲空间的计算逻辑一个新页被分配给表之后存储引擎会调用PageInit做初始化。它做的事情非常机械但很重要把页头结构体清零填好pd_pagesize_version然后把pd_lower设置为页头末尾的位置把pd_upper设置为页尾即页面大小pd_special一般也指向页尾。此时空闲空间是整个页只是页头占掉了一部分。插入元组时系统先根据元组计算总长度这个长度包括元组头、数据本身、NULL值位图以及对齐字节。然后判断这个长度是否小于当前页的空闲空间。如果放得下就把元组数据复制到pd_upper指向的位置pd_upper向页头方向回退同时往pd_lower指向的行指针数组里追加一个8字节的条目pd_lower向页尾方向前进。整个过程就是在做双向挤压。所以判断一个页是否“满”了条件其实就是pd_lower和pd_upper相遇或者间隙不足。源码里大量代码都在检查这个条件这也从侧面说明页结构在整个存储引擎里的核心地位。3. 元组与行指针HeapTupleData如何把一张表组织起来3.1 tid与LinePointer两级定位为什么是必须的页内部的组织解决了一个物理问题但要定位到“某一行的具体位置”还需要一套跨页的寻址逻辑。openGauss里每一行记录都有一个逻辑上的行ID源码里叫tidTuple ID由页号block number和页内偏移offset number组成。一个tid大概长6字节很像一个“门牌号”先找到第几页再找到页里的第几个行指针。为什么不能直接让tid指向元组数据的字节偏移因为元组数据是会被移动的。UPDATE产生新版本、VACUUM压缩空间都可能导致元组在页内换位置。如果索引和事务快照直接记录字节偏移数据一挪动所有指向它的东西就全部失效了。于是有了行指针这一层“间接定位”。行指针源码里叫LinePointer或者ItemIdData固定在页头那一侧每个占4~8字节。它记录了三个信息元组在页内的实际偏移、元组数据的长度、状态标志位。索引里存的是tidtid指向行指针行指针再指向真正的元组数据。这层间接关系是整个体系能稳定运行的关键。3.2 HeapTupleData结构体里几个关键成员从源码层面看一个“元组”不只是一段内存它被包装成一个HeapTupleData结构。核心成员有这几样t_len整个元组占用的字节数从元组头开始算起直到元组末尾。插入页时需要用它判断能否放进空闲空间。t_self这个元组自己的tid等于是它自己的门牌号。t_tableOid这个元组属于哪张表。在多表操作时这个字段用来快速判断元组来源。t_data真正指向元组头和数据区的指针。它指向的是一个HeapTupleHeaderData结构。读代码的时候别被这些东西绕晕。HeapTupleData更像是一个“信封”告诉你这封信多长、是谁的、放在哪儿真正的信纸内容在HeapTupleHeaderData里。3.3 HeapTupleHeaderData一行数据真正的大脑HeapTupleHeaderData才是元组最核心的部分它记录了一行数据的所有“身份信息”。typedef struct HeapTupleHeaderData { TransactionId t_xmin; // 插入该元组的事务ID TransactionId t_xmax; // 删除/锁定该元组的事务ID CommandId t_cid; // 插入/删除时的命令ID ItemPointerData t_ctid; // 指向自身或更新后的新版本位置 int16 t_infomask2; // 属性个数及NULL位图标记 uint16 t_infomask; // 元组状态标志位 uint8 t_hoff; // 元组头长度 bits8 t_bits[1]; // NULL值位图实际长度随属性数变化 } HeapTupleHeaderData;这里最关键的是t_xmin和t_xmax。数据库的并发控制、多版本、行级锁全都靠这两个字段撑起来。简单说t_xmin记录“谁生出了这个版本”t_xmax记录“谁把这个版本结束了”。t_ctid则指向当前元组的实际位置。当一个元组从未被更新过时它的t_ctid指向自己也就是“我就是最新版本”一旦被更新旧元组的t_ctid会指向新版本元组的位置形成一条版本链。这个设计我们在后面更新和锁的部分还会细讲。t_bits是NULL值位图。表里有很多列某些列可能是NULL。如果每个列都存一个NULL标志空间浪费很大。openGauss的做法是把所有列的NULL信息压缩成一个位图每一位对应一列。1表示这一列是NULL0表示非NULL。查询时通过HeapTupleHasNulls判断是否有NULL再按位去查。4. 插入一条记录的内核旅程heap_insert这条调用链上发生了什么4.1 从执行器到存储引擎的入口约定现在真正进入业务逻辑。INSERT语句在优化器阶段变成一个插入节点执行器运行到该节点时会调用ExecInsert。ExecInsert负责从上层拿到的数据构造一个元组slot然后把这个slot传给表访问接口。在openGauss沿用的这套体系里表访问接口最终会路由到行存引擎的heap_insert函数。它的原型大概长这样void heap_insert(Relation relation, HeapTuple tup, CommandId cid, int options, BulkInsertState bistate);参数里relation是目标表的描述符tup是已经组装好的元组cid是当前命令的IDoptions控制一些行为比如是否绕过WAL某些临时表场景下可以不开日志bistate则是用于批量插入的缓冲状态。走到heap_insert这一步就标志着数据正式进入了存储引擎的地盘。后面所有的操作都围绕着“把tup放进某个页”展开。4.2 RelationPutHeapTuple怎么帮元组找到落脚页heap_insert的核心工作是调用RelationPutHeapTuple把元组放进关系表的数据页。这个函数首先要解决一个问题放到哪个页openGauss维护了一个每事务级别的缓冲状态记录着刚刚插入过的页。如果当前事务已经往某个页里插过数据而这个页还有空余空间那就优先复用这个页。这能显著提高插入性能因为数据会被连续写入减少页的频繁切换也提高了后续顺序扫描的局部性。如果没有可复用的页系统就到表对应的Free Space Map空闲空间映射表里找FSM记录了每个页还有多少空闲空间专门用来帮插入过程快速定位“有位置”的页。FSM本身也是一棵大树状结构平时我们感知不到它但它在写入路径上几乎是无处不在的。找到页之后把页从buffer pool里锁住并固定住pin住然后调用PageAddItemExtended把元组放到页的空闲空间里。这个函数做的事情就是我们前面说的双向挤压把元组数据放在pd_upper那一侧把新行指针挂到pd_lower那一侧。整个过程会仔细检查剩余空间如果空间不够就返回错误并由上层决定是否要扩展新页。4.3 元组落页之后t_ctid赋值与WAL日志元组被放进页之后还有两件收尾的事。第一件事是设置t_ctid。新插入的元组此时还没有任何历史版本所以它的t_ctid被设置为指向自己语义是“当前元组就是最新版本”。我最初读源码时看到“指向自己”总觉得奇怪后来才意识到这是一个非常优雅的约定任何一行数据都可以通过t_ctid找到它的最新版本。如果它自己就是最新版那t_ctid就指向自己如果它不是t_ctid就指向链上的下一个版本。第二件事是写WAL日志。数据页被修改之后这个修改必须在日志里留下痕迹否则一旦系统崩溃内存里已经修改但还没来得及刷盘的页就会丢失。heap_insert会生成一条XLOG_HEAP_INSERT记录记录中包含插入的页、位置、元组内容等关键信息。真正落盘时系统会保证WAL日志先于数据页落盘这就是数据库崩溃恢复能力的根基。在这条路径上openGauss还做了不少性能优化。比如热路径上尽量减少锁竞争某些场景下把锁粒度降到页级别甚至缓冲区级别又比如批量插入时连续持有buffer避免频繁加锁解锁。读代码的时候你会看到很多为并发性能服务的小细节这些才是内核开发的真正难点。5. t_xmin与t_xmax一个元组头如何撑起MVCC和行级锁5.1 t_xmax的双重身份删除标记与锁标记刚才我们提到t_xmin和t_xmax现在专门展开讲。这是整个存储引擎里最精彩的设计之一。在MVCC机制下一个元组的生命周期由两个事务号标记。t_xmin表示“谁创建了这个版本”t_xmax表示“谁终结了这个版本”。当t_xmax有效时意味着存在一个事务已经删除或更新了这个元组。读到了这样的元组当前事务要根据自己的快照和事务状态判断到底该不该看到它。但t_xmax还有个更巧妙的用途它同时充当行级锁的标记。当某个事务想锁定一行记录时不需要像其他数据库那样维护一个庞大的锁表只需要把自己的事务ID写到那一行的t_xmax字段里同时在t_infomask里打上锁标志位即可。这样同一个字段既表示“这行被哪个事务终结了”又表示“这行正被哪个事务锁着”。判断逻辑也很简单如果锁还没释放别的想修改这行的事务就得等如果事务提交了锁自然就变成了删除标记。这个设计把并发控制信息直接内嵌到数据本身省掉了大量独立的锁管理结构。代价是VACUUM需要额外小心不能把正被锁定的元组当作普通旧版本清掉否则锁就丢了。5.2 可见性判断的三种典型场景明白了t_xmin和t_xmax之后可见性判断其实可以归结成一张表场景t_xmin状态t_xmax状态当前事务能否看到新插入且未提交等于或晚于当前快照无效不能已提交的插入同时已有删除未提交早于当前快照等于某个活跃事务能删除不可见已提交的插入删除也已提交早于当前快照早于当前快照不能这行已经“死”了真实的判断逻辑比这张表复杂得多涉及事务状态、子事务、级联回滚等边界情况。但核心思想就是一句话以当前事务的快照为坐标看t_xmin和t_xmax各自是否“已提交”。源码里的HeapTupleSatisfiesMVCC函数就是干这件事的它逐项检查这两个事务号的状态结合提交日志clog最终给出“可见、不可见、已被删除”的结论。这也是为什么快照机制如此重要。一个事务开启时快照会记录当时所有活跃事务的上下界。判断可见性时事务号在活跃列表里的记录一律当作“还没提交”这样就保证了同一事务内多次查询看到的数据是一致的。5.3 更新链的导航t_ctid的下一步指向理解UPDATE操作就能彻底搞懂t_ctid的价值。一条UPDATE在存储引擎里不是“就地修改”而是逻辑上拆成两步给旧元组打上删除标记再插入一个包含新数据的新元组。旧元组的t_xmax被设置为当前事务IDt_ctid指向新元组的物理位置新元组的t_xmin依然是当前事务IDt_ctid则指向自己。旧元组和新元组通过t_ctid构成一条版本链。这条链是MVCC的骨架。一个事务沿着t_ctid一路找过去只要发现某个版本的t_xmax对当前事务不可见就继续往链的下一个版本走直到找到对自己可见的那一版。这也是为什么索引只需要维护指向旧版本根节点的tid即可——不管版本链拉多长总是能从根部出发找到正确版本。风险也随之而来如果一个行被更新了成百上千次版本链会越来越长每次访问都得遍历整条链。这对读性能是灾难。所以后面必须有一个机制把已经没用的旧版本清理掉这就是VACUUM的活。6. 旧版本处理与空间回收VACUUM到底能清掉什么6.1 什么才算dead tuple判断一个元组能不能被清理标准看起来很直接没有任何活跃事务还能看到它。具体到实现层面一个元组成为dead tuple通常有两种情况。第一种是DELETE操作元组被标记为已删除且删除事务已经提交第二种是UPDATE操作留下的旧版本只要新版本已经存在且旧版本对所有活跃事务不可见旧版本就再也没有读取价值了。但“不可见”不是一个静态判断。如果有长事务一直开着它的快照可能还停在很早的时间点那么这个时间点之前的旧版本对它来说依然是可见的。这时候VACUUM如果强行清理就会破坏该事务的读一致性。所以系统引入了一个关键概念OldestXmin即当前所有活跃事务中最小的那个事务号。6.2 OldestXmin决定了清理的合法边界VACUUM做清理时会先计算出OldestXmin。只有t_xmax小于OldestXmin的元组才说明它对应的事务早已结束并且结束得比所有活跃事务都要早可以安全清理。这个逻辑直接解释了运维中常见的表膨胀问题。如果线上有一个大事务一直没提交OldestXmin就被它钉在一个非常早的时间点VACUUM能清理的范围被无限压缩。老版本元组越积越多表的物理空间持续膨胀查询性能随之下降。这也就是为什么“长事务是数据库杀手”这句话在存储引擎源码层面是站得住脚的。做过一次表膨胀排查之后这个机制在我脑子里就再也忘不掉了。当时某张表数据量不大但物理文件大了好几倍排查下来发现就是一个连接长时间开着事务没提交导致VACUUM一直拿不到有效的OldestXmin。所以读源码不只是懂原理它真的能帮你解释生产环境里那些最诡异的现象。6.3 autovacuum触发与手动维护的实操建议openGauss默认开启了autovacuum。它有两个触发门槛一个是表的更新或删除元组数量超过阈值另一个是残留死元组数量与总元组数之比达到阈值。达到门槛后后台进程会自动对该表执行VACUUM把dead tuple清理掉并更新FSM信息。但autovacuum不是万能的。有些场景需要手动介入比如大表刚做了一次大规模DELETE残留的死元组数量巨大autovacuum还在慢慢悠着来这时候手动执行VACUUM FULL或者VACUUM会更及时。不过要提醒一句VACUUM FULL会重写整张表并获取排他锁在线业务高峰期执行可能引发更严重的问题操作前务必评估业务窗口。VACUUM的清理过程在页级别做的事情其实就是把pd_lower和pd_upper之间的空隙重新整理。死元组的数据被清除剩余活元组向页的某一边紧凑排列行指针数组重新填充页头的空闲空间变大。这一个页被救活之后新的INSERT就能再次使用它。我自己啃代码时最深的体会是存储引擎的代码不适合一口气从头读到尾而是要以“一条数据的一生”为线索从PageInit出发经过heap_insert的插入看到可见性判断最后到VACUUM的回收把整条生命周期串起来。一旦建立起“元组即版本、版本即数据、页即容器”这个心智模型再回来看那些纷繁复杂的函数思路会清晰很多。这一篇把“插入”这条链路和它依赖的数据结构讲完了。下一篇可以顺理成章地往“更新与删除路径”上走看看MVCC的写写冲突、行级锁在源码里具体是怎么等待和唤醒的。存储引擎有意思的地方就在于你读到的每一个机制都能在真实的生产问题里找到对应。
阅读完成 · 觉得有帮助?
咨询建站