這篇以 Backend 範例系統 裡的售票搶購為例,沿著一張票從使用者打開座位圖到付款記帳的路徑,逐段寫負載超過容量時這一段先耗盡什麼資源、前一段的保護把負載轉到了哪裡、這一段出錯時由哪一段補救,最後寫售票的需求讓哪些做法可以簡化、哪些需求怎麼設計都要付出成本。一次一筆的處理、能提早回應的請求、保護把負載轉到哪裡這幾個概念的通則在 9.0 請求路徑上的負載分析,這一篇寫它們在售票上的樣子;票數、請求量、持鎖時間這類數字是範例系統的假設,軟體的預設值取自官方文件。對照的系統是 9.0.2 交易撮合的負載路徑。

售票的性質與需求

售票系統的負載設計從兩個可以利用的性質與一項無法讓步的需求出發,另有一個業務上的選擇決定了尖峰的形狀。成功請求的總數有上限:成功的購票總數不會超過票數,一個場次五千張票、十萬個帳號搶、每個帳號限購一張時,票賣完那一刻,其餘九萬五千個購票請求的結果已經確定是售完(直到付款期限到期、有票被放回為止),回應它們不必讀資料庫,拒絕它們也不會少賣任何一張票。延遲不改變請求的價值:不劃位、票價固定時,排隊幾分鐘之後買到的仍是同一種票、同一個價格,使用者因此接受排隊;對號入座時延遲改變的是剩下哪些座位可選,動態定價時連價格也跟著變,這兩種情形下這個性質只部分成立。不超賣,而且付款與放回的結果不能同時生效:同一張票不能賣給兩個人,付款成功的訂單一定有票,付款期限到期被放回的票不能同時算成已付款。

尖峰的形狀來自順序規則。範例系統採先到先得,開賣那一刻所有人同時送出請求,下文的排隊與放行都是為這個尖峰而設;改用抽籤時,請求攤在報名期間,排隊、提早告知買不到、放行速率這幾段都不需要,負載只剩抽籤結果公布之後的付款,抽籤需要報名有成本或身分驗證才擋得住多開帳號,見 9.15 無預警瞬時大流量 的〈准入控制:虛擬等候室(virtual waiting room)、waitlist 與事前註冊〉。

無法讓步的需求決定了互斥範圍(哪些請求彼此必須輪流處理,見 9.0 請求路徑上的負載分析 的〈一次一筆的處理與它的互斥範圍〉):同一張票(對號入座時是同一個座位)的認領與訂單狀態的改變,一次只能處理一筆。範圍是一張票,不是整場的庫存;整場的庫存只有在被寫成一個計數欄時,才會讓所有購票請求排成一列,下一節就是這種寫法。

全部請求更新同一個庫存計數時先耗盡的資源

全場庫存寫成一個計數欄、沒有任何保護時,每一個購票請求都在資料庫執行 UPDATE events SET remaining = remaining - 1 WHERE id = $1 AND remaining > 0。十萬個請求更新的是同一列,資料庫對這一列加的行鎖一次只讓一個交易持有,其餘的交易排隊等鎖。持有鎖的時間是交易從更新到提交的時間,提交時要把交易日誌寫到磁碟,所以這一列每秒能完成的更新數,由單一交易持有鎖的時間決定,不會因為連線池加大或伺服器加多而提高,等鎖的交易越多反而越低(MySQL 的文件寫到,大量執行緒等同一把鎖時,InnoDB 的死鎖偵測本身就會拖慢處理):假設一筆交易持有鎖 1 毫秒,這一列每秒最多完成一千筆更新,十萬個請求要排一百秒。1 毫秒是單句更新、自動提交時的量級;先讀出庫存、在應用程式裡判斷、再寫回的交易(ORM 在交易裡 SELECT ... FOR UPDATE 之後才 save() 的寫法),持鎖時間包含中間幾次網路往返,長一個量級以上,每秒完成的更新數跟著少一個量級。這種同一列被大量更新而排隊的情形叫熱點列(hot row),排隊的機制在 1.18 高頻計數的寫入與彙總 的〈熱點列:同一列更新的排隊機制〉,分散寫入的做法在同一篇的〈把寫入分開的三種做法:拆成多列、只新增事件、先在記憶體裡合併〉;以庫存計數當熱點列的例子在 1.1 高併發下的 SQL 讀寫邊界 的〈Hot Row / Lock Contention 識別與處理〉。

