DBOS 把 workflow 佇列直接建在 Postgres 一張表上,靠三個 SQL 層的調整把吞吐量從大約 100 個 workflow 每秒推到 30,000 個以上、橫跨數千台伺服器——拿掉任何一個調整,對應的瓶頸都會照原樣回來。
Postgres 佇列的三個瓶頸
DBOS 的 workflow 引擎不另外掛佇列系統,入列、出列、狀態轉換全部走 Postgres 同一張表的 SQL 交易。團隊自己講得直白:「Locking clauses make Postgres-backed queues possible」——鎖定子句不是效能調校的加分項,是這整套機制能不能成立的前提。天真寫法下,這張表卡在大約 100 個 workflow 每秒;改良過三次之後,DBOS 現在的正式環境數字是「achieving 30K workflow executions per second across thousands of servers」,換算下來大約每個月 800 億次執行。中間差的不是換了資料庫,是三個各自對準不同瓶頸的 SQL 層調整,一個接一個把上限往上推。
三個瓶頸依序卡在同一段程式碼路徑的三個不同關卡:第一關是「這筆列會不會被兩個 worker 同時搶到」,第二關是「搶到之後這筆交易會不會因為別人也在改而被打回重做」,第三關是「維護誰還在排隊這件事本身要花多少 CPU」。三關性質不同,分別要靠鎖定策略、交易隔離等級、索引涵蓋範圍來解決,沒有一個能單靠加機器解決——加再多 worker,一樣會撞上同一批最舊列;加再多資料庫連線,一樣會撞上同一筆重疊列的序列化失敗。
最終的規模數字值得放在一起看:「achieving 30K workflow executions per second across thousands of servers」,換算成月執行量是「allowing queues to scale to over 30K workflows per second, or 80B per month」。文章沒有拆解這數千台伺服器裡有多少專門跑 dequeue 查詢、多少在跑 workflow 本身,但門檻本身已經足夠說明——這不是單機調優能達到的量級,三個修法要撐住的是叢集規模的並發,不是單一 worker 的效能。
下面這張圖把三個修法放上同一條吞吐量時間軸——每個方塊對應一次瓶頸與修法,點開看各自的職責與拿掉之後會回到什麼症狀。
點擊任一機制查看職責與拿掉後的症狀 · 3 個機制
click a stage above
SKIP LOCKED · 職責
在 dequeue 查詢的 SELECT ... FOR UPDATE 後面加 SKIP LOCKED,鎖定選中的列,同時跳過已經被別的 worker 鎖住的列——把「最舊 N 筆」改寫成「最舊、且沒被鎖住的 N 筆」。
拿掉會發生什麼:所有 worker 撈到同一批最舊列,大部分白跑一趟,吞吐量卡在大約 100 個 workflow 每秒。
條件式隔離等級 · 職責
需要「全域最多 N 個併發」這種跨 worker 限制的佇列留在 REPEATABLE READ;不需要全域限制的佇列改用 READ COMMITTED。
拿掉會發生什麼:全部佇列留在 REPEATABLE READ,大約 1,000 個 workflow 每秒之後多數 dequeue 撞上序列化失敗,worker 花在重試交易上的時間超過處理 workflow 的時間。
部分索引 · 職責
索引只涵蓋 status = 'ENQUEUED' 的列,dequeue 查詢省掉排序步驟;列一被 dequeue,索引項直接刪除,不用再維護到 workflow 生命週期結束。
拿掉會發生什麼:每次狀態變化都要更新每一個索引,加上 autovacuum 清理過期索引項,大約 8,000 個 workflow 每秒後 CPU 變成新瓶頸。
三段修法會反覆用到幾個 SQL 詞彙,先擺一份速查,底下段落遇到就不再重複解釋。
點擊或 Tab 移到底線詞彙看定義 · 4 個詞彙
SKIP LOCKEDFOR UPDATE 後面加的鎖定子句,遇到已經被其他交易鎖住的列直接跳過,不等待、不報錯。、
REPEATABLE READPostgres 交易隔離等級之一,整筆交易期間看到固定的資料快照,看不到其他交易中途送出的異動。、
READ COMMITTEDPostgres 的預設隔離等級,每次查詢都看目前已提交的最新資料,可以看到其他交易同時送出的異動。、
部分索引只涵蓋符合特定條件的列的索引(例如 WHERE status = 'ENQUEUED'),不索引其餘的列。
——四個詞彙分別對應下面三段修法。
SKIP LOCKED:鎖定與跳過
最早的瓶頸出在 dequeue 查詢本身。一個沒有加鎖控制的 SELECT ... ORDER BY created_at LIMIT N 在多個 worker 同時執行時,撈回來的是同一批最舊列——DBOS 的說法是:「Every worker sees the same oldest queued workflows and attempts to dequeue them at the same time.」但每個 workflow 只能被一個 worker 真正拿到:「But each workflow can only be dequeued by a single worker, so most workers will fail to find new work and have to try again.」worker 數愈多,空轉的比例愈高,吞吐量卡在大約 100 個 workflow 每秒。
這類問題不限於 DBOS 一家。任何把 Postgres 一張表當佇列用的系統,只要 dequeue 查詢是單純的 ORDER BY ... LIMIT N,都會撞上同一種排隊搶列的競爭——worker 數愈多,同一批候選列被同時盯上的機率愈高,重試的比例也跟著往上走。合理的推測是,這也是為什麼 DBOS 把 SKIP LOCKED 形容成「one of those old Postgres tricks that keeps getting rediscovered」:它不是新語法,而是每一代拿關聯式資料庫當任務佇列用的系統,遲早都要重新學一次的教訓。
修法是 SELECT ... FOR UPDATE SKIP LOCKED。DBOS 把它拆成兩件事:「it locks the rows so that other workers cannot also select them」,然後「it skips rows that are already locked, selecting not the N oldest enqueued workflows, but the N oldest enqueued workflows that are not already locked by another worker」。鎖定負責阻止別人重複挑到同一批列,跳過負責讓每個 worker 各自往後找「還沒被鎖住」的最舊列——兩件事合起來,同一批列不會被兩個 worker 同時搶,也不會有 worker 因為前面的列被鎖住就整個查詢空手而歸。這就是為什麼 DBOS 直接說「Locking clauses make Postgres-backed queues possible」——沒有這道鎖,佇列的併發假設根本不成立。
典型的寫法大致長這樣(不是文中原始碼,是這個模式一般會寫成的樣子):
SELECT id FROM workflow_queue
WHERE status = 'ENQUEUED'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
從資料庫角度拆開看,FOR UPDATE 本身只是把選出來的列加上寫鎖,讓其他交易對這幾筆列的並發修改必須排隊等待;SKIP LOCKED 只是額外告訴查詢規劃器,遇到已經在排隊中的列就直接跳過、改看下一筆候選列,而不是卡住等待鎖釋放。可以合理推想,這個組合原本是為了「搶第一個能處理的工作」這種場景設計的——工作佇列剛好完全符合這個描述,每個 worker 要的不是某一筆特定的列,而是任何一筆還沒被別人碰過的列。
這裡有一個容易忽略的細節。推測起來,SKIP LOCKED 要跟 ORDER BY 用同一個索引配合,鎖定與跳過才能維持「挑最舊」的語意——如果排序用的索引跟鎖定實際掃描到的列對不上,鎖定順序會跟直覺不一致。這也是為什麼部分索引除了省下 CPU,同時也讓排序更穩定:ORDER BY 用的正是那個只涵蓋 ENQUEUED 列的索引。
這個機制的效果可以用一個簡化模型具體算出來:假設 dequeue 查詢固定抓最舊 K 筆,同一輪有 N 個 worker 同時嘗試——沒有 SKIP LOCKED 時,一輪最多只有 min(N, K) 個 worker 真正搶到列,其餘全部白跑。這是把上面那句話的邏輯拆開來算,不是文中的效能數字,K 的數值也是為了示範任意取的。
拖曳滑桿改變並發 worker 數 · 模型示意,非文中效能數字
N = 20,固定批次 K = 10 時:10 個 worker 搶到列,10 次白跑,一半的嘗試落空。
這也解釋了為什麼單純加更多 worker 在這個階段不但沒幫助,反而會讓浪費的比例往上走——固定批次大小下,多出來的每一個 worker,都只是多了一次注定落空的查詢。等到吞吐量卡住之後才想著加機器,只會讓資料庫連線數跟著漲,實際處理速度卻不動。
SKIP LOCKED 解決的是「搶列」本身的浪費,但它假設每個 worker 的交易彼此獨立、互不干涉。當佇列需要跨 worker 的全域限制時,這個假設不成立,瓶頸換了位置。
條件式交易隔離:不是每個佇列都需要 REPEATABLE READ
DBOS 的 dequeue 交易一開始統一跑在 REPEATABLE READ——理由很具體:「The dequeue transaction originally ran at REPEATABLE READ so it could support global queue limits like 'run at most N workflows concurrently across all workers.'」要在多個 worker 之間維持一致的「目前有幾個在跑」,交易需要看到一個固定的快照,而不是每讀一次就看到別人剛剛異動的結果。
REPEATABLE READ 底下,一筆交易在啟動的瞬間就凍結一份資料快照,之後所有讀取都看這份快照,不會因為別的交易同時 commit 而改變結果。這個保證對「全域併發上限」這種需要跨 worker 一致視角的邏輯很重要——如果每個 worker 讀到的「目前有幾個 workflow 在跑」都不一樣,限流本身就沒有意義。但這份保證是有代價的:一旦兩筆交易的寫入範圍重疊,Postgres 沒辦法悄悄合併兩邊的結果,只能讓後面驗證失敗的那一筆整筆重來。
「全域最多 N 個併發」這種限制在實務上並不罕見——例如整個叢集同時只能對某個外部 API 發出固定並發數的請求,或者某張大表同時只能跑固定數量的批次任務。這類限制的語意天生跨 worker,任何一個 worker 單獨看自己的連線數都不夠,需要一份跨 worker 一致的全域視角,這正是 REPEATABLE READ 最初被選中的原因。文章沒有提到 SERIALIZABLE——那是比 REPEATABLE READ 更嚴格的隔離等級,實務上很少出現在高流量佇列的路徑上,這裡的抉擇卡在 REPEATABLE READ 與 READ COMMITTED 之間,不涉及第三個選項。
問題出在規模:「If multiple workers concurrently modify overlapping rows, Postgres aborts one of them with a serialization failure.」worker 愈多,同時修改重疊列的機率愈高,Postgres 只能挑一個放棄、要求它整筆交易重來。DBOS 量到的門檻是:「When dequeueing more than ~1000 workflows per second, the majority of dequeue operations encountered serialization failures, creating a performance bottleneck.」重點不是失敗率變高,而是重試本身開始吃掉主要時間:「At scale, workers spent more time retrying transactions than processing workflows.」合理的推測是,單純把重試次數或退避時間往上調並不能真正解決問題——重試消耗的仍是同一批資料庫連線與 CPU,門檻只會往前移一點,不會消失,因為觸發序列化失敗的根本原因(重疊列的並發修改)完全沒有改變。
修法不是把所有佇列一律降到比較寬鬆的隔離等級,而是按需求分流:「Queues with global flow control continue using REPEATABLE READ, while queues without it use READ COMMITTED, which eliminates serialization failures entirely and dramatically improves throughput.」真正需要「全域最多 N 個併發」這種跨 worker 保證的佇列,才付 REPEATABLE READ 的重試代價;不需要的佇列換成 READ COMMITTED,每次讀取都看最新已提交的資料,不再因為別人剛好也在改同一批列而整筆重來。「徹底消除」這幾個字背後的邏輯很直接:READ COMMITTED 下每次查詢都重新看一次最新已提交的資料,交易之間不會因為「共用同一份舊快照、卻各自改了裡面的東西」而互相打架,衝突偵測機制本身就不會被觸發,重試自然也不會發生。
這種依佇列分流的做法,通常會反映在佇列本身的設定上——建立佇列時就宣告「這個佇列要不要全域併發上限」,dequeue 邏輯讀到這個旗標,再決定要開哪一種隔離等級的交易,而不是每次查詢都臨時判斷。判斷邏輯搬到設定層,交易本身只是照著旗標走,這樣才不會有人在寫新佇列時忘了選對隔離等級。
部分索引:只替 ENQUEUED 的列維護索引
隔離等級修完之後,瓶頸換到 CPU:「However, when running more than ~8000 workflows per second, we saw a new bottleneck: high CPU usage.」成因是索引維護本身:「Every workflow status update (enqueue, dequeue, completion) requires updating every index. Moreover, after indexes are updated, Postgres autovacuum has to clean up outdated index entries. At high throughput, index maintenance and vacuuming consume a substantial fraction of database CPU.」一張 workflow 表要撐查詢效能,通常會替 status、優先權、建立時間都建索引;問題是這些索引不管 workflow 目前是 ENQUEUED、RUNNING 還是 SUCCESS,都要在每次狀態轉換時整份更新,autovacuum 再回頭清掉舊版本留下的殘骸。
一般的 B-tree 索引沒有「這筆列現在處於哪個狀態」的概念——不管 workflow 是還在排隊、正在執行、還是已經結束,只要那一欄的值改變,資料庫就得同時更新每一個涵蓋這欄的索引項。狀態欄位又剛好是 workflow 生命週期裡變動最頻繁的欄位之一,等於每次狀態轉移都對所有相關索引各寫一次,Postgres 的 MVCC 模型下,每次更新還會留下一筆舊版本列,交給 autovacuum 之後再回頭清。一個說得通的解釋是,如果被更新的欄位剛好也是索引欄位,Postgres 的 HOT(Heap-Only Tuple)最佳化通常幫不上忙——HOT 只在「被更新的欄位沒有被任何索引涵蓋」時才能跳過索引更新,而狀態欄位往往正是被索引化的那一個,等於每次狀態轉移都繞不開索引維護。
修法是把索引範圍縮小到真正需要排序、需要頻繁查詢的那個狀態——只對 status = 'ENQUEUED' 的列建索引,也就是 Postgres 的部分索引。效果分兩塊:「This improves performance for two reasons. First, the dequeue query no longer needs an expensive sort step. Second, when the workflow is dequeued, Postgres can simply delete its index entry instead of maintaining it for the rest of the workflow's lifetime, reducing maintenance and auto-vacuum cost.」索引縮小到只涵蓋「還在排隊」的列,dequeue 查詢可以直接照索引順序取列,不用再對整張表排序;列一旦離開 ENQUEUED 狀態,索引項就直接被刪掉,不需要拖著它一路維護到 workflow 整個生命週期結束。對應的 DDL 大致長這樣(不是文中原始碼,是這個模式一般會寫成的樣子):
CREATE INDEX idx_queue_enqueued
ON workflow_queue (created_at)
WHERE status = 'ENQUEUED';
這個修法也有邊界。部分索引解決的是「被排除在條件之外的列不用維護索引」這一半的問題——如果同一張表上還掛著其他沒有做同樣條件限制的索引(例如給報表或審計用的複合索引),CPU 壓力還是會從那些索引上回來。合理的推測是,這類優化需要對整張表的索引清單逐一檢查,確認每一個索引是不是都只涵蓋真正需要它的那一部分列,而不是加一個部分索引就能一次到位。
| 機制 | 卡住的門檻 | 症狀 | 修法效果 |
|---|---|---|---|
| SKIP LOCKED | ~100/秒 | 所有 worker 撈到同一批最舊列,多數重試落空 | 鎖定+跳過已鎖列,只挑「還沒被鎖住」的最舊 N 筆 |
| 條件式隔離等級 | ~1,000/秒 | REPEATABLE READ 下重疊列修改觸發序列化失敗,重試時間超過處理時間 | 非全域限制佇列改用 READ COMMITTED,徹底消除序列化失敗 |
| 部分索引 | ~8,000/秒 | 每次狀態變化都要維護每一個索引,加上 autovacuum 清理,CPU 吃緊 | dequeue 省掉排序步驟,列離開 ENQUEUED 索引項即被刪除 |
三個修法互不替代:拿掉鎖定,回到搶列;把所有佇列硬塞回 REPEATABLE READ,回到序列化失敗;索引改回全表,CPU 又會被 autovacuum 吃回去。三段各自處理不同的資源競爭——列鎖、交易快照、索引維護——疊在一起才把同一張 Postgres 表撐過三個數量級的門檻。三個修法出現的順序也不是巧合:先解決能不能拿到工作,再解決拿到工作之後會不會被交易機制卡住重來,最後才輪到維護排隊清單本身的成本。合理的推測是,瓶頸依序從「鎖競爭」移到「交易衝突」、再移到「索引與 vacuum」這個順序,在其他把關聯式資料庫當佇列用的系統上也大概率會重演——三層瓶頸剛好對應 Postgres 處理一筆列的三個不同階段:拿到列、修改列、維護列的索引。
要判斷自己的系統落在哪一段,通常不需要額外的監控工具:pg_stat_activity 能看出目前是卡在等鎖還是卡在跑查詢,pg_stat_user_tables 的 n_tup_upd 與 n_dead_tup 能看出寫入與 autovacuum 的負擔有多重,pg_stat_user_indexes 則能抓出哪些索引很少被查詢真正用到、卻在每次寫入時被白白更新。這三個 view 對應的正好是三段瓶頸——鎖等待、交易重試、索引維護。
這三個修法都貼著 Postgres 自己的語法與語義。
三個檢查動作背後其實是同一件事:先確認查詢本身有沒有把並發競爭設計進去,再確認交易邊界是不是比實際需求寬,最後確認索引清單有沒有跟著資料的生命週期一起瘦身。三者都不需要換資料庫、換架構,純粹是把既有的 SQL 層調緊一點——也沒有一個依賴 DBOS 的框架本身,都是任何直接操作 Postgres 的程式碼可以照抄的調整。
可以現在就做的檢查:自己的 dequeue 查詢有沒有 SKIP LOCKED、交易隔離等級是不是預設用了比實際需求高的等級、索引有沒有還在替「已經處理完」的列維護。