<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Low-Latency on Tarragon</title><link>https://tarrragon.github.io/blog/tags/low-latency/</link><description>Recent content in Low-Latency on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 15 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/low-latency/index.xml" rel="self" type="application/rss+xml"/><item><title>4.0.4 以監控鏈盤點交易撮合系統：觀察者讀數字的位置、沒有新委託時分辨市場安靜與路徑停住、分段量測延遲，以及委託序號、帳戶部位的對帳與其他監控鏈事前偵測不到的異常</title><link>https://tarrragon.github.io/blog/backend/04-observability/monitoring-inventory-matching-engine/</link><pubDate>Thu, 15 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/04-observability/monitoring-inventory-matching-engine/</guid><description>&lt;p>這一篇盤點一個交易撮合系統的監控，範圍是委託從入口閘道、依序日誌（替委託編號、寫入多數節點的那份日誌）、撮合引擎到行情發布的處理路徑，以及讀取撮合結果的帳戶與對帳。系統的功能與需求見 &lt;a href="https://tarrragon.github.io/blog/backend/case-systems/" data-link-title="Backend 範例系統：短網址、售票搶購、電商 checkout 與交易撮合的功能、需求與規模假設" data-link-desc="Backend 教材幾篇以具體系統走完整條請求路徑的文章共用的四個範例系統：每個系統提供的功能與請求種類、不能讓步與可以讓步的需求、規模假設與起點架構，以及四個系統在成功請求的總數是否有上限、延遲是否改變請求的價值、同一個請求被處理兩次的後果上的差別">Backend 範例系統&lt;/a> 的〈交易撮合〉，負載路徑在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑&lt;/a>。盤點的工具是 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/monitoring-layers/" data-link-title="4.0 服務端監控的分層：容器與程序的自檢、服務網路內的探測、元件指標、應用層訊號、告警規則與分送、告警鏈的監看與使用者路徑的合成監控" data-link-desc="以每一層監控檢查的對象、看不到的範圍與接手的那一層，把服務端監控串成一條監控鏈，以及拿它盤點一個服務時怎麼判讀缺口的性質（檢查範圍、位置、讀取與訊號失真），涵蓋自架主機、Kubernetes 與雲端託管的情形；使用者路徑那一層另寫合成監控與真實使用者監測的差別，以及盤點時各種缺口的補法">4.0 服務端監控的分層&lt;/a> 的監控鏈：觀察者是做監控的程式（systemd、Prometheus、探測程序、心跳監看服務），監控鏈是依檢查對象排成的一串觀察者，從程序的自檢、服務網路內的探測、元件指標、應用層訊號，到告警規則與分送（規則評估、Alertmanager 與通知管道）、告警鏈的監看與使用者路徑的合成監控。每一層的缺口分成&lt;strong>檢查範圍盲區&lt;/strong>（沒有觀察者檢查那個對象）、&lt;strong>位置盲區&lt;/strong>（觀察者跟被觀察的元件共用一個依賴而一起停）、&lt;strong>讀取缺口&lt;/strong>（觀察者有結果而沒有規則讀它，或通知送不出去）與&lt;strong>訊號失真&lt;/strong>（觀察者自己壞掉沒人知道，或規則的寫法讓結果不可信），完整的定義在 4.0 的〈一層監控檢查的對象與它的缺口〉，判讀依據在 4.0 的〈以監控鏈盤點一個服務的判讀依據〉。撮合引擎以單一執行緒處理委託、延遲目標在毫秒以下，所以觀察者讀數字的位置、沒有新委託時的判讀與延遲的量法，都和 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/monitoring-inventory-single-host/" data-link-title="4.0.2 以監控鏈盤點單機短網址服務：各層現有的監控、缺口的性質與補法，以及監控鏈事前偵測不到的異常" data-link-desc="把一台主機上以 Docker Compose 跑的短網址服務現有的每一種監控放進監控鏈的各層，逐層判讀還開著的缺口屬於檢查範圍盲區、位置盲區、讀取缺口還是訊號失真，各自的補法，以及補上失效域以外的觀察者之前，主機整台停掉時沒有任何一層會通知的情形；另逐類查業務結果、資料正確性、安全、成本、到期事件與備份">4.0.2 以監控鏈盤點單機短網址服務&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/monitoring-inventory-k8s-checkout/" data-link-title="4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout：叢集內的監控、託管資料庫與第三方金流各自的失效域，以及佇列消費端與外部依賴的缺口" data-link-desc="把一個跑在託管 Kubernetes 上、有訊息佇列消費端、託管資料庫與第三方金流的電商 checkout 現有的監控放進監控鏈的各層，逐層判讀缺口與補法；另寫付款服務的 readiness 納入金流業者時對付款回呼的影響、失效域從主機換成叢集與雲端帳號之後觀察者要放在哪裡，以及業務結果、對帳、到期事件、消費端、備份、安全、第三方依賴與成本的逐類盤點">4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout&lt;/a> 的做法不同。撮合系統的內部監控各交易所不同，這一篇寫的是從機制推出的做法，引用公開文件的地方寫明出處。&lt;/p>
&lt;h2 id="撮合引擎單一執行緒時觀察者讀數字的位置輸出日誌">撮合引擎單一執行緒時觀察者讀數字的位置：輸出日誌&lt;/h2>
&lt;p>撮合引擎以單一執行緒、在記憶體裡依序處理委託，每一筆委託的處理時間以微秒計，延遲目標在毫秒以下（兩個數字都是範例系統的示意）。在這條執行緒上做任何一件需要等待的事（寫一行日誌到檔案、更新一個要上鎖的計數器、對外送一個量測封包），都直接加到每一筆委託的延遲上，也讓延遲的尾端變長。所以撮合引擎的執行緒上不能做需要等待的記錄：量測本身會改變被量測的對象。&lt;/p>
&lt;p>觀察者讀數字的來源因此移到處理路徑以外。撮合引擎每處理一筆委託，結果寫進它自己的輸出日誌（&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑&lt;/a> 的〈成交回報與行情：讓慢的接收端不拖慢撮合引擎〉），另一個程序讀這份輸出日誌，把委託數、成交數、拒絕數、各段的時間戳換算成指標，交給 Prometheus 這類觀察者讀取。這支&lt;strong>指標換算程序&lt;/strong>是一種讀輸出日誌的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/prometheus-exporter/" data-link-title="Prometheus Exporter（指標轉接程式）" data-link-desc="要監控一個不會自己輸出 Prometheus 指標的系統（主機、容器、資料庫、快取、Nginx）時，查 exporter 做什麼、它和應用程式自己開的 /metrics 差在哪，以及 exporter 讀得到資料卻缺標籤、或者讀不到它所在層以外的機器時怎麼發現">Prometheus Exporter&lt;/a>（把一個元件的狀態轉成 Prometheus 指標的程式），跑在撮合引擎的執行緒以外。輸出日誌是引擎本來就要產生的循序輸出，寫進記憶體裡的環形緩衝、由另一個執行緒落盤，引擎寫它時不等磁碟，所以只要緩衝沒有寫滿，寫輸出日誌不會把等待加到委託的延遲上。&lt;/p>
&lt;p>這要有一個前提：讀輸出日誌的程序不能反過來讓引擎等它。指標換算程序讀的是落盤之後的檔案，或以不參與流量控制的方式訂閱（Aeron 稱為 untethered subscription，只在同一條流上另有其他訂閱時才生效；只有一個訂閱時，它照樣決定發布端的速度）；指標換算程序若是環形緩衝的消費者，而發布端不能超過最慢消費者讀到的位置（LMAX Disruptor 的 gating），指標換算程序一變慢，就把緩衝填滿、讓引擎停下來等；這個範例的指標換算程序讀的是落盤之後的檔案。落盤的執行緒跟不上時緩衝同樣會滿，所以環形緩衝的填滿程度與落盤的落後量本身也要量。引擎內部的量（內部佇列的深度、每一筆的處理時間分布）不在輸出日誌裡，用同樣不等待的方式記錄：不需要上鎖的計數（例如已處理的最後一個序號），或 HdrHistogram 的 SingleWriterRecorder 這類由單一寫入者記錄、記錄時不等待的 histogram，都由別的執行緒定期讀出。這樣的代價是指標比實際的處理晚一點，而好處是觀察者壞掉、變慢時，撮合不受影響。這也決定了監控鏈各層在這個系統裡的位置：自檢與元件指標照常放在主機與程序上，應用層訊號（每一筆委託成功與否、花了多久）不在引擎的執行緒上量，由指標換算程序從輸出日誌算出。&lt;/p>
&lt;h2 id="沒有新委託時分辨市場安靜與處理路徑停住">沒有新委託時分辨市場安靜與處理路徑停住&lt;/h2>
&lt;p>一般服務的監控以「請求失敗」為訊號，撮合系統最難從輸出日誌發現的故障卻是安靜：撮合引擎卡在主迴圈裡（例如處理某一筆委託時進入無窮迴圈）、或依序日誌失去多數節點而停止接受委託時，輸出日誌裡不會有任何一筆失敗紀錄，只是什麼都沒有發生（失敗只出現在入口那一側：閘道等不到依序日誌確認的逾時，以及用戶端的重送）；而市場本身也會有安靜的時候，一個冷門的交易對幾分鐘沒有委託是正常的。只看委託數與成交數，兩種情形在指標上一模一樣。&lt;/p>
&lt;p>分辨的做法是讓撮合系統在沒有委託時也產生訊號，而且訊號要走完整條處理路徑。一個注入程序每隔固定時間（示意：一秒），直接寫進依序日誌的入口（不經過閘道），對每一個撮合引擎（依序日誌分區時，對每一個分區）各注入一筆帶著心跳標記的委託（&lt;strong>路徑心跳&lt;/strong>），它和真實委託一樣被編號、寫進依序日誌、由撮合引擎處理，引擎把它原樣寫進輸出日誌。注入程序輸出最後一次成功注入的時間（成功指依序日誌回應了「已接受」），指標換算程序記下每一個撮合引擎最後一筆路徑心跳出現在輸出日誌的時間，Prometheus 依引擎各算一次它距今多久，超過注入間隔的三倍（示意的一秒間隔下是三秒）就代表那個引擎的路徑上某一段停住，這個判斷在沒有委託時同樣成立。&lt;/p>
&lt;p>判讀時把產生訊號的元件一起看，路徑心跳停了的情形分成三種。&lt;strong>依序日誌之後的路徑停住&lt;/strong>：注入程序在運作、注入成功（依序日誌回應了「已接受」），指標換算程序也在運作、落後量正常，路徑心跳卻沒有出現在輸出日誌裡，停住的是撮合引擎或它寫輸出日誌的那一段，告警規則寫成這幾個條件同時成立。&lt;strong>依序日誌停住&lt;/strong>：注入程序在運作而注入失敗（等不到「已接受」），閘道等待依序日誌確認的逾時率也同時升高。&lt;strong>觀察者那一側停住&lt;/strong>：注入程序本身停掉，或指標換算程序讀不到、它讀到的位置落後落盤的位置越來越多，撮合本身可能正常，這時重啟撮合引擎（要載入快照、重放依序日誌）沒有用；改看不經過這兩個程序的訊號：閘道的逾時率，以及經過閘道送到同一個撮合引擎的合成交易（每一個撮合引擎各設一個測試交易對時才有）。路徑心跳持續出現、合成交易也成功，是市場安靜；路徑心跳持續出現、合成交易卻失敗，停住的是路徑心跳沒有經過的閘道那一段。撮合引擎的程序整個結束時，那個引擎的最後序號指標跟著消失：引擎的抓取目標還在服務發現裡時，&lt;code>up == 0&lt;/code> 就抓得到；目標也消失時，以 &lt;code>absent()&lt;/code> 對每一個引擎各寫一條帶引擎標籤的規則，只對整個指標寫一條的話，要全部引擎都消失才會觸發，單一引擎停掉時漏報。&lt;/p>
&lt;p>只由撮合引擎自己定時往輸出日誌寫心跳是不夠的：那筆心跳不經過依序日誌，依序日誌停住時引擎閒著，心跳照常寫進輸出日誌，看起來就是市場安靜。路徑心跳和 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-mans-switch/" data-link-title="Dead Man&amp;#39;s Switch（心跳監看）" data-link-desc="需要知道監控或排程工作自己停掉了、而它停掉時發不出任何告警時，以「時限內沒收到訊號就通知」反過來判斷">Dead Man&amp;rsquo;s Switch（心跳監看）&lt;/a> 的機制相同，都是訊號停止才通知，差別在監看者的位置：讀路徑心跳的 Prometheus 與撮合系統在同一個可用區，可用區故障時它們一起停，那種故障由可用區以外的心跳監看服務與外部探測接手（〈撮合系統接上的監控：各層的判讀與補法〉的最後一段）。它也和告警鏈的心跳（Prometheus 定時送給外部監看服務、證明告警鏈還在運作的那一筆）是兩件事。&lt;/p>
&lt;p>有委託進來時，入口那一側的兩個數字比路徑心跳更早變化：閘道等待依序日誌確認的逾時率與等待中的委託數，依序日誌停住時它們先升高。行情那一側，行情發布服務把路徑心跳以標記過的訊息轉出，它不參與 conflation（把多則更新合併成一則送出），跟不上的訂閱端也收得到；對外的行情協定多一種訊息之前，要先確認訂閱端相容，否則只在內部的行情通道送。成交回報與帳戶更新依標記略過它。行情的連線也有心跳（FIX 協定在 &lt;code>HeartBtInt&lt;/code> 秒內沒有送出任何資料時送一筆 Heartbeat），訂閱端以「多久沒收到任何訊息」判斷的是連線與發布服務還活著；撮合是否還在進行，要看收得到路徑心跳的行情通道裡是否持續出現它。&lt;/p>
&lt;p>依序日誌停住另有一個更早的訊號：健康的節點數。依序日誌靠多數節點寫入才回應「已接受」，主節點寫不到多數就不能提交，五個節點的叢集停掉三個就選不出主節點、停止接受委託（Raft 的論文寫明，任意多數節點運作且彼此能通訊時叢集就能完整運作，五個節點能容忍任意兩個故障，見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consensus-protocol/" data-link-title="Consensus Protocol" data-link-desc="讓多個獨立節點在訊息可能延遲、丟失、亂序的網路下對單一決策達成一致的演算法">Consensus Protocol&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">Quorum&lt;/a>）。這裡的「健康」指能與主節點通訊並跟得上複寫（跟得上的門檻由日誌叢集的實作決定，例如主節點多久沒收到它的複寫回應），只是程序還在而跟不上複寫的節點不算。所以告警不等到失去多數才成立，在健康節點數降到「再停一個就失去多數」時就送出（五個節點時是剩三個健康），那時依序日誌還在運作，值班還有時間處理。健康節點數的來源是依序日誌叢集自己輸出的成員狀態：主節點知道每一個節點複寫到哪一個序號，跟得上的才算健康。這個數字由主節點輸出，失去多數、選不出主節點時沒有節點輸出它，以健康節點數寫的規則跟著失效；所以每個節點也輸出自己的角色與任期，依序日誌分區時，每個分區各自判斷：沒有任何節點自稱主節點、或健康節點數的指標消失，持續時間超過選舉逾時（以規則的 &lt;code>for:&lt;/code> 設定）才告警，否則每一次正常的選舉都會誤報。節點的失效彼此相關時（同一個可用區、同一個機櫃），健康節點數看不出風險：五個節點以三比二分在兩個可用區時，放三個節點的那個可用區一故障就失去多數，而故障之前健康節點數一直是五。這是節點放置的問題，部署時要把節點分到三個以上的失效域；這個範例為了延遲把節點放在同一個可用區，失效域只能是可用區裡的機櫃或主機，整個可用區故障的風險仍在，由〈撮合系統接上的監控：各層的判讀與補法〉最後一段的外部觀察者接手。分到不同機櫃也有代價：以 AWS 為例，一台執行個體一次只能放在一個 placement group，把執行個體緊密放在一起的 cluster 類型不保證分在不同機櫃，要分機櫃得改用 spread 或 partition 類型，延遲會上升多少要實測（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/failure-domain/" data-link-title="Failure Domain（失效域）" data-link-desc="判斷兩個元件會不會因為同一件事一起停掉、備援或監控是否獨立於被保護的對象時，查它們共用了哪些會失效的依賴">Failure Domain&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/correlated-failure/" data-link-title="Correlated Failure" data-link-desc="以為獨立的多個元件共享一個隱藏的失效觸發器、在同一時刻一起壞；冗餘副本、觀測系統、跨區部署都可能共命運">Correlated Failure&lt;/a>）。&lt;/p>
&lt;h2 id="委託延遲的分段量測與時鐘">委託延遲的分段量測與時鐘&lt;/h2>
&lt;p>撮合系統的延遲要依段量，因為使用者感受到的是整段，而值班能動手的是其中一段。一筆委託的生命週期裡記四個時間戳：閘道收到時由閘道記，寫入多數節點時由依序日誌的主節點依序號記下提交的時間（提交的時間要等寫入多數之後才知道，寫不回同一筆紀錄，所以由指標換算程序依序號把它與輸出日誌合併），撮合引擎處理完時由引擎讀時鐘、寫在輸出紀錄的附帶欄位裡（這個欄位只用於量測，不參與撮合的決策），成交回報送出時由發布服務記；相鄰兩個時間戳相減就是那一段的延遲。每一段的長短由不同的資源決定：閘道到依序日誌寫入多數節點這一段，由日誌節點的磁碟寫入與節點之間的網路往返決定；依序日誌到撮合引擎處理完這一段，由撮合引擎那一條執行緒分到的 CPU 時間決定，同一台機器上的其他程序搶走 CPU、或執行環境暫停整個程序（JVM 的垃圾回收與 safepoint）時這一段先變長；撮合引擎處理完到成交回報送出這一段，由輸出日誌的落盤與發布服務讀取、送出的速度決定。&lt;/p>
&lt;p>延遲以 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/histogram/" data-link-title="Histogram" data-link-desc="說明 histogram 如何用分桶統計延遲、大小與分布">Histogram&lt;/a> 記錄、看 p99 與更尾端的百分位數：依序日誌的主節點選舉、垃圾回收或一次磁碟寫入變慢，只落在少數委託上，平均值看不出來，而那幾筆委託的成交價格正是使用者會抱怨的那幾筆（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/tail-latency/" data-link-title="Tail Latency" data-link-desc="說明 p99 / p999 等長尾延遲為何比平均延遲更能反映 saturation">Tail Latency&lt;/a>）。histogram 的桶邊界要涵蓋微秒到毫秒（示意：10 微秒到 10 毫秒）；Prometheus 官方 Go 用戶端的預設桶（&lt;code>DefBuckets&lt;/code>）從 5 毫秒起，毫秒以下的延遲會全部落在第一個桶，算出來的 p99 只是桶內的插值。&lt;/p>
&lt;p>這幾個時間戳記在不同的機器上，相減之前要先確定兩台機器的時鐘對得上：兩台機器的時鐘差一毫秒，算出來的一段延遲就差一毫秒，而延遲目標本身就在毫秒以下（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/clock-skew/" data-link-title="Clock Skew" data-link-desc="跨機器比較時間才成立的機制（時效窗口、存取憑證與數位憑證的有效期、事件排序）在決定容忍值時的判斷依據">Clock Skew&lt;/a>）。所以時鐘同步的狀態本身要監看：node_exporter 的 timex 收集器輸出核心從校時程式得到的偏差估計（&lt;code>node_timex_offset_seconds&lt;/code>），連同是否已同步（&lt;code>node_timex_sync_status&lt;/code>）與估計誤差（&lt;code>node_timex_estimated_error_seconds&lt;/code>）一起看。偏差估計只在校時程式使用核心的相位鎖定迴路（例如 ntpd）時才有值；chrony 只調整核心的頻率、不設定這個欄位，在 chrony 下 &lt;code>node_timex_offset_seconds&lt;/code> 恆為零，以它寫的規則永遠不會觸發，這本身就是一種訊號失真。用 chrony 時改讀 chrony 自己回報的偏差（&lt;code>chronyc tracking&lt;/code>，或 chrony 的 exporter），同步狀態與估計誤差照樣從 timex 讀；用 PTP 時讀 ptp4l 與 phc2sys 回報的偏差。這個偏差是每台機器對校時來源的偏差，一段跨兩台機器的延遲誤差最大是兩台偏差之和，所以每台對校時來源的門檻要設成那一段可接受誤差的一半（可接受誤差由那一段的延遲目標決定，例如那一段延遲目標的一成，示意）；偏差超過門檻時，分段延遲的數字就不可信，這是監控鏈裡訊號失真的一種。受歐盟 MiFID II 規範的交易場所有法定的上限：&lt;a href="https://www.legislation.gov.uk/eur/2017/574/annex">RTS 25（Delegated Regulation (EU) 2017/574）&lt;/a> 附件的 Table 1 規定，gateway-to-gateway 延遲在 1 毫秒以下的交易場所，時鐘與 UTC 的偏差最多 100 微秒，時間戳粒度 1 微秒或更細；這個上限是每台時鐘對 UTC 的偏差，兩台都在上限內時，彼此仍可能差到 200 微秒；受規範的交易場所取這個上限與自己算出的門檻裡較嚴的那一個。node_timex 的值是本機的自我估計，看不到網路路徑不對稱造成的誤差，所以偏差小不保證兩台機器對得上；要做到微秒級的對齊，要改用 PTP（Precision Time Protocol，IEEE 1588）這類以硬體時間戳校時的協定。&lt;/p></description><content:encoded><![CDATA[<p>這一篇盤點一個交易撮合系統的監控，範圍是委託從入口閘道、依序日誌（替委託編號、寫入多數節點的那份日誌）、撮合引擎到行情發布的處理路徑，以及讀取撮合結果的帳戶與對帳。系統的功能與需求見 <a href="/blog/backend/case-systems/" data-link-title="Backend 範例系統：短網址、售票搶購、電商 checkout 與交易撮合的功能、需求與規模假設" data-link-desc="Backend 教材幾篇以具體系統走完整條請求路徑的文章共用的四個範例系統：每個系統提供的功能與請求種類、不能讓步與可以讓步的需求、規模假設與起點架構，以及四個系統在成功請求的總數是否有上限、延遲是否改變請求的價值、同一個請求被處理兩次的後果上的差別">Backend 範例系統</a> 的〈交易撮合〉，負載路徑在 <a href="/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑</a>。盤點的工具是 <a href="/blog/backend/04-observability/monitoring-layers/" data-link-title="4.0 服務端監控的分層：容器與程序的自檢、服務網路內的探測、元件指標、應用層訊號、告警規則與分送、告警鏈的監看與使用者路徑的合成監控" data-link-desc="以每一層監控檢查的對象、看不到的範圍與接手的那一層，把服務端監控串成一條監控鏈，以及拿它盤點一個服務時怎麼判讀缺口的性質（檢查範圍、位置、讀取與訊號失真），涵蓋自架主機、Kubernetes 與雲端託管的情形；使用者路徑那一層另寫合成監控與真實使用者監測的差別，以及盤點時各種缺口的補法">4.0 服務端監控的分層</a> 的監控鏈：觀察者是做監控的程式（systemd、Prometheus、探測程序、心跳監看服務），監控鏈是依檢查對象排成的一串觀察者，從程序的自檢、服務網路內的探測、元件指標、應用層訊號，到告警規則與分送（規則評估、Alertmanager 與通知管道）、告警鏈的監看與使用者路徑的合成監控。每一層的缺口分成<strong>檢查範圍盲區</strong>（沒有觀察者檢查那個對象）、<strong>位置盲區</strong>（觀察者跟被觀察的元件共用一個依賴而一起停）、<strong>讀取缺口</strong>（觀察者有結果而沒有規則讀它，或通知送不出去）與<strong>訊號失真</strong>（觀察者自己壞掉沒人知道，或規則的寫法讓結果不可信），完整的定義在 4.0 的〈一層監控檢查的對象與它的缺口〉，判讀依據在 4.0 的〈以監控鏈盤點一個服務的判讀依據〉。撮合引擎以單一執行緒處理委託、延遲目標在毫秒以下，所以觀察者讀數字的位置、沒有新委託時的判讀與延遲的量法，都和 <a href="/blog/backend/04-observability/monitoring-inventory-single-host/" data-link-title="4.0.2 以監控鏈盤點單機短網址服務：各層現有的監控、缺口的性質與補法，以及監控鏈事前偵測不到的異常" data-link-desc="把一台主機上以 Docker Compose 跑的短網址服務現有的每一種監控放進監控鏈的各層，逐層判讀還開著的缺口屬於檢查範圍盲區、位置盲區、讀取缺口還是訊號失真，各自的補法，以及補上失效域以外的觀察者之前，主機整台停掉時沒有任何一層會通知的情形；另逐類查業務結果、資料正確性、安全、成本、到期事件與備份">4.0.2 以監控鏈盤點單機短網址服務</a> 與 <a href="/blog/backend/04-observability/monitoring-inventory-k8s-checkout/" data-link-title="4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout：叢集內的監控、託管資料庫與第三方金流各自的失效域，以及佇列消費端與外部依賴的缺口" data-link-desc="把一個跑在託管 Kubernetes 上、有訊息佇列消費端、託管資料庫與第三方金流的電商 checkout 現有的監控放進監控鏈的各層，逐層判讀缺口與補法；另寫付款服務的 readiness 納入金流業者時對付款回呼的影響、失效域從主機換成叢集與雲端帳號之後觀察者要放在哪裡，以及業務結果、對帳、到期事件、消費端、備份、安全、第三方依賴與成本的逐類盤點">4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout</a> 的做法不同。撮合系統的內部監控各交易所不同，這一篇寫的是從機制推出的做法，引用公開文件的地方寫明出處。</p>
<h2 id="撮合引擎單一執行緒時觀察者讀數字的位置輸出日誌">撮合引擎單一執行緒時觀察者讀數字的位置：輸出日誌</h2>
<p>撮合引擎以單一執行緒、在記憶體裡依序處理委託，每一筆委託的處理時間以微秒計，延遲目標在毫秒以下（兩個數字都是範例系統的示意）。在這條執行緒上做任何一件需要等待的事（寫一行日誌到檔案、更新一個要上鎖的計數器、對外送一個量測封包），都直接加到每一筆委託的延遲上，也讓延遲的尾端變長。所以撮合引擎的執行緒上不能做需要等待的記錄：量測本身會改變被量測的對象。</p>
<p>觀察者讀數字的來源因此移到處理路徑以外。撮合引擎每處理一筆委託，結果寫進它自己的輸出日誌（<a href="/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑</a> 的〈成交回報與行情：讓慢的接收端不拖慢撮合引擎〉），另一個程序讀這份輸出日誌，把委託數、成交數、拒絕數、各段的時間戳換算成指標，交給 Prometheus 這類觀察者讀取。這支<strong>指標換算程序</strong>是一種讀輸出日誌的 <a href="/blog/backend/knowledge-cards/prometheus-exporter/" data-link-title="Prometheus Exporter（指標轉接程式）" data-link-desc="要監控一個不會自己輸出 Prometheus 指標的系統（主機、容器、資料庫、快取、Nginx）時，查 exporter 做什麼、它和應用程式自己開的 /metrics 差在哪，以及 exporter 讀得到資料卻缺標籤、或者讀不到它所在層以外的機器時怎麼發現">Prometheus Exporter</a>（把一個元件的狀態轉成 Prometheus 指標的程式），跑在撮合引擎的執行緒以外。輸出日誌是引擎本來就要產生的循序輸出，寫進記憶體裡的環形緩衝、由另一個執行緒落盤，引擎寫它時不等磁碟，所以只要緩衝沒有寫滿，寫輸出日誌不會把等待加到委託的延遲上。</p>
<p>這要有一個前提：讀輸出日誌的程序不能反過來讓引擎等它。指標換算程序讀的是落盤之後的檔案，或以不參與流量控制的方式訂閱（Aeron 稱為 untethered subscription，只在同一條流上另有其他訂閱時才生效；只有一個訂閱時，它照樣決定發布端的速度）；指標換算程序若是環形緩衝的消費者，而發布端不能超過最慢消費者讀到的位置（LMAX Disruptor 的 gating），指標換算程序一變慢，就把緩衝填滿、讓引擎停下來等；這個範例的指標換算程序讀的是落盤之後的檔案。落盤的執行緒跟不上時緩衝同樣會滿，所以環形緩衝的填滿程度與落盤的落後量本身也要量。引擎內部的量（內部佇列的深度、每一筆的處理時間分布）不在輸出日誌裡，用同樣不等待的方式記錄：不需要上鎖的計數（例如已處理的最後一個序號），或 HdrHistogram 的 SingleWriterRecorder 這類由單一寫入者記錄、記錄時不等待的 histogram，都由別的執行緒定期讀出。這樣的代價是指標比實際的處理晚一點，而好處是觀察者壞掉、變慢時，撮合不受影響。這也決定了監控鏈各層在這個系統裡的位置：自檢與元件指標照常放在主機與程序上，應用層訊號（每一筆委託成功與否、花了多久）不在引擎的執行緒上量，由指標換算程序從輸出日誌算出。</p>
<h2 id="沒有新委託時分辨市場安靜與處理路徑停住">沒有新委託時分辨市場安靜與處理路徑停住</h2>
<p>一般服務的監控以「請求失敗」為訊號，撮合系統最難從輸出日誌發現的故障卻是安靜：撮合引擎卡在主迴圈裡（例如處理某一筆委託時進入無窮迴圈）、或依序日誌失去多數節點而停止接受委託時，輸出日誌裡不會有任何一筆失敗紀錄，只是什麼都沒有發生（失敗只出現在入口那一側：閘道等不到依序日誌確認的逾時，以及用戶端的重送）；而市場本身也會有安靜的時候，一個冷門的交易對幾分鐘沒有委託是正常的。只看委託數與成交數，兩種情形在指標上一模一樣。</p>
<p>分辨的做法是讓撮合系統在沒有委託時也產生訊號，而且訊號要走完整條處理路徑。一個注入程序每隔固定時間（示意：一秒），直接寫進依序日誌的入口（不經過閘道），對每一個撮合引擎（依序日誌分區時，對每一個分區）各注入一筆帶著心跳標記的委託（<strong>路徑心跳</strong>），它和真實委託一樣被編號、寫進依序日誌、由撮合引擎處理，引擎把它原樣寫進輸出日誌。注入程序輸出最後一次成功注入的時間（成功指依序日誌回應了「已接受」），指標換算程序記下每一個撮合引擎最後一筆路徑心跳出現在輸出日誌的時間，Prometheus 依引擎各算一次它距今多久，超過注入間隔的三倍（示意的一秒間隔下是三秒）就代表那個引擎的路徑上某一段停住，這個判斷在沒有委託時同樣成立。</p>
<p>判讀時把產生訊號的元件一起看，路徑心跳停了的情形分成三種。<strong>依序日誌之後的路徑停住</strong>：注入程序在運作、注入成功（依序日誌回應了「已接受」），指標換算程序也在運作、落後量正常，路徑心跳卻沒有出現在輸出日誌裡，停住的是撮合引擎或它寫輸出日誌的那一段，告警規則寫成這幾個條件同時成立。<strong>依序日誌停住</strong>：注入程序在運作而注入失敗（等不到「已接受」），閘道等待依序日誌確認的逾時率也同時升高。<strong>觀察者那一側停住</strong>：注入程序本身停掉，或指標換算程序讀不到、它讀到的位置落後落盤的位置越來越多，撮合本身可能正常，這時重啟撮合引擎（要載入快照、重放依序日誌）沒有用；改看不經過這兩個程序的訊號：閘道的逾時率，以及經過閘道送到同一個撮合引擎的合成交易（每一個撮合引擎各設一個測試交易對時才有）。路徑心跳持續出現、合成交易也成功，是市場安靜；路徑心跳持續出現、合成交易卻失敗，停住的是路徑心跳沒有經過的閘道那一段。撮合引擎的程序整個結束時，那個引擎的最後序號指標跟著消失：引擎的抓取目標還在服務發現裡時，<code>up == 0</code> 就抓得到；目標也消失時，以 <code>absent()</code> 對每一個引擎各寫一條帶引擎標籤的規則，只對整個指標寫一條的話，要全部引擎都消失才會觸發，單一引擎停掉時漏報。</p>
<p>只由撮合引擎自己定時往輸出日誌寫心跳是不夠的：那筆心跳不經過依序日誌，依序日誌停住時引擎閒著，心跳照常寫進輸出日誌，看起來就是市場安靜。路徑心跳和 <a href="/blog/backend/knowledge-cards/dead-mans-switch/" data-link-title="Dead Man&#39;s Switch（心跳監看）" data-link-desc="需要知道監控或排程工作自己停掉了、而它停掉時發不出任何告警時，以「時限內沒收到訊號就通知」反過來判斷">Dead Man&rsquo;s Switch（心跳監看）</a> 的機制相同，都是訊號停止才通知，差別在監看者的位置：讀路徑心跳的 Prometheus 與撮合系統在同一個可用區，可用區故障時它們一起停，那種故障由可用區以外的心跳監看服務與外部探測接手（〈撮合系統接上的監控：各層的判讀與補法〉的最後一段）。它也和告警鏈的心跳（Prometheus 定時送給外部監看服務、證明告警鏈還在運作的那一筆）是兩件事。</p>
<p>有委託進來時，入口那一側的兩個數字比路徑心跳更早變化：閘道等待依序日誌確認的逾時率與等待中的委託數，依序日誌停住時它們先升高。行情那一側，行情發布服務把路徑心跳以標記過的訊息轉出，它不參與 conflation（把多則更新合併成一則送出），跟不上的訂閱端也收得到；對外的行情協定多一種訊息之前，要先確認訂閱端相容，否則只在內部的行情通道送。成交回報與帳戶更新依標記略過它。行情的連線也有心跳（FIX 協定在 <code>HeartBtInt</code> 秒內沒有送出任何資料時送一筆 Heartbeat），訂閱端以「多久沒收到任何訊息」判斷的是連線與發布服務還活著；撮合是否還在進行，要看收得到路徑心跳的行情通道裡是否持續出現它。</p>
<p>依序日誌停住另有一個更早的訊號：健康的節點數。依序日誌靠多數節點寫入才回應「已接受」，主節點寫不到多數就不能提交，五個節點的叢集停掉三個就選不出主節點、停止接受委託（Raft 的論文寫明，任意多數節點運作且彼此能通訊時叢集就能完整運作，五個節點能容忍任意兩個故障，見 <a href="/blog/backend/knowledge-cards/consensus-protocol/" data-link-title="Consensus Protocol" data-link-desc="讓多個獨立節點在訊息可能延遲、丟失、亂序的網路下對單一決策達成一致的演算法">Consensus Protocol</a> 與 <a href="/blog/backend/knowledge-cards/quorum/" data-link-title="Quorum" data-link-desc="分散式系統以多數節點同意作為提交或讀取有效性的門檻">Quorum</a>）。這裡的「健康」指能與主節點通訊並跟得上複寫（跟得上的門檻由日誌叢集的實作決定，例如主節點多久沒收到它的複寫回應），只是程序還在而跟不上複寫的節點不算。所以告警不等到失去多數才成立，在健康節點數降到「再停一個就失去多數」時就送出（五個節點時是剩三個健康），那時依序日誌還在運作，值班還有時間處理。健康節點數的來源是依序日誌叢集自己輸出的成員狀態：主節點知道每一個節點複寫到哪一個序號，跟得上的才算健康。這個數字由主節點輸出，失去多數、選不出主節點時沒有節點輸出它，以健康節點數寫的規則跟著失效；所以每個節點也輸出自己的角色與任期，依序日誌分區時，每個分區各自判斷：沒有任何節點自稱主節點、或健康節點數的指標消失，持續時間超過選舉逾時（以規則的 <code>for:</code> 設定）才告警，否則每一次正常的選舉都會誤報。節點的失效彼此相關時（同一個可用區、同一個機櫃），健康節點數看不出風險：五個節點以三比二分在兩個可用區時，放三個節點的那個可用區一故障就失去多數，而故障之前健康節點數一直是五。這是節點放置的問題，部署時要把節點分到三個以上的失效域；這個範例為了延遲把節點放在同一個可用區，失效域只能是可用區裡的機櫃或主機，整個可用區故障的風險仍在，由〈撮合系統接上的監控：各層的判讀與補法〉最後一段的外部觀察者接手。分到不同機櫃也有代價：以 AWS 為例，一台執行個體一次只能放在一個 placement group，把執行個體緊密放在一起的 cluster 類型不保證分在不同機櫃，要分機櫃得改用 spread 或 partition 類型，延遲會上升多少要實測（<a href="/blog/backend/knowledge-cards/failure-domain/" data-link-title="Failure Domain（失效域）" data-link-desc="判斷兩個元件會不會因為同一件事一起停掉、備援或監控是否獨立於被保護的對象時，查它們共用了哪些會失效的依賴">Failure Domain</a>、<a href="/blog/backend/knowledge-cards/correlated-failure/" data-link-title="Correlated Failure" data-link-desc="以為獨立的多個元件共享一個隱藏的失效觸發器、在同一時刻一起壞；冗餘副本、觀測系統、跨區部署都可能共命運">Correlated Failure</a>）。</p>
<h2 id="委託延遲的分段量測與時鐘">委託延遲的分段量測與時鐘</h2>
<p>撮合系統的延遲要依段量，因為使用者感受到的是整段，而值班能動手的是其中一段。一筆委託的生命週期裡記四個時間戳：閘道收到時由閘道記，寫入多數節點時由依序日誌的主節點依序號記下提交的時間（提交的時間要等寫入多數之後才知道，寫不回同一筆紀錄，所以由指標換算程序依序號把它與輸出日誌合併），撮合引擎處理完時由引擎讀時鐘、寫在輸出紀錄的附帶欄位裡（這個欄位只用於量測，不參與撮合的決策），成交回報送出時由發布服務記；相鄰兩個時間戳相減就是那一段的延遲。每一段的長短由不同的資源決定：閘道到依序日誌寫入多數節點這一段，由日誌節點的磁碟寫入與節點之間的網路往返決定；依序日誌到撮合引擎處理完這一段，由撮合引擎那一條執行緒分到的 CPU 時間決定，同一台機器上的其他程序搶走 CPU、或執行環境暫停整個程序（JVM 的垃圾回收與 safepoint）時這一段先變長；撮合引擎處理完到成交回報送出這一段，由輸出日誌的落盤與發布服務讀取、送出的速度決定。</p>
<p>延遲以 <a href="/blog/backend/knowledge-cards/histogram/" data-link-title="Histogram" data-link-desc="說明 histogram 如何用分桶統計延遲、大小與分布">Histogram</a> 記錄、看 p99 與更尾端的百分位數：依序日誌的主節點選舉、垃圾回收或一次磁碟寫入變慢，只落在少數委託上，平均值看不出來，而那幾筆委託的成交價格正是使用者會抱怨的那幾筆（<a href="/blog/backend/knowledge-cards/tail-latency/" data-link-title="Tail Latency" data-link-desc="說明 p99 / p999 等長尾延遲為何比平均延遲更能反映 saturation">Tail Latency</a>）。histogram 的桶邊界要涵蓋微秒到毫秒（示意：10 微秒到 10 毫秒）；Prometheus 官方 Go 用戶端的預設桶（<code>DefBuckets</code>）從 5 毫秒起，毫秒以下的延遲會全部落在第一個桶，算出來的 p99 只是桶內的插值。</p>
<p>這幾個時間戳記在不同的機器上，相減之前要先確定兩台機器的時鐘對得上：兩台機器的時鐘差一毫秒，算出來的一段延遲就差一毫秒，而延遲目標本身就在毫秒以下（<a href="/blog/backend/knowledge-cards/clock-skew/" data-link-title="Clock Skew" data-link-desc="跨機器比較時間才成立的機制（時效窗口、存取憑證與數位憑證的有效期、事件排序）在決定容忍值時的判斷依據">Clock Skew</a>）。所以時鐘同步的狀態本身要監看：node_exporter 的 timex 收集器輸出核心從校時程式得到的偏差估計（<code>node_timex_offset_seconds</code>），連同是否已同步（<code>node_timex_sync_status</code>）與估計誤差（<code>node_timex_estimated_error_seconds</code>）一起看。偏差估計只在校時程式使用核心的相位鎖定迴路（例如 ntpd）時才有值；chrony 只調整核心的頻率、不設定這個欄位，在 chrony 下 <code>node_timex_offset_seconds</code> 恆為零，以它寫的規則永遠不會觸發，這本身就是一種訊號失真。用 chrony 時改讀 chrony 自己回報的偏差（<code>chronyc tracking</code>，或 chrony 的 exporter），同步狀態與估計誤差照樣從 timex 讀；用 PTP 時讀 ptp4l 與 phc2sys 回報的偏差。這個偏差是每台機器對校時來源的偏差，一段跨兩台機器的延遲誤差最大是兩台偏差之和，所以每台對校時來源的門檻要設成那一段可接受誤差的一半（可接受誤差由那一段的延遲目標決定，例如那一段延遲目標的一成，示意）；偏差超過門檻時，分段延遲的數字就不可信，這是監控鏈裡訊號失真的一種。受歐盟 MiFID II 規範的交易場所有法定的上限：<a href="https://www.legislation.gov.uk/eur/2017/574/annex">RTS 25（Delegated Regulation (EU) 2017/574）</a> 附件的 Table 1 規定，gateway-to-gateway 延遲在 1 毫秒以下的交易場所，時鐘與 UTC 的偏差最多 100 微秒，時間戳粒度 1 微秒或更細；這個上限是每台時鐘對 UTC 的偏差，兩台都在上限內時，彼此仍可能差到 200 微秒；受規範的交易場所取這個上限與自己算出的門檻裡較嚴的那一個。node_timex 的值是本機的自我估計，看不到網路路徑不對稱造成的誤差，所以偏差小不保證兩台機器對得上；要做到微秒級的對齊，要改用 PTP（Precision Time Protocol，IEEE 1588）這類以硬體時間戳校時的協定。</p>
<p>跨機器的分段需要對得上的時鐘，同一台機器上的量測則不需要：閘道以自己的單調時鐘（不受校時程式把時間調前調後的跳動影響、只受頻率微調影響的時鐘，Linux 的 <code>CLOCK_MONOTONIC</code>）量「送出到收到已接受」的來回時間，不受機器之間偏差的影響，可以拿來對照跨機器算出的分段。同一台機器上相減的兩個時間戳若取自牆上時間（會被校時程式調整的那個時鐘），校時程式在兩個時間戳之間調整時鐘也會讓差值失準。</p>
<h2 id="撮合系統接上的監控各層的判讀與補法">撮合系統接上的監控：各層的判讀與補法</h2>
<p>範例系統的監控設定如下（假設，不是實測）。閘道與撮合引擎的程序由 systemd 管理、失敗時重啟，沒有設 <code>WatchdogSec</code>；Prometheus 讀取各主機的 node_exporter 與指標換算程序輸出的指標，指標換算程序算出每個交易對的委託數、成交數、拒絕數與各段延遲；延遲的告警規則以平均值與五分鐘視窗寫；Alertmanager 依嚴重度送到值班的推播服務；一個監看程序每分鐘做一次合成交易（<a href="/blog/backend/knowledge-cards/synthetic-monitoring/" data-link-title="Synthetic Monitoring（合成監控）" data-link-desc="要知道使用者從外面連不連得到服務、或分不清某個「外部探測」從哪裡發出時，查合成監控以排程的請求模擬使用者的做法、探測端的位置怎麼決定它量得到什麼，以及它與真實使用者監測的差別">Synthetic Monitoring</a>）：以測試帳號經過閘道對一個測試用的交易對送一筆委託再取消，量從送出到收到取消回報的時間。依序日誌的節點、撮合引擎與監控都在同一個可用區的同一組實體相近的主機上（依序日誌的節點這樣放的理由在 <a href="/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑</a> 的〈依序日誌：決定順序並保證不遺失〉，監控也放在那裡是這個範例的設定）。</p>
<table>
  <thead>
      <tr>
          <th>監控鏈的層</th>
          <th>這個系統現有的監控</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>容器與程序的自檢</td>
          <td>systemd 管理閘道與撮合引擎，失敗時重啟，沒有設 <code>WatchdogSec</code></td>
          <td>讀取缺口：重啟沒有規則讀。檢查範圍盲區：撮合引擎卡在主迴圈裡時程序還在，只看程序存活的 systemd 看不出來</td>
      </tr>
      <tr>
          <td>服務網路內的探測</td>
          <td>合成交易：測試帳號每分鐘對測試交易對送一筆委託再取消</td>
          <td>檢查範圍盲區：測試交易對與真實交易對在不同的撮合引擎上時，只證明測試的那一個引擎在運作</td>
      </tr>
      <tr>
          <td>元件指標</td>
          <td>node_exporter；依序日誌的節點數、環形緩衝與執行環境的停頓沒有指標</td>
          <td>檢查範圍盲區：依序日誌的健康節點數沒有人量，失去多數之前沒有預警；環形緩衝的填滿程度、落盤的落後量與執行環境的停頓（JVM 的垃圾回收與 safepoint）沒有人量。讀取缺口：node_exporter 已輸出時鐘的同步狀態與估計誤差（<code>node_timex_*</code>），沒有規則讀</td>
      </tr>
      <tr>
          <td>應用層訊號</td>
          <td>指標換算程序算出的委託數、成交數、拒絕數與各段延遲；閘道的逾時率沒有量</td>
          <td>檢查範圍盲區：沒有路徑心跳，路徑停住與市場安靜分不出來；閘道等待依序日誌確認的逾時率沒有量。訊號失真：指標換算程序自己停掉或落後時，它輸出的指標一起消失，沒有規則發現；跨機器的分段延遲在時鐘漂移時不可信（成因見元件指標列）</td>
      </tr>
      <tr>
          <td>告警規則與分送</td>
          <td>Prometheus 規則與 Alertmanager，送到值班推播</td>
          <td>訊號失真：延遲的規則以平均值與五分鐘視窗寫，毫秒以下的延遲目標在五分鐘的平均裡看不出尾端</td>
      </tr>
      <tr>
          <td>告警鏈的監看</td>
          <td>無</td>
          <td>檢查範圍盲區：沒有心跳監看服務；補的觀察者要放在這個可用區以外，依序日誌的節點與監控在同一個可用區，可用區故障時兩者一起停</td>
      </tr>
      <tr>
          <td>使用者路徑的合成監控</td>
          <td>無</td>
          <td>檢查範圍盲區：沒有從外部經過公開入口（API 閘道與行情的公開端點）送委託、訂閱行情的探測</td>
      </tr>
  </tbody>