等鎖的每一個交易都佔著一條資料庫連線。在穩定的系統裡,同時被佔用的連線數等於請求到達率乘上每條連線被佔用的時間(Little’s Law),所以佔用時間變長,需要的連線數就跟著變多;到達率高於完成率時系統已經不穩定,差額直接累積在等待中:每秒進來一萬個扣庫存的請求(示意)而每秒只完成一千個,等待中的請求每秒增加九千,任何大小的連線池都會在幾秒內用完。連線池用完之後,新的請求在應用程式端等連線,查場次、看座位圖這些與扣庫存無關的請求也用同一個連線池,跟著等。從外面看是整個網站停止回應,而連線池耗盡只是症狀,原因是每條連線被佔用的時間因為等鎖而變長。把連線池加大不會改善,只會讓更多交易排在同一把鎖後面;讓扣庫存與其他請求用不同的連線池(Bulkhead),至少讓查場次的請求不受影響。

這些等待最後變成哪一種結果,取決於每一層等待(行鎖、連線池、呼叫方的逾時)是否設有上限、上限多長。MySQL InnoDB 的行鎖等待預設 50 秒後逾時(逾時時只回滾當下那一句,不是整個交易),PostgreSQL 的 lock_timeout 預設不設上限;應用程式端等連線的時間由連線池的設定決定。等待的上限短於呼叫方(入口、瀏覽器)的逾時時,到期變成請求失敗;上限長於呼叫方的逾時、或根本沒有上限時,呼叫方先放棄,使用者看到的是無回應。同一個預設值在不同的入口後面落在不同的結果:InnoDB 預設的 50 秒比 Nginx 預設的 proxy_read_timeout(60 秒)短,請求會先收到資料庫的錯誤;比 CloudFront 回源的回應逾時預設值(30 秒)長,CDN 會先放棄。不論是收到錯誤還是一直沒有回應,使用者都會重新整理,同一個人送出第二、第三個請求,進來的請求率因此高於真實的需求。這種由重試疊加出來的流量叫 Retry Storm,重試的請求本身又佔住鎖與連線,可以讓服務在開賣的人潮退去之後仍然停在失效狀態。各種結果的判斷標準與成因在 9.16 負載超過容量時服務的表現 的〈變慢之後走向哪一種結果:先耗盡的資源與等待是否設有上限〉。

這種寫法有兩處要改:庫存的寫法(下文〈扣庫存:資料庫逐張認領與加入 Redis 閘門的條件〉把它改成每張票一列),以及抵達資料庫的請求數(下面各段讓它接近真正可能買到票的人數)。各段從使用者那一端往資料庫走,每一段擋下或延後某一類請求:可以由快取回應的讀取、刷票腳本、還沒輪到的購票、確定買不到的購票。

邊緣:可以快取的內容與機器人過濾

座位圖頁面上的內容依變動的頻率分開處理。場館平面圖、票價、場次資訊在開賣期間不變,CDN 可以快取一整天;各區的剩餘數量每秒都在變。剩餘數量讀到幾秒前的值是可以接受的,因為最後判斷有沒有票的是扣庫存那一步:使用者看到某區還有票、按下去才知道剛賣完,結果是一次多餘的點擊,不是超賣。所以剩餘數量也能交給 CDN,只是快取時間設得短(示意:一秒)。

