PostgreSQL的並發控制以快照隔離(SI)為主,以兩階段鎖定(2PL)機制為輔。PostgreSQL對DML(增刪改查等命令)使用SI,對DDL(CREATE TABLE等命令)使用2PL。
PostgreSQL有好幾類鎖,其中最主要的是表級鎖與行級鎖,此外還有頁級鎖,諮詢鎖等等。表級鎖通常是各種命令執行時自動獲取的,或者通過事務中的LOCK語句顯式獲取;而行級鎖則是由SELECT FOR UPDATE|SHARE語句顯式獲取的。本文主要介紹PostgreSQL中的表鎖。
表級鎖通常會在執行各種命令執行時自動獲取,或者通過在事務中使用LOCK語句顯式獲取。
每種鎖都有自己的衝突集合,在同一時刻的同一張表上,兩個事務可以持有不衝突的鎖,不能持有衝突的鎖。
有些鎖是**自斥(self-conflict)**的,即最多只能被一個事務所持有。
表級鎖總共有八種模式,有著並不嚴格的強度遞增關係(例外是Share鎖不自斥)
表級鎖存在於PG的共享內存中,可以通過pg_locks系統視圖查閱。
如何記憶這麼多類型的鎖呢?讓我們從演化的視角來看這些鎖。
最開始只有兩種鎖:Share與Exclusive,共享鎖與排它鎖,即所謂讀鎖與寫鎖。讀鎖的目的是阻止表數據的變更,而寫鎖的目的是阻止一切並發訪問。這很好理解。
後來隨著多版本並發控制技術的出現(PostgreSQL使用快照隔離實現MVCC),讀不阻塞寫,寫不阻塞讀(針對表的增刪改查而言)。因而原有的鎖模型就需要升級了:這裡的共享鎖與排他鎖都有了一個升級版本,即前面多加一個ACCESS。ACCESS SHARE是改良版共享鎖,即允許ACCESS(多版本並發訪問)的SHARE鎖,這種鎖意味著即使其他進程正在並發修改數據也不會阻塞本進程讀取數據。當然有了多版本讀鎖也就會有對應的多版本寫鎖來阻止一切訪問,即連ACCESS(多版本並發訪問)都要EXCLUSIVE的鎖,這種鎖會阻止一切訪問,是最強的寫鎖。
引入MVCC後,INSERT|UPDATE|DELETE仍然使用原來的Exclusive鎖,而普通的只讀SELECT則使用多版本的AccessShare鎖。因為AccessShare鎖與原來的Exclusive鎖不衝突,所以讀寫之間就不會阻塞了。原來的Share鎖現在主要的應用場景為創建索引(非並發創建模式下,創建索引會阻止任何對底層數據的變更),而升級的多版本AccessExclusive鎖主要用於除了增刪改之外的排他性變更(DROP|TRUNCATE|REINDEX|VACUUM FULL等),這個模型如圖(a)所示。
當然,這樣還是有問題的。雖然在MVCC中讀寫之間相互不阻塞了,但寫-寫之間還是會產生衝突。上面的模型中,並發寫入是通過表級別的Exclusive鎖解決的。表級鎖雖然可以解決並發寫入衝突問題,但這個粒度太大了,會影響並發度:因為同一時刻一張表上只能有一個進程持有Exclusive鎖並執行寫入,而典型的OLTP場景是以單行寫入為主。所以常見的DBMS解決寫-寫衝突通常都是採用行級鎖來實現(下面會講到)。
行級鎖和表級鎖不是一回事,但這兩種鎖之間仍然存在著聯繫,協調這兩種鎖之間的關係,就需要引入意向鎖。
意向鎖用於協調表鎖與行鎖之間的關係:它用於保護較低資源級別上的鎖,即說明下層節點已經被加了鎖。當進程想要鎖定或修改某表上的某一行時,它會在這一行上加上行級鎖。但在加行級鎖之前,它還需要在這張表上加上一把意向鎖,表示自己將會在表中的若干行上加鎖。
舉個例子,假設不存在意向鎖。假設進程A獲取了表上某行的行鎖,持有行上的排他鎖意味著進程A可以對這一行執行寫入;同時因為不存在意向鎖,進程B很順利地獲取了該表上的表級排他鎖,這意味著進程B可以對整個表,包括A鎖定對那一行進行修改,這就違背了常識邏輯。因此A需要在獲取行鎖前先獲取表上的意向鎖,這樣後來的B就意識到自己無法獲取整個表上的排他鎖了(但B依然可以加一個意向鎖,獲取其他行上的行鎖)。
因此,這裡RowShare就是行級共享鎖對應的表級意向鎖(SELECT FOR SHARE|UPDATE命令獲取),而RowExclusive(INSERT|UPDATE|DELETE獲取)則是行級排他鎖對應的表級意向鎖。注意因為MVCC的存在,只讀查詢並不會在行上加鎖。引入意向鎖後的模型如圖(c)所示。而合併MVCC與意向鎖模型之後的鎖模型如圖(d)所示。
上面這個模型已經相當不錯,但仍然存在一些問題,譬如自斥:這裡RowExclusive與Share鎖都不是自斥的。
舉個例子,並發VACUUM不應阻塞數據寫入,而且一個表上不應該允許多個VACUUM進程同時工作。因為不能阻塞寫入,因此VACUUM所需的鎖強度必須要比Share鎖弱,弱於Share的最強鎖為RowExclusive,不幸的是,該鎖並不自斥。如果VACUUM使用該鎖,就無法阻止單表上出現多個VACUUM進程。因此需要引入一個自斥版本的RowExclusive鎖,即ShareUpdateExclusive鎖。
同理,再比如執行觸發器管理操作(創建,刪除,啟用)時,該操作不應阻塞讀取和鎖定,但必須禁止一切實際的數據寫入,否則就難以判斷某條元組的變更是否應該觸發觸發器。Share鎖滿足不阻塞讀取和鎖定的條件,但並不自斥,因此可能出現多個進程在同一個表上並發修改觸發器。並發修改觸發器會帶來很多問題(譬如丟失更新,A將其配置為Replica Trigger,B將其配置為Always Trigger,都反回成功了,以誰為準?)。因此這裡也需要一個自斥版本的Share鎖,即ShareRowExclusive鎖。
因此,引入兩種自斥版本的鎖後,就是PostgreSQL中的最終表級鎖模型,如圖(e)所示。
PostgreSQL的表級鎖的命名有些詰屈聱牙,這是因為一些歷史因素,但也可以總結出一些規律便於記憶。
最初只有兩種鎖:共享鎖(Share)與排他鎖(Exclusive)。
特徵是只有一個單詞,表示這是兩種最基本的鎖:讀鎖與寫鎖。
多版本並發控制的出現,引入了多版本的共享鎖與排他鎖(AccessShare與AccessExclusive)。
特徵是Access前綴,表示這是用於"多版本並發控制"的改良鎖。
為了處理並發寫入之間的衝突,又引入了兩種意向鎖(RowShare與RowExclusive)
特徵是Row前綴,表示這是行級別共享/排他鎖對應的表級意向鎖。
最後,為了處理意向排他鎖與共享鎖不自斥的問題,引入了這兩種鎖的自斥版本(ShareUpdateExclusive, ShareRowExclusive)。這兩種鎖的名稱比較難記:
都是以Share打頭,以Exclusive結尾。表示這兩種鎖都是某種共享鎖的自斥版本。
兩種鎖強度圍繞在Share前後,Update弱於Share,Row強於Share。
ShareRowExclusive可以理解為Share + Row Exclusive,因為Share不排斥其他Share,但RowExclusive排斥Share,因此同時加這兩種鎖的結果等效於ShareRowExclusive,即SIX。
ShareUpdateExclusive可以理解為ShareUpdate + Exclusive:UPDATE操作持有RowExclusive鎖,而ShareUpdate指的是本鎖與普通的增刪改(持RowExclusive鎖)相容,而Exclusive則表示自己和自己不相容。
Share, ShareRowUpdate, Exclusive 這三種鎖極少出現,基本可以無視。所以實際上主要用到的鎖是:
多版本兩種:AccessShare, AccessExclusive
意向鎖兩種:RowShare,RowExclusive
自斥更新排他鎖一種:ShareUpdateExclusive
通常表級鎖會在相應命令執行中自動獲取,但也可以手動顯式獲取。使用LOCK命令加鎖的方式
LOCK [ TABLE ] [ ONLY ] name [ * ] [, ...] [ IN lockmode MODE ] [ NOWAIT ]
顯式鎖表必須在事務中進行,在事務外鎖表會報錯。
鎖定視圖時,視圖定義中所有出現的表都會被鎖定。
使用表繼承時,默認父表和所有後代表都會加鎖,指定ONLY選項則繼承於該表的子表不會自動加鎖。
鎖表或者鎖視圖需要對應的權限,例如AccessShare鎖需要SELECT權限。
默認獲取的鎖模式為AccessExclusive,即最強的鎖。
LOCK TABLE只能獲取表鎖,默認會等待衝突的鎖被釋放,指定NOWAIT選項時,如果命令不能立刻獲得鎖就會中止並報錯。
命令一旦獲取到鎖, 會被在當前事務中一直持有。沒有UNLOCK TABLE命令,鎖總是在事務結束時釋放。
舉個例子,以遷移數據為例,假設希望將某張表的數據遷移到另一個實例中。並保證在此期間舊錶上的數據在遷移期間不發生變化,那麼我們可以做的就是在複製數據前在表上顯式加鎖,並在複製結束,應用開始寫入新表後釋放。應用仍然可以從舊錶上讀取數據,但不允許寫入。那麼根據鎖衝突矩陣,允許只讀查詢的鎖要弱於AccessExclusive,阻止寫入的鎖不能弱於ShareRowExclusive,因此可以選擇ShareRowExclusive或Exclusive鎖。因為拒絕寫入意味著鎖定沒有任何意義,所以這裡選擇更強的Exclusive鎖。
BEGIN;
LOCK TABLE tbl IN EXCLUSIVE MODE;
-- DO Something
COMMIT
PostgreSQL提供了一個系統視圖pg_locks,包含了當前活動進程持鎖的信息。可以鎖定的對象包括:關係,頁面,元組,事務標識(虛擬的或真實的),其他資料庫對象(帶有OID)。
樣例數據
這個視圖需要一些額外的知識才能解讀。
該視圖是資料庫集簇範圍的視圖,而非僅限於單個資料庫,即可以看見其他資料庫中的鎖。
一個進程在一個時間點只能等待至多一個鎖,等待鎖用granted=f表示,等待進程會休眠至其他鎖被釋放,或者系統檢測到死鎖。
每個事務都有一個虛擬事務標識virtualtransaction(以下簡稱vxid),修改資料庫狀態(或者顯式調用txid_current獲取)的事務才會被分配一個真實的事務標識transactionid(簡稱txid),vxid|txid本身也是可以鎖定的對象。
每個事務都會持有自己vxid上的Exclusive鎖,如果有txid,也會同時持有其上的Exclusive鎖(即同時持有txid和vxid上的排它鎖)。因此當一個事務需要等待另一個事務時,它會嘗試獲取另一個事務txid|vxid上的共享鎖,因而只有當目標事務結束(自動釋放自己事務標識上的Exclusive鎖)時,等待事務才會被喚醒。
pg_locks視圖通常並不會直接顯示行級鎖信息,因為這些信息存儲在磁碟磁碟上(),如果真的有進程在等待行鎖,顯示的形式通常是一個事務等待另一個事務,而不是等待某個具體的行鎖。
諮詢鎖本質上的鎖對象客體是一個資料庫範疇內的BIGINT,classid裡包含了該整數的高32bit,objid裡包含有低32bit,objsubid裡則說明了諮詢鎖的類型,單一Bigint則取值為1,兩個int32則取值為2。
本視圖並不一定能保證提供一個一致的快照,因為所有fastpath=true的鎖信息是從每個後端進程收集而來的,而fastpath=false的鎖是從常規鎖管理器中獲取的,同時謂詞鎖管理器中的數據也是單獨獲取的,因此這幾種來源的數據之間可能並不一致。
頻繁訪問本視圖會對資料庫系統性能產生影響,因為要對鎖管理器加鎖獲取一致性快照。
虛擬事務
一個後端進程在整個生命周期中的每一個事務都會有一個自己的虛擬事務ID。PG中事務號是有限的(32-bit整型),會循環使用。為了節約事務號,PG只會為實際修改資料庫狀態的事務分配真實事務ID,而只讀事務就不分配了,用虛擬事務ID湊合一下。txid是事務標識,全局共享,而vxid是虛擬事務標識,在短期內可以保證全局唯一性。因為vxid由兩部分組成:BackendID與LocalTransactionId,前者是後端進程的標識符(本進程在內存中進程數組中的序號),後者是一個遞增的事務計數器。因此兩者組合即可獲得一個暫時唯一的虛擬事務標識(之所以是暫時是因為這裡的後端ID是有可能重複的)
typedef struct {
BackendId backendId;
/* 後端ID,初始化時確定,其實是後端進程數組內索引號 */
LocalTransactionId localTransactionId;
/* 後端內本地使用的命令標ID,類似自增計數器 */
} VirtualTransactionId;
SELECT與UPDATE|DELETE|INSERT不會相互阻塞,即使訪問的是同一行。
I|U|D寫入操作與I|U|D寫入操作在表層面不會互斥,會在具體的行上通過RowExclusive鎖實現。
SELECT FOR UPDATE鎖定操作與I|U|D寫入在表層級也不會互斥,仍然是通過具體元組上的行鎖實現。
並發VACUUM,並發創建索引等操作不會阻塞讀寫,但它們是自斥的,即同一時刻只會有一個(所以同時在一個表上執行兩個CREATE INDEX CONCURRENTLY是沒有意義的,不要被名字騙了)
普通的索引創建CREATE INDEX,不帶CONCURRENTLY會阻塞增刪改,但不會阻塞查,很少用到。
任何對於觸發器的操作,或者約束類的操作,都會阻止增刪改,但不會阻塞只讀查詢以及鎖定。
冷門的命令REFRESH MATERIALIZED VIEW CONCURRENTLY允許SELECT和鎖定。
大多數很硬的變更:VACUUM FULL, DROP TABLE, TRUNCATE, ALTER TABLE的大多數形式都會阻塞一切讀取。
注意,鎖雖有強弱之分,但衝突關係是對等的。一個持有AccessShare鎖的SELECT會阻止後續的DROP TABLE獲得AccessExclusive鎖。後面的命令會進入鎖隊列中。
PG中每個鎖上都會有一個鎖隊列。如果事務A佔有一個排他鎖,那麼事務B在嘗試獲取其上的鎖時就會在其鎖隊列中等待。如果這時候事務C同樣要獲取該鎖,那麼它不僅要和事務A進行衝突檢測,也要和B進行衝突檢測,以及隊列中其他的事務。這意味著當用戶嘗試獲取一個很強的鎖而未得等待時,已經會阻止後續新鎖的獲取。一個具體的例子是加列:
ALTER TABLE tbl ADD COLUMN mtime TIMESTAMP;
即使這是一個不帶默認值的加列操作(不會重寫整個表,因而很快),但本命令需要表上的AccessExclusive鎖,如果這張表上面已經有不少查詢,那麼這個命令可能會等待相當一段時間。因為它需要等待其他查詢結束並釋放掉鎖後才能執行。相應地,因為這條命令已經在等待隊列中,後續的查詢都會被它所阻塞。因此,當執行此類命令時的一個最佳實踐是在此類命令前修改lock_timeout,從而避免雪崩。
SET lock_timeout TO '1s';
ALTER TABLE tbl ADD COLUMN mtime TIMESTAMP;
這個設計的好處是,命令不會餓死:不會出現源源不斷的短小隻讀查詢無限阻塞住一個排他操作。
夠用即可:使用滿足條件的鎖中最弱的鎖模式
越快越好:如果可能,可以用(長時間的弱鎖+短時間的強鎖)替換長時間的強鎖
遞增獲取:遵循2PL原則申請鎖;越晚使用激進鎖策略越好;在真正需要時再獲取。
相同順序:獲取鎖儘量以一致的順序獲取,從而減小死鎖的機率
除了手工鎖定之外,很多常見的操作都會"鎖表",最常見的莫過於添加新欄位與添加新約束。這兩種操作都會獲取表上的AccessExclusive鎖以阻止一切並發訪問。當DBA需要在線維護資料庫時應當最小化持鎖的時間。
例如,為表添加新欄位的ALTER TABLE ADD COLUMN子句,根據新列是否提供易變默認值,會重寫整個表。
ALTER TABLE tbl ADD COLUMN mtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
如果只是個小表,業務負載也不大,那麼也許可以直接這麼幹。但如果是很大的表,以及很高的負載,那麼阻塞的時間就會很可觀。在這段時間裡,命令都會持有表上的AccessExclusive鎖阻塞一切訪問。
可以通過先加一個空列,再慢慢更新的方式來最小化鎖等待時間:
ALTER TABLE tbl ADD COLUMN mtime TIMESTAMP;
UPDATE tbl SET mtime = CURRENT_TIMESTAMP; -- 可以分批進行
這樣,第一條加列操作的鎖阻塞時間就會非常短,而後面的更新(重寫)操作就可以以不阻塞讀寫的形式慢慢進行,最小化鎖阻塞。
同理,當想要為表添加新的約束時(例如新的主鍵),也可以採用這種方式:
CREATE UNIQUE INDEX CONCURRENTLY tbl_pk ON tbl(id); -- 很慢,但不阻塞讀寫
ALTER TABLE tbl ADD CONSTRAINT tbl_pk PRIMARY KEY USING INDEX tbl_pk; -- 阻塞讀寫,但很快
來替代單純的 ALTER TABLE tbl ADD PRIMARY KEY (id);