天天看點

PostgreSQL heap堆表 存儲引擎實作原理

文章目錄

  • ​​前言​​
  • ​​Heap表 實體結構​​
  • ​​PG資料庫目錄​​
  • ​​heap表 實際的資料檔案​​
  • ​​heap表 資料屬性​​
  • ​​heap表結構​​
  • ​​Heap 表的讀寫邏輯​​
  • ​​寫入​​
  • ​​第一步 初始化元組頭​​
  • ​​第二步 擷取一個 可用的page blk-num​​
  • ​​第三步 沖突檢測​​
  • ​​第四步 元組寫入到page​​
  • ​​第五步 标記page 為dirty 以及 checkpointer 程序異步刷髒頁​​
  • ​​第六步 寫入WAL​​
  • ​​第七步 Invalid relation-cache​​
  • ​​PostgreSQL find a kernel's "Fsync Bug"​​
  • ​​讀取​​
  • ​​總結​​

前言

Postgresql 的存儲引擎 是heap表引擎,用來存儲postgresql 的使用者表和系統表 的 實際資料 以及 索引資料。

了解pg 的heap 表引擎底層設計細節,能夠幫助我們更好得了解整個 pg 的讀寫 以及 事務處理鍊路。

而且 heap表引擎與 Relation cache, WAL(xlog), CheckPoint, BufferPool 都是強相關的,畢竟這一些機制或者說元件都是為了處理表資料而存在的,而heap 存儲引擎則是管理表資料在磁盤上的實體結構,是以從下向上來探索PG 能夠更為結構化 和 準确。

本節涉及到的 postgresql 源代碼版本 REL_12_2

Heap表 實體結構

PG資料庫目錄

heap表存儲引擎 是管理表資料的一個PG 元件。表資料實際的存在形态可以通過直接檢視pg資料所處 檔案系統目錄看到。

當我們使用 pg的 ​

​createdb​

​​ 指令建立了一個資料庫之後,pg 會在 ​

​$PGDATA/base​

​ 目錄下生成一個新的目錄,用來存儲這個建立好的 db 的表資料,pg 不同資料庫之間是完全實體隔離的。

其中 ​

​$PGDATA​

​​ 是我們初始化 資料庫 以及 使用 ​

​pg_ctl​

​ 啟動 pg時指定的資料存放目錄。
$ ./bin/createdb testdb
$ cd $PGDATA
$ ls -l base
total 40
drwx------ 2 admin admin  4096 Jun 24 16:44 1
drwx------ 2 admin admin  4096 Jun 24 16:44 12708
drwx------ 2 admin admin 12288 Jun 25 17:16 12709
drwx------ 2 admin admin 12288 Jun 25 22:00 16384      

其中 ​

​1​

​​, ​

​12708​

​​, ​

​12709​

​​ 目錄對應的 資料庫是在 initdb 的時候預先建立好的系統資料庫。而 ​

​16384​

​​ 則時我們建立 ​

​testdb​

​​ 時 預設建立的目錄。

可以通過 ​​

​psql​

​​ 連接配接 ​

​testdb​

​ 快速确認 其所處的 pg目錄:

$./bin/psql testdb
psql (12.2)
Type "help" for help.

testdb=# select pg_relation_filepath('pg_class');
 pg_relation_filepath
----------------------
 base/16384/1259
(1 row)      

這裡是我們連接配接到 testdb之後 檢視某一個表的資料目錄,其中 ​

​base/16384​

​​就是在 ​

​$PGDATA​

​​目錄下的資料庫目錄,​

​1259​

​​ 檔案表示的是 ​

​pg_class​

​ 這個表中的資料存儲的檔案。

heap表 實際的資料檔案

前面我們能夠通過 ​

​pg_relation_filepath​

​​ 函數确認一個表實際資料 所處的實體檔案。

如果我們在同一個資料庫中建立一個新的表,pg 會為這個表單獨配置設定一個存儲的實體檔案,且 這個表會有一個整個db 内部的唯一辨別 ​​

​oid​

​​ ,并且會存儲在 ​

​pg_class​

​​系統表内部,可以在 ​

​pg_class​

​​ 内部 ​

​select​

​​ 查找到。​

​relfilenode​

​​ 則是該表實際的實體檔案名稱,如果對這個表執行了 ​

​truncate​

​​ 則會保持 oid不變的情況下生成一個新的 ​

​relfilenode​

​,也就是一個新的檔案。

$ ./bin/psql testdb
testdb=# create table d (c1 int, c2 int, c3 int);
CREATE TABLE
testdb=# select pg_relation_filepath('d');
 pg_relation_filepath
----------------------
 base/16384/16430
(1 row)
testdb=# select relname, oid, relfilenode from pg_class where relname ='d';
 relname |  oid  | relfilenode
---------+-------+-------------
 d       | 16430 |       16430
(1 row)      