快取時間短的時候要確認一件事:快取過期的那一刻,同時在讀的使用者會不會全部回源。CDN 的每個節點各自快取,快取過期時,一個節點上同時要這個頁面的請求,若每一個都送回源站,源站會在同一刻收到大量請求(Cache Stampede)。多數 CDN 能把同一個快取鍵同時發生的回源請求合併成一個(request coalescing,CDN 的文件也稱為 request collapsing,應用程式內的同一種做法是 Singleflight),前提是剩餘數量的回應不依 cookie 或登入身分分開快取,所有人共用同一個快取鍵;符合這個前提時,源站每秒收到的剩餘數量請求約等於 CDN 節點數乘以每秒的過期次數,與在線人數無關。源站回應剩餘數量時讀的是一份每秒更新一次的唯讀計數(下文〈扣庫存:資料庫逐張認領與加入 Redis 閘門的條件〉),而不是每個請求都到資料庫去數還沒被認領的票:CDN 合併回源之後源站收到的請求不多,每次都去數也可能負擔得起,而一份每秒更新的計數讓這件事與在線人數完全無關。快取期限的計算方式在 12.2 新鮮度期限的計算,過期之後的回源驗證在 12.3 過期副本的驗證。

刷票腳本與已知的惡意來源在邊緣以 WAF 的規則與依來源位址的速率限制擋下,擋掉的請求不消耗源站的任何資源。代價是誤判:同一個公司或校園網路後面的真實使用者共用一個對外位址,依位址的限制會把他們一起擋掉;被擋掉的比例依活動而定,沒有可以通用的數字,要從過去場次的紀錄估計。依位址的限制擋得住的只是單一來源的大量請求;刷票有轉售價差這個經濟誘因,刷票者會把流量分散到大量的位址、開大量的帳號,所以「每人限購一張」等於每個帳號限購一張,要搭配事前註冊、身分驗證這類讓開帳號有成本的做法,流量來源的辨識與事前註冊在 9.15 無預警瞬時大流量 的〈流量來源的辨識:真人需求、應用層的濫用流量、體積型攻擊〉。邊緣出錯時的補救在源站:WAF 規則被繞過時,以及 CDN 本身故障、流量改為直接送到源站時,源站仍要有自己的准入控制,不能假設進來的請求都已經過濾過。

准入:排隊與放行的速率

排隊把「什麼時候處理」與「什麼時候進來」分開。站內的兩個案例是兩種形式:9.C16 SeatGeek 的虛擬等候室在使用者進入購票頁之前就讓他排隊,依事先設定的速率放行,或以同時在購票頁裡的人數為上限;9.C15 拓元 依架構圖推論,是把訂單先寫進可以大幅擴充的儲存,後端用自己處理得了的速度消費。兩種形式給使用者的回應不同:等候室這種形式可以在等候頁顯示使用者的位置(這是一般的做法,SeatGeek 的原文沒寫它的等候頁顯示什麼);寫入緩衝回應的是「已收到」,使用者可能以為已經下單,而他的請求還在等處理。

放行的速率要在業務期望的售完時間與扣庫存那一段的處理量之間取一個數字:放得太慢,票在開賣後很久才賣完;放得太快,放進來的請求超過扣庫存那一段每秒處理得了的量,請求在扣庫存那一段排隊:只用資料庫認領時,認領交易的提交處理量被超過,等待累積在連線池裡,同一個連線池上的查場次請求跟著等,〈全部請求更新同一個庫存計數時先耗盡的資源〉後半的情形重演;加了 Redis 時,先被超過的是 Redis 單一鍵的處理量。這個數字要以扣庫存那一段實測的處理量為準,量法在 9.4 Saturation Discovery。

排隊的元件自己也承受負載:沒有排隊服務時,使用者以不斷重新整理購票頁的方式確認買不買得到,那些請求落在資料庫;有了排隊服務,它們變成對排隊服務的位置查詢,十萬人每五秒查一次就是每秒兩萬個查詢,讓這個查詢便宜的做法在 9.0 請求路徑上的負載分析 的〈每一段保護把負載轉到哪裡〉。SeatGeek 的等候室為每個連上 API Gateway 的訪客記一筆連線資料,等候人數本身就是要規劃的容量(原文沒寫連線協定)。

