Raspberry Pi

MQTT-Nachrichten offline puffern in Python mit SQLite (Store and Forward)

Bauen Sie in Python einen Store-and-Forward-Publisher für MQTT, der jeden Messwert zuerst in SQLite schreibt und die Warteschlange nach der Wiederverbindung in der richtigen Reihenfolge mit QoS-1-Bestätigungen nachsendet.

Python-Gateway, das während eines Netzausfalls Nachrichten in einer SQLite-Warteschlange speichert, um MQTT-Nachrichten offline zu puffern
Probieren Sie es auf Ihrem eigenen MQTT-Broker ausHolen Sie sich im Free-Tarif einen privaten, verwalteten MQTT-Broker und starten Sie mit fertigen Codebeispielen.
Kostenlos testen Codebeispiele Keine Kreditkarte erforderlich

Um MQTT-Nachrichten in Python offline zu puffern, schreiben Sie jeden Messwert zuerst in eine lokale SQLite-Warteschlange und lassen einen separaten Sender diese Warteschlange nur dann zum Broker leeren, wenn die Verbindung steht. In diesem Tutorial bauen Sie einen solchen Store-and-Forward-Publisher mit paho-mqtt 2.x: Er übersteht Netzausfälle und Neustarts, sendet Nachrichten in der ursprünglichen Reihenfolge nach, löscht sie erst, wenn der Broker sie bestätigt hat, und hält den Speicherbedarf begrenzt. Er läuft auf einem Raspberry Pi oder jedem anderen Linux-Rechner.

Warum die eingebaute Warteschlange von paho-mqtt nicht ausreicht

paho-mqtt puffert ausgehende Nachrichten bereits, während es sich neu verbindet. Warum also etwas bauen? Weil diese Warteschlange im Arbeitsspeicher liegt. Startet der Prozess neu, bootet der Pi neu oder fällt während eines Ausfalls der Strom weg, ist alles darin verloren. Für ein Gateway am Mobilfunk, einen Standort mit unzuverlässigem WLAN oder ein Fahrzeug, das durch Tunnel fährt, sind genau das die entscheidenden Momente.

Hinzu kommt ein subtiles Problem damit, wie der Broker Publisher behandelt. Persistente Sessions und QoS 1 oder 2 schützen Nachrichten an Subscriber, die vorübergehend offline sind. Für einen Publisher, der den Broker überhaupt nicht erreicht, tun sie nichts. Dauerhaftigkeit auf der Publisher-Seite ist Aufgabe des Clients.

Das Design, das beide Probleme löst, ist eine klassische Outbox:

  1. Die Anwendung veröffentlicht nie direkt. Sie fügt eine Zeile in eine SQLite-Tabelle ein: Topic, Payload, QoS, Retain-Flag und Zeitstempel.
  2. Ein Sender-Thread liest die ältesten Zeilen, veröffentlicht sie mit QoS 1 und wartet auf das PUBACK des Brokers.
  3. Nur bestätigte Zeilen werden gelöscht. Alles andere bleibt für den nächsten Versuch erhalten.

Da SQLite eine Datei ist, übersteht die Warteschlange Neustarts. Da der Sender die Zeilen in Einfügereihenfolge über eine einzige Verbindung verarbeitet, bleibt die Reihenfolge erhalten.

Die Outbox-Tabelle entwerfen

outbox
  id        INTEGER PRIMARY KEY AUTOINCREMENT   -- defines replay order
  topic     TEXT NOT NULL
  payload   BLOB NOT NULL
  qos       INTEGER NOT NULL DEFAULT 1
  retain    INTEGER NOT NULL DEFAULT 0
  created   REAL NOT NULL                      -- unix time of the reading

Zwei SQLite-Einstellungen machen auf einem Pi einen großen Unterschied. journal_mode=WAL erlaubt dem Producer das Einfügen, während der Sender liest, und reduziert die Zahl der Schreibvorgänge pro Transaktion. synchronous=NORMAL ist im WAL-Modus ein vernünftiger Kompromiss zwischen Sicherheit und SD-Karten-Verschleiß. Legen Sie die Datenbank auf eine SSD oder eine beschreibbare Partition, wenn Sie ein schreibgeschütztes Root-Dateisystem betreiben.

Der Store-and-Forward-Publisher

Das folgende Modul ist in sich abgeschlossen. Importieren Sie OfflineBufferedPublisher, rufen Sie publish() an beliebiger Stelle in Ihrer Anwendung auf, und das Modul erledigt den Rest.

import json
import os
import sqlite3
import threading
import time
import uuid

import paho.mqtt.client as mqtt