可以看到實際的實體檔案(空的,因為我們并沒有向表内插入資料):

$ ls -l base/16384/16430
-rw------- 1 admin admin 0 Jun 25 22:46 base/16384/16430      

然後我們向這個表内部插入幾條資料,可以看到實際的表檔案已經有了資料:

testdb=# insert into d values(1, 11, 12);
INSERT 0 1
testdb=# insert into d values(2, 21, 22);
INSERT 0 1
testdb=# select * from d;
 c1 | c2 | c3
----+----+----
  1 | 11 | 12
  2 | 21 | 22
(2 rows)
------------------------------------------------
$ ls -l base/16384/16430
-rw------- 1 admin admin 8192 Jun 25 22:46 base/16384/16430      

如果我們檢視整個 資料庫 ​

​16384​

​​ 的目錄,則能夠看到一些表檔案為字首的 ​

​_fsm​

​​ 以及 ​

​_vm​

​檔案,它們分别是管理整個表 空閑空間映射 以及 可見性映射的持久化檔案,分别用于 為表資料從表檔案中配置設定存儲空間 以及 事務讀寫場景進行 資料可見性檢查的。

heap表 資料屬性

一個表的資料是放在一個檔案,随着這個表内資料越來越多,檔案也會越來越大,預設 單個表檔案的上限是 1G (可以在編譯 pg 時通過指定 ​

​--with-segsize​

​​ 設定 表檔案的大小上限,它是沒法通過 pgoptions 更改的),如果單個表檔案大小超過了1G,則會生成一個 ​

​base/16384/16430.1​

​ 檔案繼續接受新資料的寫入,以此類推。

表檔案内部 則會被分為一個個小的 ​

​page​

​​,或者說 ​

​block​

​​ 進行資料存儲管理,page 和 block 都是屬于 pg 内部的實體結構的描述術語,和 os 本身的 記憶體 page 和 磁盤 block 是不一樣的,對于 pg 表檔案的組成機關 後續統一用 page進行稱呼,預設一個 page 大小是 8K(同樣,需要在編譯的時候指定 ​

​--with-blocksize​

​ 來進行變更)。

整個表檔案由一個個page組成,每一個page有術語自己的編号(block number) 用來唯一辨別一個page。一個 page 内部則是由一個個 ​

​heaptuple​

​ 的資料元組組成,它們是實際的表内資料。

我們可以通過如下幾個 pg 内置擴充元件 ​

​pageinspect​

​​ 提供的擴充函數來初步了解一下page的組織結構 以及 page 内部的 元組的一些資訊,可以通過 ​

​create extension pageinspect;​

​ 語句打開該extension。

對于這一些資訊的解析,後面會較長的描述,可以先簡單看一下。

  1. 檢視表 ​

    ​d​

    ​ 的 第一個page 的header資訊,其中 ​

    ​page_header​

    ​ 用來擷取page的header結構,​

    ​get_raw_page​

    ​ 擷取 表 ​

    ​d​

    ​ 且 block number 為 ​

    ​0​

    ​ 即第一個的 page
testdb=# SELECT * FROM page_header(get_raw_page('b', 0));
    lsn    | checksum | flags | lower | upper | special | pagesize | version | prune_xid
-----------+----------+-------+-------+-------+---------+----------+---------+-----------
 0/164D980 |        0 |     0 |    36 |  8096 |    8192 |     8192 |       4 |         0
(1 row)      
  1. 檢視表 ​

    ​d​

    ​ 的第一個 page 的tuple資訊 ​

    ​SELECT * FROM heap_page_items(get_raw_page('d', 0));​

    ​ ,其中 ​

    ​heap_page_items​

    ​ 擷取 page 内部每一個​

    ​heaptuple​

    ​ 元組條目資訊:
testdb=# SELECT * FROM heap_page_items(get_raw_page('d', 0)) where lp < 5;
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_oid |           t_data
----+--------+----------+--------+--------+--------+----------+--------+-------------+------------+--------+--------+-------+----------------------------
  1 |   8152 |        1 |     36 |    516 |      0 |        0 | (0,1)  |           3 |       2304 |     24 |        |       | \x010000000b0000000c000000
  2 |   8112 |        1 |     36 |    517 |      0 |        0 | (0,2)  |           3 |       2304 |     24 |        |       | \x020000001500000016000000
  3 |   8072 |        1 |     36 |    518 |      0 |        0 | (0,3)  |           3 |       2304 |     24 |        |       | \x01000000300000001f000000
  4 |   8032 |        1 |     36 |    518 |      0 |        0 | (0,4)  |           3 |       2304 |     24 |        |       | \x02000000280000000e000000
(4 rows)      
  1. 接下來看 我們再深入一些,看一下元組 t_data 内部的 attrs 資訊:
