任何資料庫系統的核心功能,就是在多個交易同時修改系統狀態時,仍能實作並行並完全遵守系統的約束與屬性。PostgreSQL 完全符合 ACID,並實作了交易隔離(transaction isolation),讓應用程式的並行問題得以優雅處理。
- 並行是棘手而複雜的問題,且並行 bug 通常很難重現——所以最好倚賴既有的解決方案,而不是自己造輪子。
- 在一般程式語言中處理並行,通常得正確使用 lock、mutex、semaphore(底層靠 CPU 的原子操作);Java 提供
synchronized區塊,Erlang 則只提供訊息傳遞(mailbox 系統)、在內部代你處理並行。 - SQL 是宣告式語言:開發者只宣告意圖(想要的結果),實作方負責把每個細節做對——包括並行行為。PostgreSQL 的並行實作可靠,並且允許使用者對查詢的鎖定行為做一定程度的控制。
交易與隔離#
依 ACID 屬性,交易必須與系統中其他並行交易隔離(Isolated),而且可以依使用情境選擇隔離等級。
一個簡單的例子是線上備份:pg_dump 要對整個資料庫拍快照並匯出,這要求它的讀取與任何並行寫入完全隔離——靠 repeatable read 或 serializable 隔離等級達成。PostgreSQL 9.1 起 pg_dump 使用 serializable(在 SSI 實作之前是 repeatable read)。
SQL 標準定義了四個隔離等級,其中最嚴格的是 serializable:一組 serializable 交易的任何並行執行,保證產生與「依某種順序一次執行一個」相同的效果。其餘三個等級則以「各等級不得出現哪些現象(phenomena)」來定義。PostgreSQL 文件列出的現象如下:
- Dirty read(髒讀):交易讀到並行交易尚未提交的寫入。
- Nonrepeatable read(不可重複讀):交易重讀先前讀過的資料,發現已被另一個(初讀之後提交的)交易修改。
- Phantom read(幻讀):交易重新執行一個回傳符合條件資料列集合的查詢,發現集合因另一個剛提交的交易而改變。
- Serialization anomaly(序列化異常):一組交易成功提交的結果,與這些交易任何一種逐一執行的順序都不一致。
標準的四個等級為 read uncommitted、read committed、repeatable read、serializable:
read committed禁止 dirty read;repeatable read再禁止 nonrepeatable read;serializable禁止所有異常。
PostgreSQL 沒有實作
read uncommitted(允許髒讀的等級),預設即為read committed;而且 PostgreSQL 的repeatable read就已經禁止 phantom read。
關於 SSI#
PostgreSQL 的 serializable 實作(SSI,Serializable Snapshot Isolation)是一項了不起的工程,細節見 PostgreSQL wiki 的 Serializable 與 SSI 頁面。
研究社群花了約 20 年才找到能有效率實作 serializable snapshot isolation 的數學模型;而這項重大進展只花了一年就被納入 PostgreSQL。
並行更新與隔離#
以推文模型的轉推(retweet)為例——rts 是 tweet.message 的計數欄位:
update tweet.message
set rts = rts + 1
where messageid = 1;兩個使用者同時這麼做會發生什麼事?用手動交易控制把「同時」演出來:在第一個 psql 提示符執行——
begin;
update tweet.message
set rts = rts + 1
where messageid = 1
returning messageid, rts;交易保持開啟(idle in transaction)。再開第二個 psql 執行一模一樣的查詢——這次 update 不會返回:第一個交易還在處理 messageid = 1 那一列,在它結束前,這一列上不允許任何並行活動。
回到第一個提示符 commit 之後,行為取決於隔離等級。在預設的 read committed 下,第二個提示符的 update 解鎖並立即返回 rts = 3(在第一個交易的 rts = 2 之上累加)。
若改用 repeatable read(先在 psql 中 \set ON_ERROR_ROLLBACK off,否則 psql 會用 savepoint 保護交易、遮掉我們要觀察的行為):
start transaction isolation level repeatable read;
update tweet.message
set rts = rts + 1
where messageid = 1
returning messageid, rts;同樣讓第二個交易等待第一個交易,然後提交第一個。這次第二個交易得到的是:
ERROR: could not serialize access due to concurrent update
yesql!# commit;
ROLLBACK注意即使下了
COMMIT,得到的仍是ROLLBACK——在 PostgreSQL 中,交易一旦發生錯誤就再也不能提交。使用serializable等級時行為與repeatable read相同,錯誤訊息也一模一樣。
為並行而建模#
帶著這些並行知識回頭看 tweet.message,會發現目前的模型行不通。如同高德納(Donald Knuth)所言:我們應該在約 97% 的時候忘掉小處最佳化——過早最佳化是萬惡之源——但不該放過關鍵 3% 的機會。
資料庫系統的設計初衷就是代替應用程式碼處理並行。關鍵 3% 之一就是並行操作,而其中不可能既快又對的,是對同一目標資料列的並行更新。在我們的模型裡,favs 與 rts 計數器正會遭遇這種並行更新。舊模型就正規化形式而言沒錯(計數器依賴於訊息的鍵),但在生產環境的並行下會很難處理。
更聰明的版本——把計數器換成完整的活動紀錄:
begin;
create type tweet.action_t
as enum('rt', 'fav', 'de-rt', 'de-fav');
create table tweet.activity
(
id bigserial primary key,
messageid bigint not null references tweet.message(messageid),
datetime timestamptz not null default now(),
action tweet.action_t not null,
unique(messageid, datetime, action)
);
commit;計數器消失了,取而代之的是計算它們所需的基礎資訊全紀錄(activity 清單,動作用反正規化的 ENUM 表示)。要取回 rts、favs,就對活動紀錄計數:
select count(*) filter(where action = 'rt')
- count(*) filter(where action = 'de-rt')
as rts,
count(*) filter(where action = 'fav')
- count(*) filter(where action = 'de-fav')
as favs
from tweet.activity
join tweet.message using(messageid)
where messageid = :id;讀取計數變複雜了;但寫入時,原本的
update tweet.message set rts = rts + 1 where messageid = :id;換成了
insert into tweet.activity(messageid, action) values(:id, 'rt');用
insert取代update的價值在於並行行為與鎖定:update版的轉推必須排隊等所有並行轉推完成,而業務上恰恰希望同一小批訊息(想想網紅帳號)能承受最多的並行活動。insert的目標列還不存在,因此沒有並行競爭——每個動作寫進自己的 tuple,完全不需要鎖,生產環境的 PostgreSQL 就能撐住大得多的負載。
每次顯示都重算計數當然昂貴(而且每則推文都要顯示計數),需要快取——這留待「在 SQL 中運算與快取」一章處理。
實測並行#
實際對兩種語句做並行基準測試時,馬上會發現 activity 表設計有瑕疵:unique 約束包含了 timestamptz 欄位,而 PostgreSQL 的時間戳只精確到微秒。這種硬湊出來的唯一約束會導致:
Error: Database error 23505: duplicate key value violates unique
constraint "activity_messageid_datetime_action_key"
DETAIL: Key (messageid, datetime, action)
=(2, 2017-09-19 18:00:03.831818+02, rt) already exists.最好的處理方式就是把它拿掉:
alter table tweet.activity
drop constraint activity_messageid_datetime_action_key;接著比較 insert 版與 update 版的並行擴展性。測試方式:100 個 worker,各自對同一個 messageid 迴圈轉推。雖然「同一訊息重複轉推」不具代表性,但正好製造我們要的並行效果——多個並行交易排隊等同一列的鎖。理論上 update 版的使用者得排隊等鎖,時間報告應該看得出差異:
CL-USER> (concurrency::concurrency-test 100 10 3)
Starting benchmark for updates
Updating took 3.099873 seconds, did 1000 rts
Starting benchmark for inserts
Inserting took 2.132164 seconds, did 1000 rts在這個並行度下,update 版比 insert 版多花了近 50% 的時間。SQL 語句本身極簡單,時間差可以歸因於排隊等鎖——update 版 3 秒裡幾乎有 1 秒在等空檔。加大壓力(每個 worker 轉推 50 次)結果依然可重現:
CL-USER> (concurrency::concurrency-test 100 50 6)
Starting benchmark for updates
Updating took 5.070135 seconds, did 5000 rts
Starting benchmark for inserts
Inserting took 3.739505 seconds, did 5000 rts延伸:測試程式碼(Common Lisp)
測試用 lparallel 開多個 worker,各自連線 PostgreSQL 重複執行同一條語句,並統計成功次數與耗時。核心片段:
(defparameter *insert-rt*
"insert into tweet.activity(messageid, action) values($1, 'rt')")
(defparameter *update-rt*
"update tweet.message set rts = coalesce(rts, 0) + 1 where messageid = $1")
(defun concurrency-test (workers retweets messageid
&optional (connspec *connspec*))
(format t "Starting benchmark for updates~%")
(with-timing (rts seconds)
(run-workers workers retweets messageid *update-rt* connspec)
(format t "Updating took ~f seconds, did ~d rts~%" seconds rts))
(format t "~%")
(format t "Starting benchmark for inserts~%")
(with-timing (rts seconds)
(run-workers workers retweets messageid *insert-rt* connspec)
(format t "Inserting took ~f seconds, did ~d rts~%" seconds rts)))
(defun retweet (messageid sql)
(handler-case
(progn
(pomo:query sql messageid)
1)
(database-error (c)
(format t "Error: ~a~%" c)
0)))(另有 run-workers、retweet-many-times 負責建立 kernel、分派任務與收集結果。)
如果你知道應用需要擴展,想清楚如何避免並行活動競爭單一共享資源。這裡的共享資源是目標訊息列的
rts欄位:轉推分佈均勻時 update 也還好,可是一旦大量使用者搶著轉推同一則訊息,update 方案的擴展性代價就不容忽視。
接下來我們要實作以 tweet.activity 為基礎的模型。在這個模型裡,轉推數每次顯示都要計算、又是可見資料的一部分;而一般情況下使用者不可能確知精確的轉推數,因此我們可以實作一個具**最終一致性(eventual consistency)**性質的快取。