openGauss数据库源码解析系列文章——存储引擎源码解析(四)
上一篇我們詳細講述“3. astore元組多版本機制”、“4.astore訪存管理”及“5.astore空間管理和回收”相關內容。本篇我們將繼續為小伙伴們帶來“4.2.4 ustore”的詳細介紹。
4.2.4 ustore
ustore屬于In-place Update更新模式,中文意思為:原地更新,是openGauss內核新增的一種存儲模式。openGauss內核當前使用的行引擎采用的是Append Update(追加更新)模式,該模式在INSERT、DELETE、HOT UPDATE(頁面內更新)的場景下有較好的表現。但對于非HOT UPDATE場景,垃圾回收不夠高效。
In-place Update存儲模式提供“原地更新”能力,主要思路是將最新版本的“有效數據”和歷史版本的“垃圾數據”分離存儲。將最新版本的“有效數據”存儲在數據頁面上,而單獨開辟一段undo(回滾)空間,用于統一管理歷史版本的“垃圾數據”,因此數據空間不會由于頻繁更新而膨脹,垃圾回收效率更高。通過NUMA-aware的undo子系統設計,使得undo子系統在多核平臺上高效擴展。同時通過對元組和數據頁面結構的重新設計,減少存儲空間的占用。采用多版本索引技術,解決索引膨脹問題,徹底去除autovacuum(垃圾清理線程)機制,提升存儲空間的回收復用效率。
1. 整體框架及代碼概覽
數據庫中數據處理的本質是在保證ACID的基礎上支持盡量高的并發查詢。這種狀況下,并發控制、頁面多版本控制以及頁面存儲結構相互耦合在一起,數據庫存儲引擎需要進行整體設計從而在高并發的狀況下保證各個事務處理看到類似串行執行的效果。
在整個技術體系中多版本控制用來提升讀寫并發能力,按照多版本排列方式可以分為兩類。
(1)Oldest to New,即版本按照從最老到最新的方式進行鏈接,當一個事務訪問該元組時,先看到這個元組最老的版本,同時使用對應的可見性判斷機制,看是否是自己可見的版本,如果不是則沿著版本鏈條繼續往后看較新的版本是不是自己需要的。
(2)Newest to Old,即版本按照從最新到最老的方式進行連接,當一個事務訪問該元組時,先看到這個元組最新的版本,同時使用對應的可見性判斷機制,看是否是自己可見的版本,如果不是則沿著版本鏈條繼續往后看較老的版本是不是自己需要的。
在上面的描述中又引出一個設計點,如何組織新老數據,有如下幾種方式。
(1) 將新數據和老數據放在同樣的頁面內,即每個數據頁內放置著各個元組的新老數據,在需要進行不可見數據版本回收的時候需要遍歷所有的頁面。
(2) 將最新數據和老數據分離存儲,在實際的數據頁面內放置最新版本數據,所有的老版本數據都集中存儲,新版本數據通過一個指針指向老版本所在的數據區域,當進行不可見老版本數據回收的時候只要掃描老版本集中存放的位置即可。
當新老數據分別存儲的時候又引出第三個設計點,在對同一個頁面或者元組反復讀取時,是否要還原對應的頁面在數據緩沖區中,這個設計點有如下幾種方式。
(1) 訪問舊元組所在的頁面時,還原該頁面,并將該頁面的舊版本放入數據緩沖區中,節省一定時間內其他線程多次訪問該版本頁面帶來的合成開銷。弊端是占用更大的內存空間,同時緩沖區淘汰管理在原始LRU(Least Recently Used,最近最少使用算法)基礎上同時要考慮頁面版本。這種方式對應PCR(Page Consistency Read,頁面一致性讀),其本質的設計理念是空間換時間。
(2) 訪問元組時,沿著版本鏈還原該元組,直到找到自己對應的版本。這種方式對于短時間訪問沖突不高的場景能夠降低內存使用,但如果短時間內高頻訪問一個頁面內的元組,則每次都會遍歷版本鏈造成訪問效率低下。這種方式對應RCR(Row Consistency Read,行一致性讀)。
按照上面的描述,整個多版本控制設計分為三個維度,如圖4-10、表4-15所示。
圖4-10 多版本控制設計維度
表4-15 多版本控制設計維度
| 版本存儲方式 | 集中存儲、分離存儲 |
| 版本鏈組織方式 | Oldest to New、Newest to Old |
| 老版本管理方式 | 1、RCR,2、PCR |
當前openGauss在版本存儲方式、版本鏈組織方式上的設計選擇是集中存儲 + Oldest to New,在清理數據舊版本時需要遍歷所有的頁面找到不可見的元組版本然后清除。商用及開源的常見數據庫的多版本控制設計三維度選擇如表4-16所示。
表4-16 當前數據庫多版本控制設計選擇
常見數據庫 | 分離存儲 | Newest to Old | PCR |
集中存儲 | Oldest to New | RCR | |
分離存儲 | Oldest to New | RCR |
不同的多版本控制設計都不能做到盡善盡美,都有些不足之處,相關的缺點如下。
(1)多核系統上擴展性較差,不支持多核處理器的NUMA感知;
(2)依賴于Vacuum進行老版本回收,后臺線程定期清理;
(3)缺乏對索引多版本,全局索引、閃回等功能的支持;
(4)PCR管理方式,內存管理開銷較大。
openGauss的ustore存儲模式最大程度結合各種設計的優勢,在多版本管理上的架構設計采取的組合如表4-17所示。
表4-17 ustore在多版本管理上的架構設計
維度 | 架構設計選擇 |
版本存儲方式 | 分離存儲 |
版本鏈組織方式 | Newest to old |
老版本管理方式 | PbRCR(Page Based RCR,基于頁面的行一致性讀) |
同時為了事務能夠跨存儲格式查詢,并復用現有備份、恢復、升級等能力,openGauss定義如下的融合引擎架構設計原則。
(1) 一套并發控制系統。
(2) 一套系統表管理系統。
(3) 一套日志管理系統。
(4) 一套鎖管理系統。
(5) 一套恢復系統。
ustore架構如圖4-11所示。
ustore和astore共用事務管理、并發控制、緩沖區管理、檢查點、故障恢復管理與介質管理器管理。ustore主要功能模塊如表4-18所示。
表4-18 ustore主要功能模塊
ustore表存取管理 | 向上對接SQL引擎,提供對ustore表的行級查詢、插入、刪除、修改等操作接口,向下根據ustore表頁間、頁內結構,以及ustore表元組結構,完成對ustore表文件的遍歷和增刪改查操作 | 主要在“src/gausskernel/storage/access/ustore”目錄(單表文件管理)下 |
ustore索引存取管理 | 向上對接SQL引擎,提供對索引表的行級查詢、插入、刪除等操作接口,向下根據索引表頁間、頁內結構,以及索引表元組結構,完成對指定索引鍵的查找和增刪操作 | 抽象框架代碼在“src/gausskernel/storage/access/ubtree”目錄下 |
ustore表頁面結構 | 包括ustore表元組在頁面內的具體組織形式,在頁面內插入元組操作、頁面整理操作、頁面初始化操作等 | 主要代碼在“src/gausskernel/storage/access/ustore/knl_upage”目錄中 |
ustore表元組結構 | 包括ustore表元組的結構、填充、解構、修改、字段查詢、變形等操作 | 主要代碼在“src/gausskernel/storage/access/ustore/knl_utuple.cpp”文件中 |
Undo記錄結構 | 包括undo記錄的結構、填充、編碼、解碼等操作 | 主要代碼在“src/gausskernel/storage/access/ustore/undo”目錄中 |
多版本索引 | 包括 ustore 專用多版本索引 ubtree 的頁面結構、查詢、修改、可見性檢查、垃圾回收等模塊 | 主要代碼在“src/gausskernel/storage/access/ubtree”目錄中 |
2. 頁面元組結構
1) 元組結構
本節介紹行存儲引擎ustore表的頁面元組結構。
元組結構的定義如下:
typedef struct UHeapDiskTupleData {ShortTransactionId xid;uint16 td_id : 8, locker_td_id : 8;uint16 flag;uint16 flag2;uint8 t_hoff;uint8 data[FLEXIBLE_ARRAY_MEMBER]; } UHeapDiskTupleData;該結構體只是元組頭部的定義,真正的元組內容跟在該結構體之后,距離元組頭部起始處的偏移由t_hoff成員保存。上面元組頭部結構體部分成員信息同時也構成了該元組的系統字段(字段序號小于0的那些字段)。對各個結構體成員的含義說明如下。
(1) flag,元組屬性掩碼。包含是否有空字段標記、是否有外部TOAST標記、是否有變長字段標記、指定的事務槽位是否已被重復使用標記,以及更新、刪除、鎖等標記。
(2) flag2,元組另一個屬性掩碼。包含元組中字段個數。
(3) t_hoff,元組數據距離元組頭部結構體起始位置的偏移。
(4) data,字段的NULL值bitmap,每個字段對應一個bit位,因此是變長數組。
ustore元組頭部比astore元組頭部小一半,因此在相同大小的頁面上,ustore可以放置更多的元組。
在內存中,上述元組結構體使用時被嵌入在一個更大的元組數據結構體中,除了保存元組內容的disk_tuple成員之外,其他的成員保存了該元組的一些其他系統信息,并構成了該元組剩余的一些系統字段內容,定義如下:
該結構體幾個主要成員的含義如下。
(1) disk_tuple_size,元組長度。
(2) ctid,元組所在頁面號和頁面內元組指針下標。
(3) table_oid,該元組屬主表的OID。
常用的元組操作接口和說明如表4-19所示。
表4-19 常用的元組操作接口
| UHeapFormTuple | 利用傳入的、各個元組字段的值數組,生成一條完整的元組,一般用于插入操作 |
| UHeapDeformTuple | 利用傳入的完整元組以及各個字段的類型定義,解構各個字段的值,生成值數組,一般用于更新前的準備工作 |
| UHeapFreetuple | 釋放一條元組對應的內存空間 |
| UHeapCopyTuple | 復制一條完整的元組,包括元組頭和元組內容 |
| UHeapSlotGetAttr | 獲取一條元組中指定的用戶或系統字段值 |
| UHeapGetSysAttr | 獲取一條元組中指定的系統字段值 |
| UHeapCopyHeapTuple | 從ustore槽位構造一條astore元組 |
| UHeapToHeap | 將一條ustore元組轉換為一條astore元組 |
| HeapToUHeap | 將一條astore元組轉換為一條ustore元組 |
2) 頁面結構
ustore與astore相同,在openGauss中也使用默認的8kB頁面。其結構如圖4-12所示。
圖4-12 ustore引擎頁面結構示意圖
在一個頁面中,頁面頭部分對應的UHeapPageHeaderData結構體存儲了整個頁面的重要元信息。UHeapPageHeaderData之后有一個共享的頁內事務目錄(Transaction Directory,TD),對應元組指針變長數組。元組指針變長數組的每個數組成員存儲了頁面中從后往前的、每個元組的起始偏移和元組長度。如圖4-12所示,真正的元組內容從頁面尾部開始插入,向頁面頭部擴展;相應地,TD插槽目錄與記錄每條元組的元組指針從頁面頭定長成員之后插入,往頁面尾部擴展。這樣整個頁面中間就會形成一個空洞,以供后續插入的元組和元組指針使用。每一個ustore表里的一條具體元組都有一個全局唯一的邏輯地址(和astore表里的元組相同),它由元組所在的頁面號和頁面內元組指針數組下標組成。
頁面頭具體結構體定義如下:
其中各個成員的含義如下。
(1) pd_lsn:該頁面最后一次修改操作對應的預寫日志位置的下一位,用于檢查點推進和保持恢復操作的冪等性。
(2) pd_checksum:頁面的CRC校驗值。
(3) pd_flags:頁面標記位,用于保存各類頁面相關的輔助信息,如頁面是否有空閑的元組指針、頁面是否已滿等。
(4) pd_lower:頁面中間空洞的起始位置,即當前已使用的元組指針數組的尾部。
(5) pd_upper:頁面中間空洞的結束位置,即下一個可以插入元組的起始位置。
(6) pd_special:頁面尾部特殊區域的起始位置。該特殊位置位于第一條元組記錄和頁面結尾之間,用于存儲一些變長的頁面級元信息,如索引的輔助信息等。
(7) pd_pagesize_version:頁面的大小和版本號。
(8) potential_freespace:頁面中已被刪除和更新的元組的潛在空間。
(9) td_count:共享的頁內事務信息描述插槽的數量。
(10) pd_prune_xid:頁面清理輔助事務號(64位),通常為該頁面內現存最老的刪除或更新操作的事務號,用于判斷是否要觸發頁面級空閑空間整理。
(11) pd_xid_base:該頁面內所有元組的基準事務號(64位)。該頁面所有元組實際生效的64位XID事務號由pd_xid_base(64位)和元組頭部的XID成員(32位)相加得到。
(12) pd_multi_base:類似pd_xid_base。當對元組加鎖時,會將持鎖的事務號寫入元組中,該64位事務號由pd_multi_base(64位)和元組頭部的XID(32位)相加得到。
頁面的主要管理接口如表4-20所示。
表4-20 頁面管理接口函數
| UPageInit | 初始化一個新的ustore頁面 |
| UPageAddItem | 在頁面中插入一條新的元組 |
| UHeapPagePruneOptPage | 頁面空閑空間整理 |
為了節省每個元組存儲空間,元組頭部UHeapDiskTupleData采用32位元組XID的組合設計方式。64位的pd_xid_base和pd_multi_base儲存在頁面上,元組上儲存32位的XID。頁面上pd_xid_base和pd_multi_base也需要通過額外的邏輯進行維護:同一個頁面中所有元組實際的64位XID,一定要在pd_xid_base和pd_xid_base+232之間,所以如果新寫入的事務號和頁面上現有任意一個元組的XID事務號差距已經超過232,那么需要嘗試對現有元組進行基線移位操作,更新pd_xid_base和pd_multi_base。
3)事務目錄
事務目錄是一種常用的共享資源。它可以為數據頁上的元組(tuple)鏈接相應的事務表(Transaction Table)及undo子系統中的undo頁面。數據庫中的每個表可以自定義事務目錄的數量,并可以復用那些已完成事務占據的事務目錄。
每個數據頁默認會有4個事務目錄。根據并發需求的不同,事務目錄的數量可設置為2到128之間的任意值。在使用CREATE TABLE命令創建表時添加了一個新的選項INIT_TD以聲明所需的事務目錄數量:
當需要為新事務目錄留位置時,系統會先查找當前頁面中是否有空事務目錄。若無空事務目錄,系統將遍歷事務目錄列表來尋找可以復用的條目。條目是否可以復用取決于與該條目關聯的事務的狀態。
通常可以復用那些與已凍結或已中止的事務關聯的事務目錄。
(1) 對于已經凍結的XID,并復用該事務目錄。
對于astore而言,凍結的XID代表著事務在所有的會話中都已經不再活躍。
而在ustore中,僅當一個事務創建的所有的回滾記錄都被丟棄后,或者說沒有其他的Snapshot需要再觀察該事務創建的元組歷史版本(tuple version)時,才將該XID視為凍結。ustore中的undo回收進程會維護一個oldestXidInUndo變量,系統將通過比較XID與該變量來確定XID是否含有回滾記錄。如果XID < oldestXidInUndo,代表所有該XID產生的回滾記錄都已經被丟棄。
(2) 對于已中止的事務,在該事務被回滾后,系統才會復用相應的事務目錄條目。
(3) 對于已提交的事務,系統將不會無效化回滾記錄地址,這樣可以保證undo鏈的完整性。
當沒有事務目錄可以復用時,事務目錄將會自動擴容以容納更多的條目。需注意的是,事務目錄的后面跟隨著元組指針區,在擴展時,首先需要將row pointer array向右挪動來騰出空間。擴展后,新的事務目錄條目將會在先前的事務目錄條目之后依序添加。設計上,允許事務目錄的容量最多擴至頁面大小的約25%,即約100個事務目錄(在8kB大小的頁面中,約20Bytes/事務目錄)。目前,系統將以每次增加兩個事務目錄的方式逐步擴容,最多擴至128個事務目錄。ustore暫不支持收縮事務目錄空間。
在擴容時,可以增加的總條目數也取決于當前頁面中的可用空間。有時,頁面中的總剩余空間并不能支持事務目錄的擴容。此時若當前操作為INSERT或MULTI-INSERT,事務將會索取一個新的頁面來進行操作。若操作為UPDATE或DELETE,事務將等待10毫秒后重試獲取事務目錄。Lock timeout設置可以控制獲取事務目錄的最大等待時間。在多由短事務組成的工作負載中,等待是可以接受的。
PG stats會報告事務目錄等待等信息,以方便監測系統及描述工作負載。
事務目錄申請的過程(UHeapPageReserveTransactionSlot函數)如圖4-13所示。
圖4-13 事務目錄申請處理流程
如果當前事務需要申請一個新的事務目錄,且系統中不存在空的事務目錄時,系統會遍歷所有事務目錄并尋找可復用的事務目錄。
(1) 首先系統會遍歷事務目錄,尋找XID < oldestXidInUndo的事務目錄。這些條目將被視為已凍結。
(2) 接著系統會遍歷目標頁面上的元組。
① 系統把已刪除的元組標記為死亡,其余的標記為閑置。
② 如果系統發現元組還在活躍狀態,且相應的TD條目存在于步驟(1)給出的凍結列表之中,系統會把該事務目錄設置為UHEAPTUP_SLOT_FROZEN(凍結)。
③ 設置為凍結之后,事務目錄中的XID及Undo指針會被無效化。
(3) 如果上述的凍結操作并未產生可用的槽位,系統會遍歷事務目錄并尋找與已提交或已中止事務關聯的條目。這些條目在滿足一定條件的狀況下可被復用。
(4) 遍歷目標頁面上的元組。
① 如果系統發現元組關聯的事務目錄存在于步驟(3)給出的已提交列表中,系統就把該TD條目的flag設為UHEAP_INVALID_XACT_SLOT(無效)。
② 此外,這些事務目錄的XID被重設為無效XID。但為了維護undo鏈的完整,undo指針將被保留。
(5) 如果并未找到與已提交事務關聯的事務目錄,最后將尋找與已中止事務關聯的事務目錄。
(6) 遍歷與已中止事務關聯的事務目錄:對于每個事務目錄,沿著undo鏈執行相關的undo操作。
(7) 如果并未找到事務目錄,擴展事務目錄。
(8) 返回結果。
3. 回滾段設計與MVCC
1) 回滾段
舊版本數據會集中在回滾段的undo目錄中,為了減少讀寫沖突,舊版本數據(回滾記錄)采用追加寫的方式寫入數據目錄的undo目錄下。這樣舊版本數據的讀取和寫入不會發生沖突,同一個事務的舊版本數據也會連續存放,便于進行回滾操作。為了減少并發寫入時的競爭,undo目錄空間被劃分成多個邏輯區域(UndoZone,回滾段邏輯區域)。線程會在自己的邏輯區域上進行分配,與其他線程完全隔離,從而寫入舊數據分配空間時就不會有額外的鎖開銷。UndoZone還可以按照CPU的NUMA核進行劃分,每個線程會從當前的NUMA核上的UndoZone進行分配,進一步提升分配效率。在分配undo空間時會按照事務粒度進行記錄,舊版本數據一旦確認沒有事務進行訪問,就會進行回收。
為了在回滾段的空間尋址,回滾記錄使用8字節的指針來進行尋址,如圖4-14所示。
圖4-14 回滾記錄尋址指針
其中各個字段的含義如下:
(1) zoneId:占用20bit,表示邏輯區域的ID。
(2) blockId:占用31bit,表示塊號,默認為8k。
(3) offset:占用13bit,表示塊內偏移。
舊版本的數據采用回滾記錄的格式存入回滾段中,其中回滾記錄的格式如下所示:
其中,除了rawdata_代表了舊版本數據,其他成員均為結構體,下面對每個結構體分別進行說明。
whdr_成員由下面的結構組成:
各個字段的含義如下。
(1) xid:生成此回滾記錄的事務ID,用于檢查事務的可見性。“2)MVCC”小節有介紹。
(2) CID(Command ID,命令ID):生成此回滾記錄的命令ID,用于判斷可見性。
(3) reloid:relation對象的ID,回滾時需要。
(4) relfilenode:relfilenode對象的ID,回滾時需要。
(5) utype:操作類型,像UNDO_INSERT、UNDO_DELETE、UNDO_UPDATE等。
(6) uinfo:控制字段,用來判斷后續的結構是否存在,用來減少回滾記錄的占用空間。
wblk_成員由下面的結構組成:
(1) blkprev:指向同一個block前一條回滾記錄,用于回滾和事務可見性。“2)MVCC”小節有介紹。
(2) blkno:block number(塊號)。
(3) Offset:修改的tuple在row pointer中的偏移。
wtxn_成員由下面的結構組成。
prevurp:當一個事務的回滾記錄跨越兩個UndoZone時,后續的回滾記錄使用此指針指向前一條回滾記錄。
wpay_成員由下面的結構組成。
payloadlen:rawdata_的長度。
wtd_成員由下面的結構組成。
oldxactid:舊版本數據里事務目錄的事務ID。
wpart_成員由下面的結構組成。
partitionoid:分區表的分區對象OID。
wtspc_成員由下面的結構組成。
tablespace:表空間的OID。
回滾段使用事務目錄來記錄每個事務分配的undo空間,便于事務回滾和回收。事務發生回滾時,會讀取事務目錄中記錄的undo空間的起始位置,再讀取undo空間中的回滾記錄進行回滾操作,其中回滾記錄中的字段如下:
(1) xactId:事務ID。
(2) startUndoPtr:事務分配的undo空間開始位置。
(3) endUndoPtr:事務分配的undo空間結束位置。
(4) info_:標記值,如事務回滾狀態。
(5) dbId:數據庫對象ID。
回滾段提供分配undo空間和更新事務目錄的接口,主要接口如表4-21所示。
表4-21 回滾段主要接口
接口名 | 含義 |
AllocateUndoSpace | 為回滾記錄分配undo空間 |
UpdateTransactionSlot | 更新事務目錄 |
以ustore的刪除操作為例,undo空間分配流程如下。
(1) UheapDelete作為ustore的刪除接口,會調用UHeapPrepareUndoDelete函數準備回滾記錄(undo record)。UHeapPrepareUndoDelete函數會填充回滾記錄的各個字段(其中舊數據會設置到回滾記錄的raw data字段上),再調用PrepareUndoRecord函數分配undo空間。PrepareUndoRecord函數調用“undo::AllocateUndoSpace”函數分配undo空間,再讀取對應的回滾記錄到緩沖池中。AllocateUndoSpace函數不僅會為回滾記錄分配空間(使用“UndoZone::AllocateSpace”函數),如果是事務的第一條回滾記錄,還會調用“UndoZone::AllocateSlotSpace”函數為事務目錄分配空間。AllocateSpace函數會進行判斷,如果回滾記錄超過當前undo file的大小,就擴展當前的undo file,AllocateSlotSpace函數的邏輯類似。
(2) UheapDelete函數調用InsertPreparedUndo函數,將準備好的回滾記錄追加寫到緩沖池中的回滾段頁面。
(3) UheapDelete函數調用UpdateTransactionSlot,記錄下該事務分配的undo空間起始、事務ID、數據庫ID。如果是事務的第一次更新,會從事務目錄空間分配新的事務目錄再進行更新。
undo空間需要回收回滾記錄來保證undo空間不會無限膨脹,一旦事務id小于當前快照中最小的Xmin(oldestXmin),回滾記錄中的舊版本數據就不會被訪問,此時就可以對回滾記錄進行回收。
如前述描述undo空間中的回滾記錄按照事務ID遞增的順序存放在UndoZone中,回收的條件如下所示。
(1) 事務已經提交并且小于oldestXmin的undo空間可以回收。
(2) 事務發生回滾但已經完成回滾的undo空間可以回收。
圖4-15 undo回收過程
如圖4-15所示,UndoZone1中回收到小于oldestXmin的已提交事務16068,UndoZone2中回收到16050,UndoZone m回收到16056。而UndoZone n回收到事務16012,而事務16014待回滾但未發生回滾,因此UndoZone n回收事務id上限只到16014。其他zone的上限是oldestXmin,oldestXidInUndo會取所有undozone上的上限最小值,因此oldestXidInUndo等于16014。undo回收主要函數如表4-22所示。
表4-22 undo回收主要函數
函數名 | 操作含義 |
UndoRecycleMain | 回收線程的入口函數,會在每個zone上調用RecycleUndoSpace函數 |
RecycleUndoSpace | 按照前述條件回收undo空間,記錄日志 |
2) MVCC
ustore的可見性檢查和astore類似,將快照CSN和元組刪除和插入事務的CSN進行比較,判斷元組是否可見。ustore和astore使用同一套事務管理機制和快照管理機制。
ustore和astore最大的區別在于astore會在頁面上保留舊版本數據,而ustore在將舊版本數據放到回滾段統一存放。在需要獲取舊版本數據時,astore可以直接從tuple的頭部讀取到元組的插入和刪除的事務號(XID),來判斷元組的可見性。但是ustore需要從回滾段里讀取舊版本的事務信息,來判斷舊版本是否可見。由于從回滾段中讀取舊版本數據存在相對昂貴的開銷,ustore通過一系列的優化手段來避免從回滾段中讀取舊版本數據。
ustore在獲取元組時,會先檢查對應的事務目錄。事務目錄分成有效和無效兩種。當事務目錄是有效的,ustore直接就會得到元組上最新的事務。
如果事務目錄被凍結(FROZEN),意味著元組已經在所有的事務中都會可見。如果事務目錄中的事務id小于oldestXidInUndo,意味著元組已經足夠舊在所有事務中都可見。同時會把事務目錄置成凍結,來加速后續的查詢。
如果元組被標記有一個無效事務目錄,意味著修改元組的事務已經提交,并且比當前的事務目錄中的事務舊。此時ustore會使用事務目錄中的事務進行可見性判斷。如果可見,意味著修改元組的事務更已經可見,就不需要從undo目錄中再讀取事務信息。
圖4-16 元組查詢過程
元組不可見的場景,ustore會從undo目錄中讀取回滾記錄中的舊版本數據查找元組。例子如圖4-16所示。查找tbl表中c1=1的數據項,從索引中讀取到數據項位于block 1和offset 2,使用UHeapTupleFetch函數再從block 1中查詢到元組,需要判斷該元組的可見性。
(1) 從元組的TD讀到ITL2,和astore類似,根據CSN的大小,判斷TD2中的XID不可見,需要使用GetTupleFromUndo函數讀取回滾記錄。
(2) GetTupleFromUndo函數調用GetTupleFromUndoRecord函數讀取回滾記錄,使用InplaceSatisfyUndoRecord函數判斷其中的block 1和offset 2是滿足要求的元組。但是XID=1610可以判斷出當前頁面的tuple不可見,ustore繼續查詢更老的版本。由于舊元組的TD 1和當前的TD 2不一致,使用UHeapUpdateTDInfo從TD 2 undo鏈條進行切換,根據舊元組的TD 1找到當前的undo指針找到前一次修改。
(3) 再次讀取到回滾記錄,其中的block 1和offset 1并非要找的元組,ustore繼續查詢更老的版本,根據blkprev指針讀取前一次修改。
(4) 讀取到回滾記錄,其中的block 1和offset 3并非要找的元組,ustore繼續查詢更老的版本,根據blkprev指針讀取前一次修改。
(5) 讀取到回滾記錄,其中的block 1和offset 2是要求的元組,ustore判斷可見性。根據CSN的大小,事務可見。因此前一次命中的元組可見,即(1, abc)可見,因此查找到元組的c2等于abc。
4. 多版本索引
在openGauss中實現了多版本索引ubtree,是專用于ustore的B-Tree索引變種,相比原有的B-Tree索引有如下差異點。
(1) 支持索引數據的多版本管理及可見性檢查,能夠自主鑒別舊版本元組并進行回收,同時索引層的可見性檢查使得索引掃描(Index Scan)及僅索引掃描(Index Only Scan)性能有所提升。
(2) 在索引插入操作之外,增加了索引刪除操作,用于對被刪除或修改的元組對應的索引元組進行標記。
(3) 索引按照key + TID的順序排列,索引列相同的元組按照對應元組的TID作為第二關鍵字進行排序。
(4) 添加新的可選頁面分裂策略“insertpt”。
ubtree實現了索引訪問接口所要求的全部接口,如表4-23所示:
表4-23 ubtree訪問接口函數
aminsert | ubtinsert | 插入一個索引元組 |
ambeginscan | ubtbeginscan | 開始一次索引掃描 |
amgettuple | ubtgettuple | 獲取一個索引元組 |
amgetbitmap | ubtgetbitmap | 通過索引掃描獲取所有元組 |
amrescan | ubtrescan | 重新開始一次索引掃描 |
amendscan | ubtendscan | 結束索引掃描 |
ammarkpos | ubtmarkpos | 標記一個掃描位置 |
amrestpos | ubtrestpos | 恢復到一個掃描位置 |
ammerge | ubtmerge | 合并多個索引 |
ambuild | ubtbuild | 建立一個索引 |
ambuildempty | ubtbuildempty | 建立一個空索引 |
ambulkdelete | ubtbulkdelete | 批量刪除索引元組 |
amvacuumcleanup | ubtvacuumcleanup | 索引后置清理 |
amcanreturn | ubtcanreturn | 是否支持 Index Only Scan |
amcostestimate | ubtcostestimate | 索引掃描代價估計 |
amoptions | ubtoptions | 索引選項 |
此外,還實現了新增的的索引刪除函數UBTreeDelete。
1) 索引頁面組織
多版本索引層次結構與B-Tree索引基本相同,非葉子節點與B-Tree索引保持一致,僅頁尾的Special字段有所不同。ubtree中的Special字段UBTPageOpaqueDataInternal如下所示:
其中last_delete_xid與activeTupleCount用于索引的自治式回收,會在ustore中的“6. 空間管理和回收”一節詳細講解。
通過xid_base字段,頁面上的XID可以僅儲存基于該xid_base的一個32位偏移(Offset),節省XID存儲的空間開銷。實際的XID為頁面上的xid_base加上存儲的XID(也就是偏移)得到。
多版本中的葉子頁面的結構如圖4-17所示。
圖4-17 ubtree 葉子頁面結構示意圖
與astore堆頁面中維護版本信息的方法類似,ubtree的葉子節點中每個索引元組尾部都附加了對應的xmin和xmax。由于索引只是用于加速搜索的結構,本身不與歷史版本概念強相關,僅通過xmin和xmax來標識這個索引元組是從什么時候開始有效的,又是什么時候被刪除的,而不像astore中堆元組一樣會有指向舊版本元組的指針。
新插入的索引元組尾部用于存放xmin和xmax 空間在ubtinsert函數執行的過程中預留出來。預留的空間及xmin在索引元組插入時通過UBTreePageAddTuple函數中寫入頁面,而xmax在索引元組刪除時通過UBTreeDeleteOnPage函數中寫入頁面。
在UBTreePagePruneOpt函數中,索引元組通過其xmin和xmax信息來判斷該元組是否已經無效(Dead),進而進行獨立的頁面清理。該函數會嘗試清除所有無效的元組,并進行相應的碎片整理。
索引掃描時會調用UBTreeFirst函數定位到第一個滿足掃描條件的索引元組,然后調用UBTreeReadPage獲取當前頁面中符合索引掃描條件,且能夠通過可見性檢查的元組。可見性檢查通過UBTreeVisibilityCheckXid函數及UBTreeVisibilityCheckCid函數處理,其基本邏輯與astore類似,通過xmin與xmax及當前的快照進行可見性判斷。
在ubtree中,索引元組除了按照索引列有序排列之外,對于索引列相同的元組,還將其對應堆元組的TID作為第二關鍵字進行排序。其具體實現大致都集中在ubtbuild函數及ubtinsert函數調用的過程中,這中間對索引列相同的元組會按照TID來進行額外的比較。實現還借助了BTScanInsert結構體,該結構體定義如下:
在索引元組將TID作為第二關鍵字排序之后,用于劃分搜索空間的非葉子節點元組及葉子節點的Hikey元組(統稱Pivot元組)也需要攜帶對應的TID信息。這會使得Pivot元組占用空間增加,非葉子的扇出(fan out)降低。為了避免這一特性導致的扇出降低,若不需要比較TID即可區分兩個葉子頁面,則對應的Pivot原則中就不需要儲存TID信息。類似地,Pivot元組中也可以去掉一些不需要進行比較的索引列,這一邏輯在UBTreeTruncate函數中進行處理。原則是當比較前幾列就可以區分兩個葉子頁面時,Pivot元組中就不需要儲存后續的列。
2) 索引操作
對于原有的B-Tree索引而言,主要有四類操作:索引創建、索引掃描、索引插入以及索引刪除。下面將依次進行介紹。
(1) 索引創建。
索引創建操作由索引上的ubtbuild函數及ustore上的IndexBuildUHeapScan函數配合完成。IndexBuildUHeapScan函數負責掃描對應的ustore表,并取出每個元組的最新版本(遵循SnapshotNow的語義)以及其對應的xmin和xmax。若發現某個元組存在被就地更新的舊版本,則會將該索引標記為HotChainBroken。被標記為HotChainBroken的索引,會復用astore原有的邏輯,禁止隔離級別為可重復讀(Read Repeatable)的老事務訪問。ubtbuild函數會接收IndexBuildUHeapScan傳過來的元組,將其按照索引列及TID排序后依次插入到索引頁面中,并構建相應的元頁面及上層頁面。整個創建流程需要將所有頁面都記錄到XLOG中,并強制將存儲管理中的內容刷到永久存儲介質后才算成功結束。
(2) 索引掃描。
索引掃描與B-Tree索引基本一致,但是需要對索引元組進行可見性檢查。沒有通過可見性檢查的索引元組不會被返回,通過可見性檢查的元組仍需要在ustor 堆表上進行可見性檢查,并找到正確的可見版本。在IndexOnlyScan場景中,通過可見性檢查的元組即可直接返回,不需要再訪問堆表。
不過索引進行可見性檢查時,由于索引元組只存放了xmin和xmax而沒有CID(對應“4.2.3 astore”節堆表元組中的t_cid字段)信息,如果發現了當前事務修改過的索引元組則不能正確地通過CID來判斷其可見性。此時會將該元組視為可見,但會標記xs_recheck_itup,告知ustore的數據頁面需要在取到對應的數據元組后,再次構建對應的索引元組并與返回的索引元組進行比較,來確認該索引元組是不是真正可見。相關邏輯在 UBTreeVisibilityCheckXid、UBTreeVisibilityCheckCid以及RecheckIndexTuple函數中進行處理。
(3) 索引插入。
索引元組需要存儲對應的xmin和xmax版本信息,但其所占用的空間并不表現在IndexTupleSize中,而是對外部透明。索引插入的接口函數為ubtinsert,為了正確插入帶有版本信息的元組,需要在執行插入前增加IndexTupleSize以預留用于儲存版本信息的空間。真正將元組插入到頁面的時候,會將版本信息所占用的空間大小從IndexTupleSize中去除。
在索引插入的過程中若頁面空間不足,會首先調用UBTreePagePruneOpt函數嘗試對已經無效的元組進行清理。若清理失敗或清理成功后空間仍然不足,會進行索引頁面分裂。索引頁面分裂會在UBTreeInsertOnPage函數中進行。ubtree中存在兩種分裂策略:default以及insertpt。其中default策略會將原頁面上的內容均勻地分配到兩個頁面上,而insertpt會根據新插入元組的插入規律、插入位置及TID等信息選擇合適的分裂點。
在ubtree需要申請新的頁面時,并不會像原有的B-Tree索引一樣調用_bt_getbuf通過FSM來查找可用頁面。ubtree帶有自治式的空間管理機制,通過UBtreeGetNewPage函數獲取新頁面。該自治式空間管理機制將在空間管理和回收部分介紹。
(4) 索引刪除。
索引刪除操作用于在堆元組被刪除的同時,將對應的索引元組也標上對應的xmax。索引刪除的流程與插入類似,通過二分查找定位到待刪除元組的位置,并將xmax寫入到對應的位置。需要注意的是,要刪除的元組是索引列以及TID都匹配,且還未被寫入xmax的那個元組,這部分邏輯在UBTreeFindDeleteLoc函數中處理。在最后會調用UBTreeDeleteOnPage函數為對應的索引元組寫上xmax,更新頁面上的last_delete_xid以及activeTupleCount,并在檢測到activeTupleCount為0時將該頁面放入潛在空頁隊列(Potential Empty Page Queue)中。關于潛在空頁隊列會在空間管理和回收部分介紹。
5. 存取管理
openGauss中的ustore表訪存接口如表4-24所示。由于openGauss中ustore表只有一種頁面和元組結構,因此在上述接口中,直接實現了底層的頁面和元組操作流程。
表4-24 ustore表訪存接口
heap_open | 打開一個ustore表,得到表的相關元信息 |
heap_close | 關閉一個ustore表,釋放該表的加鎖或引用 |
UHeapRescan | 重新開始ustore表(順序)掃描操作 |
UHeapGetNext | (順序)獲取下一條元組 |
UHeapGetTupleFromPage | UHeapGetNext內部實現,單頁校驗模式 |
UHeapScanGetTuple | UHeapGetNext內部實現,單條校驗模式 |
UHeapGetPage | (順序)獲取并掃描下一個ustore表頁面 |
UHeapInsert | 在ustore表中插入一條元組 |
UHeapMultiInsert | 在ustore表中批量插入多條元組 |
UHeapDelete | 在ustore表中刪除一條元組 |
UHeapUpdate | 在ustore表中更新一條元組 |
UHeapLockTuple | 在ustore表中對一條元組加鎖 |
6. 空間管理和回收
不同于astore的空間管理和回收機制,ustore實現了自治式的空間管理機制。ustore里堆以及索引的空間分配和回收都在業務運行的過程中平穩地進行,不依賴中量級的VACUUM及AUTOVACUUM清理機制。
1) 自治式堆頁面空間管理
ustore中堆頁面的自治式空間管理,建立在與astore類似的輕量級堆頁面清理機制的基礎上。在執行DML及DQL操作的過程中,ustore都會進行堆數據頁面清理,以取代VACUUM清理機制。UHeapPagePruneOptPage函數是頁面清理的入口函數,會清理已經提交的被刪除元組。
對于astore而言,復用數據元組的行指針前必須保證對應的索引元組已經被清理。這是為了防止通過索引元組訪問已經被復用的行指針,導致取到錯誤的數據。在astore中需要通過VACUUM操作將這樣的無效索引元組統一清除掉后才能復用行指針,這使得堆頁面和索引頁面的清理邏輯耦合在一起,也會導致間斷性的大量I/O。在ustore中能高效地單獨進行數據和索引頁面的清理,因為帶有版本信息的ubtree能夠獨立檢測并過濾掉無效的索引元組,不會通過無效索引元組訪問對應的數據表。
堆頁面的空間管理機制復用openGauss中的FSM來管理UHeap中的可用空間。在UHeapPagePruneOptPage函數成功對頁面進行清理后,會將其空閑空間刷新到對應的FSM頁面中。為了避免每次頁面清理都需要更新整個樹狀結構的FSM,從而帶來額外的開銷,引入了一個更新整個FSM的概率計算。考慮當前清理后的可用空間占預留可用空間(Reserved Free Space)閾值的百分比,計算得出清理一個頁面后調用FreeSpaceMapVacuum函數的概率。也就是說,頁面清理獲得的可用空間越大,更新整個FSM的概率也就越大。
當數據元組被刪除時,會在頁面上記錄對應的潛在空閑空間(Potential Free Space),該值用于估計頁面上的空閑空間。在運行過程中,有多個場景會調用UHeapPagePruneOpt對頁面嘗試進行清理。DML語句執行過程中,INSERT、UPDATE以及DELETE操作都會拿到頁面的寫鎖。如果發現空間不足,或者檢測到潛在空閑空間到達某個閾值,會嘗試對頁面進行清理。DQL查詢語句執行的過程中若檢測到頁面上潛在空閑空間到達閾值,也同樣會嘗試申請頁面的寫鎖;如果拿到了頁面的寫鎖,會嘗試對頁面進行清理。
存在可清理的元組,但一直不被訪問的頁面不能通過這一機制正確地清理。為了解決這一問題,引入了基于概率的清理方案。在RelationGetBufferForUTuple函數尋找新的可用空間時,若通過FSM發現沒有足夠的可用空間,在對物理文件進行擴展前,會“隨機”選取一些頁面進行清理。該機制并非完全隨機選取,在多次嘗試后選取的頁面會覆蓋到整個關系的全部頁面。為了性能考慮,該過程中默認最多選取10個頁面進行清理,該數量可以通過GUC參數max_search_length_for_prune進行設置。具體的頁面選取數量通過DeadTupleRatio以及PruneSuccessRatio計算得出。其中DeadTupleRatio表示該表中無效元組的大致比例,該變量以統計信息的方式進行收集,在進行DML的過程中會進行更新;PruneSuccessRatio大致表示近幾次嘗試清理的成功率。
2) 自治式索引頁面空間管理
索引頁面的空間管理不依靠FSM數據結構,而是依靠特有的URQ(UBtree Recycle Queue)結構,簡稱為回收隊列。索引回收隊列單獨儲存在ubtree索引對應的.urq文件中,沒有原有B-Tree索引的.fsm文件。索引回收隊列相關代碼在“ubtrecycle.cpp”文件中。涉及到的主要函數接口見表4-25。
表4-25 索引回收隊列主要接口
UBTreeTryRecycleEmptyPage | 嘗試從潛在空頁隊列回收一個頁面 |
UBTreeGetAvailablePage | 獲取有效頁面(潛在空頁或空閑頁面) |
UBTreeRecordUsedPage | 記錄被成功使用的頁面 |
UBTreeRecordEmptyPage | 記錄潛在的空頁 |
UBTreeGetNewPage | 獲取新的可用頁面 |
索引中的回收隊列分為兩部分,一部分是潛在空頁隊列(Potential Empty Page Queue),一部分是可用頁面隊列(Available Page Queue)。兩個隊列都是跨頁面的循環隊列,其中每個元素都會儲存blkno以及XID。其中blkno表示該元素對應索引頁面的block number;XID表示該頁面在哪個時刻能夠被回收或復用。這些元素在循環隊列單個頁內按照XID的順序進行排序,以便于快速找到XID 小(最可能被回收或復用)的頁面。其結構如圖4-18所示。
圖4-18 ubtree回收隊列結構示意圖
對于潛在空頁隊列而言,里面存放頁內元組已經被全部刪除但還沒有全部無效的頁面,其中的XID就標志頁面中最后一個元組無效的可能時機。在系統整體的oldestXmin超過該XID后,該頁面就有可能被從索引上刪除,但也可能因為新插入元組或刪除元組的事務中止而導致頁面不能被刪除。潛在空頁隊列中的頁面在成功被刪除后會被放入可用頁面隊列,并記錄刪除時最新事務的XID。
對于可用頁面隊列而言,里面存放已經被刪除,可以或即將可以被復用的頁面。其中XID就表示該頁面可以被復用的時機。這樣的頁面復用時延是來自B-Tree索引頁面刪除時可能的并發訪問導致的,可以參考nbtree文件夾下README 關于頁面刪除的部分。
在ubtree進行索引刪除時,會更新頁面上的last_delete_xid字段以及activeTupleCount字段。若更新后activeTupleCount變為0,會將該頁面放入潛在空頁隊列,并將此時的last_delete_xid作為對應的可回收時間點。
在業務運行的過程中,索引會通過UBTreeTryRecycleEmptyPage函數不斷嘗試對潛在空頁隊列中的頁面進行回收。在索引申請新的頁面時,會通過UBTreeGetNewPage函數與可用頁面隊列交互,查找當前可用的空閑頁面。當可用頁面隊列中沒有可用頁面時,一般會通過擴展索引物理文件的方式來獲得新的頁面。但也存在物理文件批量擴展,或擴展后還未來得及使用就出錯退出的情況。此時在回收隊列的元信息頁面中保存了已正確追蹤的頁面數量,若該數量少于整個索引表的頁面數量,會嘗試去使用這一部分未追蹤的頁面,并更新已追蹤的頁面數量。
3) 中量級和重量級手動頁面清理
與astore相同,ustore也提供VACUUM語句來讓用戶主動執行對某個ustore表及其上的索引進行中量級清理。其對外表現與astore一致,可參考astore的空間管理和回收內容。
在ustore中,中量級清理同樣通過lazy_vacuum_rel函數進入,但不會調用lazy_scan_heap,而是調用LazyScanUHeap函數來進行數據頁面的清理。在進行索引清理時,會調用lazy_vacuum_index接口及LazyVacuumHeap函數來清理索引文件和堆表文件,索引清理時會調用ubtbulkdelete函數。
重量級的VACUUM FULL也與astore一致,會清理無效數據并對數據空間和索引空間重新進行組織。重量級清理的對外接口是cluster_rel函數,本質上是重新對數據進行聚簇,清理過程中會阻塞對該表的所有操作。
介紹完“4.2.4 ustore”,下篇我們將詳細介紹“4.2.5 行存儲索引機制”相關內容,敬請期待!
總結
以上是生活随笔為你收集整理的openGauss数据库源码解析系列文章——存储引擎源码解析(四)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 曼尼托巴大学计算机科学硕士,曼尼托巴大学
- 下一篇: ffmpeg 奇葩问题2