車隊管理平台:從 0 到 1 的架構設計
第一次從 0 開始負責一個完整系統的架構設計。有即時串流的基礎知識,但沒有實戰過——這篇記錄了跟主管、跟 APP Team 討論架構的過程,每個決策背後的 trade-off,以及為什麼現階段還不需要 Kafka。
Created
Mar 21, 2026
接到新任務
接到車隊管理平台的任務時,說實話第一反應是興奮的——這是第一次從 0 開始負責一個完整系統,不是接手別人寫一半的東西,而是真的從架構設計開始。
核心功能需求:
- 即時追蹤:在自建的 MapLibre 地圖上即時看到車輛位置
- 歷史定位:查詢某台車過去的移動軌跡
即時串流的概念有基本認識,但沒有實際落地過。所以第一步是找主管討論架構,先搞清楚這個規模下的合理解法長什麼樣子。
搞清楚資料從哪裡來
在討論後端架構之前,有一件事要先釐清:車輛資料的來源是 APP Team,不是我們自己的裝置。
車輛端跑的是 APP Team 開發的行動應用程式,我們的角色是「接收方」,所以還需要跟 APP Team 的討論,確認怎麼傳、傳什麼格式、頻率多高、斷線重連怎麼處理。
最後我們決定使用 MQTT 進行資料的串接,制定好規格後就可以讓 APP Team 負責設備端的開發。
MQTT 的 Pub/Sub 模型讓裝置端跟我們的後端完全解耦,APP Team 負責確保資料發布出來,後端只管訂閱,兩邊各自開發、各自維護,互不阻塞。
為什麼是 MQTT?
MQTT 是一個為 IoT / 行動裝置設計的輕量訊息協定,核心是 Publish / Subscribe:
- 裝置(Publisher)把資料發布到一個 Topic(頻道)
- 後端(Subscriber)訂閱 Topic,裝置一發布就立刻收到
- 中間有 MQTT Broker 負責轉發,兩邊不直接連線
跟 HTTP 輪詢的方案比起來,差異很明顯:
| HTTP 輪詢 | MQTT | |
|---|---|---|
| 連線模式 | 每次重建 TCP 連線 | 保持長連線 |
| 封包大小 | Header 很肥 | 極輕量,Header 最小 2 bytes |
| 斷線處理 | 需自行重試 | 內建 QoS,可保證訊息送達 |
| 雙向通訊 | 需額外設計 | Pub/Sub 雙向溝通 |
Topic 的命名規則我們這樣設計:
fleet/vehicle/{vehicle_id}/location ← 裝置回傳位置
後端用一個 Subscriber 訂閱 fleet/vehicle/+/location,+ 是萬用字元,一次訂閱所有車輛,之後車隊規模變化不需要改程式碼。
Loading architecture diagram...
Trade-off:多了一個 MQTT Broker 需要維護。但換來的是裝置端跟後端完全解耦,Broker 本身也負責管理大量長連線,這塊複雜度不用我們自己處理。
ETL / Processing Layer
MQTT Subscriber 先經過一層 ETL / Processing 後寫入 Redis & Cassandra。
- MQTT Subscriber 收到原始位置資料
- 進入 Processing Layer(ETL)
- 資料驗證(格式、欄位完整性)
- 時間標準化(timestamp normalize)
- 座標校正 / 過濾異常值
- 分流寫入不同 storage:
- Redis:整理成「最新狀態」
- Cassandra:轉成 append-only 的歷史事件格式
Redis
即時位置:只需要最新一筆,讀取要快,舊資料不需要
Redis 是 in-memory 的 key-value store,讀寫速度在毫秒以內。
每次收到 MQTT 訊息,就把最新狀態覆蓋進 Redis:
SET vehicle:{id}:location {"lat": 25.0330, "lng": 121.5654, "ts": 1711680000}
前端開地圖的時候,先從 Redis 撈全部車輛的最新狀態,在 MapLibre 上把所有圖釘一次插上去。
Trade-off:記憶體比硬碟貴,而且 Redis 重啟後資料不保證還在,所以不適合存歷史。它的職責就是只存最新一筆,舊的直接覆蓋。
Cassandra
歷史軌跡:需要時間序列的所有資料點,寫入量大,查詢模式固定
歷史軌跡的特性:
- 寫入量大,但模式固定(持續 append)
- 查詢幾乎都是「給我某台車某段時間的軌跡」
- 幾乎不需要 JOIN 或複雜 Join
Cassandra 是為高寫入量設計的分散式資料庫,這個場景剛好適合。把 (vehicle_id, date) 設為 Partition Key,同一台車同一天的資料存在一起,查歷史軌跡就是一次連續讀取。
Trade-off:如果之後要做跨車輛的地理範圍查詢,可能就需要重新設計甚至換工具。能滿足當下需求,而且一個組織的車輛數不會到非常龐大,有需求可以先透過簡單的分層解決。
Loading architecture diagram...
前端即時更新:WebSocket
前端需要在 MapLibre 地圖上即時看到車輛移動,所以不能靠前端自己 Request。
解法是 WebSocket:後端跟前端建立一條持久的雙向連線,有新資料就主動推過去。
資料流:
- MQTT Subscriber 收到位置更新
- 更新 Redis 即時狀態
- 透過 WebSocket 把更新推給所有連線中的前端
Loading architecture diagram...
Trade-off:WebSocket 暫時先用 MQTT 直接廣播。Service 會收到全部設備資料,但以目前同時段最多 500 台裝置的規模,過濾資料的效能影響幾乎可以忽略。我們優先保證架構簡單,不為了現在還沒遇到的量級做過度設計,但未來要擴張時隨時可以無縫對接 Redis。
為什麼現在還不需要 Kafka?
流量規模還不到那個量。 APP 月活躍用戶大約 7,000,以這個數字做壓力測試基準,目前的 MQTT Broker + Go Subscriber 架構完全撐得住,還有很多 headroom。
Client Service 只有一個。 現在只有一個 Backend Service 處理 MQTT 訊息,Kafka 多 Client Service 的優勢完全用不到。
MQTT Broker 本身已經提供足夠的緩衝。 短暫的流量峰值,Broker 的訊息 Queue 會先吸收,不需要 Kafka 再加一層。
簡單的架構比較好維護。 在規模還沒到的時候,過早引入 Kafka 只是把系統搞複雜、把維運成本墊高。
等到未來 Client Service 需要拆分,或是流量規模真的撐不住,那才是引入 Kafka 的時機。現在先欠著。( •̀ ω •́ )✧
最終架構
Loading architecture diagram...
Trade-off 總整理
| 決策 | 選擇 | 得到什麼 | 犧牲什麼 |
|---|---|---|---|
| 裝置通訊 | MQTT | 輕量長連線、與 APP Team 解耦 | 多一個 Broker 要維護 |
| 即時狀態 | Redis | 毫秒級讀寫 | 不適合存歷史,記憶體有限 |
| 歷史軌跡 | Cassandra | 高寫入吞吐、時序查詢快 | Schema 彈性差 |
| 前端推送 | WebSocket | 真正即時 | 有狀態連線,水平擴展需額外處理 |
| 不用 Kafka | 保持簡單 | 低維運成本、架構清晰 | 消費端擴展時需要重新評估 |
經驗總結
設計一個系統,最有價值的不是最後定案的架構圖,而是討論過程中那些「為什麼不這樣做」的對話。
Continue reading
Previous note
修了一個 MapLibre GL JS 的開源 Bug:中文字上下顛倒
公司升級 MapLibre GL JS 到 v5.x 之後,中文地名在地圖上全部上下顛倒。Issue 開著沒人修,新版本就卡在那裡不能用。自己把整個渲染 pipeline 看懂、在本地搭起測試環境、一條一條縮小範圍,最後找到根本原因並發 PR 推進主線。
Next note
原始圖資怎麼變成可視化地圖 & Maplibre Render Pipeline
從一份靜靜躺在硬碟裡的 .shp 檔案,到使用者眼前那張流暢縮放的互動地圖,中間到底發生了什麼事?這篇文章帶你一步步拆解整條 Tile Pipeline。