為什么 Postgres 中的更新操作有時感覺比插入操作慢?答案在于 Postgres 如何在后臺管理數據版本。
Postgres 高效處理并發事務能力的核心是多版本并發控制(MVCC)。
在本文中,我將探討 MVCC 在 Postgres 中的工作原理以及它如何影響寫入和讀取,以及插入和更新之間的性能差異。
-- pageinspect 擴展用于查看底層數據存儲,我們很快就會用到它!
CREATE EXTENSION IF NOT EXISTS pageinspect;
-- 創建表存儲
CREATE TABLE store ( id SERIAL PRIMARY KEY, name TEXT NOT NULL ,value INT NOT NULL );
-- 禁用表的自動清理
ALTER TABLE store SET (autovacuum_enabled = false );
設置過程包括創建一個名為“store”的示例表并禁用自動清理。這將使我們能夠觀察 Postgres 如何在沒有自動清理的情況下處理版本控制(MVCC 的實際操作),從而幫助我們更好地理解 MVCC 的工作原理。
MVCC概述
為了理解為什么更新行為與插入不同,我們首先看看 MVCC 在 Postgres 中的工作方式。
多版本并發控制 (MVCC) 是 Postgres 用于處理同一行的并發事務同時保持數據一致性和隔離性的機制。
Postgres 不會在讀取和寫入期間鎖定行,而是創建一行的多個版本,以允許事務在不相互阻塞的情況下運行。
一行的多個版本被稱為元組 (Tuples)。如果插入一行,則有 1 個元組與之關聯。如果更新同一行,則存在 2 個元組,其中一個是活的,另一個是死的。
工作原理如下:
寫入操作
每個插入或更新查詢都在一個事務中執行,每個事務都會被分配一個唯一的事務 ID。雖然該過程還涉及寫入預寫日志 (WAL) 和設置檢查點,但本文不會介紹這些細節。
您可以通過以下方式檢查postgres中的當前事務ID:
postgres = # SELECT txid_current();
txid_current
--------------
753
( 1 行)postgres = # SELECT txid_current();
txid_current
--------------
754 ( 1 行)
Postgres 中的每一行都包含與事務 ID 綁定的版本信息,用于跟蹤該行隨時間的變化狀態。此版本控制通過 xmin 和 xmax 值進行管理,這些值可以直接查詢。
postgres = #插入 store (name, value )值( 'score' , 10 );插入 0 1 postgres = #從store中選擇xmin, xmax, * 其中id = 1 ; xmin | xmax | id | name | value ------+------+----+-------+------- 755 | 0 | 1 | score | 10 ( 1行)
- min表示插入該行(即創建初始元組)的事務的 ID。
- xmax表示刪除或使該行失效的事務的 ID。由于這里的 xmax 為 0,因此表示該行處于活動狀態,未被刪除或失效。
Read Operation這些值也會影響行可見性,我們將在下面的文章中更詳細地探討。
讓我們看一下數據庫中存儲的實際元組,看看 MVCC 內部是如何管理行版本的。我們可以使用 pageinspect 擴展直接檢查元組,這使我們能夠查看表底層頁面的原始內容。
Postgres 以頁的形式將數據存儲在磁盤上,每個頁包含多個元組。理想情況下,我們感興趣的元組應該位于第一頁(即第 0 頁)。讓我們使用 pageinspect 來查詢它,看看存儲了什么:
postgres = #從heap_page_items(get_raw_page( 'store' , 0 ))中選擇lp、t_ctid、t_xmin、t_xmax ; lp | t_ctid | t_xmin | t_xmax ----+--------+--------+-------- 1 | ( 0 , 1 ) | 755 | 0 ( 1行)
我們可以看到 xmin 和 xmax 值與表上的常規 SELECT 查詢的值匹配。
- lp– 這是行指針,充當頁面內的索引,指向元組的實際位置。由于lp = 1,這意味著這是頁面中第一個且唯一的元組。
- t_ctid– 這是元組的 ID,以 (page_number, tuple_number)格式指向元組的物理位置。如果該行被更新,則會創建一個新版本,并且 ctid 將指向新的位置——稍后會詳細介紹。
- xmin–表示插入該行的事務 ID(在本例中為事務 755)。
- xmax– 表示刪除或使該行失效的事務 ID。由于 xmax = 0,因此該行仍然有效。 Postgres 中的元組由系統列(例如 xmin、xmax、ctid 等)和實際行數據組成。系統列幫助 Postgres 管理行版本和可見性,稍后我們將更詳細地探討這些內容。
當發生寫入操作(例如更新)時,現有行不會被直接修改。相反,Postgres 會創建一個包含更新值的新行版本,并保持舊行不變。然后,舊元組會被標記為“已死”,但仍可用于 MVCC 用途(例如支持并發讀取)。
讓我們更新行并檢查更改:
postgres = # UPDATE store set value = 20 where id = 1 ;
UPDATE 1 postgres = # SELECT xmin, xmax, * FROM store WHERE id = 1 ;
xmin | xmax | id | name | value
------+------+----+-------+-------
756 | 0 | 1 | score | 20
( 1 行)
該行現在有了一個新的 xmin 值 (756),它代表創建此新元組的事務 ID。由于 MVCC 機制,我們預期原始行(現在是一個死元組)仍然與新行并存。
我們可以通過使用視圖檢查活元組和死元組的數量來確認這一點pg_stat_all_tables:
postgres = #從pg_stat_all_tables中選擇n_live_tup、n_dead_tup、relname ,其中relname = 'store';n_live_tup | n_dead_tup | relname ------------+------------+--------- 1 | 1 | store (1行)
正如預期的那樣,有 1 個活動元組(更新后的行)和 1 個死亡元組(原始行)。現在讓我們檢查底層頁面以查看這兩個元組:
讓我們查詢頁面來再次查看元組。
postgres = #從heap_page_items(get_raw_page( 'store' , 0 ))中選擇lp、t_ctid、t_xmin、t_xmax ; lp | t_ctid | t_xmin | t_xmax ----+--------+--------+-------- 1 | ( 0 , 2 ) | 755 | 756 2 | ( 0 , 2 ) | 756 | 0 ( 2行)
我們現在看到了預期的兩個元組:
- 第一個元組有 xmin = 755(來自原始插入)和 xmax = 756,表明它被事務 756(更新)無效。
- 第二個元組是更新創建的新版本,其中 xmin = 756 和 xmax = 0(表示它仍然有效)。
- 請注意,t_ctid原始元組的 已從更改為(0,1)。(0,2)這是因為t_ctid指向該行的最新版本。該格式(page_number,line_pointer)表示該行的最新版本位于第 0 頁,行指針為 2。
Heap Only Tuples (HOT)
此行為是 Postgres 一項名為“僅堆元組 (HOT)”的優化的一部分。在執行 UPDATE 操作時,Postgres 不會立即更新索引以指向新元組(這樣做成本較高),而是會更新舊元組的 t_ctid 以指向新版本。這會創建一個元組鏈——索引指向舊元組,舊元組指向新元組,依此類推。
Postgres 依靠 Vacuum 進程來清理死元組,并在稍后更新索引。
您可以在 Postgres 官方文檔中了解更多關于 HOT 的信息:
👉 https://www.postgresql.org/docs/current/storage-hot.html
Vacuum Process
你可能已經注意到,在之前的例子中,vacuum 操作被禁用了。這是故意為之,因為它允許我們觀察死元組和 HOT 鏈,否則它們會被vacuum 進程清理掉。
真空在 Postgres 中扮演著至關重要的角色,它可以永久刪除死元組、回收存儲空間,并更新索引以消除熱鏈——所有這些都有助于提升讀取性能。它還可以通過釋放死元組占用的存儲空間來防止表膨脹。
清理通常配置為自動運行,但也可以手動運行。讓我們嘗試清理包含 1 個死元組的表。
postgres = # SELECT n_live_tup, n_dead_tup, relname FROM pg_stat_all_tables WHERE relname = 'store' ;
n_live_tup | n_dead_tup | relname
------------+------------+---------
1 | 1 | store ( 1 row )
--- 我們有 1 個死元組
--- 手動清理“store”表
postgres = # VACUUM store;
VACUUM
--- 我們最終得到 0 個死元組
postgres = # SELECT n_live_tup, n_dead_tup, relname FROM pg_stat_all_tables WHERE relname = 'store' ;
n_live_tup | n_dead_tup | relname
------------+------------+---------
1 | 0 |存儲
(1 行)
postgres = #從heap_page_items(get_raw_page( 'store',0 ))中選擇lp、t_ctid、t_xmin、t_xmax ; lp | t_ctid | t_xmin | t_xmax ----+--------+--------+ -------- 1 | | | 2 |(0,2)| 756 | 0 ( 2 行)
- 檢查頁面時我們只能看到一個元組,位于 lp 2,因為lp1 處的舊元組已被刪除。
然而,清理表是有代價的。它會消耗 CPU 和內存,占用原本可以用于讀寫的資源,從而造成性能瓶頸。此外,清理所需的時間可能從幾秒到幾小時不等,具體取決于所需的清理量。
真空對于 至關重要Transaction Wraparound。您可以在這里相關信息
https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-WRAPAROUND
對讀取性能的影響
MVCC 不僅影響寫入性能,還會影響讀取性能。執行查詢時,Postgres 需要確定哪個行版本有效,這會增加一些開銷:
1. 行可見性檢查
每次讀取一行時,Postgres 都會檢查其xmin和xmax值以確定它是否是最新版本。這會增加一些處理時間,尤其是在由于頻繁更新而存在多個行版本的情況下。
2. 表膨脹
當更新操作創建新的行版本時,舊版本將保留在表中,直到VACUUM操作將其刪除。隨著時間的推移,這會增加表的大小并降低讀取速度,因為 Postgres 可能需要掃描多個行版本才能找到有效的版本。
頻繁VACUUM并AUTOVACUUM有助于減少膨脹并提高讀取性能。
3. 寫入密集型工作負載中的更新與插入
讀取延遲可能會有所不同,具體取決于您是更新現有行還是插入新行:
- 插入——直接添加新行而不影響現有行。
- 更新——創建一個新的行版本并將舊版本標記為死版本,這會增加表的大小,并且需要在讀取期間做更多的工作才能找到最新版本。
對于更新,Postgres 可能需要調整索引指針,因為每次更新都會創建一個新的元組。但是,如果更新符合HOT(僅堆元組)更新的條件,則意味著索引不需要立即更新,從而提高效率。
然而,熱更新會在堆中創建元組鏈,這會增加的工作量VACUUM。Postgres 需要在讀取期間跟蹤鏈以查找最新的有效版本,并且一旦VACUUM刪除舊版本,索引可能仍需要調整以反映最新狀態。
頻繁執行自動清理有助于清理死行并保持一致的性能。如果您在寫入密集型工作負載中發現讀取速度變慢,降低更新頻率或調整數據模型可能會有所幫助。
這并不是建議你完全避免更新。但是,如果你在寫入密集型工作負載下遇到更高的讀取延遲,則值得考慮高更新頻率是否是導致此問題的原因。優化數據模型以減少或避免過度更新,可以幫助緩解此問題并提高整體性能。
對于寫入極其繁重的情況,針對高寫入吞吐量進行優化的寬列存儲(如 Cassandra 或 ScyllaDB)可以提供更好的性能。
對存儲空間的影響
由于 MVCC,我們進行的更新越多,Postgres 消耗的磁盤空間就越多,而vacuum 負責回收該空間。
讓我們用一個例子來演示一下,我將插入 100 萬條記錄并執行一些更新。我們還將查看每一步表占用的存儲空間。
postgres = #截斷store;
TRUNCATE TABLE ---
插入一百萬條記錄postgres = # DO $$
BEGINFOR i IN 1. .1000000 LOOPINSERT INTO store (name, value )VALUES ( 'Name_' || i, i * 10 ); END LOOP;
END $$;
DO
postgres = # SELECT COUNT ( * ) FROM store;count ---------1000000 ( 1 row )
讓我們看看這個表占用的空間。
postgres = # SELECTrelname AS table_name, pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROMpg_catalog.pg_statio_user_tables
WHERErelname = 'store' ; table_name | total_size------------+------------ store | 71 MB ( 1 行)
這 100 萬條記錄占用了大約 71 MB 的磁盤空間。讓我們更新所有行并檢查存儲空間(我們仍然禁用了自動清理功能)。
postgres = #更新存儲設置 值= 2000 ;
更新 1000000 postgres = # SELECT relname AS table_name, pg_size_pretty(pg_total_relation_size(relid)) AS total_size FROMpg_catalog.pg_statio_user_tables
WHERErelname = 'store' ;
table_name | total_size
------------+------------
store | 142 MB ( 1 行)
正如預期的那樣,由于更新語句之后每行都有 2 個元組(1 個死元組和 1 個活元組),因此表的大小增加了一倍。
讓我們來看看這張表占用的空間。
postgres = # VACUUM 存儲;
VACUUM postgres = # SELECTrelname AS table_name, pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROMpg_catalog.pg_statio_user_tables
WHERE relname = 'store' ; table_name | total_size
------------+------------
存儲 | 142 MB
(1 行)
postgres = # SELECT n_live_tup,n_dead_tup,relname FROM pg_stat_all_tables WHERE relname = 'store' ;
n_live_tup | n_dead_tup | relname
------------+------------+---------1000000 | 0 |存儲
(1 行)
有趣的是,在清理表之后,存儲空間仍然為 142 MB,但死元組的數量已降至零,證實清理操作已成功清理元組。
這是 Vacuum 的預期行為——它不會減少總存儲空間,而是將釋放的空間標記為可重用。這意味著任何新數據都將寫入現有存儲空間,而不是增加整體磁盤使用率。
但是,在某些情況下,這可能并不理想。如果您真的想回收磁盤空間,可以運行 VACUUM FULL 操作:
postgres = # VACUUM FULL存儲;
VACUUM postgres = # SELECTrelname AS table_name, pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROMpg_catalog.pg_statio_user_tables
WHERErelname = 'store';
table_name | total_size
------------+------------
store | 71 MB
(1 行)
在這個例子中,VACUUM FULL 回收了之前標記為可重復使用或未使用的物理磁盤空間,從而減少了整體存儲大小。
結論
我們詳細介紹了 MVCC 的工作原理以及真空過程對性能的重要性。
這篇文章中還有很多內容我沒有涉及,我鼓勵您閱讀其中的一些主題(或者除非我決定在將來的某個時候撰寫它們):
- 真空分析操作
- 重建索引操作
- 交易回溯調節
- 自動真空
要點:MVCC 允許 Postgres 高效地處理并發事務,但也帶來了一些弊端。插入操作通常比更新操作讀取/性能更快,因為更新操作會創建新的行版本,這會導致數據庫膨脹,除非通過清理操作進行妥善管理。了解 MVCC 和 HOT 有助于您微調數據庫以獲得更佳性能。
#PG證書#PG考試#PostgreSQL培訓#PostgreSQL考試#PostgreSQL認證