class OfflineBufferedPublisher:
    def __init__(self, host, port=1883, client_id=None, username=None, password=None,
                 db_path="outbox.db", max_rows=200_000, batch=20, max_rate=8.0):
        self.db_path = db_path
        self.max_rows = max_rows
        self.batch = batch
        self.min_interval = 1.0 / max_rate if max_rate else 0
        self._lock = threading.Lock()
        self._wake = threading.Event()
        self._stop = threading.Event()

        self.db = sqlite3.connect(db_path, check_same_thread=False)
        self.db.execute("PRAGMA journal_mode=WAL")
        self.db.execute("PRAGMA synchronous=NORMAL")
        self.db.execute("""CREATE TABLE IF NOT EXISTS outbox (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            topic TEXT NOT NULL, payload BLOB NOT NULL,
            qos INTEGER NOT NULL DEFAULT 1, retain INTEGER NOT NULL DEFAULT 0,
            created REAL NOT NULL)""")
        self.db.commit()

        self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
                                  client_id=client_id or f"buffered-{uuid.uuid4().hex[:8]}")
        if username:
            self.client.username_pw_set(username, password)
        if port == 8883:
            self.client.tls_set()
        self.client.reconnect_delay_set(1, 60)
        self.client.on_connect = self._on_connect
        self.client.on_disconnect = self._on_disconnect
        self.client.connect_async(host, port, keepalive=30)
        self.client.loop_start()

        self.sender = threading.Thread(target=self._drain, daemon=True)
        self.sender.start()

    # ---- Producer-Seite ------------------------------------------------
    def publish(self, topic, payload, qos=1, retain=False):
        if not isinstance(payload, (bytes, bytearray)):
            payload = json.dumps(payload).encode() if not isinstance(payload, str) else payload.encode()
        with self._lock:
            self.db.execute("INSERT INTO outbox (topic, payload, qos, retain, created) VALUES (?,?,?,?,?)",
                            (topic, payload, qos, int(retain), time.time()))
            self._enforce_limit()
            self.db.commit()
        self._wake.set()

    def pending(self):
        with self._lock:
            return self.db.execute("SELECT COUNT(*) FROM outbox").fetchone()[0]

    def _enforce_limit(self):
        count = self.db.execute("SELECT COUNT(*) FROM outbox").fetchone()[0]
        if count > self.max_rows:
            self.db.execute("DELETE FROM outbox WHERE id IN (SELECT id FROM outbox ORDER BY id LIMIT ?)",
                            (count - self.max_rows,))

    # ---- Sender-Seite --------------------------------------------------
    def _on_connect(self, client, userdata, flags, reason_code, properties):
        print("connected:", reason_code)
        self._wake.set()

    def _on_disconnect(self, client, userdata, flags, reason_code, properties):
        print("disconnected:", reason_code)

    def _drain(self):
        while not self._stop.is_set():
            if not self.client.is_connected():
                self._wake.wait(2)
                self._wake.clear()
                continue
            with self._lock:
                rows = self.db.execute(
                    "SELECT id, topic, payload, qos, retain FROM outbox ORDER BY id LIMIT ?",
                    (self.batch,)).fetchall()
            if not rows:
                self._wake.wait(5)
                self._wake.clear()
                continue
            for row_id, topic, payload, qos, retain in rows:
                info = self.client.publish(topic, payload, qos=qos, retain=bool(retain))
                try:
                    info.wait_for_publish(timeout=10)
                except (ValueError, RuntimeError):
                    break
                if not info.is_published():
                    break  # Verbindung abgebrochen; diese Zeile und alle folgenden behalten
                with self._lock:
                    self.db.execute("DELETE FROM outbox WHERE id = ?", (row_id,))
                    self.db.commit()
                if self.min_interval:
                    time.sleep(self.min_interval)

    def close(self):
        self._stop.set()
        self._wake.set()
        self.sender.join(timeout=5)
        self.client.loop_stop()
        self.client.disconnect()
        self.db.close()


if __name__ == "__main__":
    pub = OfflineBufferedPublisher(
        host=os.getenv("MQTT_HOST", "broker.connectmq.com"),
        port=int(os.getenv("MQTT_PORT", "1883")),
        client_id=os.getenv("MQTT_CLIENT_ID"),
        username=os.getenv("MQTT_USERNAME"),
        password=os.getenv("MQTT_PASSWORD"),
    )
    prefix = os.getenv("TOPIC_PREFIX", "connectmq/demo/q5w8e1/sensor1")
    n = 0
    try:
        while True:
            n += 1
            pub.publish(f"{prefix}/reading", {"seq": n, "ts": round(time.time(), 3), "value": 20 + n % 5})
            print("queued", n, "pending", pub.pending())
            time.sleep(2)
    except KeyboardInterrupt:
        pub.close()

Einen Ausfall testen

Starten Sie einen Subscriber, der die Sequenznummern ausgibt, und führen Sie dann den Publisher aus:

