Raspberry Pi

Buffer de Mensagens MQTT Offline em Python com SQLite (Store and Forward)

Construa em Python um publicador MQTT store-and-forward que grava cada leitura primeiro no SQLite e depois reenvia a fila em ordem, com confirmações QoS 1, assim que a conexão volta.

Gateway em Python armazenando mensagens em uma fila SQLite durante uma queda de rede, ilustrando o buffer de mensagens MQTT offline em Python
Rode isso no seu próprio broker MQTTTenha um broker MQTT privado e gerenciado no plano Free e comece com exemplos de código prontos.
Teste grátis Exemplos de código Sem cartão de crédito

Para fazer buffer de mensagens MQTT offline em Python, grave cada leitura primeiro em uma fila SQLite local e deixe um remetente separado esvaziar essa fila para o broker apenas quando a conexão estiver ativa. Neste tutorial você vai construir esse publicador store-and-forward com paho-mqtt 2.x: ele sobrevive a quedas de rede e reinicializações, reenvia as mensagens na ordem original, só as apaga depois que o broker as confirma e mantém o uso de disco limitado. Ele roda em um Raspberry Pi ou em qualquer máquina Linux.

Por que a fila interna do paho-mqtt não basta

O paho-mqtt já enfileira mensagens de saída enquanto reconecta, então por que construir algo? Porque essa fila vive na memória. Se o processo reiniciar, o Pi reiniciar ou a energia cair durante uma queda de rede, tudo o que está nela se perde. Para um gateway em rede celular, um site com Wi-Fi instável ou um veículo que passa por túneis, esses são exatamente os momentos que importam.

Há também um problema sutil na forma como o broker trata os publicadores. Sessões persistentes e QoS 1 ou 2 protegem mensagens para assinantes temporariamente offline. Elas não fazem nada por um publicador que não consegue alcançar o broker. A durabilidade do lado da publicação é responsabilidade do client.

O design que resolve os dois problemas é uma clássica outbox (caixa de saída):

  1. A aplicação nunca publica diretamente. Ela insere uma linha em uma tabela SQLite: topic, payload, QoS, flag de retenção e timestamp.
  2. Uma thread remetente lê as linhas mais antigas, publica-as em QoS 1 e aguarda o PUBACK do broker.
  3. Apenas linhas confirmadas são apagadas. Todo o resto fica para a próxima tentativa.

Como o SQLite é um arquivo, a fila sobrevive a reinicializações. Como o remetente processa as linhas na ordem de inserção por uma única conexão, a ordem é preservada.

Projetando a tabela outbox

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

Duas configurações do SQLite fazem grande diferença em um Pi. journal_mode=WAL permite que o produtor insira enquanto o remetente lê e reduz o número de gravações por transação. synchronous=NORMAL é um equilíbrio razoável entre segurança e desgaste do cartão SD no modo WAL. Coloque o banco em um SSD ou em uma partição gravável se você usa um sistema de arquivos raiz somente leitura.

O publicador store-and-forward

O módulo abaixo é autocontido. Importe OfflineBufferedPublisher, chame publish() de qualquer lugar da sua aplicação e ele cuida do resto.

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()

    # ---- lado do produtor ----------------------------------------------
    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,))

    # ---- lado do remetente ---------------------------------------------
    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  # a conexão caiu; mantém esta linha e todas as seguintes
                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()

Testando uma queda de rede

Inicie um assinante que imprime os números de sequência e depois execute o publicador:

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

Agora simule uma queda. Em um Pi você pode derrubar a rede com sudo ip link set wlan0 down (faça isso de um console local, não por SSH na mesma interface), esperar um minuto e trazê-la de volta com sudo ip link set wlan0 up. O contador "pending" cresce durante a queda. Quando o paho reconecta, o remetente esvazia o acúmulo e o assinante recebe, em ordem, todos os números de sequência que faltavam, seguidos das leituras ao vivo. Experimente parar o script durante a queda e iniciá-lo novamente: o acúmulo continua lá.

Ordem, duplicatas e timestamps

Ordem. O remetente publica uma mensagem por vez e aguarda sua confirmação antes de apagá-la e seguir em frente. Em uma única conexão MQTT, isso preserva a ordem original. Você pode aumentar a vazão permitindo várias mensagens em trânsito, mas mantenha uma única thread remetente e uma única conexão se a ordem importar.

Duplicatas. QoS 1 é entrega "pelo menos uma vez". Se a conexão cair depois que o broker recebeu uma mensagem, mas antes de o PUBACK chegar, a linha é enviada novamente após a reconexão. Inclua um número de sequência ou um ID único no payload para que os consumidores possam descartar duplicatas. Níveis de QoS do MQTT explica os trade-offs e por que nem o QoS 2 protege você contra uma reinicialização do client.

Timestamps. Uma mensagem reenviada chega minutos ou horas atrasada. Sempre coloque o horário da medição no payload e faça os consumidores usarem esse valor, e não o horário de chegada. Com MQTT 5 você também pode definir um intervalo de expiração de mensagem para leituras que perdem o sentido depois de um tempo, como uma posição ao vivo.

Ajustes para implantações reais

Configuração Padrão aqui Quando mudar
max_rows 200.000 Dimensione pelo espaço em disco e pela duração esperada das quedas
batch 20 Lotes maiores reduzem leituras no SQLite durante grandes acúmulos
max_rate 8 msg/s Mantém o reenvio abaixo do limite de 10 msg/s por client do servidor público; aumente em um plano privado
QoS 1 Use 0 apenas para dados que você pode perder
Local do banco Diretório de trabalho Mova para SSD ou partição gravável em sistemas somente leitura