testdb=# SELECT lp,lp_off, t_xmin, t_ctid, t_attrs FROM heap_page_item_attrs(get_raw_page('d', 0), 'd') where lp < 5;
 lp | lp_off | t_xmin | t_ctid |                   t_attrs
----+--------+--------+--------+---------------------------------------------
  1 |   8152 |    516 | (0,1)  | {"\\x01000000","\\x0b000000","\\x0c000000"}
  2 |   8112 |    517 | (0,2)  | {"\\x02000000","\\x15000000","\\x16000000"}
  3 |   8072 |    518 | (0,3)  | {"\\x01000000","\\x30000000","\\x1f000000"}
  4 |   8032 |    518 | (0,4)  | {"\\x02000000","\\x28000000","\\x0e000000"}
(4 rows)      

從以上内容中可以比較清晰得看到資料表檔案中一個page 以及 page内部對應的元組結構,擁有非常多的字段來對 page 以及 元組結構進行描述,接下來将詳細看一下整個page 以及 元組的核心結構。

heap表結構

這裡 pg 為什麼将表檔案叫做 heap table,“應該(不确定)” 是因為 在表檔案中的每一個page 内部空間的配置設定是從檔案末尾向檔案開頭配置設定,有點像是os 的堆記憶體配置設定是從低位址空間向高位址空間增長,是以直接叫做堆表檔案。

整個堆表檔案 由多個 page組成,每一個page有一個唯一辨別的 block number,接下來看看整個 page 以及其内部的heap tuple 都有哪一些字段:

PostgreSQL heap堆表 存儲引擎實作原理

其中 Page 的關鍵字段(header部分)如下:

  1. ​pd_lsn​

    ​ 一個8byte的 unsigned int,與 pg的 WAL檔案 xlog 相關,唯一辨別上一個寫入到這個page 的請求。
  2. ​pd_checksum​

    ​ 目前page 的校驗和, uint16。
  3. ​pd_flags​

    ​ 目前page 的一些flag資訊,比如是否有空的line pointer(可用的元組空間),其内部的每一個元組是否對外可見等,uint16。
  4. ​pd_lower​

    ​ 辨別目前page 空閑空間的起始偏移位置。
  5. ​pd_upper​

    ​ 辨別目前page 空閑空間的結束偏移位置。
  6. ​pd_special​

    ​ 特殊空間的 起始偏移位置,其一般會存放在整個page的最後一段(如上圖)。
  7. ​pd_pagesize_version​

    ​ 辨別page大小和目前page版本資訊。
  8. ​pd_prune_xid​

    ​ 辨別本頁面可以回收的 最老的元組 id.
  9. ​pd_linp​

    ​ 即上圖中的 line-pointer,可變長度的數組,與元組一一對應,用來存儲每一個元組在目前page内部的起始偏移位址。

可以看到整個pageheader 的這麼多個字段就是為了管理其内部的元組而服務的。

可以通過 ​​

​SELECT * FROM page_header(get_raw_page('b', 0));​

​ 語句來檢視 随着對表 b 的資料插入,整個page header的變化情況。

比如插入tuple2,會變更 pd_lower 以及 pd_upper 的偏移位址,将 pd_lower指向 為 tuple2 配置設定了line-pointer 之後的位置, 将 pd_upper 指向tuple2的起始位置(tuple2 是緊接着tuple1進行放置的),同時更新一下對應的pd_lsn 等名額。

Page 内部的 heaptuple 主要分為三個部分:除了 t_data 之外的 ​

​HeapTupleData​

​​,​

​t_data​

​​ 也就是 ​

​HeapTupleHeaderData​

​,還有一部分就是 實際的data area。

  1. ​HeapTupleData​

    ​​ 主要字段包括:​

    ​len​

    ​ 整個tuple header-data + data-area 的長度; ​

    ​t_self​

    ​ 辨別目前 tuple 所處的 page 位置(page的block-number 以及 page内的offset), ​

    ​tableOid​

    ​ 目前tuple 所處的表 的唯一辨別。
  2. ​HeapTupleHeader t_data​

    ​​,這個資料結構主要儲存整個tuple的 header 關鍵資訊。其内部的字段與 pg 要是實作的事務語義強相關,在pg 内部一個元組的可見性檢查 以及 MVCC 都是通過 ​

    ​HeapTupleHeader​

    ​ 内部的字段實作的(我們後續在源碼分析層面會較長的描述),主要字段如下:

    a. ​

    ​union t_choice​

    ​,内部主要有 ​

    ​t_heap​

    ​或者​

    ​t_datum​

    ​ 兩個字段。t_heap中主要是 事務辨別字段,​

    ​t_xmin​

    ​ 辨別插入元組時的事務id; ​

    ​t_xmax​

    ​ 辨別對目前元組進行更新/删除 時的事務id, 如果 ​

    ​t_xmax​

    ​ 被設定為0,則辨別沒有事務操作對該元素進行 更新和删除; ​

    ​t_cid​

    ​ 辨別commid id,數值的含義是辨別目前操作之前有多少個sql 指令; ​

    ​t_xvac​

    ​ 辨別執行 vaccum full 操作的id。

    b. ​

    ​t_ctid​

    ​ 唯一辨別一個元組,如果目前元組被更新或者删除,則t_ctid 則會被更新為最新的元素的​

    ​t_ctid​

    ​; 比如 :
