PgSQL · 特性分析 · 事務ID回卷問題
author: 卓刀
背景
在之前的
月報 PgSQL · 特性分析 · MVCC機制淺析中,我們了解到了:
- 事務ID(XID)使用32位無符号數來表示,順序産生,依次遞增
- 每個元組會來用(t_xmin, t_xmax)來标示自己的可用性
- t_xmin 存儲的是産生這個元組的事務ID,可能是insert或者update語句
- t_xmax 存儲的是删除或者鎖定這個元組的XID
- 每個事務隻能看見t_xmin比自己XID 小且沒有被删除的元組
其中需要注意的是,XID 是用32位無符号數來表示的,也就是說如果不引入特殊的處理,當PostgreSQL的XID 到達40億,會造成溢出,進而新的XID 為0。而按照PostgreSQL的MVCC 機制實作,之前的事務就可以看到這個新事務建立的元組,而新事務不能看到之前事務建立的元組,這違反了事務的可見性。本文将這種現象稱為XID 的回卷問題。
使用64位無符号數表示XID 可以緩解甚至解決這個問題。但是因為資料頁中每個元組都會存儲(xmin,xmax),這樣勢必會造成元組頭部資訊繼續擴大,至少擴大2個位元組。兩者權衡,PostgreSQL 社群更加期望将這些内容放在緩存中,而隻用資料頁的一個bit 位來達到64位XID 的效果(詳見
郵件清單)。目前來看,PostgreSQL 社群也是逐漸在實作這點。我們接下來主要來分析PostgreSQL 是如何解決這個問題的。
兩個事務ID的比較方法
在詳細講解PostgreSQL 解決這個問題的方法之前,我們需要先了解下兩個XID 是如何進行比較的。這部分代碼非常的簡單易懂,具體如下:
/*
* TransactionIdPrecedes --- is id1 logically < id2?
*/
bool
TransactionIdPrecedes(TransactionId id1, TransactionId id2)
{
/*
* If either ID is a permanent XID then we can just do unsigned
* comparison. If both are normal, do a modulo-2^32 comparison.
*/
int32 diff;
if (!TransactionIdIsNormal(id1) || !TransactionIdIsNormal(id2))
return (id1 < id2);
diff = (int32) (id1 - id2);
return (diff < 0);
}
其中,值得注意的是diff = (int32) (id1 - id2)。如果發生了XID 回卷後,即使id1=4294967290比id2=5(回卷後的XID)大,但因為相減後diff大于2^31,結果值轉成int32後會變成一個負數,進而讓判斷邏輯與事務回卷前都是一樣的: (int32)(id1 - id2) < 0。但是如果這裡的id2 是回卷前的XID,則這裡就會出現問題。是以,PostgreSQL 就要保證一個資料庫中兩個有效的事務之間的年齡最多是2^31,即20億。
也就是說:
- PostgreSQL中是使用2^31取模的方法來進行事務的比較
- 同一個資料庫中,存在的最舊和最新兩個事務之間的年齡最多是2^31,即20億
這時,我們可以把PostgreSQL 中事務ID了解為一個循環可重用的序列串。對其中的任一普通XID(特殊XID 除外)來說,都有20億個相對它來說過去的事務,都有20億個未來的事務,事務ID 回卷的問題得到了解決。但是可以看出這個問題得到解決的前提在同一個資料庫中存在的最舊和最新兩個事務之間的年齡是最多是2^31,接下來我們将分析PostgreSQL 如何做到的這一點。
當機清理
為了實作同一個資料庫中的最新和最舊的兩個事務之間的年齡不超過2^31,PostgreSQL 引入了當機清理(freeze)的功能。通過之前的
月報 PgSQL · 源碼分析 · AutoVacuum機制之autovacuum worker可知,在autovacuum過程中,會自動對符合條件的元組進行freeze。為了不讓問題擴散,我們會在下文具體分析符合什麼條件的元組才需要freeze,這裡會先分析不同的PostgreSQL版本freeze具體的實作。
9.4之前當機清理實作
在9.4之前的版本中,freeze實作的方法很簡單。就是對符合條件的元組直接更新元組資訊(HeapTupleFields結構體)中的t_xmin 屬性為一個特殊的XID,FrozenTransactionId(FrozenTransactionId 為2,initdb産生的catalog所對應的XID 為1,0代表無效的XID)。
以FrozenTransactionId為t_xmin的元組将會被其他所有的事務可見,這樣該元組原來對應的XID 相當于被回收了,經過不斷的處理,就可以控制一個資料庫的最老的事務和最新的事務的年齡不超過20億。
但是這樣的實作有很多問題:
- 目前可見的資料頁(通過visibility map可以快速定位)需要全部掃描,帶來大量的IO掃描
- 符合條件的元組需要更新xmin,造成大量的髒頁,帶來大量的IO
9.4之後當機清理實作
為了解決之前老版本存在的問題,9.4之後(包含9.4)不直接修改HeapTupleFields中的t_xmin,而是:
- 隻更新元組頭部資訊(HeapTupleHeaderData結構體)的t_infomask為HEAP_XMIN_FROZEN,表示該元組已經被當機清理過(frozen)
- 有些插入操作,也可以直接将記錄置為frozen,例如大批量的COPY資料,insert into等
- 整個page 如果所有記錄已經frozen,則再vm檔案中标記為FROZEN,當機清理會跳過該頁,減少了IO掃描
其中值得注意的是,如果vm頁損壞了,可以通過vacuum DISABLE_PAGE_SKIPPING強制掃描所有的資料頁。
可以看出,9.4之後對freeze的實作進行了很多方面的優化,提高了其性能。不過如果是9.4之前的資料通過pg_upgrade的腳本導入的資料,仍然會發現有t_xmin 為2的元組。當然除了上文講到的autovaccum可以周期性地進行freeze之外,我們還可以執行VACUUM FREEZE指令來強制freeze。
至此,我們弄清楚了freeze是怎麼實作的,接下來會去分析元組滿足什麼樣的條件才會觸發周期性的freeze。在PostgreSQL,這個條件是由一系列的參數設定來實作的,研究好這些參數的含義,将會更加有利于我們的日常運維。
涉及到的參數
與freeze相關的參數主要有三個:
- vacuum_freeze_min_age
- vacuum_freeze_table_age
- autovacuum_freeze_max_age
vacuum_freeze_min_age 表示表中每個元組需要freeze的最小年齡。這裡值得一提的是每次表被freeze 之後,會更新pg_class 中的relfrozenxid 列為本次freeze的XID。表年齡就是目前的最新的XID 與relfrozenxid的內插補點,而元組年齡可以了解為每個元組的t_xmin與relfrozenxid的內插補點。是以,這個參數也可以被簡單了解為每個元組兩次被freeze之間的XID 內插補點的一個最小值。增大該參數可以避免一些無用的freeze 操作,減小該參數可以使得在表必須被強制清理之前保留更多的XID 空間。該參數最大值為20億,最小值為2億。
普通的vacuum 使用visibility map來快速定位哪些資料頁需要被掃描,隻會掃描那些髒頁,其他的資料頁即使其中元組對應的xmin非常舊也不會被掃描。而在freeze的過程中,我們是需要對所有可見且未被all-frozen的資料頁進行掃描,這個掃描過程PostgreSQL 稱為aggressive vacuum。每次vacuum都去掃描每個表所有符合條件的資料頁顯然是不現實的,是以我們要選擇合理的aggressive vacuum周期。PostgreSQL 引入了參數vacuum_freeze_table_age來決定這個周期。
vacuum_freeze_table_age 表示表的年齡大于該值時,會進行aggressive vacuum,即掃描表中可見且未被all-frozen的資料頁。該參數最大值為20億,最小值為1.5億。如果該值為0,則每次掃描表都進行aggressive vacuum。
直到這裡,我們可以看出:
- 當表的年齡超過vacuum_freeze_table_age則會aggressive vacuum
- 當元組的年齡超過vacuum_freeze_min_age後可以進行freeze
為了保證上文中整個資料庫的最老最新事務差不能超過20億的原則,兩次aggressive vacuum之間的新老事務差不能超過20億,即兩次aggressive vacuum之間表的年齡增長(vacuum_freeze_table_age)不能超過20億減去vacuum_freeze_min_age(隻有元組年齡超過vacuum_freeze_min_age才會被freeze)。但是看上面的參數,很明顯不能絕對保證這個限制,為了解決這個問題,PostgreSQL 引入了autovacuum_freeze_max_age 參數。
autovacuum_freeze_max_age 表示如果目前最新的XID 減去元組的t_xmin
大于等于autovacuum_freeze_max_age,則元組對應的表會強制進行autovacuum,即使PostgreSQL已經關閉了autovacuum。該參數最小值為2億,最大值為20億。
也就是說,在經過autovacuum_freeze_max_age-vacuum_freeze_min_age的XID 增長之後,這個表肯定會被強制地進行 一次freeze。因為autovacuum_freeze_max_age最大值為20億,是以說在兩次freeze之間,XID 的增長肯定不會超過20億,這就保證了上文中整個資料庫的最老最新事務差不能超過20億的原則。
值得一提的是,vacuum_freeze_table_age設定的值如果比autovacuum_freeze_max_age要高,則每次vacuum_freeze_table_age生效地時候,autovacuum_freeze_max_age已經生效,起不到過濾減少資料頁掃描的作用。是以預設的規則,vacuum_freeze_table_age要設定的比autovacuum_freeze_max_age小。但是也不能太小,太小的話會造成頻繁的aggressive vacuum。
另外我們通過分析源碼可知,vacuum_freeze_table_age在最後應用時,會去取min(vacuum_freeze_table_age,0.95 autovacuum_freeze_max_age)。是以官方文檔推薦vacuum_freeze_table_age=0.95 autovacuum_freeze_max_age。
freeze 操作會消耗大量的IO,對于不經常更新的表,可以合理地增大autovacuum_freeze_max_age和vacuum_freeze_min_age的內插補點。
但是如果設定autovacuum_freeze_max_age 和vacuum_freeze_table_age過大,因為需要存儲更多的事務送出資訊,會造成pg_xact 和 pg_commit 目錄占用更多的空間。例如,我們把autovacuum_freeze_max_age設定為最大值20億,pg_xact大約占500MB,pg_commit_ts大約是20GB(一個事務的送出狀态占2位)。如果是對存儲比較敏感的使用者,也要考慮這點影響。
而減小vacuum_freeze_min_age則會造成vacuum 做很多無用的工作,因為當資料庫freeze 了符合條件的row後,這個row很可能接着會被改變。理想的狀态就是,當該行不會被改變,才去freeze 這行。
但是遺憾的是,無論參數怎麼調優,都存在一個問題,freeze是不能主動預測的,隻能被動觸發,是以更提倡使用者進行主動預測需要freeze 的時機,選擇合适的時間(比如說應用負載較低的時間)主動執行vacuum freeze指令。接下來我們會具體讨論如何去做關于vacuum freeze 的運維。
運維建議
由于參數設定問題或者其他問題,造成freeze 失敗,導緻資料庫最老的表年齡達到了1000萬的時候,資料庫會列印如下的warning:
WARNING: database "mydb" must be vacuumed within 177009986 transactions
HINT: To avoid a database shutdown, execute a database-wide VACUUM in "mydb".
根據提示,對該資料庫執行vacuum free指令,可以解決這個潛在的問題。注意因為非超級使用者沒有權限更新database的datfrozenxid,隻能使用超級使用者執行acuum free database_name。
如果資料庫可用的XID 空間還有100萬的時候,即目前最新XID 與資料庫最老的XID 的內插補點還差100萬達到20億,則PostgreSQL 會變為隻讀并拒絕開啟任何新的事務,同時在日志中列印如下錯誤資訊:
ERROR: database is not accepting commands to avoid wraparound data loss in database "mydb"
HINT: Stop the postmaster and vacuum that database in single-user mode.
如果出現了這種情況,根據提示,使用者可以以單使用者模式(single-user mode,詳見
連結)的方法啟動PostgreSQL并執行vacuum freeze指令。
可以看出,參數的正确設定是非常重要的。但是上文說過即使參數設定的比較合适,因為不能預測freeze 發生的時間,如果freeze發生的時間正好是資料庫比較繁忙的時間,這就會造成IO資源争搶,導緻正常的業務受損。使用者可以自己監控資料庫和表的年齡,在業務比較空閑的時間主動執行以下操作:
- 查詢目前所有表的年齡,SQL 語句如下:
SELECT c.oid::regclass as table_name,
greatest(age(c.relfrozenxid),age(t.relfrozenxid)) as age
FROM pg_class c
LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
WHERE c.relkind IN ('r', 'm');
- 查詢所有資料庫的年齡,SQL 語句如下:
SELECT datname, age(datfrozenxid) FROM pg_database;
- 設定vacuum_cost_delay為一個比較高的數值(例如50ms),這樣可以減少普通vacuum對正常資料查詢的影響
- 設定vacuum_freeze_table_age=0.5 * autovacuum_freeze_max_age,vacuum_freeze_min_age為原來值的0.1倍
- 對上面查詢的表依次執行vacuum freeze,注意要預估好時間。
目前已經有很多實作好的開源PostgreSQL vacuum freeze監控管理工具,比如說
flexible-freeze),它能夠:
- 确定資料庫的高峰和低峰期
- 在資料庫低峰期建立一個cron job 去執行flexible_freeze.py
- flexible_freeze.py 會自動對具有最老XID的表進行vacuum freeze
總結
至此,我們已經從各個角度分析了PostgreSQL 中出現的事務ID 回卷問題的解決方法。總結起來就是:
- XID 可循環利用
- XID 比較實用mod 2^31的方法
- 同一個資料庫中,存在的最舊和最新兩個事務之間的年齡最大為2^31,即20億
- 當元組滿足一定條件時,将其freeze,進而實作了将其對應的XID回收的操作
- 通過vacuum_freeze_min_age,vacuum_freeze_table_age,autovacuum_freeze_max_age參數配合,讓freeze 操作更平滑,更高效
不過,上文中我們并沒有涉及Multixacts ID 的回卷問題。Multixacts ID 的回卷和XID 的回卷問題大體相似,我們這裡不再過多贅述,有興趣的同學可以去查找下相關資料。