Fundamentos de MQTT

Cómo gestionar mensajes MQTT duplicados con QoS 1

QoS 1 significa al menos una vez, y a veces eso significa dos. Descubre por qué se producen mensajes duplicados en MQTT, por qué el flag DUP por sí solo no te salva y cómo crear consumidores idempotentes que los gestionen de forma segura.

El mismo mensaje llegando dos veces a un suscriptor, ilustrando los mensajes duplicados MQTT con QoS 1
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

Los mensajes duplicados en MQTT no son un fallo de tu broker; son una consecuencia documentada de QoS 1, que promete la entrega "al menos una vez". Cuando un corte de red se come un acuse de recibo, el emisor retransmite y tu consumidor ve el mismo mensaje dos veces. En esta guía de resolución de problemas aprenderás exactamente cuándo se producen los duplicados, qué te dice (y qué no) el flag DUP y cómo escribir un consumidor idempotente en Python que los gestione correctamente.

Por qué QoS 1 produce duplicados

QoS 1 utiliza un intercambio en dos pasos en cada salto: el emisor transmite PUBLISH y el receptor responde con PUBACK. Hasta que llega el PUBACK, el emisor conserva el mensaje y lo volverá a enviar. El artículo niveles de QoS de MQTT explicados cubre los tres niveles; aquí nos centramos en lo que falla con el nivel 1.

Se crea un duplicado siempre que el receptor recibió el mensaje pero el emisor no recibió el acuse de recibo:

  1. El dispositivo publica la lectura n.º 42 en el broker.
  2. El broker la recibe, la reenvía a los suscriptores y envía PUBACK.
  3. La conexión se cae antes de que el PUBACK llegue al dispositivo.
  4. El dispositivo se reconecta con su sesión y reenvía la n.º 42 con el flag DUP activado.
  5. El broker la trata como un mensaje nuevo y la reenvía de nuevo.

Lo mismo ocurre en el salto del broker al suscriptor. Si el suscriptor procesó un mensaje pero su PUBACK se perdió, el broker lo reenvía cuando se reanuda la sesión del suscriptor.

Ten en cuenta que un duplicado puede llegar en cualquiera de los dos saltos de forma independiente, así que un mismo mensaje publicado puede, ocasionalmente, llegar a un consumidor más de dos veces.

Qué te dice el flag DUP

Cada paquete PUBLISH tiene un bit DUP. El emisor lo activa cuando retransmite un paquete QoS 1 o QoS 2. Resulta tentador descartar todos los mensajes con DUP activado, pero es un error por varias razones:

Suposición Realidad
DUP significa que ya tengo este mensaje No necesariamente. La primera copia pudo perderse, y el reintento es la única copia que recibes
Sin DUP significa que es nuevo No. El broker reenvía el reintento de un publicador como una entrega nueva, sin DUP en el salto de salida
Los identificadores de paquete permiten detectar duplicados Solo dentro de una sesión y de una ventana de mensajes en vuelo. Los ID se reutilizan constantemente
QoS 2 lo resuelve todo QoS 2 evita los duplicados por salto, pero los reintentos a nivel de aplicación aún pueden crearlos

El flag DUP es útil para registros y diagnóstico. No es una clave de deduplicación. Una deduplicación real necesita un identificador que tú mismo incluyas en el mensaje.

Reproducir duplicados

La reentrega real depende de paquetes perdidos y es difícil de provocar a voluntad. La forma fiable de probar tu consumidor es inyectar duplicados tú mismo. Contra el servidor MQTT público gratuito en broker.connectmq.com, pensado para pruebas y no para datos de producción, publica el mismo ID de mensaje dos veces:

for n in 1 2; do
  mosquitto_pub -h broker.connectmq.com -p 1883 -i dup-pub-t9v4 -q 1 \
    -t connectmq/demo/t9v4/meter-3/energy \
    -m '{"msgId":"meter-3-000042","kwh":0.25}'
done

