2026年8月22日 星期六

MQTT不只是能傳: QoS、Retain、LWT與斷線重連

封包旅行記 EP04|可靠的 IoT 訊息設計

MQTT不只是能傳:
QoS、Retain、LWT與斷線重連

感測值送得出去,只代表展示成功;真正能進入水井村智慧養殖、樹藝故事機或場域儀表板的 MQTT,還必須回答:訊息可否重複?新訂閱者需不需要立刻看到狀態?裝置突然離線時誰來通知?斷線後如何安全恢復?

一、MQTT為什麼適合IoT?

ESP32 PublisherMQTT BrokerDjango/Node-RED/儀表板 Subscriber

MQTT採用發布/訂閱模式。ESP32不必知道Django在哪裡,只要把訊息發布到Topic;Broker負責把訊息分送給訂閱者。這種解耦讓裝置、雲端與儀表板可以分別擴充。

Topicsite/pond01/water/temp
訊息分類與路由名稱
Payload{"value":28.4}
真正傳送的資料內容
BrokerMosquitto等訊息中介站,接收並轉送訊息
ClientESP32、Django服務、手機或儀表板

重點:Broker收到訊息,不等於資料一定寫入資料庫;Publisher成功送出,也不等於每個Subscriber都已完成業務處理。

二、QoS:不是越高越好,而是語意要正確

QoS 0|最多一次

送出後不等待MQTT層確認;可能遺失,但額外負擔最低。

適合高頻、下一筆可取代上一筆的即時感測值。

QoS 1|至少一次

未收到確認會重送,因此比較不易遺失,卻可能收到重複訊息。

適合警報、控制事件、播放紀錄等重要事件。

QoS 2|恰好一次

用更完整的交換流程降低重複交付,成本與延遲最高;裝置、Broker與函式庫均須支援。

適合極少數不能遺失也不能重複的交換。

資料情境建議起點為什麼
每5秒水溫QoS 0少一筆通常可由下一筆補足;仍可在裝置端另做離線保存
溶氧過低警報QoS 1不希望輕易遺失,但接收端必須能處理重複
故事播放開始事件QoS 1+事件UUID重送時以UUID去重,避免播放次數被重複計算
燈光即時亮度滑桿QoS 0最新狀態通常比每次變化都抵達更重要
QoS的保證範圍有限:它處理的是MQTT傳遞,不會替你保證Django已寫入資料庫。重要事件仍應加入event_uuid、時間戳與應用層確認。

三、Retain:讓新訂閱者立刻取得「最後狀態」

Publisher把訊息以Retain方式發布後,Broker會為該Topic保留最後一筆Retained Message。新的Subscriber一訂閱,就會立即收到它,不必等ESP32下一次發布。

適合Retain

裝置目前在線狀態、最後水溫、目前模式、韌體版本、開關的目標狀態。

不適合Retain

「餵魚一次」、「播放故事一次」、「重新啟動」等一次性命令。新裝置訂閱時若重播舊命令,可能造成危險。

TopicRetain理由
site/pond01/status/online儀表板訂閱後立即知道裝置狀態
site/pond01/telemetry/water_temp視需求可顯示最後讀值,但介面必須同時呈現時間,避免把舊值誤認成即時值
site/pond01/cmd/feed_once避免斷線重連後再次執行舊命令

清除Retained Message:依MQTT慣例,對同一Topic發布空Payload並設定Retain,可要求Broker刪除該Retained Message;實際操作仍需依使用的Client函式庫確認。

四、LWT:裝置來不及說再見時,由Broker代為通知

LWT(Last Will and Testament,遺囑訊息)是在Client連線時,預先交給Broker的一則訊息。如果ESP32因斷電、訊號中斷或程式崩潰而非正常離線,Broker會代替它發布LWT。

連線時登記:若我異常消失請發布 offline儀表板收到並告警
  1. ESP32連線Broker時設定LWT:Topic為site/pond01/status/online,Payload為offline,並設為Retain。
  2. 連線成功後,ESP32主動在同一Topic發布Retained online
  3. 若正常關機,可先發布offline再主動Disconnect;若異常消失,由Broker發布LWT。

LWT不是即時斷線感測器:Broker通常要等到Keep Alive逾時或網路連線被判定中止後才發布,因此告警時間會受Keep Alive與網路狀況影響。

五、斷線重連:不要用while把整台ESP32卡死

初學範例常用無限while (!client.connected())反覆連線。場域系統若Broker故障,主迴圈就可能被卡住,感測、按鍵、JQ6500播放與離線保存全部停擺。

正確思路

採非阻塞重連、限制頻率、逐步增加等待時間;即使MQTT離線,裝置本地功能仍要運作。

恢復後要做

重新訂閱Topic、發布online、補送離線佇列、同步目標狀態,並防止重複事件。

重連次數等待例目的
第1次1秒+隨機抖動快速恢復短暫斷線
連續失敗2、4、8、16…秒指數退避,減少對Broker與網路的壓力
到達上限例如60秒避免等待時間無限增長
為何要加Jitter(隨機抖動)?停電復電後,數十台ESP32若同時重連,可能形成「驚群效應」;稍微錯開時間可降低Broker瞬間負荷。

六、ESP32範例:LWT、Retain與非阻塞重連

#include <WiFi.h>
#include <PubSubClient.h>

WiFiClient net;
PubSubClient mqtt(net);