# 第一個元組
testdb=# SELECT lp,lp_off, t_xmin, t_ctid, t_attrs FROM heap_page_item_attrs(get_raw_page('c', 0), 'c');
 lp | lp_off | t_xmin | t_ctid |            t_attrs
----+--------+--------+--------+-------------------------------
  1 |   8160 |    506 | (0,1)  | {"\\x57040000","\\xae080000"}
# 更新,再次檢視t_ctid
testdb=# update c SET l1 = 2;
UPDATE 1
testdb=# SELECT lp,lp_off, t_xmin, t_ctid, t_attrs FROM heap_page_item_attrs(get_raw_page('c', 0), 'c');
 lp | lp_off | t_xmin | t_ctid |            t_attrs
----+--------+--------+--------+-------------------------------
  1 |   8160 |    506 | (0,2)  | {"\\x57040000","\\xae080000"}
  2 |   8128 |    595 | (0,2)  | {"\\x02000000","\\xae080000"}
(2 rows)      

可以很明顯得看到 兩個元組的 t_ctid 被改變,第一個元組的t_ctid被變更為指向更新時産生的新的元組。

c. ​​

​t_infomask2​

​​ 辨別目前元組有多少個 attributes,即有多少列。同時,數值還編碼了一些元組類型的 flags(元組被更新?元組類型時 hot-updated / only-tuple 等)。

d. ​​

​t_infomask​

​​ 存儲了元組的屬性flag資訊,比如 目前元組是否有空列, 是否有變長的列,object-id 列是否為空等。

e. ​​

​t_hoff​

​​ 辨別整個header + 後面的bitmap的占用偏移,友善上層利用t_hoff 計算實際的data_area 資料。

f. ​​

​t_bits​

​​,是一個變長的 bitmap數組,用來辨別目前元組某一列是否為空,比如目前元組總共有四列,則t_bits 内容為 ​

​1111​

​​,如果第三列為空,則t_bits 内容變更為 ​

​1101​

​​ ;postgresql 允許的元組内部最大的列的個數為​

​MaxTupleAttributeNumber​

​​ 1664個,允許的使用者表的最大的列數為 ​

​MaxHeapAttributeNumber​

​ 1600 個,和元組内允許的最大列數有差異的原因是元組内會因為使用者對某一項的删除和更新而增加t_ctid 或者 t_xmax 這樣的列數。

  1. ​data_area​

    ​​ len - header的 剩餘長度部分都可以被轉為 元組的資料區域,也就是我們通過 ​

    ​SELECT lp,lp_off, t_xmin, t_ctid, t_attrs FROM heap_page_item_attrs(get_raw_page('c', 0), 'c');​

    ​​ 看到的 t_attrs 部分。

    有多少資料列,在 t_attrs中就有多少條資料,每一條資料 是代表一個列的數值,都是用16進制表示。比如:​​

    ​x57040000​

    ​​ 需要将低16位放在後面才能完成表示,真正數值的16進制是​

    ​0x0000 0457​

    ​。

整個元組這裡還是有非常多的字段,其中大多數都是為了 pg 的事務實作 而設計的。

能看到 PG 内部的元組 update 是 append 方式的,就是需要生成一個新的元組,不過還需要對這個元組的舊版本中的某一些字段進行更新(t_xmax),如果這個元組資料不在記憶體中,更新這個字段也就意味着需要将舊的元組讀出來進行更新,而如果更新較為頻繁也就意味着事務更新的性能并不會很好。

當然,這種屬于inplace-update的更新方式,對讀性能自然很友好;像是lsm-tree 的純append-only方式的更新場景基本就是正常的append 寫入的性能了, 基本沒有更新時性能損失的情況(如果正常的update 需要在之前版本基礎上進行更新,使用 merge-operator,則能保證更新本身的性能的情況下 降低了 讀的性能,因為實際的數值合并被放在了compaction 或者 讀中)

Heap 表的讀寫邏輯

寫入

heap表的寫入并不是将元組構造好插入到page之後直接落盤,而是将插入元組的page标記為 dirty,由專門的 checkpointer 程序進行 周期性 buffer 的 刷盤,也就是資料并非實時落盤。但是一緻性、可靠性 保障,則是會通過 postgresql 的 WAL保障。

heap元組的插入棧如下,上次執行的是​

​INSERT​

​ 語句:

