Backend Engineering·2026年5月28日·12 分鐘閱讀

用 Go 打造交易所撮合引擎:訂單簿設計與我踩過的坑

L
Louis Wu

後端工程師,做過加密貨幣/證券交易所的撮合引擎與訂單系統,專注於 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 可能就是真金白銀的損失,這也是我做這個專案最大的收穫:對正確性的敬畏。

#Go#交易所#撮合引擎#訂單簿#高併發

相關文章