mosquitto_sub -h broker.connectmq.com -p 1883 -i buffer-viewer-q5w8e1 -t "connectmq/demo/q5w8e1/#" -v
python buffered_publisher.py

Simulieren Sie nun einen Ausfall. Auf einem Pi können Sie das Netzwerk mit sudo ip link set wlan0 down trennen (tun Sie das an einer lokalen Konsole, nicht per SSH über dieselbe Schnittstelle), eine Minute warten und es mit sudo ip link set wlan0 up wieder aktivieren. Der Zähler „pending“ wächst während des Ausfalls. Sobald paho sich neu verbindet, arbeitet der Sender den Rückstau ab, und der Subscriber erhält jede fehlende Sequenznummer in der richtigen Reihenfolge, gefolgt von den Live-Messwerten. Beenden Sie das Skript testweise während des Ausfalls und starten Sie es erneut: Der Rückstau ist noch da.

Reihenfolge, Duplikate und Zeitstempel

Reihenfolge. Der Sender veröffentlicht jeweils eine Nachricht und wartet auf ihre Bestätigung, bevor er sie löscht und weitermacht. Über eine einzelne MQTT-Verbindung bleibt so die ursprüngliche Reihenfolge erhalten. Den Durchsatz können Sie erhöhen, indem Sie mehrere unbestätigte Nachrichten gleichzeitig zulassen, doch behalten Sie einen einzigen Sender-Thread und eine einzige Verbindung bei, wenn die Reihenfolge wichtig ist.

Duplikate. QoS 1 bedeutet „mindestens einmal“. Bricht die Verbindung ab, nachdem der Broker eine Nachricht empfangen hat, aber bevor das PUBACK ankam, wird die Zeile nach der Wiederverbindung erneut gesendet. Nehmen Sie eine Sequenznummer oder eine eindeutige ID in die Payload auf, damit Abnehmer Duplikate verwerfen können. MQTT-QoS-Stufen erklärt die Kompromisse und warum selbst QoS 2 Sie nicht vor einem Neustart des Clients schützt.

Zeitstempel. Eine nachgesendete Nachricht kommt Minuten oder Stunden zu spät an. Schreiben Sie immer den Messzeitpunkt in die Payload und lassen Sie die Abnehmer diesen statt der Ankunftszeit verwenden. Mit MQTT 5 können Sie für Messwerte, die nach einer Weile bedeutungslos werden, etwa eine Live-Position, zusätzlich ein Message Expiry Interval setzen.

Feintuning für reale Einsätze

Einstellung Standard hier Wann ändern
max_rows 200.000 Anhand des Speicherplatzes und der erwarteten Ausfalldauer dimensionieren
batch 20 Größere Batches reduzieren SQLite-Lesezugriffe bei großem Rückstau
max_rate 8 Nachr./s Hält das Nachsenden unter dem Limit des öffentlichen Brokers von 10 Nachrichten/s pro Client; in einem privaten Plan erhöhen
QoS 1 0 nur für Daten verwenden, deren Verlust Sie verkraften können
DB-Speicherort Arbeitsverzeichnis Bei schreibgeschützten Systemen auf SSD oder eine beschreibbare Partition verlegen

Ist die Warteschlange voll, verwirft diese Implementierung die ältesten Zeilen, was zu Telemetrie passt, bei der die jüngsten Daten am wichtigsten sind. Für Ereignisprotokolle bevorzugen Sie möglicherweise, keine neuen Zeilen mehr anzunehmen und stattdessen einen Alarm auszulösen. In beiden Fällen sollten Sie den Wert von pending() als Gesundheitsmetrik veröffentlichen, damit ein wachsender Rückstau auf Ihrem Dashboard sichtbar wird.

Betreiben Sie den Publisher als systemd-Dienst, damit er mit dem Gerät neu startet, wie in Python-MQTT-Skript beim Booten ausführen gezeigt. Weitere Möglichkeiten, MQTT-Daten lokal zu speichern, finden Sie in MQTT-Daten in SQLite mit Python, das die Subscriber-Seite behandelt.

Alternative: lokaler Broker mit Bridge

Ein weiteres verbreitetes Store-and-Forward-Design setzt einen Mosquitto-Broker direkt auf den Pi. Lokale Anwendungen veröffentlichen an localhost, und Mosquitto leitet die Topics per Bridge an den Cloud-Broker weiter und puffert Nachrichten, solange die Uplink-Verbindung ausgefallen ist. Das passt gut, wenn mehrere Prozesse auf demselben Gerät Daten veröffentlichen oder wenn Sie den Anwendungscode nicht ändern können. Die Anleitung zur Mosquitto-Bridge-Konfiguration beschreibt die Einstellungen.