成功請求的總數有上限,在這一段可以直接利用。排隊的人數遠超過剩餘票數時,排在後面的人幾乎不可能買到,排隊服務可以在回應裡提早告訴他們「排在您前面的人數已超過剩餘票數」,讓他們選擇離開或改排候補。佇列以人數計,因此不必容納十萬人:每人限購一張時,容量是剩餘票數加上一段預留給中途放棄的人;每人限購多張時,先把剩餘票數除以每人平均購買的張數換算成人數,再加上同一段預留。付款期限到期放回的票,由候補的人依序遞補,這是被告知買不到、改排候補的人仍可能等到票的原因。等候室的兩種放行方式(依速率放行或限制同時在購票頁的人數)在 9.15 的〈准入控制:虛擬等候室(virtual waiting room)、waitlist 與事前註冊〉,那一節也寫到發出的進入資格總數可以等於可售票數;等候人數與連線的容量在 9.C16 SeatGeek 的〈判讀〉。

用訊息佇列當緩衝時(API 收到請求就回「排隊中」,請求放進佇列由後端消費),佇列至少送達一次的語意會讓同一筆購票被處理兩次,所以每一次購票嘗試在進入佇列之前要有一個識別碼(Idempotency Key),扣庫存那一段以它辨認重送;售票要另外加的是「一個帳號同時只能有一筆購票在排隊」這條業務規則,因為使用者自己重新整理產生的是新的購票嘗試,佇列分不出來。重送的成因與去重的通則在 9.0 的〈佇列、使用者重按與回呼帶來的重送與去重〉,佇列的送達語意在 3.4 consumer 設計與去重。

排隊服務停掉時,系統要停止放行,而不是把所有人直接放進來。直接放行等於拿掉了這一段的保護,全部請求回到扣庫存與資料庫;停止放行時,使用者看到的是暫停售票,票還在,排隊服務恢復之後照順序繼續。要能照順序繼續,排隊的順序要存在排隊服務的程序以外,SeatGeek 把排隊序號存在 DynamoDB 的表裡。排隊保護的是一個一定處理不了全部請求的資源,所以它故障時選停止放行(fail closed);這個判斷的依據,以及邊緣速率限制為什麼反過來選全部放行(fail open)、預設值與讀到壞設定的情形,在 9.0 的〈保護元件故障時的輸出:停止放行、全部放行、保守的固定上限與預設值〉;等候室用的是購買的服務時,要在開賣之前查清楚業者在故障時的預設,並確認能不能改成停止放行。

扣庫存:資料庫逐張認領與加入 Redis 閘門的條件

扣庫存要互斥的範圍是一張票,所以把庫存從一個計數欄改成每張票一列,互斥就分散到各列上。每一張票事先在資料庫裡建一列,請求挑一列還沒被認領的票寫入自己的訂單,這個動作下文稱為認領,以 PostgreSQL 寫是:UPDATE tickets SET order_id = $1 WHERE id = (SELECT id FROM tickets WHERE event_id = $2 AND order_id IS NULL LIMIT 1 FOR UPDATE SKIP LOCKED)。FOR UPDATE SKIP LOCKED 讓子查詢跳過別的交易正在認領的列,兩個交易不會等同一把鎖,各自認領不同的票;order_id IS NULL 讓已經被認領的票不會被選中,票認領完了,請求在這裡找不到票,得到售完。認領與訂單寫入放在同一個資料庫交易裡(以購票嘗試的識別碼做訂單的唯一鍵,重送的請求寫不進第二筆),同一張票不被賣兩次,由這一個交易保證。

對號入座的場次在 tickets 表裡每一列是一個座位,認領時不挑任意一列,而是指定使用者選的那個座位:UPDATE tickets SET order_id = $1 WHERE id = $2 AND order_id IS NULL,更新了零列就是那個座位已經被別人認領;付款期限到期時把那一列的 order_id 清回 NULL,座位就回到可售。另一種常見的設計不建座位表,而是在訂單表上記座位編號,防止同一個座位賣兩次就要靠訂單表上的唯一約束;一般的唯一約束對所有狀態的訂單都生效,到期的訂單也還佔著那個座位編號,座位因此永遠回不到可售,所以這種設計要用只涵蓋有效訂單的部分唯一索引(CREATE UNIQUE INDEX ... WHERE status IN ('pending', 'paid'))。