const char* broker = "192.168.1.10";
const char* statusTopic = "site/pond01/status/online";
const char* commandTopic = "site/pond01/cmd/#";

unsigned long nextRetryAt = 0;
unsigned long retryDelayMs = 1000;
const unsigned long maxRetryMs = 60000;

void onMessage(char* topic, byte* payload, unsigned int length) {
  // 驗證Topic、Payload、長度與權限後再執行控制
}

bool connectMqtt() {
  String clientId = "pond01-" + String((uint32_t)ESP.getEfuseMac(), HEX);

  // clientId, user, password, willTopic, willQoS, willRetain, willMessage
  bool ok = mqtt.connect(clientId.c_str(), "device_user", "device_password",
                         statusTopic, 1, true, "offline");
  if (ok) {
    mqtt.publish(statusTopic, "online", true); // Retained online
    mqtt.subscribe(commandTopic, 1);           // 重連後重新訂閱
    retryDelayMs = 1000;
  }
  return ok;
}

void maintainMqtt() {
  if (WiFi.status() != WL_CONNECTED) return;

  if (mqtt.connected()) {
    mqtt.loop();
    return;
  }

  unsigned long now = millis();
  if ((long)(now - nextRetryAt) < 0) return;

  if (!connectMqtt()) {
    unsigned long jitter = random(0, 500);
    nextRetryAt = now + retryDelayMs + jitter;
    retryDelayMs = min(retryDelayMs * 2, maxRetryMs);
  }
}

void setup() {
  Serial.begin(115200);
  mqtt.setServer(broker, 1883);
  mqtt.setCallback(onMessage);
  mqtt.setKeepAlive(30);
}

void loop() {
  maintainMqtt();
  // 感測、按鍵、播放與離線佇列仍持續執行
}

此程式著重架構,帳密與Broker位址僅為示意。不同版本的MQTT函式庫支援的QoS與API可能不同,正式使用前應查閱所採用函式庫文件;外網連線應使用TLS、憑證驗證與安全保存的憑證。

七、四個功能不能互相取代

機制解決的問題不能保證
QoSClient與Broker間的MQTT傳遞品質Django一定成功處理或寫入資料庫
Retain新訂閱者立即取得某Topic最後保留值訊息是最新現場狀態;必須搭配時間戳
LWTClient異常離線時由Broker發布預設訊息零延遲偵測,也不代表設備硬體一定故障
重連網路恢復後重新建立Session與訂閱斷線期間資料自動存在;仍需離線佇列

八、常見錯誤與診斷線索

現象可能原因先檢查
連上Broker卻收不到命令重連後忘記重新Subscribe、ACL拒絕、Topic拼錯CONNACK、訂閱結果、完整Topic與權限
儀表板一直顯示online未設LWT、LWT未Retain、狀態Topic不同斷電測試與Broker日誌
一重連就再次啟動設備一次性Command被設為Retain清除舊Retained Message並重整Topic設計
資料庫出現重複紀錄QoS 1重送或應用程式重試event_uuid、唯一約束、冪等寫入
Broker恢復後大量設備仍離線阻塞重連、重試太頻繁、驚群效應指數退避、Jitter、資源使用量
顯示最後水溫但其實是昨天Retained值缺少時間Payload加入ts,UI顯示資料年齡

九、場域Topic設計範例

site/{site_id}/{device_id}/telemetry/water_temp
site/{site_id}/{device_id}/telemetry/dissolved_oxygen
site/{site_id}/{device_id}/event/story_start
site/{site_id}/{device_id}/status/online
site/{site_id}/{device_id}/status/firmware
site/{site_id}/{device_id}/cmd/set_mode
site/{site_id}/{device_id}/ack/{command_id}
  • Telemetry:週期性量測,可依資料價值選QoS 0或1。
  • Event:一次性事實,使用QoS 1、事件UUID及伺服器去重。
  • Status:目前狀態,適合Retain並附時間戳。
  • Command:控制要求,通常不Retain;加入command_id、期限與授權。
  • Ack:設備實際接受或完成命令的應用層回覆,不能只看MQTT QoS。

十、學生實作:從「能傳」走向「可靠」

No-AI|觀察封包行為

用兩個MQTT Client測試QoS 0與1;比較Retain開關前後,新Subscriber第一次收到的內容,並記錄時間。

AI Pair|設計決策辯論

請AI分別扮演裝置工程師、Broker管理者與資料工程師,評論水溫、警報、控制命令應採用的QoS與Retain;學生查證後完成決策表。

Challenge AI|斷線演練

拔除網路、重啟Broker、讓ESP32斷電;驗證LWT、非阻塞重連、重新訂閱、離線補送、UUID去重與儀表板狀態是否正確。

十一、驗收清單

檢核項目通過證據
QoS符合資料語意說得出可遺失、可重複與不可重複的差異
Retain沒有誤用於一次性命令新Client訂閱不會觸發舊動作
LWT可辨識異常離線直接斷電後,Broker會發布Retained offline
斷線不阻塞本地功能Broker關閉時,感測與按鍵仍正常
恢復後不漏不重完成重新訂閱、佇列補送與UUID去重
安全配置完成獨立帳號、最小Topic ACL、TLS與憑證管理
封包旅行記 EP04|可靠的MQTT,不是每筆都用最高QoS,而是讓每一種訊息都有正確的生命週期、失敗策略與驗證證據。

沒有留言:

張貼留言