9.0.2 交易撮合的負載路徑:閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量
這篇以 Backend 範例系統 裡的交易撮合為例,沿著一筆委託從入口閘道到成交回報的路徑,逐段寫這一段先耗盡什麼資源、出錯時由哪一段補救,以及撮合之前幾乎沒有能確定結果的委託這件事,讓負載保護的做法與售票有哪些不同;對照的系統是 9.0.1 售票搶購的負載路徑,兩篇共用的概念在 9.0 請求路徑上的負載分析。撮合引擎的內部設計各交易所不同,這一篇只寫公開資料寫得到的部分與從機制推得出的部分,推論的地方會標明;委託量這類數字是範例系統的假設,交易所的規則與案例數字取自公開文件。
撮合系統的性質與需求
撮合系統的負載設計從三個性質出發。撮合之前結果無法確定:一筆委託是否成交、以什麼價格成交,取決於它送進委託簿(一個交易對上還沒成交的買賣委託,依價格與時間排成的清單)那一刻簿上有哪些對手的委託,事先無從知道,所以沒有一群「結果已確定」的請求可以在撮合之前回應掉,可提早回應的比例低;成功請求的總數也沒有上限,每一筆被接受的委託都要處理。處理順序決定成交:撮合依價格與時間的優先順序(price-time priority,CME 的文件稱為 FIFO)配對,同價格時先到的先成交,同一個交易對的委託要以單一順序處理,兩筆委託的先後不能因為走了不同的伺服器而對調。延遲改變請求的價值:在連續撮合的市場裡(委託一到就與簿上的對手配對,這是範例系統的設定),一筆委託晚一秒送進委託簿,那時的市場已經不是使用者下單時的市場。市價單(不指定價格、以當下最好的價格成交)會以偏離使用者預期的價格成交;限價單(指定最差可接受的價格)不會以比限價差的價格成交,它的代價是掛著的價格已經過時,在市場變動之後被對手以對自己不利的時機成交。
由這三個性質推出撮合系統無法讓步的需求:每一筆被接受的委託都要處理,處理不了的要在接受之前明確拒絕;已回應「已接受」的委託不能遺失;同一筆委託不能執行兩次。售票系統在撮合這個位置之前回應大多數請求、讓其餘的排隊,撮合系統只剩兩項可用:擋掉本來就無效的委託,以及在接受之前拒絕超過處理量的委託。
入口閘道:在進入撮合之前能擋下的委託
入口閘道驗證身分與委託的格式,並檢查帳戶的狀態,擋下本來就不該進入委託簿的委託:格式錯誤、價格超出允許的範圍、帳戶餘額不足以支付這筆委託。這幾類委託的結果在閘道就能確定,回應它們不需要撮合引擎。閘道只做這些驗證時沒有自己的狀態,可以加機器擴充,負載超過時加機器就有效;一台閘道當機,用戶端的連線斷開、重新連到另一台,還沒拿到「已接受」的委託由用戶端重送,靠下文〈依序日誌〉的識別碼辨認重複。斷線的用戶端會在同一刻重連,重連要帶隨機的退避間隔,否則一台閘道的失效會變成其餘閘道(以及它們共用的 DNS、身分驗證)同時收到的大量連線。
餘額檢查有一個與順序相關的難處:帳戶餘額隨著成交不斷改變,閘道看到的餘額是撮合引擎稍早的結果,兩筆幾乎同時送出的委託可能都通過檢查,加起來卻超過餘額。常見的處理有兩種:閘道在通過時先凍結這筆委託需要的金額,讓下一筆委託看到的是凍結之後的餘額;或者把餘額的最終檢查放進撮合引擎,跟委託簿一起依序處理。閘道凍結金額讓閘道持有一份要與引擎一致的狀態,閘道當機時這份凍結紀錄要能從引擎的結果重建,否則凍結的金額卡住不放;引擎內檢查讓引擎的每一筆處理多做一件事(這兩種做法與它們的代價是從機制推出的,各交易所採用哪一種,公開資料多半沒寫)。
閘道也依帳戶限制每秒能送的請求數。Coinbase Exchange 的私有端點依 profile(帳戶底下可以分開管理資金的子帳戶)計算每秒的請求數,超過時回 429;它與 9.C3 Coinbase International Exchange 是不同的產品,限流的單位不一定相同。這種限制的作用是不讓單一用戶端的程式失控占滿撮合引擎的處理量,讓其他用戶端仍然送得進來;被擋下的委託是這個帳戶用完了配額,委託本身的結果並沒有在閘道確定。撮合的流量裡,取消與改單佔的比例很高,而取消的優先度要高於新委託:過載時擋下取消,掛在簿上的舊報價撤不掉,會在價格變動時被對手以過時的價格吃掉,所以交易所過載或恢復時常先進入只收取消(cancel-only)的狀態。另一種壓低委託量的手段是費用:歐盟 MiFID II 要求交易場所為每個商品設定參與者未成交委託與成交的比例(order-to-trade ratio)上限,並允許成員國讓受監管市場對取消比例過高的參與者收取較高的費用。依呼叫者分配配額的做法在 Rate Limit 實作。
各帳戶都沒有超過配額,加總卻超過撮合引擎處理得了的量時,閘道要明確拒絕多出來的委託,回應一個代表「未被接受」的錯誤,讓用戶端知道這筆委託不存在。售票系統在這個位置讓請求排隊,撮合系統不這麼做,因為排隊之後才送進委託簿的委託已經失去它下單時的價值;閘道前若有佇列,它的長度要讓排隊時間遠小於委託的延遲目標,超過就拒絕。這個結論來自連續撮合:有些市場刻意以固定的間隔把委託集中起來一起撮合(集合競價),例如台灣證券交易所盤中在 2020 年 3 月改為逐筆交易之前是每 5 秒撮合一次,學界也有人主張以極短間隔的批次撮合取代連續撮合(Budish、Cramton、Shim,2015),在那樣的市場裡,等到下一次撮合是規則的一部分,不會讓委託失去價值。撮合的用戶端多半是程式,被拒絕之後會立刻重送,所以拒絕的回應要帶重試的時機,重送怎麼放大成 Retry Storm 與回應要帶什麼,在 9.0 請求路徑上的負載分析 的〈多出來的請求:排隊、拒絕或請使用者重新確認〉。
依序日誌:決定順序並保證不遺失
進入撮合之前,每一筆委託先被賦予一個依序的編號,並寫進一份複寫到多台機器的日誌,寫入完成才回應用戶端「已接受」。這一步同時滿足兩項需求:編號就是撮合的順序,日誌讓已接受的委託不會因為一台機器故障而遺失。9.C3 Coinbase International Exchange 的案例頁寫的是以 RAFT 為基礎的交易邏輯,就是這一類設計;共識協定的機制在 Consensus Protocol。
負載移到這一段之後,最先超出目標的是延遲:每一筆委託的「已接受」要等日誌在多數節點寫入完成,這一來一回的網路時間與磁碟寫入時間直接算進委託的延遲。AWS 的案例頁把 Coinbase 選用 z1d 機型的理由寫成 NVMe 本地儲存的寫入速度,讓以 RAFT 為基礎的交易邏輯處理得快;節點之間的網路距離則由 Cluster Placement Group 縮短,它把節點放在同一個可用區(availability zone)裡實體上相近的位置,代價是這些節點會因為同一個可用區的故障一起停掉,而且之後要在同一個群組裡再加機器時,可能遇到該位置容量不足而加不進去(AWS 文件的說法)。處理量不夠時,日誌可以把同一段時間內到達的多筆委託合在一次寫入與複寫裡,每秒處理的委託數提高,而每一筆的延遲增加不多。
日誌的主節點故障時,其餘節點選出新的主節點,選舉期間不接受新的委託。這是在一致與可用之間選了一致:選舉期間(依逾時設定,從數百毫秒到數秒)委託送不進來,但不會出現兩個主節點各自編號、順序互相矛盾的情形。選舉要有多數的節點參與;同一個失效域(例如上面那個可用區)的故障一次停掉多數節點時,剩下的節點選不出主節點,日誌停止接受委託,直到多數恢復,或在另一個失效域以快照與日誌重建,停機的長度從秒變成小時。用戶端在這段時間送出的委託拿不到「已接受」,逾時之後重送;如果第一次已經寫進日誌、只是回應沒送到,重送會讓同一筆委託出現兩次。所以委託要帶用戶端自己產生的識別碼,日誌或撮合引擎以它辨認重送,同一個識別碼只接受一次(Idempotency Key);Coinbase Advanced Trade 的下單 API 的 client_order_id 就是這樣定義,識別碼與既有的委託重複時不建立新的委託、回傳原本那一筆。
撮合引擎:單一寫入者與記憶體內的委託簿
撮合引擎依日誌的順序逐筆處理委託,委託簿放在記憶體裡,由單一執行緒更新,不需要鎖,處理路徑上也沒有資料庫。這是 Single Writer Model:同一份狀態只有一個寫入者,順序由輸入的日誌決定。公開資料裡寫得最完整的例子是 Martin Fowler 的〈The LMAX Architecture〉(2011):LMAX 交易平台把所有市場、所有客戶的交易放在單一執行緒、完全在記憶體內處理,文中寫這個處理器在單一執行緒上每秒處理 600 萬筆訂單,輸入的訊息在處理之前要先完成解析、複寫與寫入日誌。單一執行緒涵蓋的範圍各家不同:開源的撮合引擎 exchange-core 依交易對把委託簿分片、每一片綁一顆 CPU;Coinbase 衍生品交易所在 SREcon23 的簡報寫的是整個交易系統由單一執行緒處理,建在 Raft 的實作 Aeron Cluster 之上,理由是複寫狀態要求應用程式的處理是確定性的。
確定性讓狀態可以從日誌重建。引擎的處理只依賴日誌裡的輸入時(處理時不讀時鐘、亂數這類每次不同的值,或把它們也記進日誌),處理同一份日誌一定得到同一個結果,所以定期把委託簿存成快照,故障時載入最近的快照,再依序重放快照之後的日誌,就回到故障前的狀態。這種以事件日誌為權威、狀態由重放得到的設計在 Event Sourcing。
重放讓委託簿回到停機前的狀態,而停機前掛在簿上的委託,在停機的這段時間裡已經失去價值:延遲改變請求的價值這個性質對它們同樣成立。所以停機之後不直接恢復連續撮合:先只收取消,讓使用者撤掉不再想要的委託;再以集合競價決定重新開始時的價格,讓累積的委託以單一價格成交;最後才恢復連續撮合。美國股市熔斷之後,主要上市交易所也是以競價重新開盤(Nasdaq 稱為 Halt Cross,Cboe 稱為 Halt Auction Process)。
撮合引擎的容量有兩種擴充方式,適用的範圍不同。不同的交易對(例如 BTC 對 USD、ETH 對 USD)各自有一本委託簿,彼此不影響,所以可以把交易對分到不同的引擎上,交易對越多,加機器越有效。單一熱門交易對的處理量卻只能靠一個引擎:一本委託簿不能拆成兩本,因為價格與時間的優先順序要在整本簿上成立,拆開之後兩邊各自成交,就可能出現較好的價格沒有先成交的情形。售票的互斥範圍是一張票,不同的票可以同時被不同的請求認領;委託簿的互斥範圍是整本簿,每一筆委託有自己的價格與時間,成交與否取決於簿上其他所有的委託。所以熱門交易對的處理量只能往單機效能推,這是從機制推出的結論:單一執行緒的處理量取決於單顆 CPU 的時脈與記憶體存取,加機器不會提高它。
撮合引擎不能像無狀態的服務那樣在尖峰時自動加機器。新的引擎要先有委託簿的狀態才能處理委託,日誌的共識成員也不是可以臨時增加的;而撮合系統的尖峰出現在市場劇烈波動時,正是使用者最需要下單、最不能少處理任何一筆的時候。所以容量要事先配置:以過去劇烈波動那幾天的委託量為基準,加上預留的餘裕(Headroom Budget),平常大部分時間這些容量用不到。售票系統的尖峰時間事先知道,可以在開賣前擴充、賣完就縮回;撮合系統的尖峰多半出現在事先不知道的時刻(突發消息、價格急遽變動),容量只能一直保留。
成交回報與行情:讓慢的接收端不拖慢撮合引擎
撮合引擎的輸出有兩種,可以讓步的程度不同。成交回報通知雙方各自的委託成交了多少、以什麼價格,每一筆都是帳戶部位(帳戶持有的各項資產數量)的依據,不能遺失;用戶端斷線重連時,要能從某個編號之後把漏掉的回報補回來。行情是給訂閱者看的最新成交價與各價位的委託量,一個處理不及的訂閱者錯過中間幾次報價的更新,只要拿到最新的狀態,就與一直跟得上的訂閱者看到同樣的報價;把多則更新合併成一則送出的做法叫 conflation。合併的範圍有兩種:只合併報價、成交照樣逐筆送出,或在固定的時間間隔內連成交也合併。ICE 的行情資料服務把只合併報價的那一種稱為 trade-safe conflation,選哪一種取決於訂閱者是否需要每一筆成交。
負載在這一段可能反過來回到撮合引擎。引擎若直接把輸出送給每一個接收端、等接收端收下才處理下一筆委託,一個網路很慢的訂閱者就讓所有交易對的撮合跟著變慢,這是 Backpressure 傳回了不該傳回的地方。所以引擎只把輸出寫進自己的輸出日誌,由另外的發布服務讀日誌、送給各個接收端;某個接收端跟不上時,發布服務對行情做 conflation,對成交回報則保留在日誌裡等它重連補讀,跟不上太久的連線直接斷開。發布服務自己當機時,撮合照常進行,輸出都還在日誌裡,新的發布服務從日誌的某個編號接著送,訂閱者重連之後補讀漏掉的部分(這是從輸出日誌的設計推出的,各交易所的實作公開資料多半沒寫)。監控同樣從這份輸出日誌讀數字,不進撮合引擎的執行緒,做法在 4.0.4 以監控鏈盤點交易撮合系統 的〈撮合引擎單一執行緒時觀察者讀數字的位置:輸出日誌〉。行情的報價與下一段的歷史查詢,是撮合系統裡讀到稍舊的資料不會造成錯誤結果的地方,性質與售票的座位圖相近。
帳戶的歷史委託、成交紀錄與報表放在資料庫。Coinbase 的案例頁寫的是以 Aurora 做即時與歷史的交易紀錄查詢;從機制推下去,資料庫要由輸出日誌非同步寫入,查詢才不會進入撮合的處理路徑,而代價是資料庫的寫入落後幾秒,使用者查歷史時看到的是稍舊的紀錄,委託與成交本身不受影響。
過載時的處置:明確拒絕超量委託與以重放驗證容量
撮合系統過載時,處置落在閘道:明確拒絕超過處理量的委託。被拒絕的委託沒有進入日誌,用戶端知道它不存在,不會發生一筆以為沒送出的委託在幾秒後成交的情形;而事先配置的容量決定了拒絕從多大的委託量開始。
容量的驗證以重放為主:把過去劇烈波動那幾天的委託日誌照原本的時間間隔重放到測試環境,量每一段的延遲分布與撮合引擎的處理量,確認事先配置的容量在那樣的流量下延遲仍在目標以內。用重放而不用合成的負載,理由是真實的委託流有合成負載難以重現的形狀:短時間內的突發、取消與改單佔的比例、委託集中在少數價位,而撮合引擎的處理量取決於這些形狀(每一筆委託要配對幾個對手、委託簿多深),合成負載量到的可能是另一個處理量(這是從機制推出的理由)。延遲要看分布的尾端,不看平均:日誌主節點的選舉、垃圾回收或一次磁碟寫入變慢,都只出現在尾端,而那幾筆委託成交的價格正是使用者會抱怨的那幾筆,見 Tail Latency 與 9.12 SLO 與 Performance Budget。
交易所另有一種讓全部交易同時停下的機制,常被稱為熔斷(market-wide circuit breaker),與呼叫端停止呼叫故障依賴的 Circuit Breaker 模式是不同的機制:它的觸發條件是價格(美國股市以 S&P 500 相對前一交易日收盤的跌幅判斷),目的是讓參與者在價格劇烈變動時有時間消化資訊,不是系統負載的保護。它與容量規劃有關的地方只有一點:觸發它的價格劇烈變動,正是委託量最大的時候,暫停之前的那段時間要由事先配置的容量處理。
撮合與售票的對照:可提早回應的比例與延遲的影響
兩個系統的差別落在 Backend 範例系統 〈四個系統在請求性質上的差別〉那張表的前兩欄,兩個性質怎麼決定負載保護的方向在 9.0 請求路徑上的負載分析。售票的可提早回應的比例高(票數之外的購票請求都是),前面擋得越多、扣庫存承受的越少,多出來的請求可以排隊;撮合的比例低(只有無效的委託),前面能擋的很少,多出來的委託要明確拒絕,撮合引擎每秒處理的委託數要事先配置到劇烈波動時的委託量。撮合的互斥範圍又是整本簿、縮不小,撮合這一側的做法因此全部落在處理路徑本身:依序日誌負責不遺失、單一寫入者的撮合引擎在記憶體內處理、行情由另外的發布服務送出,而容量事先配置,因為撮合引擎沒辦法在尖峰時臨時加機器。電商 checkout 在兩者之間:庫存充足時每一筆訂單都要處理(與撮合相同),而訂單晚幾秒處理結果不變(與售票相同),所以它的負載保護以排隊與降級為主。負載超過容量時各種結果的判斷標準在 9.16 負載超過容量時服務的表現。