我如何用 Go 聚合八大交易所 BTC 價格:從 goroutine 到多交易所價差分析的工程實戰

作者: Calpa Liu
字數:2608
出版:2025 年 11 月 16 日
分類: Go 後端開發 Web3 加密貨幣系統設計併發程式設計
長年在 TypeScript / Node.js 生態打滾的我,第一次用 Go 寫工具:同時從八家加密貨幣交易所抓取 BTC 價格,計算最佳買賣點與價差。這篇文章從需求、架構設計到並發實作,完整拆解我如何用 goroutine 與 channel 建立一套可擴充、可觀測的行情聚合器。

前言:從 TypeScript 走向 Go 的那一步

過去五年,我幾乎都待在 TypeScript、React、Node.js 的世界裡。從 Uniswap 查詢、NFT 智能合約,到 DeFi Dashboard,習慣的是 JavaScript 生態帶來的那種「輕快與靈活」。

但每次遇到 高併發、網路 I/O 密集 的場景,心裡總覺得少了點什麼。

直到最近,我決定用 Go 寫一個小工具:

同時從 8 家主流加密貨幣交易所——Binance、OKX、Coinbase、Bybit、Bitget、Hyperliquid、Kraken、MEXC——實時抓取 BTC 價格,計算最佳買入價、最佳賣出價與價差。

這個看起來很「小」的 side project,卻徹底改變了我對「併發工程」的理解,也讓我第一次真正體會到:為什麼這麼多基礎設施會選擇 Go


為什麼是 Go?我終於看懂 goroutine 的世界觀

長期在前端和 Node.js 生態打滾的我,其實對 Go 一直處在「看很多、沒真寫」的狀態。

我知道 Docker、Kubernetes、Prometheus、很多區塊鏈相關工具都用 Go 寫,但這些專案的規模太大,很難直接從裡面理解語言特性背後的設計哲學。

真正讓我動手的,是一個很單純的問題:

如果我要同時查詢 8 個交易所的 BTC 價格,用 JavaScript 的 async/await + Promise.all,會發生什麼事?

表面上,看起來一切都很合理,Promise.all 會一次併發發出八組 HTTP 請求,等全部完成後再把結果打包回來。但實務上跑久了,你會發現有幾個很實在的痛點:只要有一間交易所特別慢,整體結果就被 最慢的那一家 API 綁住;如果中間發生 timeout 或整家服務掛掉,錯誤處理邏輯會開始變得又長又醜;而當你持續增加交易所數量、提高查詢頻率時,整個事件循環本身也會逐漸變成瓶頸。

Go 的思路完全不同。每一間交易所請求都被丟進一個 輕量級 goroutine 裡處理(不是作業系統 thread,開銷非常小),結果則透過 channel 往回送,由 main goroutine 專心負責收集與聚合。這不只是語法的差別,而是 模型的差異:在 Node.js 裡,你更像是在「管理一堆 promise」;而在 Go 裡,你是在「編排一群彼此獨立工作的 goroutine」。

對這類 「N 個獨立任務併行,最後聚合結果」 的場景來說,Go 幾乎是為它量身打造。


從需求反推架構:我想要的是一個什麼樣的行情聚合器?

在寫任何程式之前,我先把自己真正想要的東西講清楚。我希望這個工具能夠盡可能貼近「實時」,也就是同一瞬間一起拉取八家交易所的 BTC 價格,減少時間上的誤差;我也希望就算某一兩家交易所 API 掛掉,整體查詢還是能繼續完成,不會因為單點失效就全盤崩潰。此外,新增第九家、第十家交易所時,最好只需要多寫一個小模組,而不是動到整個 main 邏輯;最後,我想要一眼就能看懂哪一家最便宜、哪一家最貴、價差有多大。

如果用傳統 for 迴圈或 Promise.all,這些需求其實都很容易踩雷。沒處理好 timeout 時,一家卡死就會拖累全局,變成典型的 串行風險;新增交易所時,錯誤處理與重試邏輯寫在一起,程式碼會越來越難維護;錯誤處理、重試機制、JSON 解析全混在同一層,也讓可讀性變得很差。