Die Kompromisse sollte man kennen. Sie betreiben dann auf jedem Gateway einen Broker mit eigener Konfiguration, eigenen Updates und eigenen Zugangsdaten. Die Warteschlangenlimits und das Persistenzverhalten von Mosquitto müssen sorgfältig abgestimmt werden, um Neustarts zu überstehen, und die Fehlersuche bei Datenlücken bedeutet, zwei Broker statt einem zu untersuchen. Die Python-Outbox hält alles in einem einzigen Prozess, den Sie vollständig kontrollieren und mit einem Unit-Test prüfen können, was viele Teams für ein Gateway mit einer einzigen Aufgabe einfacher finden. Wählen Sie die Bridge, wenn sich viele lokale Producer einen Uplink teilen, und die Outbox, wenn eine Anwendung die Daten besitzt.

Broker-Einstellungen für die Produktion

Der kostenlose öffentliche MQTT-Broker genügt für diesen Test, doch alles, was dort veröffentlicht wird, ist öffentlich. Für echte Daten erstellen Sie einen privaten Broker bei ConnectMQ und starten den Publisher dann mit MQTT_PORT=8883, MQTT_USERNAME=YOUR_USERNAME, MQTT_PASSWORD=YOUR_PASSWORD und MQTT_CLIENT_ID=YOUR_CLIENT_ID aus dem Portal. Ein verwalteter MQTT-Broker sorgt für die Verfügbarkeit auf der Serverseite, während diese Outbox die Lücken auf der Client-Seite schließt. Die paho-mqtt-Dokumentation beschreibt MQTTMessageInfo und die Reconnect-Optionen im Detail.

Dieses Muster ist ein zentraler Baustein für jedes Gateway in der Reihe Raspberry-Pi-MQTT-Tutorial, vom CAN-Bus-Leser bis zum LoRa-Empfänger.

Mit ConnectMQ keinen Messwert mehr verlieren

Kombinieren Sie eine dauerhafte lokale Outbox mit einem zuverlässigen Broker, und Ausfälle werden für Ihre Dashboards unsichtbar. Erstellen Sie unter app.connectmq.com kostenlos und ohne Kreditkarte einen Serverless-Broker, richten Sie diesen Publisher per TLS darauf aus und beobachten Sie nach dem nächsten Netzausfall, wie der Rückstau in den Live-Topic-Explorer von ConnectMQ abfließt.

Häufig gestellte Fragen

Puffert eine persistente MQTT-Session Nachrichten für einen Publisher?

Nein. Persistente Sessions halten Nachrichten für Subscriber vor, die offline sind. Ein Publisher, der den Broker nicht erreicht, muss lokal puffern, und genau das leistet diese Outbox.

Kommen nachgesendete Nachrichten in der richtigen Reihenfolge an?

Ja, solange ein einziger Sender die Zeilen in ID-Reihenfolge über eine Verbindung veröffentlicht und auf jede Bestätigung wartet, wie in diesem Code.

Kann ich das mit asyncio verwenden?

Ja. Dasselbe Outbox-Design funktioniert mit aiomqtt: Führen Sie die Drain-Schleife als asynchronen Task aus und verwenden Sie aiosqlite oder führen Sie SQLite-Aufrufe in einem Thread-Executor aus.

Wie vermeide ich, die SD-Karte zu verschleißen?

Verwenden Sie den WAL-Modus, halten Sie Payloads kompakt und legen Sie die Datenbank nach Möglichkeit auf eine SSD. Alle paar Sekunden einen Messwert zu schreiben, ist für guten Speicher eine moderate Last.

Verwandte Artikel

Raspberry Pi RFID MQTT: Zutrittsprotokollierung mit dem RC522-Leser

Schließen Sie einen RC522-RFID-Leser an einen Raspberry Pi an, lesen Sie Tags mit Python, prüfen Sie sie gegen eine lokale Allowlist und veröffentlichen Sie jeden Zutrittsversuch als MQTT-Ereignis, das Logger, Dashboards und Alarmwerkzeuge verarbeiten können.

7 Min. Lesezeit

Pico W vs. Pico 2 W für MQTT: RP2040 vs. RP2350 bei Leistung, Speicher und TLS

Der Pico 2 W behält Formfaktor und WLAN-Chip des Pico W, ersetzt aber den RP2040 durch den schnelleren RP2350. Dieser Vergleich zeigt, was das für MQTT-Geräte bedeutet: CPU, Speicher, TLS, Sicherheitsfunktionen, Softwareunterstützung und welches Board für welches Projekt.

8 Min. Lesezeit

Alle Artikel →

Ihr privater MQTT-Broker ist nur wenige Klicks entfernt

Konto anlegen, Broker-Zugangsdaten erhalten und das erste Gerät verbinden — ohne Server, die installiert, gepatcht oder skaliert werden müssen.