逐張認領比計數欄快,差別在提交時的磁碟寫入能不能合併:計數欄上,下一個交易要等前一個提交、放開列鎖才能更新,每一次提交各自等一次寫盤;逐張認領的各個交易不共用列,同時提交的交易可以合在一次日誌寫入裡(PostgreSQL 文件稱為 group commit)。1.18 在筆電上的 Docker 量過兩種寫法:同一列更新每秒約 650 到 900 筆,不共用列的寫入每秒約一萬一千到一萬五千筆,逐張認領落在不共用列的那個量級(環境與數字見 1.18 高頻計數的寫入與彙總 的〈熱點列:同一列更新的排隊機制〉,自己的資料庫要自己量)。認領的子查詢要有只涵蓋 order_id IS NULL 的部分索引,否則越接近售完,它要掃過的已認領列越多。有排隊放行時,抵達資料庫的購票請求就是放行的速率:放行速率設在每秒 50 筆時(示意),五千張票約一百秒放完,逐張認領處理得了,資料庫一個儲存就同時負責扣庫存與不超賣,不必在兩個儲存之間對帳。範例系統有排隊放行,屬於這一種。座位圖與排隊服務要讀的剩餘數量,由一個背景工作每秒從資料庫數一次還沒被認領的票、把結果放在應用程式的記憶體或快取裡供讀取;這份唯讀的計數落後一秒,不參與扣減,所以不必另設扣庫存的儲存。

加入 Redis 扣庫存的理由只有一個:系統前面沒有排隊放行,開賣的十萬個請求直接打到扣庫存這一段,需要一道閘門在資料庫之前擋掉售完之後的那九萬五千個。Redis 提高不了售完的速度:在 Redis 扣減成功的請求,仍要逐一到資料庫認領,售完速度的上限仍是資料庫認領的處理量,而且這約等於票數的請求會在開賣的幾秒內一起湧到資料庫,資料庫的認領要在這幾秒內處理完這一批。座位圖只需要便宜的剩餘數量時,上一段那份唯讀計數就夠,不必讓 Redis 參與扣減。Redis 以單一執行緒依序執行指令,一段 Lua 腳本把「讀剩餘數量、大於零就減一」兩個動作放在同一次執行裡,執行期間不會有其他指令插進來,所以兩個請求不會同時讀到「剩一張」;每一筆不等磁碟寫入(Redis 預設只定時做快照,開了 AOF 也預設每秒才同步一次磁碟),也沒有鎖的取得與釋放,每秒處理的數量比資料庫高出許多。Redis 在這裡是一道擋掉多數請求的閘門:在 Redis 扣減成功的請求,才到資料庫做上面那個認領,資料庫仍然是不超賣的保證。Redis 原子操作的種類與分散式鎖的限制在 2.1 高併發下的 Redis 讀寫邊界。

