用 Go 打造交易所撮合引擎:訂單簿設計與我踩過的坑
後端工程師,做過加密貨幣/證券交易所的撮合引擎與訂單系統,專注於 Go 與高一致性系統。
撮合引擎是交易所的心臟。它要做的事情聽起來很單純:把買單和賣單湊在一起成交。但當你真的動手寫,而且還要承受每秒上萬筆下單、絕對不能算錯一分錢的時候,魔鬼全在細節裡。這篇是我用 Go 實作撮合引擎時,真正踩過、也真正影響到正式環境的幾個重點。
為什麼撮合引擎要單執行緒
第一個反直覺的決定:撮合核心不要用多執行緒。
很多人第一反應是「這麼高的吞吐量,當然要平行處理」。但撮合的本質是一連串對共享狀態(訂單簿)的循序操作,而且結果必須是確定性的——同樣一串輸入,重跑一次要得到一模一樣的成交結果,否則你沒辦法做災難復原,也沒辦法對帳。
我的作法是:
- 所有下單、撤單事件先進到一條 channel
- 單一 goroutine 從 channel 取出事件,循序套用到記憶體中的訂單簿
- 撮合產生的成交結果再丟到另一條 output channel,交給其他 goroutine 去寫 DB、推 WebSocket
這樣撮合核心完全沒有鎖競爭,效能反而比加一堆 mutex 的多執行緒版本好,而且邏輯單純到可以完整單元測試。平行化要做在「撮合核心的外面」——序列化、持久化、推播,而不是核心本身。
訂單簿的資料結構
訂單簿要支援的操作:依價格找到最佳買價/賣價、在某個價位掛上新單、撤掉某張單、價位空了要移除。
我最後的結構是兩層:
- 外層用「價格 → 該價位的訂單佇列」的有序結構。買單由高到低、賣單由低到高
- 每個價位內維護一條 FIFO 佇列,確保價格優先、時間優先
關於外層該用什麼,我踩過一個坑:一開始為了「找最佳價」方便,用了 heap。但 heap 不適合撤單——撤單要從中間移除元素,heap 做不到 O(log n) 的任意刪除。後來改成依價格排序的結構搭配一個 map 從訂單 ID 直接定位到節點,撤單才變成 O(1) 找到、O(1) 從佇列移除。
教訓:選資料結構時不要只看「主路徑」最快,要看你所有操作的綜合成本。撤單在交易所其實非常頻繁,掛單後馬上撤是高頻交易的常態。
浮點數會害死你
金額和數量絕對不能用 float64。0.1 + 0.2 不等於 0.3 這件事,在撮合引擎裡會變成成交價對不起來、帳算不平。我用整數來存最小單位(例如價格存到小數點後 8 位就乘以 10 的 8 次方,用 int64 存),所有運算都在整數域進行。這點重要到我後面單獨寫了一篇講金額處理。
撮合之後才是真正的難題
撮合本身寫完你會很有成就感,然後就會被現實打臉。真正難的是「撮合之後」:
持久化與崩潰復原 記憶體中的訂單簿如果機器掛了怎麼辦?我的作法是 **event sourcing**:把所有進入撮合核心的事件依序寫進 append-only 的 log,訂單簿狀態純粹是這串事件重播的結果。重啟時重播 log 就能還原到崩潰前的狀態。再搭配定期 snapshot,避免每次都要從頭重播幾千萬筆。
成交結果的下游一致性 一筆成交會同時牽動:雙方資產餘額、訂單狀態、K 線、推播。這些不可能在同一個 DB transaction 裡。我用的是「撮合結果作為單一事實來源,下游各自冪等消費」——每筆成交有唯一序號,下游處理時用序號去重,確保重送不會重複扣款。
自我成交與風控 同一個帳號的買單撞到自己的賣單要不要成交?多數交易所要做 self-trade prevention。這要在撮合當下就判斷,不能事後補。
小結
如果要我把撮合引擎的經驗濃縮成幾句:
- 核心單執行緒、確定性,平行化放到外圍
- 資料結構要照顧撤單,不只是找最佳價
- 金額用整數,永遠不要碰浮點
- event sourcing 讓崩潰復原與對帳變得可行
- 下游全部設計成冪等
撮合引擎是少數「寫對」比「寫快」重要一百倍的系統。一個 bug 可能就是真金白銀的損失,這也是我做這個專案最大的收穫:對正確性的敬畏。