總覽與定位
WebSocket 篇把「通道」做到極致;本篇的協定從第一行規格開始,就沒打算只當一條通道。
WebSocket 篇的最後留下一份帳單。WebSocket 這套設計預設你有完整的 TLS 與 HTTP 堆疊、一顆可以常時醒著跑心跳的 CPU,以及不計較每個位元組的頻寬;而且它從頭到尾只負責把位元組送到對面——訊息會不會重複、掉線那段時間的訊息去了哪裡、裝置突然斷電時誰替它說最後一句話,協定一概不管。
把這三個「不管」擺到一顆靠兩顆三號電池撐三年、掛在 NB-IoT 上、每天要被電梯與地下室吞掉十幾次連線的感測器面前,它們就不再是「應用層自己處理一下」的細節,而是會決定整個系統能不能成立的東西。MQTT(Message Queuing Telemetry Transport) 的選擇是把它們全部搬進協定:一則訊息的投遞保證、離線期間的訊息去向、以及裝置死亡時的公告,都由規格本身定義,而不是由你寫的第 3000 行 JavaScript 定義。
mqtt)。一個是通道、一個是通道上的語義。MQTT 是 OASIS 標準(目前主流是 3.1.1 與 5.0),跑在 TCP 之上,預設 port 1883。名字裡的 “Message Queuing” 是歷史遺留——它不是 JMS 那種佇列協定,也跟任何 MQ 產品沒有繼承關係;它 1999 年被設計出來時要解的問題是「用衛星鏈路把油管上的 SCADA 資料送回來,而衛星頻寬按位元組計費」。這個出身決定了它後來的每一個設計取捨。
加密版本沿用 TLS 篇的說法:MQTTS = MQTT over TLS,port 8883,常搭配 mTLS 做裝置身分(ALPN 值 mqtt)。而在瀏覽器裡,MQTT 走的是 WebSocket 當 transport——這是本篇與 WebSocket 篇真正的接點,第 6 節會說清楚兩者的分工。
1 · WebSocket 篇那張生命週期表的右欄,一半被搬進了協定
WebSocket 篇的總覽一節用一張生命週期表列出「協定提供什麼/你必須自己做什麼」,並在 caption 裡預告了本篇。現在把那張表的右欄拿出來,逐格對照 MQTT 的答案:
| 問題 | WebSocket 的答案 | MQTT 的答案 | 本篇章節 |
|---|---|---|---|
| 訊息要送給誰 | 你自己在 payload 裡塞 channel 欄位,伺服器自己維護訂閱表 | Broker 按 topic 路由,訂閱用 wildcard 表達 | 第 2 節 · Broker 與 topic |
| 訊息會不會重複/掉 | 不管(TCP 只保證這條連線上不亂不掉) | QoS 0 / 1 / 2 三級投遞保證,寫在協定裡 | 第 3 節 · QoS |
| 剛上線的人怎麼知道現況 | 自己另外拉一次快照 API | Retained message:訂閱當下立刻收到最後一則 | 第 4 節 · Retained |
| 掉線那段時間的訊息 | 自己做序號補發或 resume token | Persistent session:Broker 替離線的你排隊 | 第 4 節 · Persistent session |
| 裝置突然斷電 | 對面只會看到 1006,沒有人知道原因 | Last Will:Broker 代發你事先寫好的遺囑 | 第 4 節 · Last Will |
| 連線還活著嗎 | Ping/Pong control frame,週期與判定全自訂 | Keep Alive 寫進 CONNECT,逾時門檻由規格定死 | 第 5 節 · Keep Alive |
左欄不是在說 WebSocket 做錯了——它刻意只當通道,所以能被任何應用語義複用。右欄則是用「綁定一種語義」換「不必再寫一次」:一旦你的訊息模型不是 pub/sub,MQTT 的這些好處全部作廢。
2 · 一個貫穿全篇的視角:device constraints
本篇每一節都會回到同一組限制上,它們解釋了 MQTT 幾乎所有「看起來很小氣」的設計:
| 限制面向 | 具體樣貌 | 它逼出 MQTT 的哪一個設計 |
|---|---|---|
| 電池 | 每次喚醒都要付電 | 固定標頭最小 2 bytes;Keep Alive 可拉長到分鐘級;QoS 0 零往返,送完就能睡 |
| 頻寬 | 按位元組計費 | 沒有冗長標頭、沒有每次請求都要重送的 metadata;5.0 的 Topic Alias 再把長 topic 壓成兩個位元組(見第 5 節) |
| 網路 | 時斷時續 | persistent session 替離線裝置排隊;Last Will 讓 Broker 代發死亡公告(見第 4 節) |
| 規模 | 十萬~百萬條長連線 | 裝置一律主動建長連線(外往內預設不通),Broker 單點承接;路由靠訂閱樹比對,不必逐一定址 |
| 運算 | MCU 等級,沒有 JSON 解析器 | payload 對協定完全不透明(愛放什麼二進位格式都行);客戶端狀態機小到塞得進韌體 |
右欄才是重點:MQTT 幾乎每一個「看起來很小氣」的設計,都能在左欄找到它的來源。
最直接的體現是固定標頭最小只有 2 bytes(1 byte 的封包型別+旗標,加上 1–4 bytes 的變長 Remaining Length)。放進本系列的脈絡:HTTP 篇的一個 HTTP 請求光標頭就是好幾百 bytes(HPACK 之後仍有動態表成本)、HTTP/2 的 frame 標頭固定 9 bytes、WebSocket 篇的 WebSocket frame 最小 2 bytes——MQTT 與 WebSocket 打平,但它用這 2 bytes 換到的是「一整套訊息語義」,而不只是「一個訊息邊界」。
2 bytes 是固定標頭。一則 QoS 0 的 PUBLISH 實際上還要帶「2 bytes 的 topic 長度 + topic 字串本身」;QoS 1/2 再加 2 bytes 的 Packet Identifier。所以當你的 topic 長成 factory/taipei/line-3/robot-07/telemetry/temperature(50 bytes)而 payload 只有 4 bytes 的溫度值時,協定開銷佔了九成以上。這正是 MQTT 5.0 要引入 Topic Alias 的原因(見第 5 節)。談頻寬時請算完整封包,不要只念固定標頭那個數字。
Broker 與 topic:一個中介,換三種解耦
MQTT 沒有「客戶端連客戶端」這回事。所有人都只認識 Broker——而這件事買到的東西比看起來多。
MQTT 的拓撲極其單純:每個客戶端只跟 Broker 建一條長連線,發佈者把訊息 PUBLISH 給 Broker,Broker 決定要複製給哪些訂閱者。發佈者永遠不知道有沒有人在聽、有幾個人在聽、聽的人是不是同一批。這種「刻意讓兩端互相不認識」正是 pub/sub 的核心價值,通常拆成三層:
| 解耦維度 | 意思 | 對 IoT 為什麼關鍵 |
|---|---|---|
| 空間解耦 | 雙方不需要知道對方的位址 | 裝置在 NAT/行動網路後面,位址每天換好幾次(TCP/UDP 篇已定調「外往內預設不通,裝置一律主動建長連線」) |
| 時間解耦 | 雙方不需要同時在線 | 感測器一天只醒 4 次;後台服務要重啟部署。錯開在 IoT 是常態不是異常 |
| 同步解耦 | 發佈是非阻塞的,不等對方處理完 | MCU 沒有餘裕維護「等 3 個下游回應」的狀態機 |
代價寫在同一行字裡:Broker 變成所有語義的集中點,也變成整個系統唯一的單點。這句話會在第 8 節與連線擴充篇各被提一次。
| 生命週期階段 | Device → Broker(請求) | Broker → Device(回應) |
|---|---|---|
| 連線 | CONNECT | CONNACK(帶 Session Present) |
| 訂閱 | SUBSCRIBE、UNSUBSCRIBE | SUBACK(帶實際授予的 QoS)、UNSUBACK |
| 傳送 | PUBLISH | (QoS 0 無回應;QoS 1/2 的 ack 見第 3 節) |
| 保活 | PINGREQ | PINGRESP |
| 收尾 | DISCONNECT | (無回應;MQTT 5.0 起 Broker 也可主動送 DISCONNECT 並帶原因碼) |
五個階段、四對往返:CONNECT/SUBSCRIBE/UNSUBSCRIBE/PINGREQ 每一個請求都有對應的 ack——PUBLISH 是唯一的例外,它有沒有 ack、有幾個,全由 QoS 等級決定(第 3 節)。MQTT 3.1.1 一共只有 14 種控制封包(5.0 多一個 AUTH),這張表就覆蓋了九成流量。
1 · Topic:一個用 / 分層的 UTF-8 字串,如此而已
MQTT 的 topic 沒有型別、沒有 schema、不需要事先建立。它就是一個 UTF-8 字串,用 / 切成階層:
規格區分兩個很容易混用的名詞:Topic Name 是發佈時用的具體字串,不得含 wildcard;Topic Filter 是訂閱時用的樣板,才可以含 wildcard。兩個 wildcard 各管一件事:
| Wildcard | 匹配範圍 | 放置規則 | 例子 |
|---|---|---|---|
+ | 恰好一層,不跨 / | 可出現在任意層、可出現多次;該層必須整層都是 + | factory/+/+/+/status 命中所有廠區所有機台的狀態 |
# | 剩下的所有層(含零層) | 只能是最後一個字元,且前面必須是 / 或整個 filter 就是 # | factory/taipei/line-3/# 命中三線底下一切 |
兩個容易被追問的細節:(1) sport/# 也會匹配 sport 本身(# 可以吃零層)。(2) 以 $ 開頭的 topic(如 Broker 自身指標的 $SYS/...、5.0 共享訂閱的 $share/...)不會被第一層的 + 或 # 匹配到——所以訂 # 不會把 Broker 的內部指標一起吸進來。
Broker 收到一則 PUBLISH 時做的唯一一件事,就是拿 topic name 去比對訂閱樹。下面兩個欄位都可以自由輸入:上面是訂閱用的 Topic Filter、下面是你自己想試的 topic name(它會排在固定語料的第一列)。每一格都是逐層比對的實際結果,不是事先寫好的答案。
+ / #)
+ 吃掉一層
# 吞掉剩下所有層
比到這一層就不符(後面不再比)
三個值得停下來看的地方:(1) 不符的那一格右邊全部變虛線——比對是逐層短路的,這也是「比對成本」那個數字算出來的方式。(2) 輸入 sport/# 配 sport 會命中,且標示「# 吞掉 0 層」。(3) 輸入 # 時 $SYS/broker/uptime 仍然落空——$ 開頭的 topic 不被第一層的 wildcard 匹配。
RabbitMQ 的 queue 是實體:要宣告、有容量、有堆積深度、可以在管理介面上看到 1,200 則待處理。Kafka 的 partition 是實體:磁碟上真的有那些 segment 檔。MQTT 的 topic 兩者都不是。它只是 Broker 在收到 PUBLISH 那一瞬間拿來比對訂閱樹的一個字串——比中了就複製轉發,沒比中就直接丟棄。
推論有三個,而且都是實務上被反覆踩的:(1) 沒有任何訂閱者在線時發佈的訊息會消失(除非它是 retained,或某個離線客戶端有 persistent session)。(2) topic 數量可以是動態且巨量的(每台裝置一組),因為它不佔儲存空間——但訂閱樹的比對成本會隨層級與 wildcard 複雜度上升。(3) 「這個 topic 積壓了多少訊息」這個問題在 MQTT 裡沒有意義;積壓只會發生在每個客戶端各自的離線佇列裡。
2 · Topic 設計準則:把「訂閱端想怎麼切」寫進層級順序
topic 一旦上線就幾乎改不動(韌體都燒進去了),所以它是 MQTT 系統裡最貴的一個設計決定。核心原則只有一條:層級順序要讓訂閱端能用 wildcard 切出它想要的視角。
# 好的 topic:靜態層級(可過濾)→ 動態識別 → 語義方向
factory/taipei/line-3/robot-07/telemetry/temperature # 上報:裝置寫、後台讀
factory/taipei/line-3/robot-07/cmd/set-speed # 下發:後台寫、裝置讀
factory/taipei/line-3/robot-07/status # presence:retained + LWT
# 訂閱端用 wildcard 切視角
factory/taipei/+/+/telemetry/temperature # 全廠溫度(+ 不跨層,剛好卡在機台這層)
factory/taipei/line-3/# # 三線的一切(# 只能放最後)
factory/+/+/+/status # 全域 presence 看板
# 反例
sensor/data # 沒有層級,訂了就是全收,無法切
device/550e8400-.../temperature # 識別擺太前面,無法「跨裝置看同一種指標」
factory/taipei/#/cmd # 非法:# 不是最後一個字元
另外三條慣例值得寫進團隊規範:(1) 不要用前導 /(/factory/... 等於多一個空層級,合法但沒人想要)。(2) topic 大小寫敏感,統一小寫加連字號。(3) 上報與下發要分不同的 topic 子樹(telemetry/ 對 cmd/)——否則裝置會收到自己剛發出去的訊息,這是新手第一個無限迴圈。(MQTT 5.0 的 No Local 訂閱選項可以擋,但分開設計更乾淨。)
/ 分層的字串,+ 匹配一層、# 匹配剩下所有層且只能放最後——而 topic 是路由鍵不是佇列,沒有訂閱者的訊息直接丟棄。
QoS 0 / 1 / 2:投遞保證怎麼被寫進協定
TCP 已經保證不掉不亂了,為什麼還需要三級 QoS?因為 TCP 保證的是「這條連線上」,而連線本身會死。
先回答那個必問的追問。TCP 的可靠性(TCP/UDP 篇)作用範圍是一條活著的連線:序號與重傳讓位元組不掉不亂,但當連線在 write() 成功之後、對端應用處理完之前斷掉,發送端根本無法分辨這則訊息是「已被處理」還是「消失在半路」。TCP 的 ACK 只代表「對端核心收下了」,不代表「對端應用處理了」。QoS 補的就是這一段——它是應用層對應用層的確認,而且明確定義了重傳與去重的責任歸屬。
| 等級 | 語義 | 封包往返 | 失敗時會發生什麼 | 成本 |
|---|---|---|---|---|
| QoS 0 | At most once(至多一次) | 1 個封包,沒有 ack | 連線斷掉那一瞬間的訊息直接消失,沒有人會知道 | 最低:無狀態、無重傳、無 Packet Id |
| QoS 1 | At least once(至少一次) | 2 個:PUBLISH → PUBACK | ack 遺失就重送,接收端可能拿到重複 | 發送端要保留訊息直到收到 ack |
| QoS 2 | Exactly once(恰好一次) | 4 個:PUBLISH → PUBREC → PUBREL → PUBCOMP | 重送會被去重,接收端保證只處理一次 | 雙方都要存狀態;兩個來回,延遲最高 |
實務分佈非常懸殊:絕大多數遙測資料用 QoS 0 或 1(下一筆溫度一分鐘後就來了,掉一筆不值得付重傳成本);QoS 2 只留給「重複執行會出事」的下發指令——開閘門、扣款、韌體升級觸發。
1 · QoS 1 為什麼會重複
sequenceDiagram
participant P as Publisher
participant B as Broker
participant S as Subscriber
P->>B: PUBLISH id=42 DUP=0
B->>S: 轉發(第 1 次)
B--xP: PUBACK id=42 在回程遺失
P->>B: PUBLISH id=42 DUP=1(逾時重送)
B->>S: 轉發(第 2 次)
Note over B,S: QoS 1 接收端無去重義務
重複發生在 Broker → Subscriber 這一段,而不是遺失的那一段——PUBACK 掉的是回程,發佈端只知道「我沒收到 ack」,無法分辨訊息到底有沒有送達。
關鍵在最後一格:QoS 1 的接收端沒有去重義務。它看到同一個 Packet Identifier 再來一次,規格要求它就是再處理一次。DUP=1 這個旗標只是「這是一次重送」的提示,不能拿來當去重依據——因為在多跳(bridge、叢集)情境下,一則對你而言全新的訊息也可能帶著 DUP=1 抵達。
HTTP 篇已經完整定義過冪等性(約束的是伺服器狀態,不是回應內容)。在 MQTT 上這件事變得很具體:QoS 1 的成本只有一次 ack,卻能擋掉絕大多數的訊息遺失;重複的代價則可以用一個應用層的訊息 id 消掉——payload 裡帶 msg_id 或 seq,接收端存最近 N 筆做去重。
這比 QoS 2 划算太多:QoS 2 每則要兩個來回、雙方都要落地狀態,在弱網上那兩個額外 RTT 是實打實的電量與延遲。「QoS 1 + 應用層去重」是實務上壓倒性的預設答案。
2 · QoS 2 為什麼要四步——兩對交換,各解一個問題
四步流程常被死記成「握手兩次」,但它其實是兩對語義完全不同的交換:
| 階段 | 封包 | 接收端做了什麼 | 沒有這一對會怎樣 |
|---|---|---|---|
| 第一對 | PUBLISH → PUBREC | 把 Packet Id 記下來(標記為「已收到、待完成」),並回報「我收到了」 | 就退化成 QoS 1:發送端不知道對方是否具備去重能力 |
| 第二對 | PUBREL → PUBCOMP | 看到 PUBREL 才真正交付給應用,然後忘掉這個 Packet Id 並回 PUBCOMP | 接收端永遠不知道何時可以忘記那個 id,去重表會無限成長;發送端也不敢重用 id |
用一句話收攏:第一對解決「不重複處理」,第二對解決「什麼時候可以安全地忘記」。Packet Identifier 只有 16 bits(1–65535,且 0 不合法),是一種必須被回收的稀缺資源——第二對交換就是那個回收協定。這個「先記下、再確認可以遺忘」的兩階段結構,和分散式交易的 2PC 是同一個形狀。
流程中的每一個封包都可能遺失,所以規格也定義了重送規則:發送端逾時未收到 PUBREC 就用 DUP=1 重送 PUBLISH(接收端因為已經記下 id,不會再交付一次,只補回一個 PUBREC);未收到 PUBCOMP 就重送 PUBREL(注意 PUBREL 重送時 DUP 不設)。整條流程的狀態在 persistent session 裡是要跨連線存活的——這是第 4 節的伏筆。
3 · 最高頻的誤區:QoS 是分段的
一則訊息從發佈者到訂閱者經過兩段獨立的 MQTT 交易:publisher → Broker 是一段,Broker → subscriber 是另一段。兩段各自協商、各自跑自己的 ack 流程,互不相干。第一段的 QoS 由發佈者在 PUBLISH 裡指定,第二段的 QoS 則由下面這個公式決定。
「被授予」也是一個常被略過的字。訂閱者在 SUBSCRIBE 裡要求一個 QoS,Broker 在 SUBACK 裡回覆實際授予的等級——它可以降級(甚至回失敗)。所以會出現這種情境:裝置辛辛苦苦用 QoS 2 發了訊息,某個以 QoS 0 訂閱的看板收到的仍然是 fire-and-forget,掉了也不會有人知道。
更根本的一點:MQTT 從來沒有承諾 end-to-end exactly-once。它承諾的是「每一段 hop 上的 exactly-once」。中間只要有 Broker 重啟、訊息在 Broker 記憶體裡還沒落地、或流量經過 bridge 到另一個 Broker,端到端的重複就仍然可能發生。要真正的一次語義,答案永遠是在應用層做冪等,而不是把 QoS 調到 2。這一點在第 6 節對照 Kafka 的 exactly-once 時會再出現。
QoS 1/2 的訊息在收到最終 ack 之前叫 inflight,發送端必須保留它。客戶端函式庫與 Broker 都會限制「同時最多幾則 inflight」(常見預設 10–20;MQTT 5.0 把它正式化成 Receive Maximum 屬性,雙方在 CONNECT/CONNACK 互相宣告)。
這個數字直接決定了弱網下的吞吐上限:如果 RTT 是 400 ms、inflight 上限是 10,那麼每秒最多 25 則,跟你的 CPU 和頻寬完全無關。裝置端遙測突然「卡住不上報」,第一個該看的就是 inflight 是不是被塞滿了——這是 TCP/UDP 篇談 backpressure 時那條因果鏈在 MQTT 上的具體長相。
Broker 替你記住的三件事
Retained message 管「我剛上線,現在是什麼狀況」;persistent session 管「我不在的時候」;Last Will 管「我回不來了」。
這三件事有一個共同結構:把「應該有人在線上」這個假設拆掉。它們也正是把 MQTT 從「pub/sub 語法」變成「訊息系統」的那部分。
1 · Retained message:每個 topic 的最後一則快照
發佈時把 RETAIN 旗標設為 1,Broker 除了照常轉發給當下的訂閱者之外,還會把這則訊息存起來,覆蓋該 topic 上的前一則。之後任何新訂閱者一訂上,立刻就會收到它,不必等下一次上報。
| 性質 | 說明 |
|---|---|
| 每個 topic 幾則 | 恰好一則(最新的覆蓋舊的)。它是快照不是歷史,沒有任何回溯能力 |
| 存活多久 | 直到被覆蓋或被清除;不受 session 影響,客戶端斷線也還在 |
| 怎麼清除 | 對同一個 topic 發佈一則 payload 長度為 0 且 RETAIN=1 的訊息。這則空訊息不會被存下來,只會把既有的抹掉 |
| 收到時怎麼分辨 | 因新訂閱而投遞的 retained 訊息會帶 RETAIN=1;正常轉發給既有訂閱者的則是 0——客戶端可以據此分辨「這是歷史快照」還是「這是剛發生的事」 |
典型用途:裝置的目前狀態(在線與否、目前設定值、韌體版本)。反例:不要拿它存感測器讀數的時序資料——它只留一則,而且沒有時間軸。MQTT 5.0 額外提供 Retain Handling 訂閱選項(0 = 照送、1 = 只在這是全新訂閱時送、2 = 完全不送),解掉「重連後重複收到一堆 retained」這個很煩的問題。
2 · Persistent session:Broker 替離線的你排隊
客戶端在 CONNECT 裡宣告要不要保留 session。這是 3.1.1 與 5.0 差異最明顯的地方之一:
| MQTT 3.1.1 | MQTT 5.0 | |
|---|---|---|
| 旗標 | Clean Session = 0 表示要保留 | Clean Start = 0(是否沿用既有 session)+ Session Expiry Interval(斷線後留多久,秒) |
| 存活期 | 沒有明確上限,由 Broker 實作決定——殭屍 session 會慢慢吃光記憶體 | 明確可控:0 = 斷線即銷毀、0xFFFFFFFF = 永不過期、其餘為秒數 |
| 兩件事是否分離 | 綁在一起:要保留 session 就永遠保留 | 分離:可以「這次開新的(Clean Start=1),但斷線後保留一小時」 |
3.1.1 那個沒有 TTL 的設計是實務上真實的營運痛點:測試時用過的每一個 client id 都會在 Broker 上留下一份訂閱與佇列。「升 5.0 最有感的一項」通常就是 Session Expiry。
Session 裡到底存了什麼,是面試最容易問細的地方:
| session 裡的項目 | 為什麼非存不可 | 重連後的影響 |
|---|---|---|
| 客戶端的所有訂閱 | 訂閱是狀態不是請求,重建一次要一趟往返 | Session Present=1 時原封不動;=0 就得全部重新 SUBSCRIBE |
| 已送出但未收到 ack 的 QoS 1/2 訊息 | 發送端不留著就沒東西可重送 | 沿用同一批 Packet Identifier,帶 DUP=1 重送 |
| 已收到但未走完 QoS 2 流程的 Packet Id | 去重表若隨連線消失,exactly once 當場破功 | 補完 PUBREL/PUBCOMP,不會重複交付 |
| 離線期間累積的 QoS 1/2 訊息 | 這就是「時間解耦」的實作本體 | 一重連就整批投遞 |
| QoS 0 訊息 | 不排隊——規格允許 Broker 選擇保留,實務上都不留。離線期間發的 QoS 0 直接消失,重連後補不回來 | |
最後一列是反例,也是最常被踩的一格:開了 persistent session 卻收不到離線訊息,九成是因為那些訊息是用 QoS 0 發的。
最後一格是關鍵:QoS 0 的訊息在你離線時直接被丟掉(規格允許 Broker 選擇保留,但實務上都不留)。所以「我明明開了 persistent session,重連後卻沒收到離線訊息」——九成是因為那些訊息是用 QoS 0 發的。這也讓 QoS 從一個「傳輸可靠性」的旋鈕,變成一個「離線期間值不值得替你存」的旋鈕。
另外兩件必須綁在一起講:(1) session 用 Client Identifier 當鍵——所以 client id 必須穩定且唯一;用隨機 UUID 每次重連都是新 session(persistent 形同虛設),而兩台裝置撞同一個 id 則會互相把對方踢下線,形成一個非常難查的無限重連迴圈。(2) Broker 在 CONNACK 裡回一個 Session Present 旗標,告訴你它究竟有沒有找到舊 session。客戶端必須看這個旗標:如果是 0,代表訂閱全部沒了,你得重新 SUBSCRIBE——很多「重連後就再也收不到訊息」的 bug 就出在沒檢查它。
3 · Last Will and Testament:裝置斷電時,Broker 替它說最後一句話
WebSocket 篇談保活時花了一整節講 half-open:一端已經消失,另一端卻毫無察覺。MQTT 沒辦法讓 half-open 消失(那是 TCP 的事),但它做了一件更有用的事——讓「我死掉時要通知誰、說什麼」在還活著的時候就先講好。
客戶端在 CONNECT 封包裡就可以夾帶一份遺囑:Will Topic、Will Payload、Will QoS、Will Retain。Broker 收下後存著,只在特定條件下才發出去:
| 連線結束的方式 | Broker 會不會發 LWT | 為什麼 |
|---|---|---|
客戶端送了 DISCONNECT 才斷 | 不會 | 這是「我要走了」,不是意外——Broker 收到 DISCONNECT 時會丟棄那份遺囑 |
| Keep Alive 逾時 | 會 | 最常見的觸發:裝置斷電、進隧道、電池耗盡(第 5 節) |
| TCP 連線被 RST/FIN 意外中斷 | 會 | 網路故障、行程被 kill、中間設備拆連線 |
| 協定錯誤/認證後違規(如訂了無權的 topic) | 會 | Broker 主動關閉且視為異常 |
MQTT 5.0 補了兩個很實用的東西:Will Delay Interval(延遲 N 秒才發遺囑;若裝置在這段時間內帶著同一個 client id 回來,遺囑就取消)——這正好消掉「行動網路抖一下就發出離線告警」的誤報;以及帶 Reason Code 的 DISCONNECT,讓客戶端可以說「我是正常關機」還是「我要斷線但請幫我發遺囑」。
這是 MQTT 最漂亮的一個組合技,也幾乎是每個 IoT 系統都會實作的第一個功能:
1. 裝置連上後,立刻對 .../robot-07/status 發佈 online,帶 RETAIN=1。
2. 同一台裝置在 CONNECT 時就把 LWT 設成:同一個 topic、payload offline、Will Retain = 1。
3. 裝置正常運作時,retained store 上是 online;裝置斷電後 Keep Alive 一逾時,Broker 代發 offline 並覆蓋掉 retained。
效果是:任何時候上線的看板,只要訂閱 factory/+/+/+/status,一瞬間就能拿到全廠所有機台的當前在線狀態,完全不需要一個「裝置狀態」資料庫,也不需要輪詢。少了 retain,新看板要等到下一次狀態變化才知道;少了 LWT,斷電的裝置會永遠停在 online。
連線層:Keep Alive、MQTTS 與裝置身分
前面三節都建立在「Broker 知道你還在不在」之上。這一節處理那條連線本身,以及連上來的到底是誰。
1 · Keep Alive:把保活的判定寫死在規格裡
WebSocket 篇談保活的章節建了一張三層保活分工表(TCP keepalive/協定層 Ping-Pong/應用層 heartbeat),並且結論是:週期與逾時判定全部由你自己決定。MQTT 走的是同一個機制、不同的哲學——它把這件事變成連線建立時的協商參數:
| WebSocket Ping/Pong(見 WebSocket 篇) | MQTT Keep Alive | |
|---|---|---|
| 週期怎麼決定 | 應用自訂,兩端各自為政 | 客戶端在 CONNECT 裡宣告秒數(16-bit,最大 65535 秒;0 = 停用);5.0 的 Broker 還可以在 CONNACK 裡用 Server Keep Alive 覆寫它 |
| 誰主動 | 伺服器發 Ping,瀏覽器自動回 Pong(JS 無法主動發) | 客戶端主動發 PINGREQ,Broker 回 PINGRESP |
| 什麼時候要發 | 固定週期 | 只在沒有其他封包可送時才發——任何一個封包(PUBLISH、SUBSCRIBE…)都算數,等於免費保活 |
| 逾時判定 | 自訂:連續 N 次無 Pong | 規格定死(見下) |
也就是說:客戶端宣告 60 秒,Broker 就會在連續 90 秒沒有收到任何封包時斷開這條連線——並且觸發第 4 節的 Last Will。這個 1.5 倍的寬容量是留給時脈飄移與網路抖動的。反過來,客戶端送出 PINGREQ 後若遲遲收不到 PINGRESP,也應該主動斷線重連(規格建議「合理時間內」,函式庫通常實作成一個 Keep Alive 週期)。
Keep Alive 的選值是純粹的 device constraints 題,而且和 TCP/UDP 篇的 NAT 結論直接掛鉤(那裡已經定調:心跳既是活性偵測,也是餵養 NAT 映射——這裡不重講):
| 選值 | 換到什麼 | 付出什麼 |
|---|---|---|
| 短(30–60 s) | 離線偵測快(presence 準)、NAT 與 LB 的 idle timeout 一定來不及先到 | 行動網路上每次 PINGREQ 都要喚醒無線模組,電池殺手;百萬連線時 Broker 的 CPU 帳單也很可觀 |
| 長(10–20 分鐘) | 極省電,適合電池供電的感測器 | 裝置死掉後最久要 1.5 倍才被發現;而且必須確認路徑上每一段的 idle timeout 都比它長,否則連線會被中間設備靜默拆掉而你毫不知情 |
實務起手式:市電供電的閘道器 60 秒;電池裝置盡量拉長,但先量測路徑上最短的 idle timeout(電信商 NAT 常見 5–30 分鐘),取它的一半。這條原則和 WebSocket 篇面試折衷一節的「心跳週期必須小於路徑上最短的 idle timeout」是同一條,只是這裡的成本單位從「流量」換成了「電池壽命」。
2 · MQTTS 與裝置身分:mTLS 在這裡才真正變成主流做法
TLS 篇已經把 mTLS 的機制講完(雙向憑證、CertificateVerify、私有 CA、短效憑證輪替),這裡只補「為什麼 IoT 是它最自然的戰場」。答案是裝置沒有人類:沒有人可以輸入密碼、沒有瀏覽器可以跳登入頁、也沒有辦法在外洩後叫使用者改密碼。一台裝置一張憑證,於是變成最乾淨的身分模型。
| 身分方案 | 怎麼做 | 適合 | 風險 / 限制 |
|---|---|---|---|
| X.509 + mTLS | 私有 CA 簽發 per-device 憑證,Broker 驗證後把 CN/SAN 當 client 身分 | 產線可燒錄、生命週期長的裝置 | 要建 PKI 與撤銷機制(CRL/OCSP 在裝置端常不可行,實務多靠短效憑證輪替+Broker 端黑名單);MCU 要有足夠 RAM 做非對稱運算 |
| Username / Password | CONNECT 封包內建這兩個欄位,Broker 查後端驗證 | 改造成本最低、閘道器場景 | 必須跑在 TLS 之上(否則明文);一定要 per-device 不同——共用一組帳密等於整批裝置一起淪陷,卻是最常見的做法 |
| Token(JWT 等) | 把短效 token 放進 password 欄位,Broker 驗簽 | 手機 App、能連後端換 token 的裝置 | token 會過期,但 MQTT 連線可能活好幾天——需要「到期前重連」或 5.0 的 AUTH 封包做連線內重新認證 |
兩個一定要成對記住的結論。(1) 沿用 TLS 篇:mTLS 是 authentication 不是 authorization——證明了「你是 robot-07」,不代表 robot-07 有權訂閱 factory/#。Broker 端必須另外做 topic 級 ACL(把 client id 綁進 topic、限制它只能讀寫 factory/+/+/robot-07/#),否則任何一台被拆解的裝置都能監聽全廠。(2) 沿用 TLS 篇談版本選型的章節:「只開 TLS 1.3 還是保留 1.2」在 IoT 要先盤點——你不能像 Web 一樣宣布淘汰舊版,因為現場那批 2018 年出廠、跑著舊版 mbedTLS 的裝置,韌體可能根本升不上去。
# 訂閱端:wildcard + QoS 1,保留 session(-c = disable clean session)
$ mosquitto_sub -h broker.example.com -p 8883 --cafile ca.crt \
-i dash-01 -c -q 1 -t 'factory/taipei/+/+/status' -v
factory/taipei/line-3/robot-07/status online # 一訂上就收到 retained,不必等下次上報
# 發佈端:mTLS 身分 + 上線公告(retained) + 事先寫好的遺囑
$ mosquitto_pub -h broker.example.com -p 8883 --cafile ca.crt \
--cert robot-07.crt --key robot-07.key \
-i robot-07 --keepalive 60 \
-t 'factory/taipei/line-3/robot-07/status' -m 'online' -q 1 -r \
--will-topic 'factory/taipei/line-3/robot-07/status' \
--will-payload 'offline' --will-qos 1 --will-retain
這兩條指令就是第 4 節那個 presence 模式的完整實作:-r 讓 online 成為 retained,--will-* 讓 Broker 在 90 秒(60 × 1.5)沒收到封包時代發 offline 覆蓋它。把 mosquitto_pub 那個行程直接 kill -9,就能在訂閱端看到遺囑跳出來——這是驗證 LWT 設定最快的方法(用 Ctrl-C 反而不行,因為函式庫會送出乾淨的 DISCONNECT)。
3 · MQTT 5.0 相對 3.1.1:值得知道的五項
3.1.1 仍然是裝置端相容性最好的版本,但雲端 Broker 幾乎都支援 5.0,新專案沒有理由不用。除了前面已經出現的 Session Expiry、Will Delay、Retain Handling、Receive Maximum、Server Keep Alive 之外,還有五項值得點名:
| 特性 | 解決什麼 |
|---|---|
| Reason Code + Reason String | 3.1.1 的 ack 幾乎沒有錯誤資訊,連線被拒只知道「失敗」。5.0 在所有 ack 上都帶原因碼——這是可觀測性上最大的一項改善 |
| Shared Subscription | 訂閱 $share/{group}/{filter},同組內的訂閱者輪流收訊息而不是每人一份——這才讓 MQTT 有了類似 consumer group 的競爭消費模型(第 6 節對照) |
| Message Expiry Interval | 給訊息設 TTL。離線佇列裡放了三天的「開燈」指令,裝置回來時不應該還被執行 |
| Topic Alias | 把長 topic 字串在連線內映射成一個 2-byte 整數,之後只送整數——直接回應第 1 節那筆「50 bytes topic 配 4 bytes payload」的帳單 |
| Request / Response | Response Topic + Correlation Data 兩個屬性,把請求/回應關聯做進協定。這正是 WebSocket 篇談 framing 的章節那張表裡「WebSocket 沒有、要自己加 id 欄位」的那一格 |
另外 5.0 還有 User Properties(任意 key-value,等於 MQTT 版的 HTTP 標頭)與 Subscription Identifier(讓客戶端知道這則訊息是因為哪一條訂閱被送來的——多重 wildcard 訂閱時很有用)。
mqtt),IoT 場景下 per-device X.509 + mTLS 是最乾淨的身分方案,但 mTLS 只做認證,topic 級 ACL 必須另外做。
定位對照:WebSocket、Kafka、RabbitMQ 各自在解什麼
這一節只回答一個問題:什麼時候不該用 MQTT。而答案幾乎都是「因為你要的是另外三個東西之一」。
1 · 與 WebSocket:一個是通道,一個是通道上的語義
| WebSocket | MQTT | |
|---|---|---|
| 拓撲 | 點對點:客戶端 ↔ 伺服器 | 星狀:所有人 ↔ Broker,發佈者與訂閱者互不認識 |
| 訊息定址 | 沒有——你自己在 payload 裡設計 | topic + wildcard 訂閱,由協定路由 |
| 投遞保證 | 沒有(TCP 之外什麼都沒有) | QoS 0/1/2 |
| 離線 / 死亡 | 沒有——重連後的狀態恢復全部自己寫 | persistent session + retained + Last Will |
| 典型客戶端 | 瀏覽器、行動 App | MCU、閘道器、車機、感測器 |
| 原生瀏覽器支援 | 有(new WebSocket) | 沒有——瀏覽器不能開裸 TCP,必須走 MQTT over WebSocket |
最後一列是實務上最重要的一條綁定。MQTT over WebSocket 的作法是把 MQTT 的位元組流塞進 WebSocket 的 binary frame,子協定名固定為 mqtt(Sec-WebSocket-Protocol: mqtt),通常直接跑在 wss:// 443 上——順帶解掉「企業防火牆只開 443」這個現實問題。注意分層:WebSocket 的 frame 邊界與 MQTT 的封包邊界不對齊,一個 WS frame 可以裝多個 MQTT 封包、一個 MQTT 封包也可以跨多個 frame,所以接收端仍然要照 MQTT 的 Remaining Length 自己切。這就是「一個是通道、一個是通道上的語義」最具體的樣子。
2 · 與 Kafka、RabbitMQ:三個都叫「訊息系統」,但幾乎沒有一格是一樣的
這是本篇最需要記住的一張表。面試裡「MQTT 跟 Kafka 差在哪」如果只答「一個給 IoT 一個給大數據」,會被追問到底;真正的差異在投遞語義、消費模型、順序、保留與吞吐這五個維度上,而且每一格的差異都會反過來限制它能做什麼。
| 維度 | MQTT | RabbitMQ(AMQP 0-9-1) | Kafka |
|---|---|---|---|
| 本質 | 裝置接入協定 | 訊息中介/任務佇列 | 分散式 commit log |
| Delivery semantics | QoS 0/1/2,但分段(per-hop),無 end-to-end exactly-once | publisher confirm + consumer ack;至少一次,重複要自己防 | 預設至少一次;idempotent producer + transaction 可做Kafka 內部的 exactly-once |
| Consumer model | Push:Broker 主動推給所有匹配的訂閱者,客戶端無 offset 概念 | Push(basic.consume + prefetch)為主;同一 queue 上多 consumer 競爭 | Pull:consumer group 分配 partition,offset 由消費端自己管 |
| 扇出(fan-out) | 天然扇出:一則訊息複製給 N 個訂閱者。要競爭消費得用 5.0 的 shared subscription | 用 fanout/topic exchange 綁到多個 queue,複製發生在 exchange | 多個 consumer group 各自獨立讀同一份 log,互不影響 |
| Ordering | 同一發佈者、同一 topic、同一 QoS 內有序;跨 topic 或重傳後無保證 | 單 queue 單 consumer 有序;一旦多 consumer 或 requeue 就破 | partition 內嚴格有序(同 key → 同 partition),這是它最強的保證 |
| Retention | 幾乎不保留:只有每 topic 一則 retained + 離線 session 佇列 | 投遞並 ack 後即刪除——queue 是暫存不是儲存 | 按時間/大小保留(天~永久),消費不刪除 |
| 重播 / 回溯 | 不可能(沒有 log) | 不可能(除非另建 replay 機制) | 可以:seek 到任意 offset 或時間戳重讀 |
| 連線規模 | 十萬~百萬條長連線;2-byte 標頭、Keep Alive 可拉到十幾分鐘 | 千~萬(每連線多個 channel),協定較重 | 數千個 client;每個 client 要抓 metadata、跑 fetch 迴圈、參與 rebalance |
| Throughput | 每則小、總量中等;瓶頸在連線數與扇出倍數 | 中等;routing 能力豐富但單 queue 吞吐有限 | 極高:順序寫、批次、零拷貝,單 partition 就能吃下數十 MB/s |
| 典型場景 | 感測器上報、遠端下發指令、車聯網、行動推播 | 任務佇列、RPC、工作流、需要複雜路由與死信 | 事件溯源、串流處理、入湖、指標管線 |
把整張表壓成一句:MQTT 解決「怎麼把幾十萬台不穩定的裝置接進來」,RabbitMQ 解決「怎麼把工作可靠地分派給 worker」,Kafka 解決「怎麼把事件存成一份可以重讀的歷史」。三者不是同一個問題的三種答案,因此不可直接替換。
「用 Kafka 直接接裝置」:Kafka client 是重量級的——要拉 metadata、維護 fetch 迴圈、參與 consumer group rebalance,MCU 上根本跑不動;而且它沒有 Last Will、沒有 retained、沒有為省電設計的 keep-alive,也扛不住幾十萬條時斷時續的連線(每次重連都可能觸發 rebalance)。
「用 MQTT 當事件儲存」:MQTT 沒有 log。新上線的分析服務無法回頭讀昨天的資料,訂閱之後才發生的事才看得到;retained 只有一則,也不是歷史。想「重跑一次上週的資料」在純 MQTT 架構下是不可能的——這通常是團隊決定引入 Kafka 的那一天。
3 · 所以實務上的答案是接力,不是二選一
cmd/ 子樹,由 Broker 找到裝置、按 QoS 投遞、離線就排隊 ──────
分工只有一句話:Broker 負責「接入」(連線數、弱網重連、QoS、LWT),Kafka 負責「留存與分發」(一份可重播的 log)——而下發指令時方向反過來,因為只有 Broker 知道 robot-07 現在連在哪。
這是 IoT 後端幾乎標準化的長相,分工非常清楚:MQTT Broker 負責「接入」——扛連線數、處理弱網重連、跑 QoS 與 LWT、替離線裝置排隊、省裝置的電;Kafka 負責「留存與分發」——把事件寫成一份可重播的 log,讓分析、告警、入湖、機器學習各自用獨立的 consumer group 讀同一份資料,互不干擾,且新服務上線可以回頭補跑歷史。中間那個 bridge 通常是 Broker 內建的 connector(EMQX/HiveMQ/VerneMQ 都有)或雲端的規則引擎(AWS IoT Core Rule → Kinesis/MSK)。
反方向同樣重要:後台要下發指令時,是從 Kafka 或應用服務寫回 MQTT 的 cmd/ 子樹,由 Broker 負責找到那台裝置、按 QoS 投遞、離線就排隊。Kafka 完全無法自己完成這件事——它不知道 robot-07 現在連在哪。而三者也常同時存在:MQTT 接裝置、Kafka 存事件、RabbitMQ 派工單給人類操作員或第三方系統。
控制封包的位元組帳單
第 1 節說「2 bytes 標頭只是行銷話術的一半」。這一節把另一半攤開:一則 PUBLISH 到底是哪些位元組組成的。
MQTT 的每一個控制封包都是同一個三段式結構:固定表頭(1 byte 的「4 bit 封包型別 + 4 bit 旗標」,加上 1–4 bytes 的剩餘長度)、可變表頭(依封包型別而異:PUBLISH 是「2 bytes 長度前綴 + topic 字串」再加上只有 QoS ≥ 1 才出現的 2 bytes Packet Identifier;CONNECT 是協定名、版本、連線旗標與 Keep Alive;SUBSCRIBE 是 Packet Identifier),以及payload(協定完全不看內容)。所謂「最小 2 bytes」指的只有固定表頭那一段,而且是在剩餘長度只佔 1 byte 時;真正要付錢的是中間那一段——尤其是每則訊息都要重送一次的 topic 字串。
剩餘長度這個欄位值得單獨講,因為它是 MQTT 省位元組的手法裡最漂亮的一個:它用變長編碼,每個 byte 只拿 7 bit 存數值、最高位當接續位元(1 表示「後面還有一個 byte」)。所以 127 bytes 以內只花 1 byte、16,383 以內 2 bytes、2,097,151 以內 3 bytes、上限 268,435,455 花 4 bytes——小封包不必為大封包的可能性預付欄位寬度。要注意它算的是「這個封包在固定表頭之後還有幾個 bytes」,不包含它自己與第 1 個 byte。下面這台拆解器就把這些規則真的跑一遍:你調 topic、payload、QoS、旗標與 Keep Alive,它把位元組一格一格組出來。
這台拆解器和子網篇那條「固定 32 格位元條」的差別,就在格數本身是輸出:把 payload 從 71 調到 72,剩餘長度會從 1 格變 2 格、位元展開多長出一整列,總長度一次跳 2 bytes;把 topic 換成中文,長度前綴算的是 UTF-8 的 bytes 而不是字元數。另外兩個算出來的數字值得看:(1) 「離下次進位還剩幾 bytes」告訴你這個封包還能長多大而不多付一個欄位。(2) 每日 PINGREQ 次數是 ceil(發佈間隔 ÷ Keep Alive) − 1 乘上每日發佈次數——只要發佈間隔小於 Keep Alive,這個數字就是 0,因為任何封包都算保活,心跳等於免費。
它組的是 MQTT 3.1.1 的布局(CONNECT 可變表頭固定 10 bytes:0x00 0x04 'M' 'Q' 'T' 'T' + 版本 + 連線旗標 + 2 bytes Keep Alive),不編碼 5.0 的 properties 區——5.0 會在可變表頭與 payload 之間多一段屬性,本質上是同一套「長度前綴 + 內容」的疊加。CONNECT 的遺囑固定用 <client-id>/status 與 7 bytes 的 offline;PUBLISH 的 payload 因為協定完全不看內容,所以只調大小、不編內容(超過 12 bytes 的部分會摺疊成一格)。每日流量假設「除了這筆交換之外沒有其他封包」,那是電池裝置的典型情況,也是 PINGREQ 會真的被送出去的前提。
面試折衷與實務選型
MQTT 的選型幾乎全部發生在兩個地方:QoS 要開幾級,以及狀態該放在 Broker 還是應用。
| 決策點 | 選項 | Trade-off |
|---|---|---|
| QoS 等級 | 0 vs 1 vs 2 | 遙測用 0/1(掉一筆,下一筆一分鐘後就來);會改變狀態的下發指令用 1 + 應用層冪等;QoS 2 只給「重複執行會出事且無法做冪等」的極少數操作,因為它要多一個來回、雙方都要落地狀態 |
| Session | Clean vs Persistent | Persistent 讓裝置斷線期間的指令不遺失,代價是 Broker 要替每台裝置維護佇列(記憶體與磁碟成本 × 裝置數)。一定要設 Session Expiry,否則測試用過的 client id 會永久佔用資源 |
| Keep Alive | 短 vs 長 | 短:presence 準、NAT 不會先斷;長:省電。上限是路徑上最短的 idle timeout,取其一半是安全起手式 |
| Topic 結構 | 粗(少層級) vs 細(多層級) | 細:訂閱端能用 wildcard 精準切視角、ACL 好寫;粗:Broker 的訂閱樹比對便宜。但 topic 上線後幾乎改不動(韌體燒死),寧可一開始多切兩層 |
| Payload 格式 | JSON vs 二進位(CBOR/protobuf) | JSON 好除錯、生態成熟;MCU 上 JSON 解析器可能比整支韌體還大,且每則多幾十 bytes——按位元組計費的 NB-IoT 上這是真金白銀 |
| 裝置身分 | mTLS vs 帳密 vs token | mTLS 最乾淨但要建 PKI 與輪替機制;帳密最省事但絕對不能全機隊共用一組;token 要處理「連線活得比 token 久」 |
| 協定版本 | 3.1.1 vs 5.0 | 新專案直接上 5.0(Session Expiry、Reason Code、Shared Subscription、Topic Alias 都很有感);但現場舊裝置的韌體可能只支援 3.1.1,Broker 要能同時服務兩種版本 |
| 要不要接 Kafka | 純 MQTT vs MQTT + Kafka | 只要出現「回頭重跑歷史資料」「多個下游各自消費同一份事件」「事件要留三個月」任一項,就是該 bridge 的訊號。純 MQTT 架構下這三件事都做不到 |
共同結構:MQTT 把狀態集中到 Broker 換取裝置端的極簡——每多開一級 QoS、多留一份 session、多存一則 retained,省下的是裝置的電與程式碼,付出的是 Broker 的記憶體、磁碟與複雜度。這個天平的支點永遠在「你有幾台裝置」。
Q:TCP 已經可靠了,為什麼還要 QoS?
答:TCP 只保證「一條活著的連線上」不掉不亂,而且 ACK 只代表對端核心收下。連線斷掉時發送端無法分辨訊息是被處理了還是消失了。QoS 是應用層對應用層的確認,並定義了重傳與去重的責任歸屬。
Q:QoS 2 是不是就等於 end-to-end exactly-once?
答:不是。QoS 是分段的——publisher→Broker 與 Broker→subscriber 各自協商,訂閱端實得等於兩者取 min。QoS 2 保證的是每一段 hop 的 exactly-once;真正的一次語義要在應用層做冪等。
Q:QoS 2 為什麼要四個封包?
答:兩對交換。第一對(PUBLISH/PUBREC)讓接收端記下 Packet Id 以便去重;第二對(PUBREL/PUBCOMP)確認何時可以安全忘記那個 id 並回收它——否則去重表會無限成長,發送端也不敢重用 16-bit 的 Packet Identifier。
Q:我開了 persistent session,為什麼重連後沒收到離線訊息?
答:三個可能——訊息是用 QoS 0 發的(不排隊)、client id 每次重連都變(等於每次都是新 session)、或是 CONNACK 的 Session Present 是 0 而客戶端沒有重新 SUBSCRIBE。
Q:怎麼做裝置在線狀態?
答:LWT + retained 一組。上線發 online(retain=1),CONNECT 時把遺囑設成同一個 topic 的 offline(will retain=1)。斷電後 Keep Alive 的 1.5 倍逾時,Broker 代發並覆蓋。新看板一訂上就拿到全機隊現況。
Q:MQTT 跟 WebSocket 怎麼選?
答:不是二選一。WebSocket 是點對點通道,MQTT 是 Broker 中介的 pub/sub 語義;瀏覽器裡跑 MQTT 就是拿 WebSocket 當 transport(子協定 mqtt,走 wss 443)。要的是「自訂雙向訊息」選 WebSocket,要的是「投遞保證 + 離線 + presence」選 MQTT。
Q:MQTT 跟 Kafka 差在哪?
答:五個維度——MQTT 是 push、無 offset、幾乎不保留、天然扇出、扛百萬連線;Kafka 是 pull、consumer group 自管 offset、按時間保留可重播、partition 內嚴格有序、吞吐極高但 client 很重。所以標準架構是 MQTT 接裝置、bridge 到 Kafka 做留存與流處理。
把本篇收攏成一句:MQTT 用一個 Broker 換掉了裝置端的複雜度。訊息該送給誰(topic 路由)、會不會重複(QoS)、離線時去哪(session 佇列)、剛上線怎麼知道現況(retained)、死掉了誰來說(Last Will)——這五件事在 WebSocket 上全部是你的程式碼,在 MQTT 上全部是 Broker 的職責。裝置端因此可以小到跑在一顆幾十 KB RAM 的 MCU 上。
而這正是問題所在。retained store、每個 client id 的 session 與離線佇列、每條連線的 inflight window、整棵訂閱樹——全部活在那一台 Broker 的記憶體裡。這句話你在 WebSocket 篇談連線狀態的章節已經看過一次,當時的主詞是 WebSocket gateway。兩條線在這裡合流了:無論你選的是 WebSocket 還是 MQTT,只要連線數超過一台機器扛得住的量,真正的題目就變成同一個——長連線要怎麼橫向擴充?訊息怎麼跨節點找到「持有那條連線的那一台」?Broker 組成叢集之後,session 與 retained 要不要複製、誰來判定 Keep Alive 逾時並代發 Last Will、發版時怎麼把幾十萬條連線優雅地趕去別台而不引爆重連風暴?還有,哪些工作應該下沉到 edge、哪些必須留在中心?系列收尾的連線擴充篇處理它。