main
 PostmasterMain
  ServerLoop
   BackendStartup
  BackendRun
   PostgresMain
    exec_simple_query # 詞法解析/文法解析/優化器
     PortalRun # 已經生産執行計劃,開始執行
    PortalRunMulti
     ProcessQuery # 執行 DML
      standard_ExecutorRun
       ExecutePlan # 執行器
      ExecProcNode # 選擇執行函數
       ExecModifyTable 
        ExecInsert
         table_tuple_insert # 通過 default_table_access_method選擇執行函數
        heapam_tuple_insert # 選擇了 heap access method.
         heap_insert # 插入元組      

在 ​

​heapam_tuple_insert​

​​ 中會先從 HeapTupleSlot 中拿到實際的元組資料,并構造一個 HeapTuple 傳回,但是這個 HeapTuple 對象并沒有header資訊,是以 這個 HeapTuple對象的填充會放在 ​

​heap_insert​

​中。

這裡我們主要關注的是 ​

​heap_insert​

​ 如何更新元組 以及 将一個元組插入到 表檔案的page 中。

函數内部邏輯 大體分為如下幾步:

  1. 初始化元組頭
  2. 從 Relation cache 中擷取一個 可用的page block-number
  3. 做事務上的沖突檢測(rw/ww)
  4. 将元組資訊添加到這個 page中
  5. 标記 page 為dirty
  6. 寫 WAL
  7. 标記 relation-cache 中舊的 tuple 所在的buffer 失效

第一步 初始化元組頭

在函數 ​

​heap_prepare_insert​

​ 之中:

static HeapTuple
heap_prepare_insert(Relation relation, HeapTuple tup, TransactionId xid,
          CommandId cid, int options)
{
  ...
  tup->t_data->t_infomask &= ~(HEAP_XACT_MASK);
  tup->t_data->t_infomask2 &= ~(HEAP2_XACT_MASK);
  tup->t_data->t_infomask |= HEAP_XMAX_INVALID;
  // xid 是目前操作元組的 transaction id,這裡是更新到了我們前面說的 t_xmin之中。
  HeapTupleHeaderSetXmin(tup->t_data, xid);
  // 更新 t_cid,即 commid-id,用來辨別目前事務操作之前有多少個 command.
  HeapTupleHeaderSetCmin(tup->t_data, cid);
  // 更新 t_xmax,即 對目前元組發生update 或者 delete 的事務id,預設是0.
  HeapTupleHeaderSetXmax(tup->t_data, 0); /* for cleanliness */
  // 更新 t_tableOid 為 表 oid.
  tup->t_tableOid = RelationGetRelid(relation);
  ...
  // 後面是處理以下 較大元組的的情況,比如一個元組内部列數超過我們之前說的最大上限1664個,
  // 這種情況會通過 toast 機制進行處理。
}      

第二步 擷取一個 可用的page blk-num

第二步,從relation cache 中擷取一個可用的 page,傳回的是這個page對應的block number.

這一步 pg 會先根據 heap-size 和上一次使用的 page進行check,比如上一次的page 剩餘空間是足夠存放目前 heaptuple 的, 則直接将這個 page 對應的block-number 傳回。

如果不夠,則需要從 free-list-manager 中查找空閑可用的page,也就是讀取 最開始描述資料庫目錄時提到的 ​

​_fsm​

​​ 檔案,如果 FSM 也沒有足夠的page,則通過 relation管理的 ​

​smgr​

​ – storage manager 從磁盤檔案系統上取一塊檔案空間。

将最後拿到的 可用的 block-number 傳回即可。

Buffer
RelationGetBufferForTuple(Relation relation, Size len,
              Buffer otherBuffer, int options,
              BulkInsertState bistate,
              Buffer *vmbuffer, Buffer *vmbuffer_other)
{
  ...
  // 前面利用輸入的 參數做一些hup-size的檢查,heapsize 如果小于 MaxHeapTupleSize,
  // 表示一定可以配置設定到一個可用的 page.
  if (targetBlock == InvalidBlockNumber && use_fsm)
  {
    // 擷取空閑的block number
    // 1. 先嘗試從 FSM 中擷取,擷取不到
    // 2. 再嘗試 利用 smgr 從磁盤檔案系統上配置設定,配置設定不到
    // 則說明磁盤空間不足,直接傳回 -1。
    targetBlock = GetPageWithFreeSpace(relation, len + saveFreeSpace);
    ...
  }

loop:
  // 1. 将 targetBlock 對應的page 讀到記憶體中
  // 2. 做一些 buffer 的可見性檢查
  // 3. 沒有問題,則擷取到 記憶體中的page,标記其為dirty
      (新的page,插入需要後續checkpoint刷盤)
  //  4. 傳回 block-number (代碼中時buffer)
  while (targetBlock != InvalidBlockNumber)
  {
  ...
      /* lock other buffer first */
      buffer = ReadBuffer(relation, targetBlock);
      if (PageIsAllVisible(BufferGetPage(buffer)))
        visibilitymap_pin(relation, targetBlock, vmbuffer);
      LockBuffer(otherBuffer, BUFFER_LOCK_EXCLUSIVE);
      LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE);

  ...
      // 擷取 page,如果是新的,則标記這個page 為dirty.
      page = BufferGetPage(buffer);
      if (PageIsNew(page))
      {
        PageInit(page, BufferGetPageSize(buffer), 0);
        MarkBufferDirty(buffer);
      }
      // 再次檢查這個 page 可用空間足夠,足夠則直接傳回buffer.
      pageFreeSpace = PageGetHeapFreeSpace(page);
      if (len + saveFreeSpace <= pageFreeSpace)
      {
        /* use this page as future insert target, too */
        RelationSetTargetBlock(relation, targetBlock);
        return buffer;
      }
...
  }
