Однажды я перезапустил брокер на дачной малине ради смены карты памяти. Всё поднялось за минуту, дашборд открылся — и показал пустоту: свет в доме, замок и насос стояли со знаками вопроса. Причина простая: статусные топики я публиковал только по изменению, изменений с прошлого вечера не случилось, а брокер после рестарта ничего не помнит — в основах pub-sub он фигурировал почтамтом, а почтамт не архив. Тогда я впервые подумал, что почтамт мог бы и запоминать последнее письмо на каждый адрес: пользователь-то не знает, что статус менять нечем.
Вторая история из той же серии: утром я подписался на dacha/greenhouse/temp, а период публикации — десять минут. Десять минут дашборд рисовал пустой график и честно ждал первой телеметрии — тот же эффект даёт подписка до первой публикации: брокеру просто нечего присылать. Оба случая лечатся одним флагом: retained-сообщение — это обещание брокеру хранить последнее сообщение топика и отдавать его каждому новому подписчику немедленно, до всякого живого трафика.
Флаг выглядит мелочью — один аргумент в publish — но он меняет характер системы: без него MQTT помнит только настоящее, с ним — ещё и последнее прошлое. Разберём механику, что стоит хранить, что категорически нет, и как вычистить retained-мусор, оставшийся от экспериментов, — я насобирал его однажды на полтора десятка топиков, и каждый черновик скетча с новым именем оставлял в брокере слот, который жил вечно.
Механика: один слот на топик
Включается флаг третьим аргументом: client.publish(topic, payload, true) — в первом скетче на PubSubClient мы этот вызов писали с двумя аргументами, третий как раз просит брокера сохранить сообщение. Хранение устроено аскетично: на каждый топик ровно один слот, не история. Новая публикация с retain затирает старую. При подписке брокер сначала отправляет все сохранённые значения, попавшие под ваши маски, и только потом транслирует живой поток — подписчику это выглядит как мгновенный ответ: доли секунды вместо ожидания первого тика телеметрии.
Снять retained можно единственным способом: опубликовать в тот же топик пустой payload — ноль байтов — с retain=true, и брокер удалит слот. Пробел не считается: это уже сообщение длиной в один байт. В консоли то же самое делает mosquitto_pub с флагами -r и -n. Кстати о рестартах из моей истории: по умолчанию Mosquitto держит слоты в оперативной памяти и теряет их при перезапуске; строка persistence true в конфиге сохраняет их на диск — проверьте свой, если статусы должны переживать рестарт малины. Без retain сообщение живёт один проход по подписчикам и умирает; с retain — вечно, пока не затёрто новым или не стёрто пустым.
Числа для наглядности. Телеметрия с периодом в десять минут заставляет нового подписчика ждать в среднем пять минут, в худшем случае все десять. С retained — ноль секунд в любой момент, хоть через минуту после перезапуска брокера. Погрешность тут ещё психологическая: без retained человек смотрит на пустой дашборд и перезапускает всё подряд, с retained — смотрит на данные. Именно ради этой разницы флаг придуман: дашборд, который знает состояние дома сразу, а не по милосердия таймеров плат.
Две детали, которые экономят время. Первая: retained-доставка помечается — клиент получает сообщение с установленным флагом retain и может отличить архив от живого потока; в PubSubClient callback получает этот флаг последним аргументом. Вторая: слот хранится на каждый топик отдельно, и маска при подписке разворачивается в набор подходящих слотов — подпишитесь на home/+/light/state и сразу получите весь свет в доме, сколько бы ламп там ни было.
#include <PubSubClient.h> WiFiClient net;PubSubClient client(net);const int RELE = 7;int svet = 0; void publishState(int v) { svet = v; if (v == 1) { digitalWrite(RELE, HIGH); } else { digitalWrite(RELE, LOW); } client.publish("home/livingroom/light/state", String(v), true);} void setup() { Serial.begin(9600); pinMode(RELE, OUTPUT); digitalWrite(RELE, LOW); WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) { delay(300); } client.setServer("192.168.1.50", 1883); client.connect("esp32-livingroom"); publishState(svet); Serial.println("status vinos srazu, do pervogo izmeneniya");} void loop() { client.loop(); if (Serial.available() > 0) { String cmd = Serial.readStringUntil(10); cmd.trim(); if (cmd == "1") publishState(1); if (cmd == "0") publishState(0); if (cmd == "wipe") { client.publish("home/livingroom/light/state", "", true); Serial.println("slot styort pustym payload s retain"); } }}Что хранить, а что нет
| Данные | Retain | Почему |
|---|---|---|
| Статусы: light/state, замок, насос | да | новый подписчик сразу знает факт; дашборд после перезапуска не пустой |
| Конфиги: уставки, пороги, расписание | да | плата переподписалась и мгновенно получила актуальные настройки |
| Телеметрия раз в 10 секунд | осторожно | вернётся значение, которому час; без метки времени дашборд примет архив за жизнь |
| События: кнопка, протечка | нет | событие из прошлого не должно оживать при переподключении |
| Логи и журнал событий | нет | архив ведёт база данных, а не слот брокера: слот хранит одно сообщение, журнал — тысячи |
| Команды: set, cmd | нет | подписчик выполнит устаревший приказ — как мой насос ночью |
Правило, к которому я пришёл: retained — для истин, актуальных до следующей публикации. Статус света остаётся правдой, пока его не сменят; уставка термостата — тоже. Телеметрия — правда только до следующего измерения, поэтому ретейнить её бессмысленно и вредно: датчик, отключённый на зиму, продолжит показывать осенние 26 градусов как текущие, и дашборд не отличит архив от жизни. Я двое суток искал, почему полив не срабатывает, — а смотрел на retained-значение, замороженное три дня назад. С тех пор в моих скетчах правило: если значение имеет право на сохранность — оно и есть статус; если право прожить секунду — оно событие.
Если телеметрию всё же хочется видеть сразу после подписки — кладите в payload метку времени и принимайте решение по ней: урок про ArduinoJson показывает, как упаковать значение и штамп в одно сообщение. Это подход промышленных систем: значение без времени — не значение. Мой компромисс для дома проще: телеметрия без retain, а свежесть дашборда обеспечивается коротким периодом публикации — десять секунд вместо десяти минут, как в уроке про millis-публикацию. Это и не нагрузка: восемь тысяч шестьсот сообщений в сутки брокер на малине переваривает не заметив.
Про память брокера: слот — не бесплатно. Если ретейнить телеметрию в топики с уникальными именами — с номером пакета или минутой в имени, — слоты множатся, и таблица retained на Raspberry растёт бесконечно: утечка памяти по дизайну. MQTT пятой версии добавил срок жизни сообщений, но PubSubClient живёт в 3.1.1, где retained вечен. Экономьте слоты: состояния — да, поток — нет.
Retained и чистая сессия — независимые механизмы, и их путают: retained приходит даже при чистой сессии, потому что хранится не в сессии клиента, а в базе брокера. В уроке про clientId и статусы подключения мы разбирали, что чистая сессия стирает подписки — но не содержимое слотов. Поэтому дашборд с чистой сессией после каждого reconnect всё равно мгновенно получает статусы: флаги живут в разных местах.
Логичное продолжение темы — завещание: LWT позволяет брокеру самому опубликовать статус офлайн, когда плата исчезнет вместе с Wi-Fi. В связке retained-статус плюс завещание получается полный жизненный цикл устройства — но это отдельный урок, там и встретимся.
И про порядок доставки: retained приходят раньше живых сообщений, поэтому не пугайтесь, если первое значение в callback на долю секунды старше второго — сначала брокер разгребает архив, потом транслирует настоящее. Я однажды отлаживал несуществующий сбой: первое показание свежего датчика было вчерашним, а это был просто чужой retained-слот от старых экспериментов с тем же именем топика.
- publish(topic, payload, true) просит брокера хранить сообщение: один слот на топик, новая публикация затирает старую
- При подписке retained приходят мгновенно и раньше живого потока — дашборд знает состояние дома сразу
- Ретейнить факты и конфиги; не ретейнить команды и события: архивный приказ выстрелит при переподключении
- Телеметрию без метки времени ретейнить не советую: отключённый датчик покажет осень как сегодня
- Снятие retained — пустой payload с retain=true: publish(topic, String(), true) или mosquitto_pub -r -n
- 1. Плата публикует статус только при изменении и молчала час. Новый подписчик подключился без retained. Когда он увидит статус?
- 2. Как снять retained-сообщение с топика?
- 3. Подписчик подключился с чистой сессией (clean session=true). Придут ли ему retained-сообщения?
- 4. Почему я никогда не ставлю retain на командный топик dacha/pump/on?
- 5. Устройство ретейнит телеметрию в топики с номером минуты в имени. Что случится с брокером?
Частые вопросы по теме урока
- Плата публикует статус только при изменении и молчала час. Новый подписчик подключился без retained. Когда он увидит статус?
- Без retained брокер транслирует только живой поток: пока статус не изменится, топик молчит. retained-слот решает это мгновенно при подписке. После следующего изменения состояния — может, никогда
- Как снять retained-сообщение с топика?
- Брокер удаляет слот только на пустой payload с флагом retain — ноль байтов. В консоли то же делает mosquitto_pub -r -n -t топик. Опубликовать пустой payload с retain=true
- Подписчик подключился с чистой сессией (clean session=true). Придут ли ему retained-сообщения?
- Чистая сессия обнуляет подписки клиента, но не retained-слоты: они лежат в базе брокера и рассылаются любому свежеподписавшемуся. Да: retained хранится не в сессии, а в базе брокера
- Почему я никогда не ставлю retain на командный топик dacha/pump/on?
- Слот отдаёт последнюю команду любому новому подписчику: мой насос включился ночью сам — плата получила недельной давности приказ. Хранить надо факты, не приказы. Переподписавшаяся плата получит устаревший приказ и выполнит его
- Устройство ретейнит телеметрию в топики с номером минуты в имени. Что случится с брокером?
- Слот один на топик, уникальные имена множат слоты. MQTT 3.1.1 не умеет срок жизни сообщений — а PubSubClient живёт именно в этой версии протокола. Таблица retained растёт бесконечно: каждый уникальный топик — новый слот