先講一個你已經知道的 Hub-Spoke
你可能沒聽過「Hub-Spoke 拓撲」這個詞,但你每天都在使用它。
打開任何一張航空公司的航線圖。你會看到幾個巨大的節點——桃園、成田、新加坡樟宜——從這些節點輻射出密密麻麻的航線,連接到幾十個較小的城市。大節點是 Hub(樞紐),小城市是 Spoke(輻條)。
點對點(上)vs Hub-Spoke(下):透過中心節點轉發,大幅減少連線數量。圖片來源:Wikipedia(公有領域)
為什麼航空公司要這樣設計?因為如果每個城市都直飛其他城市,30 個城市需要 435 條航線。但如果所有城市都先飛到 Hub,再從 Hub 轉飛,只需要 30 條航線。Hub 是協調者,集中處理調度、轉機、和資源分配。
物流也一樣。FedEx 的全球運轉中心在曼菲斯。你從台北寄一個包裹到高雄,它可能先飛到曼菲斯再飛回來——聽起來荒謬,但集中分揀比點對點轉運更有效率。
這個模式在資訊系統中也很常見:一個中心節點協調多個邊緣節點。資料在 Hub 集中,Spoke 負責第一線操作。
但傳統 Hub-Spoke 有一個致命假設:Hub 永遠在線。
航班可以等 Hub 機場開放。包裹可以等分揀中心處理。但在災難現場,如果 Hub 掛了,病人不能等。
xGrid 的 Hub-Spoke 做了兩個關鍵修改:每個 Spoke 都是完整的系統,不只是終端。 而且——任何一個 Spoke 都能在五分鐘內升級為新的 Hub。
兩層網路 — 有線同步,無線操作
xGrid 的部署不只是「兩台裝置一條網線」。它是兩層各自獨立的網路疊在一起:
操作層(WiFi) 資料層(Ethernet)
iPad ─┐ ┌─────────────────────┐
iPad ─┤ │ Hub A │
iPad ─┤ WiFi ←── Hub A ───┤ CIRS + MIRS + HIRS │
│ │ mDNS broadcast │
│ └──────────┬──────────┘
iPad ─┐ │
iPad ─┤ WiFi ←── Spoke B ────────────┤ Switch
│ │
iPad ─┐ ┌──────────┤
iPad ─┤ WiFi ←── Spoke C ─┘ │
│ ┌──────────┘
iPad ─┐ │
iPad ─┤ WiFi ←── Spoke D ─┘
資料層是 Ethernet Switch 骨幹——Hub 和所有 Spoke 透過有線網路連接,跑資料庫同步、snapshot 推送、跨站查詢。這是站與站之間的溝通管道。
操作層是 WiFi——每台 RPi 都開著自己的 WiFi 熱點,覆蓋它負責的實體區域。護理師的 iPad 連上最近那台 RPi 的 WiFi,打開 PWA 就能操作。
這兩層完全獨立。Switch 壞了?WiFi 不受影響,每個站的 iPad 繼續操作,只是站與站之間暫時失去同步。某台 RPi 的 WiFi 故障?Ethernet 同步照跑,那個區域的 iPad 改連鄰近的 WiFi 就好。
一層斷了,另一層撐著。
分級配置 — 從前進站到醫學中心
不是每個部署都需要五台機器。xGrid 的拓撲可以根據規模伸縮:
| 部署等級 | 配置 | 連線方式 | 同時操作平板 |
|---|---|---|---|
| 醫學中心 | 1 Hub + 4 Spoke | 8-port Switch | ~75 台 |
| 區域醫院 | 1 Hub + 3 Spoke | 5-port Switch | ~60 台 |
| 地區醫院 | 1 Hub + 1 Spoke | 直連網線 | ~30 台 |
| 前進站 | 1 Standalone | 無需網路 | ~15 台 |
前進站只有一台 RPi,不需要任何網路基礎設施。一台機器、一個電源、一台 iPad——就是一個完整的醫療站。需要擴編?帶另一台 RPi 過來,插上網線,它自動成為 Spoke。
每台 RPi 都是完整系統 — Golden Image
這是整個架構最關鍵的設計決策:每台 RPi 出廠時都預裝了所有東西。
CIRS(資源系統)、MIRS(臨床系統)、HIRS(居家庫存)——全部裝好。角色不是由硬體決定,而是由一個設定檔 /etc/xgrid/role.conf 決定。同一台機器,改一行設定,就從 Spoke 變成 Hub。
這意味著你不需要準備「Hub 專用機」和「Spoke 專用機」。每台 RPi 都是同一張 SD 卡映像檔燒出來的。倉庫裡放的不是「兩種零件」,而是「一堆相同的備品」。任何一台壞了,從箱子裡拿一台新的,插上去,設定角色,繼續。
Spoke 模式下,CIRS 不啟動——省記憶體、省 CPU、避免衝突。但它在那裡,隨時可以叫醒。
斷線不是故障,是預期狀態
傳統的系統把網路斷線當成「故障」來處理——偵測到斷線、觸發告警、等待恢復。
xGrid 把斷線當成「正常」來設計。每台裝置都是完整的系統——有自己的資源系統、自己的資料庫。斷線只是暫時失去了同步能力,不是失去了運作能力。
這就是 xGrid 版 Hub-Spoke 跟航空版最大的差異:Spoke 不是等待 Hub 指令的終端,而是能獨立運作的完整系統。Hub 提供的是協調,不是能力。
三階段雙向同步
當 Hub 和 Spoke 之間的 Ethernet 連線正常時,它們自動進行雙向同步:
第一階段 — 健康檢查
Hub 先確認 Spoke 是否在線。如果 30 秒內沒回應,跳過這次同步。同時檢測兩台裝置的時鐘是否一致——如果時間差超過 30 秒,拒絕同步,避免時間戳混亂。
第二階段 — 臨床資料推送(Hub → Spoke)
Hub 把臨床事件推給所有 Spoke:病患資料、掛號、處方、生命徵象、交班紀錄。同時,Hub 每五分鐘將整個 CIRS 資料庫的 snapshot 推送到每台 Spoke。
每份 snapshot 都附帶一個 metadata 檔:包含 sha256 雜湊值、schema 版本、Hub 的 epoch 編號。Spoke 收到後立刻比對 sha256——不一致的標記為損壞,不會被使用。通過驗證的 snapshot 透過 latest.good symlink 標記為可用候選。每台 Spoke 保留最近 12 份,也就是一小時的滾動備份。
這些經過驗證的 snapshot,是接下來兩個場景的基礎。
第三階段 — 資源資料回收(Spoke → Hub)
Hub 從 Spoke 拉回資源事件:庫存異動、血庫操作、手術記錄、藥品發放。資源系統是這些資料的權威來源。
每次同步只傳上次同步之後的變更,不是整個資料庫。快速、省頻寬。
六種衝突解決策略
兩台裝置在斷線期間各自修改了同一筆資料,接回來時怎麼辦?
答案取決於資料的性質:
| 策略 | 適用資料 | 邏輯 |
|---|---|---|
| 直接追加 | 生命徵象、交班紀錄、發放紀錄 | 不可修改的紀錄,兩邊都保留 |
| 較新的勝 | 病患基本資料 | 比較修改時間,取最新版 |
| 主站優先 | 掛號、處方、手術紀錄 | Hub 是臨床資料的權威來源 |
| 數量相加 | 庫存數量 | 兩邊的消耗各自累加 |
| 人工處理 | 血袋、管制藥品 | 不自動解決,等人確認 |
| 現場優先 | 設備狀態 | 在現場的操作者說了算 |
最值得注意的是人工處理。血袋和管制藥品的衝突不允許自動解決——因為錯誤的代價太高。一袋血被兩個站同時標記為「已發放」?這不是可以用時間戳解決的問題。系統會標記衝突,等待負責人員確認。
數量相加也很有意思:主站消耗了 5 個紗布,衛星站消耗了 3 個。正確答案不是「以較新的為準」(那會遺失其中一邊的消耗),而是 5 + 3 = 8 個被消耗。
拔線即走 — Spoke 升級為 Hub
這是整個架構最強大的能力。
想像這個場景:你的部署是一個 Hub 加三個 Spoke,處理一個大規模傷亡事件。兩小時後,接到指揮中心通知——十公里外發現了第二個傷亡集中點,需要立刻開設第二個醫療站。
你走到其中一台 Spoke,拔掉 Ethernet 網線,把它和電池、iPad 一起裝進背包。到了新地點,接上電源,SSH 進去,下一個指令:
sudo xgrid-promote
Promote 是一個原子狀態機。它先在設定檔寫入 promoting 狀態,然後依序執行每個步驟:驗證 latest.good snapshot 的 sha256 和 schema 版本、載入 CIRS 資料庫、將 hub_epoch 加一、啟動 CIRS 並確認回應正常、打開 WiFi 熱點並確認 hostapd 運作、設定 Hub 的靜態 IP、透過 mDNS 廣播自己的存在。
如果任何一個步驟失敗,整個流程回滾——停止 CIRS、關閉 hotspot、還原設定檔——機器回到 Spoke 模式,失敗原因寫入 log。不會有「升級到一半卡住」的半成品狀態。
成功後,iPad 連上新的 WiFi,打開 PWA——你面前是一個完整的醫療站,帶著原本 Hub 五分鐘前的所有病患資料。
原本的 Hub 繼續運作,少了一個 Spoke。新的 Hub 獨立運作,有自己的 WiFi 覆蓋區域、自己的 CIRS。兩個站各自收治病患,等到任務結束再合併資料。
不需要事先規劃。不需要「Hub 專用機」。任何一台 Spoke,隨時可以帶走、升級、獨立。
Hub 損壞 — 五分鐘內接手
另一個場景:Hub 的硬體故障了。電源燒了、SD 卡壞了、或是被掉落的天花板砸中。
每台 Spoke 都在持續監測 Hub 的心跳——每 30 秒一次。連續三次沒回應(90 秒),Spoke 的 PWA 上會出現紅色橫幅:「Hub 離線,正在搜尋備援...」。
這時操作員做一個決定:指定其中一台 Spoke 接手。透過 PWA 上的按鈕或 SSH 指令觸發 promote。
Promote 腳本載入 latest.good——一個永遠指向最後一份通過 sha256 驗證的 snapshot 的 symlink。損壞的、被竄改的 snapshot 永遠不會進入候選清單。腳本還會檢查 snapshot 的 schema 版本是否與當前 CIRS 相容——版本不合就拒絕載入,而不是載入後爆炸。
載入完成後,新 Hub 啟動 CIRS、打開 WiFi 熱點、透過 mDNS 廣播 _xgrid-hub._tcp。其他 Spoke 偵測到新的廣播,驗證 cluster ID 後自動重連。
整個接手過程,病患資料的損失上限是五分鐘(RPO)。Hub 每五分鐘推送一次 snapshot,所以 Spoke 手上永遠有一份近乎即時的備份。在大量傷患湧入的高峰期,操作員可以手動觸發即時同步,把 RPO 壓得更低。
為什麼不自動升級? 因為在斷網環境下,你無法確定 Hub 是真的壞了還是只是網線鬆了。如果兩台 Spoke 同時自動 promote,你會得到兩個 Hub 各自收治病患——這叫 split-brain,合併資料會是一場災難。所以 promote 必須是人的決定,不是機器的判斷。
但光靠「人的自律」還不夠。萬一有人在混亂中多按了一次呢?
殭屍 Hub 與腦裂防護 — 不靠自律靠機制
「不要同時升級兩台」是一條規則。規則在災難現場會被打破。所以 xGrid 加了一個機械性的安全閥:hub_epoch 任期制。
每次 Spoke promote 成 Hub,role.conf 裡的 hub_epoch 就加一。初始 Hub 是 epoch 1。第一次 promote 產生 epoch 2。下一次 promote 產生 epoch 3。這個 epoch 編號嵌入每一份 snapshot、每一次 mDNS 廣播、每一次同步握手。
這帶來三層保護:
殭屍自動降級。 Hub A(epoch 1)壞了,Spoke B promote(epoch 2)接手。過了一陣子,有人把 Hub A 的電源接回去,它重新開機。啟動時它掃描網路,發現有一個 Hub 正在廣播 epoch 2——比自己的 epoch 1 高。Hub A 不會試圖搶回主權,它自動降級為 Spoke。不需要人去關它,不需要人去按什麼按鈕。過期的 Hub 自己讓位。
Spoke 雙 Hub 偵測。 如果某個 Spoke 在重連時同時看到兩個不同 epoch 的 Hub 廣播,它不會自己挑一個連。它會跳出橘色警告:「偵測到多個主站,請聯繫管理員。」 Spoke 拒絕自動重連,直到人類解決這個歧義。
叢集隔離。 每個部署有一個唯一的 cluster_id——建立叢集時產生的 UUID。Spoke 只接受相同 cluster_id 的 Hub。你的 Spoke 不會意外連上隔壁部署的 Hub,即使它們接在同一條網路上。
Epoch 機制不能完全預防腦裂——如果兩個完全斷網的子群各自 promote 了一個 Hub,你確實會得到兩個獨立的 Hub。但它保證:只要兩個子群重新接上網路的那一刻,較低 epoch 的 Hub 會自動降級。 問題不是「如何防止腦裂」,而是「如何在腦裂發生後最快速地自動修正」。
服務發現 — 不再寫死 IP
Hub 換了一台,IP 位址可能也換了。在傳統系統裡,你得去每台 Spoke 改設定檔、改 IP。在災難現場,這種手動操作是不可接受的。
xGrid 用 mDNS(multicast DNS)解決這個問題。Hub 啟動時——不管是原始 Hub 還是剛 promote 的新 Hub——都會透過 avahi-daemon 在區域網路上廣播 _xgrid-hub._tcp 服務,附帶自己的 IP、epoch、cluster ID、站名。
Spoke 不寫死 Hub 的 IP。它監聽 mDNS 廣播,發現帶有正確 cluster ID 且 epoch 更高的 Hub 時,自動更新連線目標。PWA 也走同樣的邏輯:如果 API 連不上了,它偵測到新 Hub 的 mDNS 廣播後,自動切換 API 位址,畫面上跳出綠色提示:「已連線至 {站名}@epoch{N}」。
不用改設定檔。不用記 IP 位址。網路自己描述自己。
mDNS 只在同一個 Layer 2 broadcast domain 內運作——這正好是 Ethernet Switch 提供的環境。接在同一台 Switch 上的所有 RPi 都能互相發現。
Headless 操作 — iPad 就是你的控制台
xGrid 的 RPi 永遠不接螢幕、不接鍵盤。沒有顯示器、沒有滑鼠。
所有臨床操作透過 WiFi 上的 PWA 完成——iPad、iPhone、任何有瀏覽器的裝置。每台 RPi 都開著自己的 WiFi 熱點,覆蓋它負責的區域。護理師拿著 iPad 走到哪個區域,連上那個區域的 WiFi,打開 PWA,直接操作。
系統管理透過 SSH。一台筆電連上 Hub 的 WiFi,ssh dno@10.0.0.1,就能管理整個拓撲——查看各站狀態、觸發同步、執行 promote 或 demote。
這意味著 RPi 的所有 USB port 都是空的。不需要接周邊設備,不需要額外的線材。一台 RPi、一條電源線、一條網線(如果是 Spoke)——就是全部。
站點合併:撤離時的資料整合
當一個站被迫撤離,它的資料需要合併到存活的站。
四種合併模式:
- 完整合併:兩站資料全部合入目標站
- 選擇性合併:只合併選定的物資類別
- 備份還原:從外接儲存裝置還原
- 緊急關站:保存資料後關閉站點
如果是被 promote 出去的 Spoke 要回歸,它需要先 demote 回 Spoke 模式——停止 CIRS、停止 mDNS 廣播、關閉 WiFi 熱點、切回 DHCP——然後重新接上 Switch。Demote 和 promote 一樣是原子操作:任何步驟失敗就回滾到 Hub 模式,不會出現「降級到一半」的尷尬狀態。
每次合併都完整記錄:從哪個站、合了多少物資、多少血袋、多少設備、多少手術紀錄。在災難結束後,你需要能回答「那個站撤離時,東西都去了哪裡?」
設計哲學:為斷線而生
大多數系統的設計前提是「網路是可靠的」,然後為不可靠的情況做例外處理。
xGrid 的設計前提是「網路是不可靠的」,然後為可靠的情況做優化。
這個顛倒導致了完全不同的設計決策:
- 每個節點都是完整系統(不是只能顯示畫面的終端機)
- 每台機器都預裝所有軟體(角色由設定檔決定,不是由硬體決定)
- 任何 Spoke 都能升級為 Hub(不需要「特殊的 Hub 機器」)
- 同步是定期的批次操作(不是即時的持續連線)
- 每份 snapshot 都經過 sha256 驗證(損壞的資料不會進入候選)
- 衝突解決是預設行為(不是例外處理)
- 人工判斷是某些情況的正確答案(不是需要消除的缺陷)
- Promote 是人的決定(不是機器的自動反應——因為 split-brain 比等待更危險)
- 但過期的 Hub 自動讓位(因為 epoch 是事實,不是自律)
網線被踢掉不是故障。Switch 被砸壞不是末日。Hub 燒掉不是終結。
它們只是拓撲重組的觸發點。