...
}      

第三步 沖突檢測

第三步是進行沖突檢測,直接調用函數 ​

​CheckForSerializableConflictIn​

​(事務部分的邏輯會和 WAL 機制單獨分析一篇,本文中暫時不進行展開)。

第四步 元組寫入到page

接下來就到第四步了,将 元組資料添加到擷取到的page 中,調用函數 ​

​RelationPutHeapTuple​

​。

  1. 先在 PageAddItem,将tuple 的 t_data 資料部分 插入到page中,更新page header資訊,并傳回 tuple 所在page的 offnum.
  2. 将offnum 和 buffer 更新到 tuple 的 t_self中。這樣就能夠在tuple 中确認該tuple 所在的 block-number 以及 page偏移位址了。
void
RelationPutHeapTuple(Relation relation,
           Buffer buffer,
           HeapTuple tuple,
           bool token)
{
  ...
  // 拿到上文中擷取到 buffer ,也就是 block-number,拿到對應的page.
  /* Add the tuple to the page */
  pageHeader = BufferGetPage(buffer);
  // 在宏  PageAddItem 調用 PageAddItemExtended 函數 将 tup 的 data資料添加到page中。
  // 其中 PageAddItemExtended 函數中,會更新 page的 header 資訊:
  // 比如 pd_lower, pd_upper, line-pointer 也就是 pd_linp 等
  // 更新完成這一些資訊之後會将 tuple 傳入的 t_data 資訊 memcpy 到page中。
  offnum = PageAddItem(pageHeader, (Item) tuple->t_data,
             tuple->t_len, InvalidOffsetNumber, false, true);
  ...
  // 将offnum 和 buffer 更新到 tuple 的 t_self中.
  ItemPointerSet(&(tuple->t_self), BufferGetBlockNumber(buffer), offnum);
  ...      

第五步 标記page 為dirty 以及 checkpointer 程序異步刷髒頁

第五步 會做一個 page 的标記,前面第二步時 拿一個可用的page block-number 過程中會為一個新的 page打上ditry 标記;如果是update場景,或者複用已有的page,則第二步不會打diry。是以,這裡是更新完成了 tuple 到這個page,則需要 通過 ​

​MarkBufferDirty(buffer);​

​ 對該page 寫入 dirty标記。

需要注意的是 到這裡,關于 heap 表檔案的元組插入就已經完成了,後續的 WAL 寫入 以及 invalid relation cache 都是與堆表檔案本身無關的,而元組所在page 的實際寫盤是通過額外的 checkpoint 程序進行寫入的。

這裡可以做一個很簡單的測試,先檢視已有資料的表檔案,利用 ​

​pg_relation_filepath​

​​函數能确認目前表對應的具體的heap檔案,具體使用 可以參考前面 介紹資料庫目錄。可以直接 vim 這個檔案,檢視它的十六進制, 一般模式下​

​:%!xxd​

​​,從最後看能看到實際的資料:
PostgreSQL heap堆表 存儲引擎實作原理
上面是之前插入的元組,每一個元組有兩個 attribute,上面中能看到很多個元組,有一些是update産生的,需要在 vacuum 程序進行處理。

checkpointer 程序 是PG 衆多子程序中的一個,定期 對記憶體中緩存的資料進行落盤,包括 CLOG(事務狀體資料), 子事務,Relation map, snapshot , buffer pool 髒頁(淘汰頁)等。

PostgreSQL heap堆表 存儲引擎實作原理

checkpointer 子程序是在 postgresql 啟動時在PostmasterMain 初始化的子程序。

其調用棧如下:

CheckpointerMain
  CreateCheckPoint
    CheckPointGuts
      CheckPointBuffers      

在函數 ​

​CheckPointBuffers​

​​ 中,會先通過 ​

​BufferSync​

​​ 将 buffer 中的資料統一通過 posix write 寫入到磁盤;再通過​

​ProcessSyncRequests​

​ 對 buffer寫入的page 所處的路徑調用 fsync。

第六步 寫入WAL

第六步 寫WAL,如果使用者在 ​

​postgresql.conf ​

​​ 檔案中設定了 ​

​wal_level=0​

​, 則會關閉 WAL 功能。同樣,因為 wal 的源碼邏輯 是 和事務體系相關聯的,是以本篇也暫時不會描述。

代碼 還是在 ​

​heap_insert​

​之中

/* XLOG stuff */
if (!(options & HEAP_INSERT_SKIP_WAL) && RelationNeedsWAL(relation))
{
  ...
}      

應該是9系版本及以前,PG的WAL 還是同步落盤的,後來為了分離IO排程,又搞了一個wal-writer 子程序,來單獨排程寫 WAL的邏輯。

第七步 Invalid relation-cache

這裡是在對元組更新的場景下, 如果這個元組所在的page 存在于 relation-cache 之中, 則需要告訴relation-cache 讓這個page失效,因為資料已經發生了變更。

關于 relation-cache 和 catalog(系統表)後續會詳細介紹,這裡也是擁有非常多的細節。

invalid 的代碼在:

void
CacheInvalidateHeapTuple(Relation relation,
             HeapTuple tuple,
             HeapTuple newtuple)
{
  ...
}      

到此,整個heap_insert 的邏輯是執行完了。

可以看到 PG 為了将IO 排程和核心邏輯處理分離,設計了很多子程序進行異步IO排程(所有的IO 排程都會通過 smgr 存儲管理器進行),這樣對于 PG 核心代碼可維護性 以及 性能來說都是最為友善的。

回到我們的 堆表檔案中的 page落盤,其在 INSERT 的主鍊路上并不會直接落盤,而是插入到 記憶體中對應的 pg page區域中,對 page 做一個标記,後續通過 checkpointer 進行 髒頁的異步落盤(調用fsync)。

因為我們建表的時候沒有在表上面建立索引,是以插入元組的時候并不會有索引的插入。如果我們建立了索引,則在​

​ExecInsert​

​​ --> ​

​table_tuple_insert​

​​ 再 調用 heap 表寫page完成之後還需要執行 ​

​ExecInsertIndexTuples()​

​ 進行索引的插入。

PostgreSQL find a kernel’s “Fsync Bug”

下面簡單提一個 pg 社群認為的一個 核心 fsync 的 bug,這個讨論很有趣:

在2018年的時候 pg 社群發現了一個 “os-核心 fsync 的bug”,當時在 os-kernel社群, PG社群 以及 其他關系型資料庫社群傳的沸沸揚揚:)。

