摘要
RateHub 是一個統一行情基礎服務,負責接入 Twelvedata、Finnhub、Binance、Whitezzzs 等上游,為業務系統提供實時價格與多週期 K 線數據。
這個項目經歷了兩次明顯的架構變化:第一版採用 Connect / Share 分離的分佈式架構,以 MongoDB 作為共享事實來源;第二版收斂為基於 JDK 21、Netty、內存狀態和 MySQL 的單體實時服務。
這次收斂並不是從“先進架構”退回“簡單架構”,而是重新匹配數據生命週期、實時鏈路和當前業務規模。
1. 背景與約束
子公司成立於 2023 年 6 月。我在 8 月到崗時,研發以項目制推進,幾乎每個項目都維護一套獨立代碼。行情接入也隨項目複製,每套系統分別連接 Twelvedata、Finnhub 等上游平臺。
問題集中在三個約束上:
- 上游連接成本:Twelvedata、Finnhub 的 API Key 對 WebSocket 連接數有限制,一個 Key 通常只能承載 1 到 3 條連接。重複接入導致 Key 成本接近每月 2,000 美元。
- 行情數據的時效性:最新價屬於高頻變化的短生命週期狀態,消費方更關心當前值和推送延遲,而不是每一次變化都被持久化。
- 歷史數據的可查詢性:K 線需要回溯、補償和按週期查詢,必須具備穩定的持久化模型。
因此,RateHub 的目標不是再建設一套交易業務系統,而是將上游行情接入、實時狀態管理和歷史 K 線存儲收斂為統一的基礎能力。
2. 第一版:Connect / Share 分離
2.1 組件與職責
第一版技術棧為 JDK 17、Spring Boot、MyBatis Plus、MongoDB、Kafka、XXL-Job、Redis、Redisson 和 Vue 3。
架構拆分為兩個角色:
| 組件 | 主要職責 | 運行方式 |
|---|---|---|
| Connect | 作為 WebSocket 客戶端連接上游,接收實時行情並寫入 MongoDB | 集群部署 |
| Share | 作為 WebSocket 服務端向下遊推送實時行情,通過 HTTP 提供 K 線查詢 | 集群部署 |
| XXL-Job | 調度分鐘、小時、天、周、月、年等週期的 K 線任務 | 定時任務 |
| Redis / Redisson | 任務協調、分佈式鎖和短期共享狀態 | 共享基礎設施 |
當時的核心數據路徑是:
上游 WebSocket
│
▼
Connect 集群
│ 每次行情變化寫入
▼
MongoDB(共享事實來源)
│
├── Share 讀取最新行情
│ └── WebSocket 推送給下游
│
└── K 線聚合任務讀取
└── HTTP 查詢 / 週期性補償
實時行情以 WIF-USDC 一類的交易對作為主鍵。K 線按分鐘、小時、天、周、月、年等週期聚合,以時間作為唯一維度,記錄開、高、低、收等字段。
2.2 這個設計解決了什麼
第一版的設計邏輯是成立的:
- Connect 與 Share 可以獨立擴容;
- MongoDB 作為共享數據源,避免實例只依賴本地內存而產生狀態差異;
- K 線任務可以通過 Redisson 鎖協調,避免集群重複執行;
- 上游連接、下游推送和歷史查詢之間有明確的職責邊界。
問題不在於這些組件“不能用”,而在於數據路徑沒有區分實時狀態和歷史事實。
2.3 Kafka 的旁路定位
第一版中的 Kafka 主要用於異步傳遞非實時核心路徑的消息,使用範圍比分發鏈路更分散,但沒有成為實時行情報價的廣播總線。
典型場景是 WebSocket 連接監控。上游接入服務將連接狀態、斷線和監控事件寫入 Kafka,管理服務異步消費後寫入 MongoDB,並向管理端 WebSocket 推送:
上游接入服務
│
▼
Kafka
│
▼
管理服務消費者
├── MongoDB
└── 管理端 WebSocket
這種方式將行情接入服務與管理服務解耦。管理服務暫時不可用時,接入服務仍可以繼續接收上游行情,不需要同步調用管理服務,也不會因為管理端的處理延遲直接阻塞上游連接邏輯。
各個接入模塊還保留了 Kafka 的消費者配置,用於服務間事件傳遞、監控狀態同步以及部分任務異步化。但實時報價仍然沿著下面的路徑處理:
上游 WebSocket
│
▼
接入服務
│
▼
MongoDB
│
▼
管理服務定時查詢
│
▼
下游 WebSocket
因此,Kafka 在第一版中承擔的是監控事件和服務間通知的旁路職責,並沒有改變 MongoDB 作為實時行情共享狀態的事實來源。
3. 第一版線上問題:持久化與阻塞調用進入熱路徑
3.1 行情變化造成讀寫放大
行情的基本特徵是高頻、小粒度、持續更新。第一版把每一次上游變化都寫入 MongoDB,再由 Share 端持續讀取並推送:
一次上游變化
→ 一次數據庫寫入
→ 一次或多次數據庫讀取
→ 一個或多個下游連接推送
這條鏈路把數據庫放進了實時熱路徑。即使某個標的當前沒有有效訂閱,輪詢、聚合和任務掃描仍然可能繼續製造 I/O。隨著連接和標的數量增加,存儲層承擔了超過其必要職責的工作。
這裡的關鍵判斷是:**最新行情是可恢復的運行時狀態,不是必須逐條保存的業務事實。**只要能夠重新連接上游,系統就可以重新建立最新狀態。
3.2 阻塞型 HTTP 調用放大資源競爭
K 線補償和查詢依賴大量併發 HTTP 請求。當上遊響應變慢時,傳統線程模型中的平臺線程會被阻塞。請求堆積後,連接池、線程池和任務隊列開始相互擠壓:
上游延遲升高
→ HTTP 請求佔用平臺線程
→ 線程池排隊
→ 任務執行時間拉長
→ 連接池與任務隊列繼續堆積
→ 平臺整體響應變慢
這一版的問題並不是 MongoDB 本身“不適合行情”,也不是同步 HTTP 調用本身不可用,而是實時數據訪問模式和 K 線請求併發模型都與當時的資源約束不匹配:前者使數據庫成為實時鏈路的瓶頸,後者使應用線程資源成為併發瓶頸。
4. 第二版:收斂為單體實時服務
第二版選用 JDK 21、Spring Boot、MyBatis Plus、Netty、MySQL、Redis、Kafka 和 Vue 3,並將 Connect 與 Share 的職責收斂到同一個可獨立部署的服務中。Kafka 主要用於異步調度和削峰,不進入實時行情報價的主鏈路。
4.1 按數據生命週期重新分層
第二版首先把數據按生命週期分為三類:
| 數據類型 | 生命週期 | 存儲策略 | 原因 |
|---|---|---|---|
| 最新行情 | 短生命週期、可從上游恢復 | JVM 內存 | 減少實時路徑上的持久化與讀取 |
| K 線歷史 | 長生命週期、需要回溯和查詢 | MySQL | 支持週期聚合、補償和穩定查詢 |
| 協調狀態 | 短期共享、跨實例可見 | Redis | 支持必要的鎖、協調和共享狀態 |
新的主路徑變為:
上游 WebSocket
│
▼
統一連接層
│
├── JVM 內存中的最新行情
│ └── 按交易對聚合訂閱並由 Netty 推送
│
└── 週期聚合任務
└── 批量寫入 MySQL 作為歷史數據
4.2 關鍵實現取捨
- 統一連接層:上游接入不再隨著業務項目複製,連接管理、重連和訂閱關係集中處理。
- 內存保存最新行情:實時讀寫不再經過數據庫,避免高頻變化造成持久化和查詢放大。
- 批量寫入 K 線:K 線按週期聚合後批量寫入 MySQL,將歷史數據從實時鏈路中分離出來。
- Netty 承擔長連接:對外 WebSocket 服務採用事件驅動模型,適合大量長連接和異步推送。
- 按交易對聚合訂閱:多個客戶端訂閱同一標的時,只維護一份上游訂閱和一份內存狀態,避免重複觸發上游請求。
- Kafka 異步調度:K 線請求和週期任務通過 Kafka 排隊,再由消費者執行上游查詢、緩存、校驗和結果推送,避免慢 HTTP 調用直接佔用 WebSocket 事件處理鏈路。
- 虛擬線程承接阻塞調用:JDK 21 的虛擬線程用於處理 Kafka 消費者中的阻塞型 HTTP 調用,降低等待 I/O 時對平臺線程的佔用。
虛擬線程並不會讓慢接口變快。上游調用仍然需要設置超時、限流、熔斷、重試退避和連接池隔離。它解決的是線程資源利用率,不是上游服務質量問題。
4.3 Kafka:異步調度與隊列治理
Kafka 在第二版中承擔的是任務編排和削峰職責,而不是實時行情報價的傳輸層。實時行情仍然沿著“上游 WebSocket → JVM 內存 → Netty 推送”的路徑處理;Kafka 只承接可能阻塞、可以排隊或需要併發控制的工作。
主要有三類場景:
- K 線 WebSocket 請求:客戶端通過 WebSocket 請求 K 線時,服務端先將請求寫入
socket-candlestick-topic,由 Kafka 消費者調用上游接口,完成查詢、緩存、連續性校驗和結果推送。上游響應變慢時,等待發生在消費者側,不會直接阻塞 Netty 的事件處理鏈路。 - 週期性 K 線任務:每分鐘 K 線、迷你 K 線和漲幅數據任務由
@Scheduled發佈 Kafka 消息,再由對應的@KafkaListener消費。消費者使用 Redis 分佈式鎖避免重複執行,使用虛擬線程併發請求多個標的,隨後將結果寫入 Redis 並推送給訂閱客戶端。 - 隊列規模治理:系統定期檢查指定主題的消息規模,超過閾值時觸發治理策略,避免 K 線請求持續積壓、隊列無限增長。對於可以安全重試的 K 線請求,必要時可以清理或重建對應主題,再由客戶端重新發起請求。這個機制是容量保護措施,不代表可以替代消費者限流、超時和失敗重試策略。
因此,Kafka 的引入並沒有改變第二版的實時報價主路徑,而是把 K 線這類具有等待時間和突發併發特徵的工作從長連接處理鏈路中隔離出來。
5. 為什麼選擇單體,而不是繼續拆分
第二版的單體不是把所有邏輯揉成一個模塊,而是把具有強時序關係的實時路徑放到同一個部署單元中:
接收行情 → 更新內存狀態 → 匹配訂閱 → 推送下游
這條鏈路在當前階段具備幾個特徵:
- 狀態更新和推送之間存在高頻交互;
- 實時狀態可以從上游恢復,不要求跨節點共享完整歷史;
- 當前容量尚未證明需要獨立擴容每一個組件;
- 減少網絡跳轉和共享存儲讀寫,比預先拆分服務更直接地降低複雜度。
因此,單體是對當前約束的優化,而不是對未來擴展的否定。代碼內部仍然按連接管理、行情狀態、訂閱路由、K 線聚合和歷史存儲劃分邊界,為後續拆分保留遷移路徑。
6. 架構結論與後續拆分條件
RateHub 的演進可以概括為:
分佈式組件複用
→ 共享數據庫承載實時熱路徑
→ 識別短生命週期狀態與歷史數據的差異
→ 收斂實時鏈路
→ 保留可獨立演進的內部邊界
第一版更強調擴展性和共享狀態一致性,第二版更強調實時路徑上的低延遲、低 I/O 和可控複雜度。兩套方案都不是普遍正確的答案,差異來自業務規模、數據生命週期和可用性目標。
兩版雖然都使用 Kafka,但它在系統中的位置並不相同:
| 版本 | Kafka 的主要用途 |
|---|---|
| 第一版 | 服務間傳遞 WebSocket 監控事件、異步同步狀態 |
| 第二版 | K 線請求、K 線定時補數和相關任務調度 |
因此,第二版實時鏈路的縮短,並不是因為把第一版的 Kafka 換成了另一種消息隊列,而是因為最新報價從 MongoDB 共享狀態改成了 JVM 內存狀態,並由上游回調直接觸發下游推送。
未來只有在以下條件被真實負載證明之後,才值得拆分:
- 單實例連接數或 JVM 內存成為明確瓶頸;
- 下游推送吞吐需要獨立擴容;
- 上游連接管理與業務發佈節奏產生隔離需求;
- 可用性目標要求實時狀態跨節點冗餘;
- 監控數據能夠說明拆分後的邊界會降低而不是增加系統複雜度。
架構不是提前寫完的藍圖,而是一組隨著約束變化不斷被驗證的假設。真正重要的不是“用了多少服務”,而是每一次拆分或收斂,都能回答它解決了哪個瓶頸,以及為什麼現在值得付出這份複雜度。