Un consumidor que suma kwh sin deduplicación informa ahora de 0,5 en lugar de 0,25. Ese es exactamente el tipo de fallo que provoca la reentrega de QoS 1 en producción, solo que más raro y más difícil de detectar.

Crear un consumidor idempotente

Un consumidor idempotente produce el mismo resultado tanto si procesa un mensaje una vez como cinco. Hay dos técnicas habituales:

  1. Idempotencia natural. Diseña el efecto para que repetirlo sea inofensivo: "fijar la temperatura en 21,5" en lugar de "subirla 0,5"; hacer un upsert de una fila con clave por dispositivo y marca de tiempo en lugar de insertarla.
  2. Deduplicación explícita. Asigna a cada mensaje un ID único y recuerda los ID procesados recientemente.

Este es un consumidor completo con paho-mqtt 2.x que usa deduplicación explícita con una caché limitada en tamaño y en tiempo:

import json
import time
import uuid
from collections import OrderedDict
import paho.mqtt.client as mqtt

BROKER = "broker.connectmq.com"
TOPIC = "connectmq/demo/t9v4/+/energy"
DEDUP_TTL = 600          # segundos que se recuerda un ID
DEDUP_MAX = 50_000       # límite de uso de memoria

seen = OrderedDict()     # msgId -> momento en que se vio por primera vez
totals = {}

def already_seen(msg_id):
    now = time.time()
    while seen:
        oldest_id, ts = next(iter(seen.items()))
        if now - ts > DEDUP_TTL or len(seen) > DEDUP_MAX:
            seen.popitem(last=False)
        else:
            break
    if msg_id in seen:
        return True
    seen[msg_id] = now
    return False

def on_connect(client, userdata, flags, reason_code, properties):
    client.subscribe(TOPIC, qos=1)

def on_message(client, userdata, msg):
    try:
        data = json.loads(msg.payload)
        msg_id = data["msgId"]
    except (ValueError, KeyError):
        print("rejected message without msgId")
        return
    if already_seen(msg_id):
        print(f"duplicate {msg_id} (dup flag={msg.dup}) ignored")
        return
    meter = msg.topic.split("/")[-2]
    totals[meter] = totals.get(meter, 0) + data["kwh"]
    print(f"{meter} total={totals[meter]:.2f} kWh")

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
                     client_id=f"dedup-{uuid.uuid4().hex[:8]}")
client.on_connect = on_connect
client.on_message = on_message
client.connect(BROKER, 1883, keepalive=30)
client.loop_forever()

Ejecútalo y luego lanza el bucle de doble publicación de la sección anterior. El primer mensaje actualiza el total; el segundo se notifica como duplicado y se ignora.

Elegir un buen ID de mensaje

El ID debe crearlo el publicador original, una sola vez, y reutilizarse en cada reintento. Opciones:

  • ID de dispositivo más número de secuencia, como meter-3-000042. Compacto y útil también para comprobar el orden de los mensajes MQTT. Persiste el contador o añade un ID de arranque para que no se repita tras un reinicio.
  • UUID generado por mensaje. Sencillo y único a nivel global, a costa de 36 bytes.
  • ID de dispositivo más marca de tiempo de la medición, si el dispositivo nunca produce dos lecturas con la misma marca de tiempo.

No generes el ID en el consumidor ni en una capa intermedia; para entonces, un duplicado parece un mensaje nuevo.

Escalar la deduplicación

La caché en memoria anterior funciona para un único proceso consumidor. Para sistemas más grandes:

  • Restricciones de base de datos. Un índice único sobre msg_id convierte los duplicados en conflictos inofensivos. Con un upsert (INSERT ... ON CONFLICT DO NOTHING en PostgreSQL), a menudo es la única deduplicación que necesitas.
  • Caché compartida. Si varios consumidores de una suscripción compartida pueden recibir reintentos del mismo mensaje, el conjunto de ID vistos debe ser compartido, por ejemplo en Redis con un TTL.
  • Dimensionado del TTL. La ventana solo necesita cubrir retrasos de reentrega realistas: la expiración de tu sesión más un margen. Los duplicados rara vez llegan con horas de diferencia, salvo que las sesiones duren tanto.