所以我選擇用一個 模組化設計 來拆解這個問題:

.
├── exchanges/
│   ├── types.go         # 統一的 PriceResult 結構
│   ├── binance.go       # FetchBinance()
│   ├── okx.go           # FetchOKX()
│   ├── coinbase.go      # FetchCoinbase()
│   ├── bybit.go         # FetchBybit()
│   ├── bitget.go        # FetchBitget()
│   ├── hyperliquid.go   # FetchHyperliquid()
│   ├── kraken.go        # FetchKraken()
│   └── mexc.go          # FetchMEXC()
└── main.go              # 協調層

核心想法只有一句話:

每個交易所一個檔案、一個 fetch 函數、職責單一。


實作細節:用 Go 寫一個乾淨的並發查價器

1. 統一數據結構(types.go

第一步是定義一個所有交易所都會回傳的結構:

package exchanges

type PriceResult struct {
    Exchange string
    Price    float64
    Err      error
}

之所以這樣設計,是因為 channel 裡傳的類型必須一致,main 才能用一個乾淨的迴圈把所有結果收集起來。另一方面,Err 直接掛在結構上也讓後續的錯誤聚合變得很自然:呼叫端只要判斷這個欄位是不是空的,就能決定要不要納入統計。這整個做法算是我在 Go 裡實踐 介面與資料結構先行 的一個小例子。


2. 單一交易所的 Fetch 函數

以 Binance 為例,我在 binance.go 裡大致會這麼做:

func FetchBinance(ch chan PriceResult) {
    // 1. 呼叫 REST API,例如:
    //    https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT
    // 2. 解析 JSON,取得 price 欄位
    // 3. 將字串轉成 float64
    // 4. 透過 channel 把結果丟回去

    ch <- PriceResult{
        Exchange: "Binance",
        Price:    95311.53,
        Err:      nil,
    }
}

實作時你會發現,每一家交易所的 API 格式、路徑與 symbol 命名其實都不一樣:Binance 用的是 BTCUSDT,Coinbase 則是 BTC-USD,OKX 又是 BTC-USDT。我刻意在 各自的 Fetch 函數內部吸收這些差異,讓外部世界永遠只看到統一的 PriceResult

換句話說,呼叫端只在乎「你給我一個 PriceResult 就好」,至於你精確怎麼打 API、怎麼 parse JSON、怎麼處理錯誤,全都包在各自的模組裡。這其實就是 介面隔離原則(Interface Segregation) 在這個小專案裡的一種具體實踐。


3. 協調層:main 如何用 goroutine + channel 併發收集結果?

main.go 最核心的一段目前長這樣:

func main() {
    ch := make(chan exchanges.PriceResult)

    go exchanges.FetchBinance(ch)
    go exchanges.FetchOKX(ch)
    go exchanges.FetchCoinbase(ch)
    go exchanges.FetchBybit(ch)
    go exchanges.FetchBitget(ch)
    go exchanges.FetchHyperliquid(ch)
    go exchanges.FetchKraken(ch)
    go exchanges.FetchMEXC(ch)

    results := make([]exchanges.PriceResult, 0, 8)
    for range 8 {
        results = append(results, <-ch)
    }

    fmt.Println("Exchange      Price (USD)")
    fmt.Println("---------------------------")

    bestBid := 1e12
    bestAsk := 0.0
    var bidEx, askEx string

    for _, r := range results {
        if r.Err != nil {
            fmt.Println(r.Exchange, "Error:", r.Err)
            continue
        }

        fmt.Printf("%-15s %.2f\n", r.Exchange, r.Price)

        if r.Price < bestBid {
            bestBid = r.Price
            bidEx = r.Exchange
        }
        if r.Price > bestAsk {
            bestAsk = r.Price
            askEx = r.Exchange
        }
    }

    fmt.Println("\nBest Bid:", bestBid, "(", bidEx, ")")
    fmt.Println("Best Ask:", bestAsk, "(", askEx, ")")
    fmt.Println("Spread:", bestAsk-bestBid)
}

這段程式碼裡有幾個我特別喜歡的設計。每個 go exchanges.FetchX(ch) 都是完全獨立的 goroutine,誰先回來不重要,只要把結果丟進 channel,就會被 main 一個一個接住。results := make(..., 0, 8) 先幫我們把切片容量預留好,避免在收集過程中不斷重新配置記憶體。如果未來要新增第九家交易所,也只是多寫一行 go exchanges.FetchYYY(ch),然後把 for range 8 改成 for range 9,不需要動到其他邏輯。

整體的並發模型幾乎是直接映射業務邏輯:請八個人去問八家交易所現在的 BTC 價格,等八個人都回報,最後算出誰最便宜、誰最貴、價差多少。中間沒有 callback hell,也沒有錯綜複雜的 promise chain,程式碼讀起來就像是在用自然語言描述需求一樣。


實際執行:如何在本機跑 go-btc

如果你想實際體驗這個小工具,可以照著以下步驟操作。

git clone https://github.com/calpa/go-btc.git
cd go-btc

go build ./...

go run .
# 或者
go run main.go

終端機中會看到類似輸出(實際數字依當下行情而定):

Exchange      Price (USD)
---------------------------
Bybit           95306.10
Coinbase        95294.12
Binance         95311.53
OKX             95312.30
Hyperliquid     95312.50
Bitget          95312.54
Kraken          95312.60

Best Bid: 95294.115 ( Coinbase )
Best Ask: 95312.60 ( Kraken )
Spread: 18.485

因為每一家交易所的請求都是併發進行的,所以每次執行時輸出順序不一定相同,但最終算出來的最佳買價、最佳賣價與價差會根據當下市場即時更新。

專案原始碼可以在這裡找到:https://github.com/calpa/go-btc


往工程靠攏:超時、重試與錯誤聚合

上面的程式在 demo 場景下可以正常運作,但只要稍微往「工程實戰」靠近,就一定會遇到更多現實世界的雜訊:API timeout、短暫的 5xx 或 rate limit、甚至是某一家交易所偶發性的錯誤回應。

超時控制:context.WithTimeout

Go 的 context 非常適合做超時控制,例如:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)

