跳轉到

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 生命週期

stateDiagram-v2 [*] --> PoolIdle: Lambda 啟動任務\n(USER_ID = -1) PoolIdle --> Claimed: 接收 pool-claim\n訊息(約 332ms) Claimed --> Connecting: 載入憑證\n初始化券商 SDK Connecting --> Active: 券商連線\n已建立 Connecting --> Failed: 連線錯誤 Failed --> Connecting: 自動重試\n(3 次嘗試) Failed --> Terminated: 超過最大重試次數 Active --> Processing: 從 Redis 佇列\n收到訂單 Processing --> Active: 訂單完成\n回應已寫入 Active --> HealthCheck: 60s 健康檢查 HealthCheck --> Active: 健康 HealthCheck --> Reconnecting: 連線中斷 Reconnecting --> Active: 已重連 Reconnecting --> Terminated: 重連失敗 Active --> IdleTimeout: 30 分鐘\n無活動 IdleTimeout --> Terminated: 優雅關閉\n關閉券商連線 Terminated --> [*]: ECS 任務停止\nRedis 標記過期 state Active { [*] --> Heartbeat Heartbeat --> Heartbeat: 更新 Redis 標記\n每 5s(TTL 30s) }

生命週期時序

狀態轉換 持續時間 機制
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 佇列上的認領訊息。

運作方式

  1. pool_manager Lambda 每 5 分鐘透過 EventBridge 執行
  2. 計算 Redis 中目前的池 Worker 數量(鍵模式:worker:active:-1:* 或中繼資料掃描)
  3. 與目標池大小比較(Terraform 變數)
  4. 啟動或終止 Worker 以達到目標

認領流程

  1. API 判斷使用者需要 Worker → 發送到 worker-control.fifo
  2. worker_control Lambda 檢查 Redis 中的池可用性
  3. Lambda 發送認領訊息到 pool-claim 佇列:{ user_id, encrypted_credentials }
  4. 池 Worker 的 BLPOP 取出訊息
  5. Worker 從 USER_ID=-1 轉換為 USER_ID={claimed_user}
  6. Worker 解密憑證,建立券商連線
  7. Worker 在 Redis 中設定 worker:active:{user_id},TTL 30s
  8. 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 之間沒有直接的網路連線。

flowchart TB A["FastAPI"] -->|LPUSH| RQ["請求佇列"] RQ -->|BLPOP| W["Worker"] W -->|SET| RS["回應鍵"] RS -->|GET| A2["FastAPI 輪詢回應"] W -->|"每 5s SET"| HB["心跳標記"]

鍵模式

類型 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 存活協定的基礎:

  1. Worker 每 5 秒呼叫 SET worker:active:{user_id} {timestamp} EX 30
  2. 如果 Worker 死亡,鍵在 30 秒後過期(6 次未發送心跳)
  3. API 在路由訂單前檢查該鍵——若不存在,觸發 Worker 啟動
  4. 維護 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 除了監控訂單佇列外,也監控控制訊息佇列:

worker:control:{user_id}:messages

目前支援的控制訊息:

訊息 動作
reload_credentials 關閉券商連線,重新從資料庫取得並解密憑證,重連。無需重啟 Worker。
shutdown 優雅關閉——關閉所有連線,停止心跳,退出。

這實現了無服務中斷的憑證輪換。當使用者在儀表板中更新其券商密碼時,API 推送 reload_credentials 訊息,Worker 在數秒內取得並處理。


孤立偵測與復原

維護系統持續運行,偵測並清理 Redis 狀態與 ECS 實際狀態之間的不一致。

flowchart TB EB["EventBridge<br/>每 60 秒"] --> MT["λ maintenance<br/>(協調器)"] MT -->|"掃描"| Redis["Valkey<br/>所有 worker:active:* 鍵"] MT -->|"列出"| ECS["ECS<br/>所有運行中任務"] MT -->|"比較"| Decision{"偵測異常"} Decision -->|"孤立標記<br/>有 Redis 鍵,無 ECS 任務"| MW1["λ maintenance_worker<br/>刪除過期 Redis 鍵"] Decision -->|"孤立任務<br/>有 ECS 任務,無 Redis 鍵"| MW2["λ maintenance_worker<br/>停止 ECS 任務"] Decision -->|"過期標記<br/>心跳已過期"| MW3["λ maintenance_worker<br/>清理資源"] MW1 & MW2 & MW3 -->|"結果"| CW["CloudWatch 指標<br/>Shioaji/Maintenance 命名空間"]

異常類型

異常 偵測方式 解決方式 原因
孤立標記 (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 認領流程中的競態條件

扇出架構

維護函式使用扇出模式進行平行處理:

  1. 協調器maintenance)在一次掃描中取得所有 Redis 標記和 ECS Task
  2. 將異常分成每批 100 個
  3. 為每批非同步呼叫 maintenance_worker Lambda(InvokeAsync
  4. 每個 Worker 獨立處理其批次
  5. 結果發佈到 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 中。