加入 Redis 之後,負載移到 Redis,而 Redis 有自己先耗盡的資源。連線數:同時連到 Redis 的連線數,是應用程式這一側同時在執行的程序數乘以每個程序各開的連線數,算法、持久連線與連線代理的差別在 9.0 請求路徑上的負載分析 的〈每一段保護把負載轉到哪裡〉;售票特別要算的是開賣前為了接住流量把主機擴到幾十台、以及幾個場次同時開賣共用同一組 Redis 時,總數可能超過 maxclients 預設的一萬。單一鍵的處理量:全場的剩餘數量是一個鍵,而一個鍵只存在 Redis 叢集的一個分片上,加分片不會提高這個鍵的處理量,這是 Hot Key;Redis 裡的計數又回到了一個計數欄的形狀,只是這個計數欄每秒處理得了的量高得多。不夠時可以把庫存拆成多個鍵(五千張票拆成十個各五百張的鍵),代價是有的鍵扣完了、別的鍵還有票,請求要換一個鍵再試;拆鍵影響的只是閘門的處理量,不超賣仍由資料庫的認領保證。對號入座的場次每個座位各是一個鍵,以「座位鍵不存在才寫入、並設保留期限」的寫法(SET seat:A12 <訂單> NX EX 600,EX 600 是 600 秒的保留期限,對應範例系統十分鐘的付款期限)讓先寫入的人保留那個座位。腳本執行時間:Lua 腳本執行時 Redis 不處理任何其他指令,腳本跑超過 busy-reply-threshold(預設 5 秒)之後,其他用戶端的指令收到的是 BUSY 錯誤,腳本本身並不會被終止,而已經執行過寫入的腳本也不能以 SCRIPT KILL 停下;所以腳本要短,不在裡面做大範圍的查詢或迴圈。

Redis 出錯時,可能讓已經扣掉的剩餘數量回升。Redis 的複寫是非同步的,主節點回應「扣成功」之後,這個寫入不一定已經送到副本;主節點故障、副本被提升為新的主節點時,最後幾筆扣減會遺失,Redis 當機重啟時最後一段還沒寫到磁碟的扣減也會遺失。新的剩餘數量比實際多,Redis 會多放一些請求去資料庫,而資料庫的認領在票認領完時讓它們得到售完,所以不會超賣。

記帳與付款期限

只用資料庫認領時,扣庫存與訂單寫入在同一個交易裡,交易失敗時兩者一起不生效,這一段接下來寫的補償與對帳都不需要。加入 Redis 之後,Redis 扣成功、資料庫的交易卻失敗時,這張票既沒有賣出去也沒有回到可售。Redis 與資料庫是兩個儲存,沒有一個交易能同時包住兩者,所以寫入的順序與失敗時的補償要明確:先在 Redis 扣,再在資料庫交易裡認領並寫入訂單,資料庫的交易失敗時把 Redis 的數量加回去。補償本身也可能沒執行,例如程序在 Redis 扣減之後、資料庫交易之前當機(使用者這時收到錯誤或逾時,多半會再試一次),所以還要一個定期的對帳工作:比對 Redis 的剩餘數量與資料庫裡還沒被認領的票數,以資料庫為準修正 Redis。兩邊的數字在處理中的請求(Redis 已扣、資料庫還沒提交)存在時本來就對不上,所以對帳要在一段時間沒有新的扣減之後執行,或者兩者擇一,改成逐筆比對:Redis 每次扣減時記下購票嘗試的識別碼,對帳找出記了識別碼而資料庫沒有對應訂單、且已超過處理時限的那幾筆。補償沒有執行時造成的是少賣,票在對帳之後回到可售,符合範例系統對少賣的要求:開賣後短暫沒有全部賣出可以接受,只要之後回收得回來。對帳的做法在 Data Reconciliation,跨儲存的補償在 Saga。

付款交給第三方金流。付款的請求數也約等於票數,而集中在開賣後的前幾分鐘,金流業者對每個商家有自己的速率限制,超過時回應錯誤或變慢。付款的呼叫要用自己的連線池與逾時,不能和購票流量共用。拓元的架構圖上,付款層是獨立的一組機器、直連金流的伺服器;這樣切開的作用是讓選位與下單的流量不會塞住付款(作用是推論,案例頁沒寫拓元的理由)。金流業者停掉時,訂單停在待付款,付款期限照常計時,金流恢復時大量訂單已經過期,使用者付不了款。處理的方式有兩種,各有代價:付款期限設得比金流業者常見的中斷時間長,票被保留得更久,放回與重新賣出都跟著延後;在金流故障期間暫停計時,要先判定金流故障,判定本身會誤判(金流只是變慢時暫停了、或已經停了卻沒暫停)。依賴方停掉時的處理在 8.15 Vendor / 第三方依賴事故處理。