¿Deberías usar simplemente QoS 2?

QoS 2 añade un intercambio en cuatro pasos que garantiza la entrega exactamente una vez en cada salto. Cuesta más idas y vueltas y más estado en el broker, y no te protege frente a un dispositivo que agota su tiempo de espera y vuelve a publicar la misma lectura como un mensaje nuevo a nivel de aplicación. En planes limitados puede que ni siquiera esté disponible; por ejemplo, el plan Serverless gratuito de ConnectMQ admite QoS hasta 1, mientras que el plan Serverless de pago añade QoS 2. Consulta QoS máximo en MQTT para ver cómo los brokers anuncian y rebajan el QoS. En la mayoría de los sistemas, QoS 1 más consumidores idempotentes es el diseño más robusto y económico.

Lista de comprobación para la resolución de problemas

  1. ¿Los duplicados coinciden con reconexiones? Revisa los registros del cliente en busca de desconexiones y timeouts de keepalive.
  2. ¿Tu publicador reintenta a nivel de aplicación además de QoS 1? Eso duplica las vías de reintento.
  3. ¿Dos clientes comparten un client ID y se expulsan mutuamente, provocando reanudaciones de sesión constantes? Consulta el cliente MQTT se desconecta constantemente.
  4. ¿El consumidor tarda en confirmar? Un procesamiento largo dentro del callback retrasa el PUBACK y amplía la ventana de reentrega.
  5. ¿Cada mensaje lleva un ID creado en el origen?

Los duplicados y la elección de tu broker

ConnectMQ implementa la semántica estándar de QoS 1 y QoS 2, así que los consejos anteriores se aplican directamente. Como los client ID están vinculados a las credenciales emitidas, es más fácil evitar el bucle de reconexión por client ID duplicado que a menudo provoca reentregas masivas. Un broker MQTT gestionado se encarga por ti del almacenamiento de sesiones y de los reintentos; la idempotencia de tus consumidores sigue siendo responsabilidad tuya, en cualquier broker. El tutorial de MQTT es un buen siguiente paso para tener una visión general.

Empieza con ConnectMQ

Crea un broker gratuito en app.connectmq.com con el plan Serverless, sin tarjeta de crédito. Apunta el consumidor con deduplicación hacia él con tu usuario, contraseña y client ID de ConnectMQ, y tus totales seguirán siendo correctos aunque la red no lo sea.

Preguntas frecuentes

¿Por qué recibo el mismo mensaje MQTT dos veces?

Con QoS 1, un acuse de recibo perdido hace que el emisor reenvíe. El receptor vuelve a recibir el mensaje, algo permitido por la garantía de al menos una vez.

¿Puedo confiar en el flag DUP para descartar duplicados?

No. DUP marca una retransmisión en un salto, pero puede que la primera copia nunca llegara, y el broker no traslada el flag a los suscriptores.

¿QoS 0 produce duplicados?

No, el protocolo nunca retransmite QoS 0. En cambio, puede perder mensajes.

¿Durante cuánto tiempo debo recordar los ID de mensaje?

El tiempo suficiente para cubrir los retrasos de reconexión y reanudación de sesión, normalmente minutos. Dimensiónalo a partir del intervalo de expiración de tu sesión más un margen de seguridad.

Artículos relacionados

¿Quién inventó MQTT? La historia, de los oleoductos al IoT

MQTT lo inventaron en 1999 Andy Stanford-Clark, de IBM, y Arlen Nipper, de Arcom, para monitorizar oleoductos vía satélite. Sigue su camino hacia el open source, OASIS, ISO y los brokers MQTT gestionados en la nube de hoy.

8 min de lectura

Por qué tu cliente MQTT se desconecta constantemente (y cómo solucionarlo)

Los bucles de reconexión son uno de los problemas más habituales de MQTT. Conoce las cinco causas típicas, desde los client ID duplicados hasta los timeouts de keep alive y los paquetes demasiado grandes, y sigue un flujo de diagnóstico que localiza la tuya.

9 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.