Quando a fila está cheia, esta implementação descarta as linhas mais antigas, o que se adequa a telemetria em que os dados recentes importam mais. Para logs de eventos, talvez você prefira parar de aceitar novas linhas e disparar um alarme. De qualquer forma, publique a contagem de pending() como métrica de saúde, para que um acúmulo crescente fique visível no seu dashboard.

Execute o publicador como serviço do systemd para que ele reinicie junto com o dispositivo, como mostrado em executar um script Python MQTT na inicialização. Para outras formas de armazenar dados MQTT localmente, veja MQTT para SQLite em Python, que cobre o lado do assinante.

Alternativa: um broker local com bridge

Outro design store-and-forward comum coloca um broker Mosquitto no próprio Pi. As aplicações locais publicam em localhost, e o Mosquitto faz a bridge dos topics para o broker na nuvem, enfileirando mensagens enquanto o uplink está fora. É uma boa opção quando vários processos no mesmo dispositivo publicam dados, ou quando você não pode alterar o código da aplicação. O guia de configuração de bridge do Mosquitto cobre as configurações.

Vale conhecer os trade-offs. Agora você opera um broker em cada gateway, com configuração, atualizações e credenciais próprias. Os limites de fila e o comportamento de persistência do Mosquitto exigem ajuste cuidadoso para sobreviver a reinicializações, e depurar lacunas de dados significa olhar para dois brokers em vez de um. A outbox em Python mantém tudo dentro de um único processo que você controla totalmente e pode testar com um teste unitário, o que muitas equipes acham mais simples para um gateway de finalidade única. Escolha a bridge quando muitos produtores locais compartilham um uplink, e a outbox quando uma única aplicação é dona dos dados.

Configurações de broker para produção

O servidor MQTT público gratuito serve para este teste, mas tudo o que é publicado ali é público; ele é para testes e protótipos, não para dados de produção. Para dados reais, crie um broker privado na ConnectMQ e inicie o publicador com MQTT_PORT=8883, MQTT_USERNAME=YOUR_USERNAME, MQTT_PASSWORD=YOUR_PASSWORD e MQTT_CLIENT_ID=YOUR_CLIENT_ID obtidos no portal. Um broker MQTT gerenciado cuida da disponibilidade do lado do servidor, enquanto esta outbox cobre as lacunas do lado do client. A documentação do paho-mqtt descreve em detalhes o MQTTMessageInfo e as opções de reconexão.

Esse padrão é um bloco fundamental para todo gateway da série tutorial de MQTT com Raspberry Pi, de leitores de CAN bus a receptores LoRa.

Nunca perca uma leitura com a ConnectMQ

Combine uma outbox local durável com um broker confiável e as quedas de rede ficam invisíveis para os seus dashboards. Crie um broker Serverless gratuito em app.connectmq.com, sem cartão de crédito, aponte este publicador para ele via TLS e veja o acúmulo escoar para o explorador de topics ao vivo da ConnectMQ depois da sua próxima queda de rede.

Perguntas frequentes

A sessão persistente do MQTT faz buffer de mensagens para um publicador?

Não. Sessões persistentes guardam mensagens para assinantes offline. Um publicador que não consegue alcançar o broker precisa fazer buffer localmente, que é o que esta outbox faz.

As mensagens reenviadas chegam na ordem certa?

Sim, desde que um único remetente publique as linhas em ordem de ID por uma única conexão e aguarde cada confirmação, como neste código.

Posso usar isso com asyncio?

Sim. O mesmo design de outbox funciona com aiomqtt: execute o loop de esvaziamento como uma tarefa assíncrona e use aiosqlite ou execute as chamadas SQLite em um executor de threads.

Como evito desgastar o cartão SD?

Use o modo WAL, mantenha os payloads compactos e coloque o banco em um SSD sempre que possível. Gravar uma leitura a cada poucos segundos é uma carga modesta para um armazenamento de boa qualidade.

Artigos relacionados

Registro de Acessos Raspberry Pi RFID MQTT com o Leitor RC522

Ligue um leitor RFID RC522 a um Raspberry Pi, leia tags em Python, confira-as em uma lista de permissões local e publique cada tentativa de acesso como um evento MQTT que loggers, dashboards e ferramentas de alerta podem consumir.

8 min de leitura

Confiabilidade do Raspberry Pi: Rodando um Pi 24/7 como Gateway IoT

Um guia prático para manter um gateway MQTT com Raspberry Pi rodando por meses: desgaste do armazenamento, sistemas de arquivos somente leitura, alimentação, refrigeração, watchdogs de hardware e software e monitoramento remoto de saúde.

9 min de leitura

Pico W vs Pico 2 W para MQTT: Desempenho, Memória e TLS do RP2040 vs RP2350

O Pico 2 W mantém o formato e o chip Wi-Fi do Pico W, mas troca o RP2040 pelo RP2350, mais rápido. Esta comparação mostra o que isso significa para dispositivos MQTT: CPU, memória, TLS, recursos de segurança, suporte de software e qual placa comprar para cada projeto.

9 min de leitura

Todos os artigos →

Seu broker MQTT privado está a poucos cliques de distância

Crie uma conta, receba as credenciais do seu broker e conecte seu primeiro dispositivo — sem servidores para instalar, atualizar ou escalar.