背景是 os-核心本身在處理 DRAM page落盤的時候預設是通過 write-back 機制,write-back的pd-flush 核心線程刷 記憶體髒頁失敗了(可能os硬體問題,記憶體髒頁的存儲其實對 pg 來說 是 checkpoint 刷 buffers 的第一個階段,隻是将buffers 資料 寫入到os的 page-cache中,後續才會調用fsycn),當時核心對于這種髒頁的錯誤處理“不足”(pg社群的開發者是這樣認為的),然後 pg 在 checkpointer 刷 buffers 的第二個階段會調用 fsync,這個時候因為核心本身 write-back 刷記憶體的某一個髒頁是失敗的,這個時候 fsync 也會處理失敗,但是對于PG來說,在發現 fsync 失敗的時候會根據 fsync 失敗的錯誤碼進行重試,fsync的失敗傳回的錯誤碼核心确實沒有區分是 write-back 失敗的那一些髒頁,這樣 pg 重試 fsync的時候 核心傳回成功了。

這個時候問題就來了,因為失敗的那一些髒頁不一定落盤,對PG來說,他們認為調用了 fsync 成功 之後所有的資料都一定會在磁盤上,然而事實是 這一些髒頁資料還是記憶體裡,假如os 斷電什麼的,這一些資料就丢了,違背了 PG 對fsync 的語義了解,存在丢資料的情況(穩定性問題,極為嚴重)。

是以PG社群 認為 核心應該為 fsync 這一語義在這種場景下的問題負責,然而核心社群認為這種問題fsync 很難避免,因為有背景write-back線程在執行的過程中 sync 也有可能失敗,而且PG 的 checkpointer 程序的 smgr 并沒有儲存所有的打開過的檔案fd(pg 為了避免 程序打開的fd 數量達到核心上限,做了隻會緩存一部分fd到記憶體得優化),在有需要的時候才去重新打開fd,也就是fsync 的時候可能需要重新打開一部分檔案,在打開之前可能也會發生一些 os 的 write-back 錯誤,打開過程中在4.13 及更新的核心版本 不會報錯,那打開檔案之後調用fsync 可能會成功,這種場景下更是會丢資料,核心開發者認為 PG 的這種checkpoint 刷髒頁的機制(寫完記憶體, 重新打開檔案fd ,調用fsync )本身就會存在 調用fsync時前面發生過失敗,導緻部分記憶體髒頁未落盤的情況。

