Worker 隔離系統¶
4pass 最關鍵的架構決策是逐使用者 Worker 隔離:每位活躍使用者都獲得一個專屬的 ECS 容器來運行其券商連線。這不只是為了方便——而是由四個硬性約束所驅動的必要條件。
商業要點
逐使用者隔離對於券商 API 連線綁定和故障隔離是不可妥協的。Pool 預熱(897ms 認領延遲)使此模型在規模化時在經濟上可行 — 若無此機制,45-60 秒的冷啟動延遲對交易平台而言是不可接受的。結果:使用者獲得專屬、故障隔離的券商連線,並以每使用者每月 $1.27 的可變成本實現亞秒級就緒。
為什麼需要逐使用者隔離¶
| 約束條件 | 說明 |
|---|---|
| 券商 API 綁定 | 部分券商 SDK(特別是 Shioaji)會將連線綁定到發起的 IP 或程序。跨使用者共享程序會破壞連線親和性 (session affinity)。 |
| 憑證安全 | 每個 Worker 只載入一位使用者的解密券商憑證。即使發生記憶體傾印 (memory dump) 或核心傾印 (core dump),任何 Worker 都無法存取其他使用者的密鑰。 |
| 故障隔離 | 券商 SDK 當機、OOM 或連線掛起只會終止該使用者的 Worker。所有其他使用者不受影響。編排器會在 60 秒內重新啟動故障的 Worker。 |
| 資源隔離 | 記憶體密集的券商連線被限制在 1024 MB 硬性上限。失控的程序觸發的 OOM Kill 只影響該容器——EC2 主機及其他 29 個 Worker 不受干擾地繼續運行。 |
不可妥協
共享 Worker 架構在此行不通。單一 Shioaji 連線消耗 800 MB 會使其他使用者資源不足。某個券商 API 逾時會阻塞所有人的事件迴圈。逐使用者隔離是唯一可行的模式。
Worker 生命週期¶
生命週期時序¶
| 狀態轉換 | 持續時間 | 機制 |
|---|---|---|
| Pool 閒置 → 已認領 | 約 332ms | SQS pool-claim 訊息接收 |
| 已認領 → 活躍 | 1-5s | 憑證解密 + 券商握手 |
| 活躍 → 處理中 | <10ms | Redis BLPOP 從請求佇列取出 |
| 處理中 → 活躍 | 50-500ms | 券商 API 呼叫 + 回應寫入 |
| 活躍 → 閒置逾時 | 30 分鐘 | 無訂單或控制訊息 |
| 健康檢查間隔 | 60s | 券商連線 ping |
| 心跳間隔 | 5s | Redis SET,TTL 30s |
Pool 預熱¶
Pool 是實現亞秒級 Worker 就緒的關鍵。預熱的 Worker 在 ECS 叢集中以 USER_ID=-1 運行,執行一個精簡的事件迴圈,只等待 SQS pool-claim 佇列上的認領訊息。
運作方式¶
- pool_manager Lambda 每 5 分鐘透過 EventBridge 執行
- 計算 Redis 中目前的池 Worker 數量(鍵模式:
worker:active:-1:*或中繼資料掃描) - 與目標池大小比較(Terraform 變數)
- 啟動或終止 Worker 以達到目標
認領流程¶
- API 判斷使用者需要 Worker → 發送到
worker-control.fifo - worker_control Lambda 檢查 Redis 中的池可用性
- Lambda 發送認領訊息到
pool-claim佇列:{ user_id, encrypted_credentials } - 池 Worker 的
BLPOP取出訊息 - Worker 從
USER_ID=-1轉換為USER_ID={claimed_user} - Worker 解密憑證,建立券商連線
- Worker 在 Redis 中設定
worker:active:{user_id},TTL 30s - Worker 開始心跳迴圈(5 秒間隔)
效能基準比較¶
| 路徑 | 步驟 | 延遲 |
|---|---|---|
| Pool Claim | SQS → Lambda 呼叫 | 565ms |
| Lambda → pool claim → Worker 就緒 | 332ms | |
| 總計 | 897ms | |
| RunTask(冷啟動) | SQS → Lambda 呼叫 | 565ms |
| Lambda → ECS RunTask → Task 運行 | 3,103ms | |
| 總計 | 3,659ms | |
| 冷啟動 EC2 | 無可用執行個體 → ASG 啟動 | 45,000-60,000ms |
| + ECS Task 排程 | +3,000ms | |
| 總計 | 48,000-63,000ms |
Pool 快 4 倍
Pool 預熱將使用者感知延遲從 3.6 秒降低到 1 秒以下。對於秒數至關重要的交易平台,這是在預期價格成交與錯過行情之間的差異。
Redis 佇列通訊¶
API 與 Worker 之間的所有通訊都透過 Redis 列表,使用請求/回應模式。API 程序與任何 Worker 之間沒有直接的網路連線。
鍵模式¶
| 鍵 | 類型 | TTL | 操作 |
|---|---|---|---|
trading:user:{user_id}:requests |
List | 無 | API: LPUSH / Worker: BLPOP(阻塞,30s 逾時) |
trading:response:{request_id} |
String | 60s | Worker: SET / API: GET 搭配輪詢 |
worker:active:{user_id} |
String | 30s | Worker: 每 5s SET / API 與 Lambda: GET |
worker:metadata:{user_id} |
Hash | 30s | Worker: HSET(task_arn、instance_id、launched_at) |
worker:control:{user_id}:messages |
List | 無 | API: LPUSH / Worker: LPOP(非阻塞檢查) |
心跳機制¶
心跳是 Worker 存活協定的基礎:
- Worker 每 5 秒呼叫
SET worker:active:{user_id} {timestamp} EX 30 - 如果 Worker 死亡,鍵在 30 秒後過期(6 次未發送心跳)
- API 在路由訂單前檢查該鍵——若不存在,觸發 Worker 啟動
- 維護 Lambda 掃描所有
worker:active:*鍵以偵測孤立 Worker
30s TTL 搭配 5s 重新整理提供 25 秒的緩衝——足以承受短暫的 Redis 連線中斷,而不會錯誤地宣告 Worker 死亡。
連線管理¶
每個 Worker 以懶載入方式維護券商連線——連線在首次使用時建立,並在工作階段存活期間快取。
連線鍵¶
連線以 4 元組為鍵:(broker_name, broker_type, simulation, account_id)。一位同時擁有 Shioaji 模擬帳戶和 Shioaji 正式帳戶的使用者會獲得兩個獨立的券商連線。
連線生命週期¶
| 事件 | 動作 |
|---|---|
| 帳戶的第一筆訂單 | 建立連線,向券商進行身份驗證 |
| 後續訂單 | 重複使用快取的連線 |
| 連線錯誤 | 標記為無效,下一筆訂單時自動重連 |
| 健康檢查(60s) | Ping 券商 API,驗證連線存活 |
| 閒置逾時(30 分鐘) | 關閉所有連線,關閉 Worker |
| 憑證重新載入 | 關閉受影響的連線,使用新憑證重連 |
健康檢查¶
在閒置期間每 60 秒,Worker 會 ping 每個活躍的券商連線:
- 健康:無動作
- 不健康:關閉連線,標記為下一筆訂單時重連
- 券商 API 無法連線:記錄警告,下次檢查時重試
這可以在過期連線導致下單失敗之前就捕捉到它們。
控制訊息¶
Worker 除了監控訂單佇列外,也監控控制訊息佇列:
目前支援的控制訊息:
| 訊息 | 動作 |
|---|---|
reload_credentials |
關閉券商連線,重新從資料庫取得並解密憑證,重連。無需重啟 Worker。 |
shutdown |
優雅關閉——關閉所有連線,停止心跳,退出。 |
這實現了無服務中斷的憑證輪換。當使用者在儀表板中更新其券商密碼時,API 推送 reload_credentials 訊息,Worker 在數秒內取得並處理。
孤立偵測與復原¶
維護系統持續運行,偵測並清理 Redis 狀態與 ECS 實際狀態之間的不一致。
異常類型¶
| 異常 | 偵測方式 | 解決方式 | 原因 |
|---|---|---|---|
| 孤立標記 (Orphan Mark) | Redis 鍵存在,無匹配的 ECS Task | 刪除 Redis 鍵 | Task 當機而未清理 |
| 孤立任務 (Orphan Task) | ECS Task 運行中,無 Redis 鍵 | 停止 ECS Task | Redis 鍵過期(網路分區) |
| 過期標記 (Stale Mark) | Redis 鍵 TTL 已過期但未被刪除 | 清理關聯的鍵 | Worker 凍結(死鎖、OOM) |
| 重複 Worker | 同一 user_id 有兩個 Task | 停止較舊的 Task | 認領流程中的競態條件 |
扇出架構¶
維護函式使用扇出模式進行平行處理:
- 協調器(
maintenance)在一次掃描中取得所有 Redis 標記和 ECS Task - 將異常分成每批 100 個
- 為每批非同步呼叫
maintenance_workerLambda(InvokeAsync) - 每個 Worker 獨立處理其批次
- 結果發佈到 CloudWatch 自訂指標
這可以線性擴展:在 10,000 個活躍 Worker 時,協調器呼叫約 100 個 maintenance_worker Lambda 平行處理,在 5 秒內完成完整掃描。
資源配置¶
| 資源 | 值 | 備註 |
|---|---|---|
| CPU | 64 單元 | 一個 vCPU 的 3.125%。足夠——Worker 是 I/O 密集型(等待券商 API 回應)。 |
| 記憶體(軟性限制) | 384 MB | ECS 排程目標。正常工作集為 150-250 MB。 |
| 記憶體(硬性限制) | 1024 MB | OOM Kill 閾值。券商 SDK 記憶體洩漏會在此被捕捉。 |
| 每個執行個體的 ECS Task 數 | 30 | 保守上限(理論上按軟性限制可達 42)。 |
OOM Kill 行為
當容器超過 1024 MB 時,Linux 核心的 OOM Killer 會終止容器程序。EC2 執行個體不受影響——所有其他容器繼續運行。ECS 代理偵測到已停止的 Task,維護 Lambda 在 60 秒內偵測到缺失的心跳,編排器啟動替代品。使用者經歷短暫的中斷(< 2 分鐘),但不會有資料遺失——所有訂單狀態都在 Redis 和 PostgreSQL 中。