這樣一來,每一間交易所的請求都有一個明確的時間上限,單一交換所最多只被允許卡住五秒;只要逾時,就會被 context 取消,不至於因為某一家服務掛著不回,就把整體查詢拖到完全失去意義。

重試邏輯:在 Fetch 內部自行處理

對於短暫錯誤(例如偶發的 network error、暫時性 5xx),我傾向在各自的 Fetch 函數裡面加上一層簡單的重試:最多重試幾次,中間稍微 sleep 一下,如果還是失敗就老實地回傳 Err。這樣一來,每家交易所都能有自己的重試策略,不需要把這些細節硬塞在 main 裡,協調層也能保持乾淨。

錯誤聚合:把 Err 當一等公民

前面我們在 PriceResult 裡已經有 Err 欄位,收集結果時邏輯也很直覺,可以寫成下面這樣:

for i := 0; i < 8; i++ {
    result := <-ch

    if result.Err != nil {
        fmt.Printf("%s failed: %v\n", result.Exchange, result.Err)
        continue
    }

    results = append(results, result)
}

這種寫法比在 JavaScript 裡一個一個 try/catch promise 要乾淨很多,也比較貼近 Go 一貫的錯誤風格:錯誤就是資料的一部分,要好好處理。


這個設計為什麼讓我覺得「順手」?

做完第一版 go-btc 之後,我回頭梳理了一下,發現之所以覺得這個架構順手,其實跟 Go 的語言特性很有關係。goroutine 與 channel 這兩個原語本身就很直覺:前者就是「並行做一件事」,後者就是「傳遞結果」;開幾十、幾百個 goroutine 也不是什麼大不了的事,不需要像面對傳統 thread 那樣擔心記憶體與切換成本。加上每個 Fetch 函數都可以透過 fake HTTP client 或 mock data 來做單元測試,channel 端也能獨立驗證聚合邏輯,整體測試體驗相當友善。最後,編譯成一個單一 binary、丟到任何有相同架構的機器就能跑,沒有額外 runtime 依賴,部署心智負擔也小很多。

