sean.
Back to notes

車隊管理平台:從 0 到 1 的架構設計

第一次從 0 開始負責一個完整系統的架構設計。有即時串流的基礎知識,但沒有實戰過——這篇記錄了跟主管、跟 APP Team 討論架構的過程,每個決策背後的 trade-off,以及為什麼現階段還不需要 Kafka。

Created

Mar 21, 2026

01

接到新任務

接到車隊管理平台的任務時,說實話第一反應是興奮的——這是第一次從 0 開始負責一個完整系統,不是接手別人寫一半的東西,而是真的從架構設計開始。

核心功能需求:

  • 即時追蹤:在自建的 MapLibre 地圖上即時看到車輛位置
  • 歷史定位:查詢某台車過去的移動軌跡

即時串流的概念有基本認識,但沒有實際落地過。所以第一步是找主管討論架構,先搞清楚這個規模下的合理解法長什麼樣子。
02

搞清楚資料從哪裡來

在討論後端架構之前,有一件事要先釐清:車輛資料的來源是 APP Team,不是我們自己的裝置。

車輛端跑的是 APP Team 開發的行動應用程式,我們的角色是「接收方」,所以還需要跟 APP Team 的討論,確認怎麼傳、傳什麼格式、頻率多高、斷線重連怎麼處理。

最後我們決定使用 MQTT 進行資料的串接,制定好規格後就可以讓 APP Team 負責設備端的開發。

MQTT 的 Pub/Sub 模型讓裝置端跟我們的後端完全解耦,APP Team 負責確保資料發布出來,後端只管訂閱,兩邊各自開發、各自維護,互不阻塞。

03

為什麼是 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 本身也負責管理大量長連線,這塊複雜度不用我們自己處理。


04

ETL / Processing Layer

MQTT Subscriber 先經過一層 ETL / Processing 後寫入 Redis & Cassandra。

  1. MQTT Subscriber 收到原始位置資料
  2. 進入 Processing Layer(ETL)
    • 資料驗證(格式、欄位完整性)
    • 時間標準化(timestamp normalize)
    • 座標校正 / 過濾異常值
  3. 分流寫入不同 storage:
    • Redis:整理成「最新狀態」
    • Cassandra:轉成 append-only 的歷史事件格式
05

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 重啟後資料不保證還在,所以不適合存歷史。它的職責就是只存最新一筆,舊的直接覆蓋

06

Cassandra

歷史軌跡:需要時間序列的所有資料點,寫入量大,查詢模式固定

歷史軌跡的特性:

  • 寫入量大,但模式固定(持續 append)
  • 查詢幾乎都是「給我某台車某段時間的軌跡」
  • 幾乎不需要 JOIN 或複雜 Join

Cassandra 是為高寫入量設計的分散式資料庫,這個場景剛好適合。把 (vehicle_id, date) 設為 Partition Key,同一台車同一天的資料存在一起,查歷史軌跡就是一次連續讀取。

Trade-off:如果之後要做跨車輛的地理範圍查詢,可能就需要重新設計甚至換工具。能滿足當下需求,而且一個組織的車輛數不會到非常龐大,有需求可以先透過簡單的分層解決。

Loading architecture diagram...

07

前端即時更新:WebSocket

前端需要在 MapLibre 地圖上即時看到車輛移動,所以不能靠前端自己 Request。

解法是 WebSocket:後端跟前端建立一條持久的雙向連線,有新資料就主動推過去。

資料流:

  1. MQTT Subscriber 收到位置更新
  2. 更新 Redis 即時狀態
  3. 透過 WebSocket 把更新推給所有連線中的前端

Loading architecture diagram...

Trade-off:WebSocket 暫時先用 MQTT 直接廣播。Service 會收到全部設備資料,但以目前同時段最多 500 台裝置的規模,過濾資料的效能影響幾乎可以忽略。我們優先保證架構簡單,不為了現在還沒遇到的量級做過度設計,但未來要擴張時隨時可以無縫對接 Redis。

08

為什麼現在還不需要 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 的時機。現在先欠著。( •̀ ω •́ )✧

09

最終架構

Loading architecture diagram...

10

Trade-off 總整理

決策 選擇 得到什麼 犧牲什麼
裝置通訊 MQTT 輕量長連線、與 APP Team 解耦 多一個 Broker 要維護
即時狀態 Redis 毫秒級讀寫 不適合存歷史,記憶體有限
歷史軌跡 Cassandra 高寫入吞吐、時序查詢快 Schema 彈性差
前端推送 WebSocket 真正即時 有狀態連線,水平擴展需額外處理
不用 Kafka 保持簡單 低維運成本、架構清晰 消費端擴展時需要重新評估
11

經驗總結

設計一個系統,最有價值的不是最後定案的架構圖,而是討論過程中那些「為什麼不這樣做」的對話

Continue reading