核心社群建議 PG 社群在這種問題的最好的解決辦法就是 使用 DIO(direct io),也就是 checkpointer 落盤的時候不要先寫page-cache再批量fsync了,直接調用 DIO得了,但是 DIO 意味着整個 os 的page-cache用不了,而且對 PG的 寫鍊路 可以說是非常大(對于這樣的bug,改造成本太高了)。

後來,看到 PG的 commit 是在fsync 失敗之後 做了panic(其他的 innodb 和 wiredtiger 也都做了同樣的改造),核心這裡也提了一些建議,可能需要在新的核心版本上才能上(DIO 是linux 為資料庫應用專門開發的一個 io 特性,簡單,對核心來說省事 ,對資料應用來說穩定。但是現如今看來,資料庫領域的卷已經帶入到了核心了,因為大家都想要好的性能,dio 的性能實在是讓大家沒有太大的競争力;當然,如果選擇自維護 page-cache 也能達到效果,但是對于 IO 鍊路的改造沒有巨量人力和時間成本的投入,根本做不出來像核心這樣的穩定性和性能;為了迎合使用者對性能的需求 以及 卷過其他資料庫,大家還是都預設選擇了 buffer i/o)。

說的有點多,這塊後面還是會仔細看看這個 PG 發現的bug,看看能不能複現。主要的是,PG 社群的幾個 PMC 在讨論這個 bug的時候 提到 ​

​kernel brain damage​

​​ 以及 ​

​100% unreasonable​

​,以 torvalds 性格看到可能要爆粗口了… 不過 torvalds 沒有參與讨論,不然可以吃一個大瓜。

更多的細節可以去下文直接看:

  1. 郵件讨論連接配接:​​The “fsyncgate 2018” mailing list thread​​
  2. 核心社群對 fsync bug 的讨論 : ​​LWN.net article “PostgreSQL’s fsync() surprise”​​
  3. 核心提升了一下 block-layer 的錯誤處理機制 : ​​LWN.net article “Improved block-layer error handling”​​
  4. 關于fsync 是否安全的 一篇研究論文 : ​​Can Applications Recover from fsync Failures​​

讀取

讀取鍊路 從 SELECT 語句開始,排程的棧如下:

main
 PostmasterMain
  ServerLoop
   BackendStartup
  BackendRun
   PostgresMain
    exec_simple_query # 詞法解析/文法解析/優化器
     PortalRun # 已經生産執行計劃,開始執行
      PortalRunSelect # 執行計劃是 查詢,這裡和 insert的執行計劃是不一樣的
       standard_ExecutorRun
        ExecutePlan
         ExecProcNode
          ExecScan
           ExecScanFetch
            SeqNext
             table_scan_getnextslot
              heap_getnextslot      

主體邏輯是在 ​

​heap_getnextslot​

​ 函數中,該函數内部邏輯分為兩部分:

  1. 從磁盤上讀取對應的 tuple 坐在的page 資料到記憶體中。
  2. 将讀取上來的tuple 填充到 可以被使用者讀取到的 ​

    ​TupleTableSlot​

    ​ 資料結構中。

第一部分是在 如下的調用棧中,最終會通過 ​

​ReadBuffer_common​

​ 産生實際的 read io,即 通過 PG smgr 存儲管理器進行磁盤讀取。

heap_getnextslot
 heapgettup_pagemode
  heapgetpage
   ReadBufferExtended
    ReadBuffer_common      

讀上來的 page 還會 在 ​

​heapgetpage​

​​ 函數中進行可見性檢查,這一部分有一些根據 PG 提供的隔離級别的規則來設定的可見性,能夠較為高效得對一個元組/Page 的可見性進行判斷 ,判斷邏輯主要集中在 ​

​HeapTupleSatisfiesVisibility​

​ 函數中。

在第二部分中,主要做一些資料結構成員上的填充,通過函數 ​

​ExecStoreBufferHeapTuple​

​​ 進行,将tuple 元組 以及 tuple 元組所在的 page 放入到 ​

​TupleTableSlot​

​ 之中,由上層進行資料解析傳回給用戶端。

讀鍊路因為 workload 比較單一,相對來說簡單很多,核心是 如何從磁盤讀取 page 以及 如何對讀上來的 page 按照預置好的 pg 事務可見性規則進行可見性檢測 這兩部分。

總結

因為本篇文章也隻是單純關注 PG 的存儲引擎部分,對于PG 的事務 以及 其周邊伴随的 wal-writer, 可見性檢測,vacuum, checkpointer 等機制沒有描述,因為 整個PG 作為 百萬行代碼級别的關系型資料庫,其每一個小功能/元件 的實作都有非常多的細節,還需要持續深入學習。

從heap 表引擎設計來看, pg 将應用邏輯 和 io 排程分割開,PG 主程序隻需要專注于DML 等語句的CPU 邏輯排程,而 IO 層面交給專門的背景程序,對性能更為友好。