</table>
<p>重啟次數由 node_exporter 的 systemd 收集器輸出（開啟它的重啟計數之後），以它寫一條規則送出告警，自檢那一列的讀取缺口就補上了。同一列的檢查範圍盲區（撮合引擎卡在主迴圈），systemd 的 <code>WatchdogSec</code> 也偵測得到：程序要定期回報，沒回報就判失敗。回報不必在撮合引擎的執行緒上做，同一個程序裡的另一個執行緒可以在讀到引擎的最後序號有前進時才送出（有路徑心跳時，市場安靜序號也會前進；沒有路徑心跳時，市場安靜序號不前進，這個做法會把安靜判成卡住）。systemd 的文件寫的是接受主程序送出的回報，同一個程序裡的其他執行緒也算主程序，這一點是從機制推出的。這個做法另有一個盲點：依序日誌停住時引擎收不到委託，序號同樣不前進，逾時之後 systemd 會終止一個本身正常的撮合引擎。真正的代價在逾時的處置：systemd 會終止這個帶著委託簿狀態的程序，恢復要載入快照、重放依序日誌，停頓若只是一時的垃圾回收，終止反而把幾秒的停頓變成更長的停機。卡住的成因若是某一筆委託（處理它時進入無窮迴圈），引擎的處理是確定的，重放到同一筆就會再卡住，處理同一份輸入的備援副本也會一起卡住，自動重啟只會變成重啟迴圈。所以這個範例把偵測交給路徑心跳，由值班依訊號處置：引擎的最後序號停在同一個值、而依序日誌的位置還在前進時，下一個序號的那筆委託就是嫌疑，處置是暫停那個交易對，接上 9.0.2 的只收取消、再以集合競價重開的流程。</p>
<p>探測那一列的盲區，優先的補法是對每一個撮合引擎各設一個測試交易對。把測試委託送進真實交易對有兩個限制：價格要在閘道允許的範圍內、站在不會立即成交的那一側（買單低於最佳買價），太遠離市價的委託在閘道就被擋下，到不了撮合引擎；而刻意掛單再取消貼近操縱市場的定義（美國商品交易法把「在成交前就打算取消的出價或報價」定為 spoofing），要先確認交易所的規則與主管機關的要求允許這種測試。</p>
<p>元件指標那一列，依序日誌的健康節點數以「健康節點數小於多數加一」寫告警（五個節點時剩三個健康就送出），沒有節點自稱主節點時的規則與節點放置的前提在〈沒有新委託時分辨市場安靜與處理路徑停住〉；時鐘偏差以校時程式自己回報的偏差寫規則（chrony 下不能用 <code>node_timex_offset_seconds</code>），門檻的算法與指標的選法在〈委託延遲的分段量測與時鐘〉。環形緩衝的填滿程度與落盤的落後量由引擎以不需要上鎖的計數輸出，填滿程度持續升高時，引擎離「停下來等落盤或等消費者」已經不遠；JVM 這類有暫停的執行環境，由另一個執行緒讀出每次停頓的時間與次數（例如 JVM 的 GC 通知），停頓時間超過延遲目標的那幾次，就是尾端延遲的成因之一。</p>
<p>應用層訊號那一列，路徑心跳同時補上兩件事：撮合引擎卡在主迴圈與依序日誌停住，都讓它停止出現，路徑心跳停了時怎麼分出是依序日誌之後的路徑停住、依序日誌停住還是觀察者那一側停住，寫在〈沒有新委託時分辨市場安靜與處理路徑停住〉。閘道等待依序日誌確認的逾時率另寫一條規則。指標換算程序本身也是觀察者，要有它自己的「讀不到它」規則（<code>up == 0</code> 或 <code>absent()</code>），並輸出它的落後量（<a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">Consumer Lag</a>）。落後量分成兩段各自歸因：落盤的位置減去指標換算程序讀到的位置，是觀察者這一段；引擎的最後序號減去落盤的位置，是落盤的落後，屬於處理路徑，它一直增加時環形緩衝會滿、引擎會停。落後量同時以筆數與時間計：筆數是撮合引擎最後一個序號減去指標換算程序讀到的最後一個序號，時間是最新一筆輸出紀錄的時間戳減去它讀到的那一筆的時間戳。筆數換成時間要除以當下的處理速率，市場安靜與忙碌時同一個筆數代表的時間可以差好幾個量級，所以門檻以時間寫，筆數用來判斷落後的成因是讀得慢還是輸出多。</p>
<p>告警規則那一列，延遲的規則改以 histogram 的高百分位數寫，視窗縮短到能看出一次幾秒的停頓（示意：一分鐘），代價是告警更容易被短暫的抖動觸發，所以分成兩條：短視窗的高百分位數送聊天頻道、持續數分鐘的才呼叫值班。</p>
<p>告警鏈的監看與使用者路徑這兩層，觀察者都要放到這個可用區以外：為了延遲把撮合系統放進同一個可用區，同一個可用區的故障就會讓撮合、依序日誌與監控一起停，而那正是最需要有人被通知的時候（<a href="/blog/backend/04-observability/observability-shared-fate/" data-link-title="4.25 觀測共命運失效：工具退化時的訊號與人層設計" data-link-desc="觀測系統跟被觀測系統共享失效域、在最需要時一起退化；out-of-band 訊號、優雅降級、以及工具盲飛時客服與工程師怎麼還能應對">4.25 可觀測性與被觀測系統的共命運失效</a>）。外部的探測從使用者那一端經過公開入口送測試委託、訂閱行情，比對路徑心跳是否持續出現在行情裡。告警鏈的監看也要證明通知送得出去：以 <code>alertmanager_notifications_failed_total</code> 寫的送失敗規則，路由到上面補在可用區以外的那個心跳監看服務，作為與 Prometheus 定時心跳分開的另一個檢查項目。</p>
<h2 id="監控鏈事前偵測不到的異常以資料正確性為主逐類查撮合系統">監控鏈事前偵測不到的異常：以資料正確性為主逐類查撮合系統</h2>
<p>撮合系統在監控鏈以外最重的一類是資料正確性：同一筆委託被處理兩次或撮錯，結果是重複成交與錯的帳戶部位（<a href="/blog/backend/case-systems/" data-link-title="Backend 範例系統：短網址、售票搶購、電商 checkout 與交易撮合的功能、需求與規模假設" data-link-desc="Backend 教材幾篇以具體系統走完整條請求路徑的文章共用的四個範例系統：每個系統提供的功能與請求種類、不能讓步與可以讓步的需求、規模假設與起點架構，以及四個系統在成功請求的總數是否有上限、延遲是否改變請求的價值、同一個請求被處理兩次的後果上的差別">Backend 範例系統</a> 的〈四個系統在請求性質上的差別〉），而監控鏈的每一層都會照常顯示正常。其餘各類（業務結果、到期事件、備份、排程工作與背景工作、安全、第三方依賴與成本）的分類與通則在 <a href="/blog/backend/04-observability/monitoring-outside-the-chain/" data-link-title="4.0.1 監控鏈事前偵測不到的異常：業務結果、資料正確性、到期事件、排程與背景工作、備份、安全、第三方依賴與成本的監看" data-link-desc="監控鏈各層都正常時仍會發生的幾類異常：業務停了而程式回應正常、資料寫錯、TLS 憑證與網域到期、排程工作與佇列消費端沒跑、備份還原不回來、以帳號正常操作進行的攻擊、第三方業者停掉與帳單失控，各自要先定義的預期、檢查它的規則與承接的專章">4.0.1 監控鏈事前偵測不到的異常</a>，以下逐類寫撮合系統裡的對象。</p>
<p><strong>資料正確性</strong>分成三項檢查。<strong>序號連續檢查</strong>：輸出日誌的序號要連續，每一個消費端（行情發布、成交回報、帳戶更新）對每一個撮合引擎的輸出各自以「收到的序號與上一個相差不是一」偵測斷號（每個引擎有自己的序號），斷號代表有一段輸出沒有送到這個消費端。<strong>部位對帳</strong>：帳戶的部位（帳戶持有的各項資產數量，資金也是其中一項）在前一次對帳的切點上的值，加上這段期間的成交、手續費、出入金與資金劃轉，要等於帳戶系統裡現在的部位，每天重算一次比對。<strong>成交價比對</strong>：行情公布的最新成交價要等於成交紀錄裡最後一筆的價格，取樣比對。序號連續檢查在單一消費端內完成，部位對帳與成交價比對是兩份資料之間的比對（<a href="/blog/backend/knowledge-cards/data-reconciliation/" data-link-title="Data Reconciliation" data-link-desc="說明多個資料來源不一致時如何比對、修復與留下證據">Data Reconciliation</a>），兩份資料都在持續變動、寫入的時間也不同（帳戶歷史的資料庫由輸出日誌非同步寫入，落後幾秒），所以比對以輸出日誌的序號為切點：每個撮合引擎有自己的序號，所以切點是每個引擎各一個序號，帳戶系統與歷史資料庫依引擎各自記下套用到哪一個序號；每天在固定的時間取每個引擎當時的最後一個序號當切點，帳戶系統在切點上記下部位，等兩邊都套用到切點之後，比對截至切點的部位與成交。出入金與資金劃轉不在輸出日誌裡，序號切點涵蓋不到它們，要另外以出入金帳本的位置當切點、與撮合的切點一起取，或讓出入金也經過依序日誌。24 小時交易的市場沒有收盤這個安靜的時刻，不取切點的比對會因為兩邊落在不同序號而持續報不一致。</p>
<p>序號連續檢查、部位對帳與成交價比對用到的成交資料都來自同一份輸出日誌，所以抓的是消費端漏掉、重複或算錯的紀錄；撮合引擎自己撮錯的成交在輸出日誌裡就已經是錯的，這三項檢查都抓不到。只靠輸出日誌能檢查的不變量抓得到其中幾種撮錯：成交價不劣於任一方的限價、一筆委託的成交數量不超過委託數量、連續撮合的狀態下處理之後委託簿不交叉（最佳買價低於最佳賣價；集合競價收單期間委託簿本來就允許交叉）、每筆成交的買賣數量相等、取消之後不再成交、剩餘數量等於委託數量減去累計成交與取消的數量。價格優先與時間優先的順序錯了時，這些不變量仍然成立，那一種錯要另外以獨立實作的撮合邏輯重放同一段依序日誌、比對成交結果，這是從機制推出的做法。</p>
<p><strong>業務結果</strong>：成交量的正常範圍隨市場劇烈變化，只和上週同一時段比較會誤報；日內與週內的規律可以當基線的一部分，再加上同一時段其他交易對的成交量變化；同一個商品在其他交易場所也有交易時，再比較那些場所的成交量：其他交易對或其他場所都在成交而這裡沒有，排除本場所自己的價格限制與暫停交易之後，才是異常（基線的建法在 <a href="/blog/backend/04-observability/anomaly-detection/" data-link-title="4.14 Anomaly Detection" data-link-desc="把 ML / statistical baseline 訊號跟 rule-based alert 整合">4.14 Anomaly Detection</a>）。交易所處在只收取消、集合競價這類特殊狀態時，狀態本身寫成一個指標，告警規則依狀態調整，否則重開期間每一條成交量的規則都會成立；停機後先只收取消、再以集合競價重開的流程，寫在 <a href="/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑</a> 的〈撮合引擎：單一寫入者與記憶體內的委託簿〉。</p>
<p><strong>到期事件</strong>：數位憑證只在建立連線的 TLS 握手時驗證，閘道與行情公開端點的憑證到期時，已經連著的長連線不受影響，之後每一條新連線與重連都握手失敗，斷線的用戶端在重試迴圈裡累積；換上新憑證的那一刻，累積的用戶端一起連上，形成一次集中的連線湧入（<a href="/blog/backend/knowledge-cards/thundering-herd/" data-link-title="Thundering Herd" data-link-desc="說明大量工作同時被喚醒或同時競爭資源時的尖峰風險">Thundering Herd</a>；<a href="/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑</a> 的〈入口閘道：在進入撮合之前能擋下的委託〉寫的重連退避）；憑證到期要在剩下一段天數時告警，與 4.0.1 的做法相同。</p>
<p><strong>備份</strong>：撮合系統的備份是委託簿的快照加上快照之後的依序日誌，還原的方式是重放；還原得回來要實際演練一次：在另一個環境載入快照、重放依序日誌，比對重放後的委託簿與正式環境同一個序號時的委託簿是否相同（只用於量測的附帶欄位，例如處理完的時間戳，不在比對範圍內）。重放結果不同的成因有四種：演練環境與正式環境的引擎版本或設定不同、快照的切點與依序日誌的序號沒對齊、依序日誌有缺段，以及撮合的決策讀了時鐘或亂數這類不在依序日誌裡的輸入。只有撮合決策的不確定性代表引擎有缺陷，這個缺陷平常不會出現在任何指標上，只有演練會發現。</p>
<p><strong>排程工作與背景工作</strong>：輸出日誌的每一個消費端（行情發布、成交回報、帳戶更新、寫入帳戶歷史與報表資料庫的程序、指標換算程序）監看各自的落後量，以時間計（筆數與時間的關係在〈撮合系統接上的監控：各層的判讀與補法〉的應用層訊號那一段），門檻依消費端的需求分開定：行情與成交回報落後幾百毫秒使用者就看得到，報表落後幾分鐘沒有人發現。每天重算部位的對帳工作完成時對心跳監看服務送一次心跳。</p>
<p><strong>安全</strong>：管理操作（調整交易對的參數、暫停交易、修改帳戶的速率限制）寫進稽核紀錄，與 <a href="/blog/backend/04-observability/audit-log-governance/" data-link-title="4.12 Audit Log 邊界與 PII 治理" data-link-desc="把稽核訊號從 operational log 拆出、按法規與不變性治理">4.12 Audit Log 邊界與 PII 治理</a> 的做法相同；受監管的交易所另有保存委託與成交紀錄的法規要求，不在本篇範圍。</p>
<p><strong>第三方依賴</strong>與<strong>成本</strong>：撮合的處理路徑上沒有第三方，出入金與身分驗證的業者屬於帳戶那一側，盤點方式同 <a href="/blog/backend/04-observability/monitoring-inventory-k8s-checkout/" data-link-title="4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout：叢集內的監控、託管資料庫與第三方金流各自的失效域，以及佇列消費端與外部依賴的缺口" data-link-desc="把一個跑在託管 Kubernetes 上、有訊息佇列消費端、託管資料庫與第三方金流的電商 checkout 現有的監控放進監控鏈的各層，逐層判讀缺口與補法；另寫付款服務的 readiness 納入金流業者時對付款回呼的影響、失效域從主機換成叢集與雲端帳號之後觀察者要放在哪裡，以及業務結果、對帳、到期事件、消費端、備份、安全、第三方依賴與成本的逐類盤點">4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout</a> 的〈監控鏈事前偵測不到的異常：逐類查 checkout〉裡第三方依賴的那一段；容量事先配置、不自動擴充，成本的變化主要來自硬體與機房的配置變更，日常的成本告警在這個系統裡不重要。</p>
]]></content:encoded></item><item><title>9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量</title><link>https://tarrragon.github.io/blog/backend/09-performance-capacity/matching-engine-load-path/</link><pubDate>Thu, 15 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/09-performance-capacity/matching-engine-load-path/</guid><description>&lt;p>這篇以 &lt;a href="https://tarrragon.github.io/blog/backend/case-systems/" data-link-title="Backend 範例系統：短網址、售票搶購、電商 checkout 與交易撮合的功能、需求與規模假設" data-link-desc="Backend 教材幾篇以具體系統走完整條請求路徑的文章共用的四個範例系統：每個系統提供的功能與請求種類、不能讓步與可以讓步的需求、規模假設與起點架構，以及四個系統在成功請求的總數是否有上限、延遲是否改變請求的價值、同一個請求被處理兩次的後果上的差別">Backend 範例系統&lt;/a> 裡的交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，逐段寫這一段先耗盡什麼資源、出錯時由哪一段補救，以及撮合之前幾乎沒有能確定結果的委託這件事，讓負載保護的做法與售票有哪些不同；對照的系統是 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/ticketing-load-path/" data-link-title="9.0.1 售票搶購的負載路徑：熱點列與連線池、CDN 合併回源、排隊放行的速率、資料庫認領與加入 Redis 閘門的條件、付款期限的條件更新" data-link-desc="以售票搶購為例，沿著一張票從座位圖到付款記帳的路徑，寫出全部請求更新同一個庫存計數時熱點列與連線池的耗盡順序，邊緣快取、排隊放行、資料庫逐張認領與加入 Redis 扣庫存各自把負載轉給了哪一段、自己出錯時由哪一段補救，以及成功請求的總數有上限、延遲不改變請求的價值、先到先得這幾個前提讓哪些做法可以簡化、哪些需求無法簡化">9.0.1 售票搶購的負載路徑&lt;/a>，兩篇共用的概念在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/load-path-analysis/" data-link-title="9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置" data-link-desc="沿著一個會改變狀態的請求從用戶端到資料庫的路徑分析負載時共用的幾個概念：必須一次只處理一筆的那件事與它的互斥範圍、需要經過它的請求裡結果事先已確定的比例、多出來的請求要排隊、拒絕還是請使用者重新確認、每一段保護把負載轉給了哪個元件（保護元件自己的狀態、連線數、輪詢、下游接收端、外部配額）、佇列與重按帶來的重送怎麼去重、保護元件故障或輸出錯的值時下游讀到什麼，以及依賴變慢、部分失效與恢復時的第二波湧入">9.0 請求路徑上的負載分析&lt;/a>。撮合引擎的內部設計各交易所不同，這一篇只寫公開資料寫得到的部分與從機制推得出的部分，推論的地方會標明；委託量這類數字是範例系統的假設，交易所的規則與案例數字取自公開文件。&lt;/p>
&lt;h2 id="撮合系統的性質與需求">撮合系統的性質與需求&lt;/h2>
&lt;p>撮合系統的負載設計從三個性質出發。&lt;strong>撮合之前結果無法確定&lt;/strong>：一筆委託是否成交、以什麼價格成交，取決於它送進委託簿（一個交易對上還沒成交的買賣委託，依價格與時間排成的清單）那一刻簿上有哪些對手的委託，事先無從知道，所以沒有一群「結果已確定」的請求可以在撮合之前回應掉，可提早回應的比例低；成功請求的總數也沒有上限，每一筆被接受的委託都要處理。&lt;strong>處理順序決定成交&lt;/strong>：撮合依價格與時間的優先順序（price-time priority，CME 的文件稱為 FIFO）配對，同價格時先到的先成交，同一個交易對的委託要以單一順序處理，兩筆委託的先後不能因為走了不同的伺服器而對調。&lt;strong>延遲改變請求的價值&lt;/strong>：在連續撮合的市場裡（委託一到就與簿上的對手配對，這是範例系統的設定），一筆委託晚一秒送進委託簿，那時的市場已經不是使用者下單時的市場。市價單（不指定價格、以當下最好的價格成交）會以偏離使用者預期的價格成交；限價單（指定最差可接受的價格）不會以比限價差的價格成交，它的代價是掛著的價格已經過時，在市場變動之後被對手以對自己不利的時機成交。&lt;/p>
&lt;p>由這三個性質推出撮合系統無法讓步的需求：&lt;strong>每一筆被接受的委託都要處理，處理不了的要在接受之前明確拒絕&lt;/strong>；已回應「已接受」的委託不能遺失；同一筆委託不能執行兩次。售票系統在撮合這個位置之前回應大多數請求、讓其餘的排隊，撮合系統只剩兩項可用：擋掉本來就無效的委託，以及在接受之前拒絕超過處理量的委託。&lt;/p>
&lt;h2 id="入口閘道在進入撮合之前能擋下的委託">入口閘道：在進入撮合之前能擋下的委託&lt;/h2>
&lt;p>入口閘道驗證身分與委託的格式，並檢查帳戶的狀態，擋下本來就不該進入委託簿的委託：格式錯誤、價格超出允許的範圍、帳戶餘額不足以支付這筆委託。這幾類委託的結果在閘道就能確定，回應它們不需要撮合引擎。閘道只做這些驗證時沒有自己的狀態，可以加機器擴充，負載超過時加機器就有效；一台閘道當機，用戶端的連線斷開、重新連到另一台，還沒拿到「已接受」的委託由用戶端重送，靠下文〈依序日誌〉的識別碼辨認重複。斷線的用戶端會在同一刻重連，重連要帶隨機的退避間隔，否則一台閘道的失效會變成其餘閘道（以及它們共用的 DNS、身分驗證）同時收到的大量連線。&lt;/p>
&lt;p>餘額檢查有一個與順序相關的難處：帳戶餘額隨著成交不斷改變，閘道看到的餘額是撮合引擎稍早的結果，兩筆幾乎同時送出的委託可能都通過檢查，加起來卻超過餘額。常見的處理有兩種：閘道在通過時先凍結這筆委託需要的金額，讓下一筆委託看到的是凍結之後的餘額；或者把餘額的最終檢查放進撮合引擎，跟委託簿一起依序處理。閘道凍結金額讓閘道持有一份要與引擎一致的狀態，閘道當機時這份凍結紀錄要能從引擎的結果重建，否則凍結的金額卡住不放；引擎內檢查讓引擎的每一筆處理多做一件事（這兩種做法與它們的代價是從機制推出的，各交易所採用哪一種，公開資料多半沒寫）。&lt;/p>
&lt;p>閘道也依帳戶限制每秒能送的請求數。Coinbase Exchange 的私有端點依 profile（帳戶底下可以分開管理資金的子帳戶）計算每秒的請求數，超過時回 &lt;code>429&lt;/code>；它與 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/coinbase-ultra-low-latency-exchange-2023/" data-link-title="9.C3 Coinbase International Exchange：超低延遲交易的逆向容量設計" data-link-desc="為什麼 Coinbase 國際交易所選 Cluster Placement Group &amp;#43; z1d 而不是自動擴容 — 延遲敏感型負載的容量取捨">9.C3 Coinbase International Exchange&lt;/a> 是不同的產品，限流的單位不一定相同。這種限制的作用是不讓單一用戶端的程式失控占滿撮合引擎的處理量，讓其他用戶端仍然送得進來；被擋下的委託是這個帳戶用完了配額，委託本身的結果並沒有在閘道確定。撮合的流量裡，取消與改單佔的比例很高，而取消的優先度要高於新委託：過載時擋下取消，掛在簿上的舊報價撤不掉，會在價格變動時被對手以過時的價格吃掉，所以交易所過載或恢復時常先進入只收取消（cancel-only）的狀態。另一種壓低委託量的手段是費用：歐盟 MiFID II 要求交易場所為每個商品設定參與者未成交委託與成交的比例（order-to-trade ratio）上限，並允許成員國讓受監管市場對取消比例過高的參與者收取較高的費用。依呼叫者分配配額的做法在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/rate-limit-implementation/" data-link-title="Rate Limit 實作" data-link-desc="單機 middleware / Redis 分散式限速 / 配額設計 — 概念見 DevOps 流量管控，本章聚焦後端實作">Rate Limit 實作&lt;/a>。&lt;/p>
&lt;p>各帳戶都沒有超過配額，加總卻超過撮合引擎處理得了的量時，閘道要明確拒絕多出來的委託，回應一個代表「未被接受」的錯誤，讓用戶端知道這筆委託不存在。售票系統在這個位置讓請求排隊，撮合系統不這麼做，因為排隊之後才送進委託簿的委託已經失去它下單時的價值；閘道前若有佇列，它的長度要讓排隊時間遠小於委託的延遲目標，超過就拒絕。這個結論來自連續撮合：有些市場刻意以固定的間隔把委託集中起來一起撮合（集合競價），例如台灣證券交易所盤中在 2020 年 3 月改為逐筆交易之前是每 5 秒撮合一次，學界也有人主張以極短間隔的批次撮合取代連續撮合（Budish、Cramton、Shim，2015），在那樣的市場裡，等到下一次撮合是規則的一部分，不會讓委託失去價值。撮合的用戶端多半是程式，被拒絕之後會立刻重送，所以拒絕的回應要帶重試的時機，重送怎麼放大成 Retry Storm 與回應要帶什麼，在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/load-path-analysis/" data-link-title="9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置" data-link-desc="沿著一個會改變狀態的請求從用戶端到資料庫的路徑分析負載時共用的幾個概念：必須一次只處理一筆的那件事與它的互斥範圍、需要經過它的請求裡結果事先已確定的比例、多出來的請求要排隊、拒絕還是請使用者重新確認、每一段保護把負載轉給了哪個元件（保護元件自己的狀態、連線數、輪詢、下游接收端、外部配額）、佇列與重按帶來的重送怎麼去重、保護元件故障或輸出錯的值時下游讀到什麼，以及依賴變慢、部分失效與恢復時的第二波湧入">9.0 請求路徑上的負載分析&lt;/a> 的〈多出來的請求：排隊、拒絕或請使用者重新確認〉。&lt;/p>
&lt;h2 id="依序日誌決定順序並保證不遺失">依序日誌：決定順序並保證不遺失&lt;/h2>
&lt;p>進入撮合之前，每一筆委託先被賦予一個依序的編號，並寫進一份複寫到多台機器的日誌，寫入完成才回應用戶端「已接受」。這一步同時滿足兩項需求：編號就是撮合的順序，日誌讓已接受的委託不會因為一台機器故障而遺失。&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/cases/coinbase-ultra-low-latency-exchange-2023/" data-link-title="9.C3 Coinbase International Exchange：超低延遲交易的逆向容量設計" data-link-desc="為什麼 Coinbase 國際交易所選 Cluster Placement Group &amp;#43; z1d 而不是自動擴容 — 延遲敏感型負載的容量取捨">9.C3 Coinbase International Exchange&lt;/a> 的案例頁寫的是以 RAFT 為基礎的交易邏輯，就是這一類設計；共識協定的機制在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consensus-protocol/" data-link-title="Consensus Protocol" data-link-desc="讓多個獨立節點在訊息可能延遲、丟失、亂序的網路下對單一決策達成一致的演算法">Consensus Protocol&lt;/a>。&lt;/p>
&lt;p>負載移到這一段之後，最先超出目標的是延遲：每一筆委託的「已接受」要等日誌在多數節點寫入完成，這一來一回的網路時間與磁碟寫入時間直接算進委託的延遲。AWS 的案例頁把 Coinbase 選用 z1d 機型的理由寫成 NVMe 本地儲存的寫入速度，讓以 RAFT 為基礎的交易邏輯處理得快；節點之間的網路距離則由 Cluster Placement Group 縮短，它把節點放在同一個可用區（availability zone）裡實體上相近的位置，代價是這些節點會因為同一個可用區的故障一起停掉，而且之後要在同一個群組裡再加機器時，可能遇到該位置容量不足而加不進去（AWS 文件的說法）。處理量不夠時，日誌可以把同一段時間內到達的多筆委託合在一次寫入與複寫裡，每秒處理的委託數提高，而每一筆的延遲增加不多。&lt;/p>
&lt;p>日誌的主節點故障時，其餘節點選出新的主節點，選舉期間不接受新的委託。這是在一致與可用之間選了一致：選舉期間（依逾時設定，從數百毫秒到數秒）委託送不進來，但不會出現兩個主節點各自編號、順序互相矛盾的情形。選舉要有多數的節點參與；同一個失效域（例如上面那個可用區）的故障一次停掉多數節點時，剩下的節點選不出主節點，日誌停止接受委託，直到多數恢復，或在另一個失效域以快照與日誌重建，停機的長度從秒變成小時。用戶端在這段時間送出的委託拿不到「已接受」，逾時之後重送；如果第一次已經寫進日誌、只是回應沒送到，重送會讓同一筆委託出現兩次。所以委託要帶用戶端自己產生的識別碼，日誌或撮合引擎以它辨認重送，同一個識別碼只接受一次（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency-key/" data-link-title="Idempotency Key（冪等鍵）" data-link-desc="同一操作重送時該由誰生成識別碼、存多久、衝突怎麼回——冪等性質的對外契約落地機制">Idempotency Key&lt;/a>）；Coinbase Advanced Trade 的下單 API 的 &lt;code>client_order_id&lt;/code> 就是這樣定義，識別碼與既有的委託重複時不建立新的委託、回傳原本那一筆。&lt;/p>
&lt;h2 id="撮合引擎單一寫入者與記憶體內的委託簿">撮合引擎：單一寫入者與記憶體內的委託簿&lt;/h2>
&lt;p>撮合引擎依日誌的順序逐筆處理委託，委託簿放在記憶體裡，由單一執行緒更新，不需要鎖，處理路徑上也沒有資料庫。這是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/single-writer-model/" data-link-title="Single Writer Model" data-link-desc="說明單寫者模型如何序列化寫入，並成為系統的容量邊界">Single Writer Model&lt;/a>：同一份狀態只有一個寫入者，順序由輸入的日誌決定。公開資料裡寫得最完整的例子是 Martin Fowler 的〈The LMAX Architecture〉（2011）：LMAX 交易平台把所有市場、所有客戶的交易放在單一執行緒、完全在記憶體內處理，文中寫這個處理器在單一執行緒上每秒處理 600 萬筆訂單，輸入的訊息在處理之前要先完成解析、複寫與寫入日誌。單一執行緒涵蓋的範圍各家不同：開源的撮合引擎 exchange-core 依交易對把委託簿分片、每一片綁一顆 CPU；Coinbase 衍生品交易所在 SREcon23 的簡報寫的是整個交易系統由單一執行緒處理，建在 Raft 的實作 Aeron Cluster 之上，理由是複寫狀態要求應用程式的處理是確定性的。&lt;/p>
&lt;p>確定性讓狀態可以從日誌重建。引擎的處理只依賴日誌裡的輸入時（處理時不讀時鐘、亂數這類每次不同的值，或把它們也記進日誌），處理同一份日誌一定得到同一個結果，所以定期把委託簿存成快照，故障時載入最近的快照，再依序重放快照之後的日誌，就回到故障前的狀態。這種以事件日誌為權威、狀態由重放得到的設計在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判斷標準與代價">Event Sourcing&lt;/a>。&lt;/p>
&lt;p>重放讓委託簿回到停機前的狀態，而停機前掛在簿上的委託，在停機的這段時間裡已經失去價值：延遲改變請求的價值這個性質對它們同樣成立。所以停機之後不直接恢復連續撮合：先只收取消，讓使用者撤掉不再想要的委託；再以集合競價決定重新開始時的價格，讓累積的委託以單一價格成交；最後才恢復連續撮合。美國股市熔斷之後，主要上市交易所也是以競價重新開盤（Nasdaq 稱為 Halt Cross，Cboe 稱為 Halt Auction Process）。&lt;/p>
&lt;p>撮合引擎的容量有兩種擴充方式，適用的範圍不同。不同的交易對（例如 BTC 對 USD、ETH 對 USD）各自有一本委託簿，彼此不影響，所以可以把交易對分到不同的引擎上，交易對越多，加機器越有效。單一熱門交易對的處理量卻只能靠一個引擎：一本委託簿不能拆成兩本，因為價格與時間的優先順序要在整本簿上成立，拆開之後兩邊各自成交，就可能出現較好的價格沒有先成交的情形。售票的互斥範圍是一張票，不同的票可以同時被不同的請求認領；委託簿的互斥範圍是整本簿，每一筆委託有自己的價格與時間，成交與否取決於簿上其他所有的委託。所以熱門交易對的處理量只能往單機效能推，這是從機制推出的結論：單一執行緒的處理量取決於單顆 CPU 的時脈與記憶體存取，加機器不會提高它。&lt;/p>
&lt;p>撮合引擎不能像無狀態的服務那樣在尖峰時自動加機器。新的引擎要先有委託簿的狀態才能處理委託，日誌的共識成員也不是可以臨時增加的；而撮合系統的尖峰出現在市場劇烈波動時，正是使用者最需要下單、最不能少處理任何一筆的時候。所以容量要事先配置：以過去劇烈波動那幾天的委託量為基準，加上預留的餘裕（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/headroom-budget/" data-link-title="Headroom Budget" data-link-desc="說明容量規劃中為應付異常 burst &amp;#43; AZ 故障 &amp;#43; forecast 誤差的安全餘量">Headroom Budget&lt;/a>），平常大部分時間這些容量用不到。售票系統的尖峰時間事先知道，可以在開賣前擴充、賣完就縮回；撮合系統的尖峰多半出現在事先不知道的時刻（突發消息、價格急遽變動），容量只能一直保留。&lt;/p></description><content:encoded><![CDATA[<p>這篇以 <a href="/blog/backend/case-systems/" data-link-title="Backend 範例系統：短網址、售票搶購、電商 checkout 與交易撮合的功能、需求與規模假設" data-link-desc="Backend 教材幾篇以具體系統走完整條請求路徑的文章共用的四個範例系統：每個系統提供的功能與請求種類、不能讓步與可以讓步的需求、規模假設與起點架構，以及四個系統在成功請求的總數是否有上限、延遲是否改變請求的價值、同一個請求被處理兩次的後果上的差別">Backend 範例系統</a> 裡的交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，逐段寫這一段先耗盡什麼資源、出錯時由哪一段補救，以及撮合之前幾乎沒有能確定結果的委託這件事，讓負載保護的做法與售票有哪些不同；對照的系統是 <a href="/blog/backend/09-performance-capacity/ticketing-load-path/" data-link-title="9.0.1 售票搶購的負載路徑：熱點列與連線池、CDN 合併回源、排隊放行的速率、資料庫認領與加入 Redis 閘門的條件、付款期限的條件更新" data-link-desc="以售票搶購為例，沿著一張票從座位圖到付款記帳的路徑，寫出全部請求更新同一個庫存計數時熱點列與連線池的耗盡順序，邊緣快取、排隊放行、資料庫逐張認領與加入 Redis 扣庫存各自把負載轉給了哪一段、自己出錯時由哪一段補救，以及成功請求的總數有上限、延遲不改變請求的價值、先到先得這幾個前提讓哪些做法可以簡化、哪些需求無法簡化">9.0.1 售票搶購的負載路徑</a>，兩篇共用的概念在 <a href="/blog/backend/09-performance-capacity/load-path-analysis/" data-link-title="9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置" data-link-desc="沿著一個會改變狀態的請求從用戶端到資料庫的路徑分析負載時共用的幾個概念：必須一次只處理一筆的那件事與它的互斥範圍、需要經過它的請求裡結果事先已確定的比例、多出來的請求要排隊、拒絕還是請使用者重新確認、每一段保護把負載轉給了哪個元件（保護元件自己的狀態、連線數、輪詢、下游接收端、外部配額）、佇列與重按帶來的重送怎麼去重、保護元件故障或輸出錯的值時下游讀到什麼，以及依賴變慢、部分失效與恢復時的第二波湧入">9.0 請求路徑上的負載分析</a>。撮合引擎的內部設計各交易所不同，這一篇只寫公開資料寫得到的部分與從機制推得出的部分，推論的地方會標明；委託量這類數字是範例系統的假設，交易所的規則與案例數字取自公開文件。</p>
<h2 id="撮合系統的性質與需求">撮合系統的性質與需求</h2>
<p>撮合系統的負載設計從三個性質出發。<strong>撮合之前結果無法確定</strong>：一筆委託是否成交、以什麼價格成交，取決於它送進委託簿（一個交易對上還沒成交的買賣委託，依價格與時間排成的清單）那一刻簿上有哪些對手的委託，事先無從知道，所以沒有一群「結果已確定」的請求可以在撮合之前回應掉，可提早回應的比例低；成功請求的總數也沒有上限，每一筆被接受的委託都要處理。<strong>處理順序決定成交</strong>：撮合依價格與時間的優先順序（price-time priority，CME 的文件稱為 FIFO）配對，同價格時先到的先成交，同一個交易對的委託要以單一順序處理，兩筆委託的先後不能因為走了不同的伺服器而對調。<strong>延遲改變請求的價值</strong>：在連續撮合的市場裡（委託一到就與簿上的對手配對，這是範例系統的設定），一筆委託晚一秒送進委託簿，那時的市場已經不是使用者下單時的市場。市價單（不指定價格、以當下最好的價格成交）會以偏離使用者預期的價格成交；限價單（指定最差可接受的價格）不會以比限價差的價格成交，它的代價是掛著的價格已經過時，在市場變動之後被對手以對自己不利的時機成交。</p>
<p>由這三個性質推出撮合系統無法讓步的需求：<strong>每一筆被接受的委託都要處理，處理不了的要在接受之前明確拒絕</strong>；已回應「已接受」的委託不能遺失；同一筆委託不能執行兩次。售票系統在撮合這個位置之前回應大多數請求、讓其餘的排隊，撮合系統只剩兩項可用：擋掉本來就無效的委託，以及在接受之前拒絕超過處理量的委託。</p>
<h2 id="入口閘道在進入撮合之前能擋下的委託">入口閘道：在進入撮合之前能擋下的委託</h2>
<p>入口閘道驗證身分與委託的格式，並檢查帳戶的狀態，擋下本來就不該進入委託簿的委託：格式錯誤、價格超出允許的範圍、帳戶餘額不足以支付這筆委託。這幾類委託的結果在閘道就能確定，回應它們不需要撮合引擎。閘道只做這些驗證時沒有自己的狀態，可以加機器擴充，負載超過時加機器就有效；一台閘道當機，用戶端的連線斷開、重新連到另一台，還沒拿到「已接受」的委託由用戶端重送，靠下文〈依序日誌〉的識別碼辨認重複。斷線的用戶端會在同一刻重連，重連要帶隨機的退避間隔，否則一台閘道的失效會變成其餘閘道（以及它們共用的 DNS、身分驗證）同時收到的大量連線。</p>
<p>餘額檢查有一個與順序相關的難處：帳戶餘額隨著成交不斷改變，閘道看到的餘額是撮合引擎稍早的結果，兩筆幾乎同時送出的委託可能都通過檢查，加起來卻超過餘額。常見的處理有兩種：閘道在通過時先凍結這筆委託需要的金額，讓下一筆委託看到的是凍結之後的餘額；或者把餘額的最終檢查放進撮合引擎，跟委託簿一起依序處理。閘道凍結金額讓閘道持有一份要與引擎一致的狀態，閘道當機時這份凍結紀錄要能從引擎的結果重建，否則凍結的金額卡住不放；引擎內檢查讓引擎的每一筆處理多做一件事（這兩種做法與它們的代價是從機制推出的，各交易所採用哪一種，公開資料多半沒寫）。</p>
<p>閘道也依帳戶限制每秒能送的請求數。Coinbase Exchange 的私有端點依 profile（帳戶底下可以分開管理資金的子帳戶）計算每秒的請求數，超過時回 <code>429</code>；它與 <a href="/blog/backend/09-performance-capacity/cases/coinbase-ultra-low-latency-exchange-2023/" data-link-title="9.C3 Coinbase International Exchange：超低延遲交易的逆向容量設計" data-link-desc="為什麼 Coinbase 國際交易所選 Cluster Placement Group &#43; z1d 而不是自動擴容 — 延遲敏感型負載的容量取捨">9.C3 Coinbase International Exchange</a> 是不同的產品，限流的單位不一定相同。這種限制的作用是不讓單一用戶端的程式失控占滿撮合引擎的處理量，讓其他用戶端仍然送得進來；被擋下的委託是這個帳戶用完了配額，委託本身的結果並沒有在閘道確定。撮合的流量裡，取消與改單佔的比例很高，而取消的優先度要高於新委託：過載時擋下取消，掛在簿上的舊報價撤不掉，會在價格變動時被對手以過時的價格吃掉，所以交易所過載或恢復時常先進入只收取消（cancel-only）的狀態。另一種壓低委託量的手段是費用：歐盟 MiFID II 要求交易場所為每個商品設定參與者未成交委託與成交的比例（order-to-trade ratio）上限，並允許成員國讓受監管市場對取消比例過高的參與者收取較高的費用。依呼叫者分配配額的做法在 <a href="/blog/backend/09-performance-capacity/rate-limit-implementation/" data-link-title="Rate Limit 實作" data-link-desc="單機 middleware / Redis 分散式限速 / 配額設計 — 概念見 DevOps 流量管控，本章聚焦後端實作">Rate Limit 實作</a>。</p>
<p>各帳戶都沒有超過配額，加總卻超過撮合引擎處理得了的量時，閘道要明確拒絕多出來的委託，回應一個代表「未被接受」的錯誤，讓用戶端知道這筆委託不存在。售票系統在這個位置讓請求排隊，撮合系統不這麼做，因為排隊之後才送進委託簿的委託已經失去它下單時的價值；閘道前若有佇列，它的長度要讓排隊時間遠小於委託的延遲目標，超過就拒絕。這個結論來自連續撮合：有些市場刻意以固定的間隔把委託集中起來一起撮合（集合競價），例如台灣證券交易所盤中在 2020 年 3 月改為逐筆交易之前是每 5 秒撮合一次，學界也有人主張以極短間隔的批次撮合取代連續撮合（Budish、Cramton、Shim，2015），在那樣的市場裡，等到下一次撮合是規則的一部分，不會讓委託失去價值。撮合的用戶端多半是程式，被拒絕之後會立刻重送，所以拒絕的回應要帶重試的時機，重送怎麼放大成 Retry Storm 與回應要帶什麼，在 <a href="/blog/backend/09-performance-capacity/load-path-analysis/" data-link-title="9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置" data-link-desc="沿著一個會改變狀態的請求從用戶端到資料庫的路徑分析負載時共用的幾個概念：必須一次只處理一筆的那件事與它的互斥範圍、需要經過它的請求裡結果事先已確定的比例、多出來的請求要排隊、拒絕還是請使用者重新確認、每一段保護把負載轉給了哪個元件（保護元件自己的狀態、連線數、輪詢、下游接收端、外部配額）、佇列與重按帶來的重送怎麼去重、保護元件故障或輸出錯的值時下游讀到什麼，以及依賴變慢、部分失效與恢復時的第二波湧入">9.0 請求路徑上的負載分析</a> 的〈多出來的請求：排隊、拒絕或請使用者重新確認〉。</p>
<h2 id="依序日誌決定順序並保證不遺失">依序日誌：決定順序並保證不遺失</h2>
<p>進入撮合之前，每一筆委託先被賦予一個依序的編號，並寫進一份複寫到多台機器的日誌，寫入完成才回應用戶端「已接受」。這一步同時滿足兩項需求：編號就是撮合的順序，日誌讓已接受的委託不會因為一台機器故障而遺失。<a href="/blog/backend/09-performance-capacity/cases/coinbase-ultra-low-latency-exchange-2023/" data-link-title="9.C3 Coinbase International Exchange：超低延遲交易的逆向容量設計" data-link-desc="為什麼 Coinbase 國際交易所選 Cluster Placement Group &#43; z1d 而不是自動擴容 — 延遲敏感型負載的容量取捨">9.C3 Coinbase International Exchange</a> 的案例頁寫的是以 RAFT 為基礎的交易邏輯，就是這一類設計；共識協定的機制在 <a href="/blog/backend/knowledge-cards/consensus-protocol/" data-link-title="Consensus Protocol" data-link-desc="讓多個獨立節點在訊息可能延遲、丟失、亂序的網路下對單一決策達成一致的演算法">Consensus Protocol</a>。</p>
<p>負載移到這一段之後，最先超出目標的是延遲：每一筆委託的「已接受」要等日誌在多數節點寫入完成，這一來一回的網路時間與磁碟寫入時間直接算進委託的延遲。AWS 的案例頁把 Coinbase 選用 z1d 機型的理由寫成 NVMe 本地儲存的寫入速度，讓以 RAFT 為基礎的交易邏輯處理得快；節點之間的網路距離則由 Cluster Placement Group 縮短，它把節點放在同一個可用區（availability zone）裡實體上相近的位置，代價是這些節點會因為同一個可用區的故障一起停掉，而且之後要在同一個群組裡再加機器時，可能遇到該位置容量不足而加不進去（AWS 文件的說法）。處理量不夠時，日誌可以把同一段時間內到達的多筆委託合在一次寫入與複寫裡，每秒處理的委託數提高，而每一筆的延遲增加不多。</p>
<p>日誌的主節點故障時，其餘節點選出新的主節點，選舉期間不接受新的委託。這是在一致與可用之間選了一致：選舉期間（依逾時設定，從數百毫秒到數秒）委託送不進來，但不會出現兩個主節點各自編號、順序互相矛盾的情形。選舉要有多數的節點參與；同一個失效域（例如上面那個可用區）的故障一次停掉多數節點時，剩下的節點選不出主節點，日誌停止接受委託，直到多數恢復，或在另一個失效域以快照與日誌重建，停機的長度從秒變成小時。用戶端在這段時間送出的委託拿不到「已接受」，逾時之後重送；如果第一次已經寫進日誌、只是回應沒送到，重送會讓同一筆委託出現兩次。所以委託要帶用戶端自己產生的識別碼，日誌或撮合引擎以它辨認重送，同一個識別碼只接受一次（<a href="/blog/backend/knowledge-cards/idempotency-key/" data-link-title="Idempotency Key（冪等鍵）" data-link-desc="同一操作重送時該由誰生成識別碼、存多久、衝突怎麼回——冪等性質的對外契約落地機制">Idempotency Key</a>）；Coinbase Advanced Trade 的下單 API 的 <code>client_order_id</code> 就是這樣定義，識別碼與既有的委託重複時不建立新的委託、回傳原本那一筆。</p>
<h2 id="撮合引擎單一寫入者與記憶體內的委託簿">撮合引擎：單一寫入者與記憶體內的委託簿</h2>
<p>撮合引擎依日誌的順序逐筆處理委託，委託簿放在記憶體裡，由單一執行緒更新，不需要鎖，處理路徑上也沒有資料庫。這是 <a href="/blog/backend/knowledge-cards/single-writer-model/" data-link-title="Single Writer Model" data-link-desc="說明單寫者模型如何序列化寫入，並成為系統的容量邊界">Single Writer Model</a>：同一份狀態只有一個寫入者，順序由輸入的日誌決定。公開資料裡寫得最完整的例子是 Martin Fowler 的〈The LMAX Architecture〉（2011）：LMAX 交易平台把所有市場、所有客戶的交易放在單一執行緒、完全在記憶體內處理，文中寫這個處理器在單一執行緒上每秒處理 600 萬筆訂單，輸入的訊息在處理之前要先完成解析、複寫與寫入日誌。單一執行緒涵蓋的範圍各家不同：開源的撮合引擎 exchange-core 依交易對把委託簿分片、每一片綁一顆 CPU；Coinbase 衍生品交易所在 SREcon23 的簡報寫的是整個交易系統由單一執行緒處理，建在 Raft 的實作 Aeron Cluster 之上，理由是複寫狀態要求應用程式的處理是確定性的。</p>
<p>確定性讓狀態可以從日誌重建。引擎的處理只依賴日誌裡的輸入時（處理時不讀時鐘、亂數這類每次不同的值，或把它們也記進日誌），處理同一份日誌一定得到同一個結果，所以定期把委託簿存成快照，故障時載入最近的快照，再依序重放快照之後的日誌，就回到故障前的狀態。這種以事件日誌為權威、狀態由重放得到的設計在 <a href="/blog/backend/knowledge-cards/event-sourcing/" data-link-title="Event Sourcing" data-link-desc="說明用 append-only 事件流取代 mutable state 作為正式紀錄的設計模式、需求判斷標準與代價">Event Sourcing</a>。</p>
<p>重放讓委託簿回到停機前的狀態，而停機前掛在簿上的委託，在停機的這段時間裡已經失去價值：延遲改變請求的價值這個性質對它們同樣成立。所以停機之後不直接恢復連續撮合：先只收取消，讓使用者撤掉不再想要的委託；再以集合競價決定重新開始時的價格，讓累積的委託以單一價格成交；最後才恢復連續撮合。美國股市熔斷之後，主要上市交易所也是以競價重新開盤（Nasdaq 稱為 Halt Cross，Cboe 稱為 Halt Auction Process）。</p>
<p>撮合引擎的容量有兩種擴充方式，適用的範圍不同。不同的交易對（例如 BTC 對 USD、ETH 對 USD）各自有一本委託簿，彼此不影響，所以可以把交易對分到不同的引擎上，交易對越多，加機器越有效。單一熱門交易對的處理量卻只能靠一個引擎：一本委託簿不能拆成兩本，因為價格與時間的優先順序要在整本簿上成立，拆開之後兩邊各自成交，就可能出現較好的價格沒有先成交的情形。售票的互斥範圍是一張票，不同的票可以同時被不同的請求認領；委託簿的互斥範圍是整本簿，每一筆委託有自己的價格與時間，成交與否取決於簿上其他所有的委託。所以熱門交易對的處理量只能往單機效能推，這是從機制推出的結論：單一執行緒的處理量取決於單顆 CPU 的時脈與記憶體存取，加機器不會提高它。</p>
<p>撮合引擎不能像無狀態的服務那樣在尖峰時自動加機器。新的引擎要先有委託簿的狀態才能處理委託，日誌的共識成員也不是可以臨時增加的；而撮合系統的尖峰出現在市場劇烈波動時，正是使用者最需要下單、最不能少處理任何一筆的時候。所以容量要事先配置：以過去劇烈波動那幾天的委託量為基準，加上預留的餘裕（<a href="/blog/backend/knowledge-cards/headroom-budget/" data-link-title="Headroom Budget" data-link-desc="說明容量規劃中為應付異常 burst &#43; AZ 故障 &#43; forecast 誤差的安全餘量">Headroom Budget</a>），平常大部分時間這些容量用不到。售票系統的尖峰時間事先知道，可以在開賣前擴充、賣完就縮回；撮合系統的尖峰多半出現在事先不知道的時刻（突發消息、價格急遽變動），容量只能一直保留。</p>
<h2 id="成交回報與行情讓慢的接收端不拖慢撮合引擎">成交回報與行情：讓慢的接收端不拖慢撮合引擎</h2>
<p>撮合引擎的輸出有兩種，可以讓步的程度不同。<strong>成交回報</strong>通知雙方各自的委託成交了多少、以什麼價格，每一筆都是帳戶部位（帳戶持有的各項資產數量）的依據，不能遺失；用戶端斷線重連時，要能從某個編號之後把漏掉的回報補回來。<strong>行情</strong>是給訂閱者看的最新成交價與各價位的委託量，一個處理不及的訂閱者錯過中間幾次報價的更新，只要拿到最新的狀態，就與一直跟得上的訂閱者看到同樣的報價；把多則更新合併成一則送出的做法叫 conflation。合併的範圍有兩種：只合併報價、成交照樣逐筆送出，或在固定的時間間隔內連成交也合併。ICE 的行情資料服務把只合併報價的那一種稱為 trade-safe conflation，選哪一種取決於訂閱者是否需要每一筆成交。</p>
<p>負載在這一段可能反過來回到撮合引擎。引擎若直接把輸出送給每一個接收端、等接收端收下才處理下一筆委託，一個網路很慢的訂閱者就讓所有交易對的撮合跟著變慢，這是 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">Backpressure</a> 傳回了不該傳回的地方。所以引擎只把輸出寫進自己的輸出日誌，由另外的發布服務讀日誌、送給各個接收端；某個接收端跟不上時，發布服務對行情做 conflation，對成交回報則保留在日誌裡等它重連補讀，跟不上太久的連線直接斷開。發布服務自己當機時，撮合照常進行，輸出都還在日誌裡，新的發布服務從日誌的某個編號接著送，訂閱者重連之後補讀漏掉的部分（這是從輸出日誌的設計推出的，各交易所的實作公開資料多半沒寫）。監控同樣從這份輸出日誌讀數字，不進撮合引擎的執行緒，做法在 <a href="/blog/backend/04-observability/monitoring-inventory-matching-engine/" data-link-title="4.0.4 以監控鏈盤點交易撮合系統：觀察者讀數字的位置、沒有新委託時分辨市場安靜與路徑停住、分段量測延遲，以及委託序號、帳戶部位的對帳與其他監控鏈事前偵測不到的異常" data-link-desc="把一個交易撮合系統的監控放進監控鏈的各層判讀缺口：撮合引擎單一執行緒時觀察者為什麼要從輸出日誌讀數字、沒有新委託時怎麼分辨市場安靜還是處理路徑停住、委託延遲怎麼依段量測與時鐘同步的前提、依序日誌多數節點與同一可用區的失效域，以及輸出序號的斷號、帳戶部位與行情資料的一致性這類監控鏈事前偵測不到的異常">4.0.4 以監控鏈盤點交易撮合系統</a> 的〈撮合引擎單一執行緒時觀察者讀數字的位置：輸出日誌〉。行情的報價與下一段的歷史查詢，是撮合系統裡讀到稍舊的資料不會造成錯誤結果的地方，性質與售票的座位圖相近。</p>
<p>帳戶的歷史委託、成交紀錄與報表放在資料庫。Coinbase 的案例頁寫的是以 Aurora 做即時與歷史的交易紀錄查詢；從機制推下去，資料庫要由輸出日誌非同步寫入，查詢才不會進入撮合的處理路徑，而代價是資料庫的寫入落後幾秒，使用者查歷史時看到的是稍舊的紀錄，委託與成交本身不受影響。</p>
<h2 id="過載時的處置明確拒絕超量委託與以重放驗證容量">過載時的處置：明確拒絕超量委託與以重放驗證容量</h2>
<p>撮合系統過載時，處置落在閘道：明確拒絕超過處理量的委託。被拒絕的委託沒有進入日誌，用戶端知道它不存在，不會發生一筆以為沒送出的委託在幾秒後成交的情形；而事先配置的容量決定了拒絕從多大的委託量開始。</p>
<p>容量的驗證以重放為主：把過去劇烈波動那幾天的委託日誌照原本的時間間隔重放到測試環境，量每一段的延遲分布與撮合引擎的處理量，確認事先配置的容量在那樣的流量下延遲仍在目標以內。用重放而不用合成的負載，理由是真實的委託流有合成負載難以重現的形狀：短時間內的突發、取消與改單佔的比例、委託集中在少數價位，而撮合引擎的處理量取決於這些形狀（每一筆委託要配對幾個對手、委託簿多深），合成負載量到的可能是另一個處理量（這是從機制推出的理由）。延遲要看分布的尾端，不看平均：日誌主節點的選舉、垃圾回收或一次磁碟寫入變慢，都只出現在尾端，而那幾筆委託成交的價格正是使用者會抱怨的那幾筆，見 <a href="/blog/backend/knowledge-cards/tail-latency/" data-link-title="Tail Latency" data-link-desc="說明 p99 / p999 等長尾延遲為何比平均延遲更能反映 saturation">Tail Latency</a> 與 <a href="/blog/backend/09-performance-capacity/slo-performance-budget/" data-link-title="9.12 SLO 與 Performance Budget" data-link-desc="performance budget 跟 SLO / error budget 的對接">9.12 SLO 與 Performance Budget</a>。</p>
<p>交易所另有一種讓全部交易同時停下的機制，常被稱為熔斷（market-wide circuit breaker），與呼叫端停止呼叫故障依賴的 <a href="/blog/backend/knowledge-cards/circuit-breaker/" data-link-title="Circuit Breaker" data-link-desc="說明下游持續失敗時如何暫停呼叫並保護系統">Circuit Breaker</a> 模式是不同的機制：它的觸發條件是價格（美國股市以 S&amp;P 500 相對前一交易日收盤的跌幅判斷），目的是讓參與者在價格劇烈變動時有時間消化資訊，不是系統負載的保護。它與容量規劃有關的地方只有一點：觸發它的價格劇烈變動，正是委託量最大的時候，暫停之前的那段時間要由事先配置的容量處理。</p>
<h2 id="撮合與售票的對照可提早回應的比例與延遲的影響">撮合與售票的對照：可提早回應的比例與延遲的影響</h2>
<p>兩個系統的差別落在 <a href="/blog/backend/case-systems/" data-link-title="Backend 範例系統：短網址、售票搶購、電商 checkout 與交易撮合的功能、需求與規模假設" data-link-desc="Backend 教材幾篇以具體系統走完整條請求路徑的文章共用的四個範例系統：每個系統提供的功能與請求種類、不能讓步與可以讓步的需求、規模假設與起點架構，以及四個系統在成功請求的總數是否有上限、延遲是否改變請求的價值、同一個請求被處理兩次的後果上的差別">Backend 範例系統</a> 〈四個系統在請求性質上的差別〉那張表的前兩欄，兩個性質怎麼決定負載保護的方向在 <a href="/blog/backend/09-performance-capacity/load-path-analysis/" data-link-title="9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置" data-link-desc="沿著一個會改變狀態的請求從用戶端到資料庫的路徑分析負載時共用的幾個概念：必須一次只處理一筆的那件事與它的互斥範圍、需要經過它的請求裡結果事先已確定的比例、多出來的請求要排隊、拒絕還是請使用者重新確認、每一段保護把負載轉給了哪個元件（保護元件自己的狀態、連線數、輪詢、下游接收端、外部配額）、佇列與重按帶來的重送怎麼去重、保護元件故障或輸出錯的值時下游讀到什麼，以及依賴變慢、部分失效與恢復時的第二波湧入">9.0 請求路徑上的負載分析</a>。售票的可提早回應的比例高（票數之外的購票請求都是），前面擋得越多、扣庫存承受的越少，多出來的請求可以排隊；撮合的比例低（只有無效的委託），前面能擋的很少，多出來的委託要明確拒絕，撮合引擎每秒處理的委託數要事先配置到劇烈波動時的委託量。撮合的互斥範圍又是整本簿、縮不小，撮合這一側的做法因此全部落在處理路徑本身：依序日誌負責不遺失、單一寫入者的撮合引擎在記憶體內處理、行情由另外的發布服務送出，而容量事先配置，因為撮合引擎沒辦法在尖峰時臨時加機器。電商 checkout 在兩者之間：庫存充足時每一筆訂單都要處理（與撮合相同），而訂單晚幾秒處理結果不變（與售票相同），所以它的負載保護以排隊與降級為主。負載超過容量時各種結果的判斷標準在 <a href="/blog/backend/09-performance-capacity/overload-outcomes/" data-link-title="9.16 負載超過容量時服務的表現：變慢、請求失敗、無回應、程序終止，尖峰過去或應對切換之後仍持續的停在失效、擺盪與第二波湧入，以及應對選出的降級回應，各自的判斷標準、成因、應對與看得到它的監控層" data-link-desc="負載超過容量（過載）時，服務呈現的結果分成變慢、請求失敗（抵達後被拒與根本沒有抵達）、無回應、程序終止，以及尖峰過去、依賴恢復或應對切換之後仍持續的停在失效、擺盪與第二波湧入；每一種的判斷標準、通常的成因與應對，哪一種先出現由先耗盡的資源與等待是否設有上限決定，影響範圍怎麼隨時間擴大，服務端監控的各層各自看得到哪一種，降級回應怎麼計數，過載在資料上造成的重複寫入、接受後遺失與讀到舊資料，以及描述一次過載要寫出的項目">9.16 負載超過容量時服務的表現</a>。</p>
]]></content:encoded></item></channel></rss>