某種程度上,也回到了那句老話:好的設計,不是堆疊更多功能,而是用最少的複雜度解決最核心的問題。


如果要做成「完全體」,我會怎麼演進?

目前 go-btc 還是一個偏 MVP 的 side project,如果真的要往「生產等級工具」走,我心裡大概有幾個演進方向。

第一個方向是動態註冊交易所,用介面取代現階段一行一行 go exchanges.FetchX(ch) 的寫法。未來 main 可以抽象成一個 Fetcher 介面:

type Fetcher interface {
    Name() string
    Fetch(ctx context.Context) PriceResult
}

在 main 裡就能用一個迴圈去跑所有實作:

for _, fetcher := range fetchers {
    go func(f Fetcher) {
        ch <- f.Fetch(ctx)
    }(fetcher)
}

這樣新增交易所時,只要多實作一個 Fetcher,再把實例塞進 fetchers slice,不需要再去動 main 的協調邏輯。

第二個方向是資料持久化與監控。如果想做 價差統計與策略回測,我會考慮定時將查價結果寫進時序資料庫(例如 InfluxDB 或 Prometheus),再透過 Grafana 把不同交易所之間的歷史價差、spread 熱力圖、各家 API latency 可視化出來,順便加上一些告警規則:一旦價差超過某個閾值,就透過 Telegram 或 Slack 推播提醒。

第三個方向是 API 化,讓這個工具不再只是 CLI,而是變成其他服務也能使用的價格來源。現在 go-btc 比較接近一個命令列工具,未來可以加上一層很薄的 HTTP API:

func handlePrices(w http.ResponseWriter, r *http.Request) {
    // 觸發一次聚合查詢,回傳 JSON
}

func main() {
    http.HandleFunc("/api/prices", handlePrices)
    http.ListenAndServe(":8080", nil)
}

前端可以用 React 做一個簡單儀表板,其他後端服務也能直接呼叫 /api/prices 取得即時行情,整個聚合器就成為團隊裡共用的基礎服務。

第四個方向是更聰明的採樣策略。不是每一秒都必須查八家交易所,在價差很小、市場波動不大的時候,可以適度降低頻率;當價差拉大、波動放大時,再提升查詢密度,甚至優先監控歷史上常出現套利機會的幾個組合。

最後,若未來整體系統演進成微服務架構,我會考慮引入 gRPC,讓 go-btc 以 gRPC 服務的形式對外提供結構化行情資料。這樣在不同語言與平台之間協作時,溝通成本會更低,型別也更有保障。


回頭看:為什麼這個專案非 Go 不可?

回到一開始的問題:

為什麼這次不是繼續用熟悉的 TypeScript / Node.js,而是選擇 Go?

對我來說,答案反而變得很單純。Node.js 非常擅長處理 I/O 密集與實時互動的場景,用來做 Web 後端、WebSocket 服務或是快速迭代的產品原型都很順手;Go 則更適合大量併發、低延遲的情境,尤其是工具型程式、微服務、實時行情聚合與監控系統這種對穩定性與效能要求都很高的任務。

go-btc 這個問題本身,就非常符合「實時行情聚合 + 低延遲比價 + 高併發 I/O」這個組合,放在 Go 這個座標上可以說是剛剛好。

在這個座標系裡,Go 幾乎是天然解。

如果未來我要做一個「WebSocket 實時推送價格更新」的前端,我大概會維持這樣的分工:聚合層由 Go 負責,把各家交易所的行情接進來並整理好;推送層則交給 Node.js 或 Bun 來做 WebSocket 服務,把已經加工好的資料推送到前端。到這個時候,語言選擇就不再是宗教戰爭,而是一個很單純的 工程工具選擇問題


學 Go 之後,我感受到的「開發節奏差異」

