任何資料庫系統的核心功能,就是在多個交易同時修改系統狀態時,仍能實作並行並完全遵守系統的約束與屬性。PostgreSQL 完全符合 ACID,並實作了交易隔離(transaction isolation),讓應用程式的並行問題得以優雅處理。

  • 並行是棘手而複雜的問題,且並行 bug 通常很難重現——所以最好倚賴既有的解決方案,而不是自己造輪子。
  • 在一般程式語言中處理並行,通常得正確使用 lock、mutex、semaphore(底層靠 CPU 的原子操作);Java 提供 synchronized 區塊,Erlang 則只提供訊息傳遞(mailbox 系統)、在內部代你處理並行。
  • SQL 是宣告式語言:開發者只宣告意圖(想要的結果),實作方負責把每個細節做對——包括並行行為。PostgreSQL 的並行實作可靠,並且允許使用者對查詢的鎖定行為做一定程度的控制。

交易與隔離#

依 ACID 屬性,交易必須與系統中其他並行交易隔離(Isolated),而且可以依使用情境選擇隔離等級。

一個簡單的例子是線上備份:pg_dump 要對整個資料庫拍快照並匯出,這要求它的讀取與任何並行寫入完全隔離——靠 repeatable readserializable 隔離等級達成。PostgreSQL 9.1 起 pg_dump 使用 serializable(在 SSI 實作之前是 repeatable read)。

SQL 標準定義了四個隔離等級,其中最嚴格的是 serializable:一組 serializable 交易的任何並行執行,保證產生與「依某種順序一次執行一個」相同的效果。其餘三個等級則以「各等級不得出現哪些現象(phenomena)」來定義。PostgreSQL 文件列出的現象如下:

  • Dirty read(髒讀):交易讀到並行交易尚未提交的寫入。
  • Nonrepeatable read(不可重複讀):交易重讀先前讀過的資料,發現已被另一個(初讀之後提交的)交易修改。
  • Phantom read(幻讀):交易重新執行一個回傳符合條件資料列集合的查詢,發現集合因另一個剛提交的交易而改變。
  • Serialization anomaly(序列化異常):一組交易成功提交的結果,與這些交易任何一種逐一執行的順序都不一致。

標準的四個等級為 read uncommittedread committedrepeatable readserializable

  • 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 的 SerializableSSI 頁面。

研究社群花了約 20 年才找到能有效率實作 serializable snapshot isolation 的數學模型;而這項重大進展只花了一年就被納入 PostgreSQL。

並行更新與隔離#

以推文模型的轉推(retweet)為例——rtstweet.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% 之一就是並行操作,而其中不可能既快又對的,是對同一目標資料列的並行更新。在我們的模型裡,favsrts 計數器正會遭遇這種並行更新。舊模型就正規化形式而言沒錯(計數器依賴於訊息的鍵),但在生產環境的並行下會很難處理。

更聰明的版本——把計數器換成完整的活動紀錄:

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 表示)。要取回 rtsfavs,就對活動紀錄計數:

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-workersretweet-many-times 負責建立 kernel、分派任務與收集結果。)

如果你知道應用需要擴展,想清楚如何避免並行活動競爭單一共享資源。這裡的共享資源是目標訊息列的 rts 欄位:轉推分佈均勻時 update 也還好,可是一旦大量使用者搶著轉推同一則訊息,update 方案的擴展性代價就不容忽視。

接下來我們要實作以 tweet.activity 為基礎的模型。在這個模型裡,轉推數每次顯示都要計算、又是可見資料的一部分;而一般情況下使用者不可能確知精確的轉推數,因此我們可以實作一個具**最終一致性(eventual consistency)**性質的快取。