付款期限到期要放回票,而付款回呼與到期工作可能同時處理同一筆訂單:使用者在範例系統十分鐘的付款期限快到時付款,金流的回呼與到期的排程工作同時送到。兩者都要寫成有條件的更新,讓其中只有一個成立:到期工作執行 UPDATE orders SET status = 'expired' WHERE id = $1 AND status = 'pending',更新了一列才把票放回;付款回呼執行 UPDATE orders SET status = 'paid', payment_id = $2 WHERE id = $1 AND status = 'pending'。排程服務與金流回呼都可能把同一個動作執行兩次(排程服務在少數情況下會重送任務,金流業者在沒收到回應時會重送回呼,Stripe 的文件寫明同一個事件可能送達不只一次),所以付款回呼更新了零列時,再讀一次訂單的狀態,會是下面三種之一:已經是 paid 而且付款識別碼相同,是同一筆付款的重送,回呼不必再做任何事;已經是 paid 而付款識別碼不同,是使用者對同一筆訂單付了第二次款,後到的那一筆要退款;已經是 expired,是到期之後才到的付款,這筆款項也要退款。到期工作更新零列只有兩種成因,訂單已經付款,或前一次執行已經放回過票,兩種都不必再做任何事,所以票不會被放回兩次。

付款期限也會讓放回集中發生。五千張票在開賣後約一百秒內被認領(示意),它們的十分鐘付款期限就集中在十分鐘之後的那一百秒裡到期,沒付款的票一次放回,候補的人同時被通知、同時回來購票,形成第二次的尖峰;排隊與放行速率在這時要再用一次,不能在第一次售完之後就關掉。尖峰之後再一次湧入的情形在 9.16 負載超過容量時服務的表現 的〈尖峰過去或應對切換之後仍持續的情形:停在失效、擺盪與第二波湧入〉。

售票需求帶來的簡化,以及無法簡化的部分

成功請求的總數有上限,讓排隊服務可以提早告訴後面的人買不到,讓資料庫只需要處理約等於票數的認領;放行速率攤開之後,資料庫逐張認領處理得了,一個儲存就同時負責扣庫存與不超賣;Redis 只在前面沒有排隊放行時才值得當閘門加入,而加入的代價是補償與對帳。延遲不改變請求的價值(不劃位、固定票價時),讓放行速率可以設在後端實測的處理量,而不是開賣當下的請求量。座位圖的剩餘數量允許落後,讓它可以交給 CDN 與一個便宜的讀取來源。

對號入座時,延遲不改變價值只部分成立:排隊與逐張認領照樣可用,而排得越後面能選的座位越少,排隊的順序規則(先到先得或抽籤)因此直接決定誰拿到好座位,是業務上要公布的規則。

無法簡化的是同一張票的互斥:庫存可以從一個計數欄拆成每張票一列,前面可以加一道 Redis 閘門,而同一張票的互斥最後都由資料庫的認領與條件更新保證。抵達資料庫的請求,從開賣第一分鐘全部種類加起來每秒數萬個(範例系統的假設),降到總數約等於票數、以放行速率每秒數十個(示意)進來,而每張票的互斥始終存在。跨段的交接也無法簡化:每一個可能重送的地方(佇列、排程、金流回呼、使用者重按)都要有識別碼、條件更新或業務規則,任何一段漏掉,就是一張票賣兩次或一筆款扣兩次。

這套做法成立,依賴的是成功請求的總數有上限、延遲不改變請求的價值,以及先到先得的順序規則。成功請求的總數沒有上限時(庫存充足的電商 checkout),擋掉的每一個請求都是少一筆訂單,提早回應結果這個做法不存在;延遲改變請求的價值時(連續撮合的交易所),排隊本身就讓請求失去下單時的意義,過載時要明確拒絕;改用抽籤時,開賣瞬間的尖峰不存在,排隊與放行那幾段都不需要。交易撮合的負載路徑在 9.0.2 交易撮合的負載路徑,四個範例系統在成功請求的總數與延遲這兩個性質上的位置在 Backend 範例系統 的〈四個系統在請求性質上的差別〉。