這次用 Go 寫完一個完整的小工具,我對開發節奏的感受也跟以往很不一樣。Go 帶來的抽象層其實不多,但都相當穩健:httpjsoncontextsync 這些標準庫加起來,大概就能撐住八成以上的需求,不太需要在各種 Web 框架之間來回切換。型別系統則是認真而且強制的,TypeScript 雖然很強大,但在 JavaScript 生態裡,大家總是有辦法繞過型別檢查;Go 則會在編譯期直接把錯誤攔下來,逼你正面面對。部署方面也變得很單純:編譯出來就是一個 binary,丟上去就能跑,不用再為 Node 版本、npm 套件相依或環境差異煩惱。

這些特性讓我重新思考「好用」這件事。好用不一定是功能最多,而是在對的問題上,用最剛好的複雜度把事情做好。

Go 後端開發 Web3 加密貨幣 系統設計 併發程式設計
關於 Calpa

Calpa 擅長使用 TypeScriptReact.jsVue.js 建立 Responsive Website。

他積極參與開源社區,曾在 2019 年的香港開源大會上擔任講者,提供工作經驗和見解。此外,他也在 GitHub 上公開分享個人博客程式碼,已獲得超過 300 顆星星和 60 個分支的支持。

他熱愛學習新技術,並樂意分享經驗。他相信,唯有不斷學習才能跟上快速演變的技術環境。

熱門文章

最新文章

Vibe Coding Idea Lab:AI 共創 × 靈感實作 × MVP 引爆場
精選來自超過 700 位學員、Discord 社群與線上共創活動的 idea。我們用 AI 快速落實創意,用 prompt 引爆行動,用共創測試價值。現有 ${ideas.length} 個 idea,歡迎隨緣分享。
圖片管理中心
管理圖片資源
IP 查詢
快速查詢和定位 IP 地址的地理位置和相關信息
Python 運行器
無需後端、無需登入,只需打開瀏覽器即可運行 Python 代碼(由 Pyodide 提供支持)
封面圖生成器
自動創建適合各種平台的文章封面圖
原作(青山剛昌)產生器
一鍵創建原作(青山剛昌)的封面圖
日本色彩
探索和使用傳統日本色彩
部落格內容洞察儀表板
以視覺化儀表板方式追蹤文章成效、分享熱度與分類分布,協助創作者掌握內容表現。
蒙特卡羅估算 π
使用蒙特卡羅方法演示 π 值的估算過程
LLM
使用 LLM 模型進行聊天
活動圖生成器
一鍵創建活動的封面圖
Wagmi Card
一鍵創建 Wagmi 的封面圖
Facebook Quote
Facebook Quote
Music Macro Language (MML) Studio
用程式語法編寫旋律,用音符構築想像
Blurhash
一鍵創建 Blurhash
文字分類器
使用 MediaPipe TextClassifier 分類文字
前端工程師免費工具資源
前端工程師免費工具資源
後端工程師免費工具資源
後端工程師免費工具資源
全端工程師免費工具資源
全端工程師免費工具資源
Web3 工程師免費工具資源
Web3 工程師免費工具資源
紫微斗數排盤系統|結合 AI 的命盤性格與事業財務分析生成器
紫微斗數排盤工具,輸入生日與時辰,自動生成完整命盤分析提示(Prompt)。結合最專業紫微理論與 AI 助力,助你深入解析性格、事業、財務與人際課題。免費使用,適合命理師及紫微愛好者。
PixAI Prompt 組合器|快速打造可用於 AI 繪圖的語言拼圖
使用 PixAI 卻不會寫 prompt?這個工具幫你一鍵組裝角色、表情、風格語彙,輸出高品質繪圖提示語句(Prompt),可直接貼入 PixAI 使用。適合插畫師、創作者、AI 新手與 VTuber 角色開發者。
你擁有的 .eth 是誰?一鍵查出 ENS 背後地址
只要輸入一個 ENS 名稱,我們就能幫你查出它指向哪個以太坊地址,還能看到頭像。如果你常常看到 .eth 名稱卻不知道誰在背後,這個小工具幫得上忙。
水果切割圖生成器