Решаем проблемы роста нагрузки в умных домах
- пятница, 4 апреля 2025 г. в 00:00:14
Команда SberDevices столкнулась с необходимостью масштабирования системы для поддержки сотен тысяч IoT-устройств. Система была хрупкой и не справлялась с пиками трафика. Это приводило к инцидентам, когда девайсы теряли связь с сервером и одновременно пытались переподключиться, создавая лавинообразную нагрузку.
Всем привет! Меня зовут Вадим Трегубов, я техлид бекэнда платформы умного дома в SberDevices. Сегодня расскажу про особенности работы с IoT, проблемах роста нагрузки, возникающие у проектов интернета вещей и наших решениях, которые помогли их избежать.
Всё началось с того, что мы хотели избавиться от vendor-lock. Это чувствительная для бизнеса тема, особенно из-за ухода поставщиков решений с нашего рынка. К 2022 году мы уже наработали свои платформенные сервисы: управление голосом, создание сценария автоматизации. Хотелось их использовать еще шире, глубже и качественнее. Плюс ко всему, мы бы имели полный цикл поддержки устройств: выпуск их на рынок, обновление прошивок, докатка и улучшение пользовательского опыта.
При этом платформа должна соответствовать базовым принципам:
IoT-устройствам нужна связь с облаком. Лампочки, розетки, чайники соединяются с облаком, чтобы мы могли ими управлять.
Устройств очень много. Количество девайсов в нашем проекте измеряется сотнями тысяч.
Message Queuing Telemetry Transport. Это принятый в индустрии стандарт, легковесный протокол, который без проблем читают даже ограниченные микроконтроллеры в самих устройствах.
Безотказная работа. Например, выйдя ночью в туалет, пользователь ожидает, что по датчику сработает умная лампочка, освещая его путь.
Иной паттерн нагрузки. Так как устройства поддерживают постоянную связь с бэкендом — RPS довольно низкий. Если соединение разорвано, девайс переподключается.
Учитывая особенности работы с IoT, мы сформулировали требования к архитектуре:
Отказоустойчивость — в любое время дня и ночи у пользователя должно всё работать.
Высокая доступность, если у нас что-то случилось, даже деградация маленького сегмента не должна влиять на пользователя.
Горизонтальная масштабируемость, ведь в планах было солидное количество устройств.
Быстродействие, низкие задержки — нажал на кнопку, получишь результат, причем максимально быстро.
Входная точка всей будущей архитектуры — MQTT-брокер. Через него к бэкенду подключаются все устройства. Он получает команду от пользователя через мобильное приложение или умную колонку и взаимодействует с другими сервисами, обеспечивая бизнес-логику.
Базовая схема взаимодействия:
А так выглядела схема проектируемой архитектуры:
Соединения устройств распределяются через эластичный балансировщик нагрузки в облаке (ELB) между несколькими инстансами MQTT-брокера. Nginx обрабатывает SSL-уровень. Дальше с остальными сервисами брокеры взаимодействуют через шину NATS. Это одна из наших наработок. С помощью NATS мы решаем проблему кластеризации — распределения и объединения по разным зонам доступности — и маршрутизацию команд от устройств к облаку и наоборот.
NATS используем для отказоустойчивости, чтобы деградация одного кластера не задела другие. Шина может группироваться сама по себе и представлять так называемые leaf nodes. Эти ноды изолируют инстансы в одном дата-центре, чтобы они не использовали ресурсы соседних.
Подробнее о том, как работает NATS, можно почитать тут.
Приступив к разработке, сначала изучили готовые решения на рынке — вдруг не придется ничего писать самим. Всего «подопытных» было 4:
EMQX
Mosquitto
NanoMQ
VerneMQ
EMQX – это промышленное решение, мощный производительный комбайн. Написан на Erlang.
Но у нас были бы сложности с его лицензированием. Плюс, ему нужны определенные компетенции в поддержке. Опять же, завязываться на какого-то поставщика нам не очень-то хотелось.
Также популярен Mosquitto, написанный на C++.
Правда, он больше подходит для энтузиастов, которые хотят что-то настроить дома или небольших систем – промышленную нагрузку с ним выдержать сложно.
Оставшиеся решения (NanoMQ и VerneMQ) нам совсем не подходили.
Из-за неактивного сообщества и скудной документации пришлось бы самостоятельно решать проблемы и баги. Разбираться с этим не хотелось.
В итоге решили — делать свое.
За две недели собрали прототип и доказали, что он в принципе функционирует. Потом наладили и реализовали весь MQTT протокол для работы устройств. Два месяца исправляли «детские болячки». Провели синтетический нагрузочный тест: дали трафик в 30 тысяч клиентов, каждый отправил по 300 сообщений. По метрикам всё было хорошо.
После состоялся первый релиз в продакшн. Схема архитектуры немного отличалась от предыдущей. Было две виртуальные машины, на каждой по Nginx для терминирования SSL и самописному брокеру, которые общаются с Nats. Всё это балансируется с помощью ELB от нашего облачного провайдера SberCloud.
Провели бета-тест примерно на 1000 устройств и начали продажи. По началу всё шло хорошо. В первый год количество пользователей выросло в 50 раз, и мы быстро достигли показателей нагрузки из лабораторного тестирования.
И тут начались проблемы.
Случилось всё ночью. Провайдер перезагрузил сетевые туннели, и все устройства разом потеряли соединение, и также хором попытались переподключиться. Такую нагрузку мы уже выдержать не смогли. Пришлось аккуратными манипуляциями с полосой пропускания, чтобы проходила только часть нагрузки, не роняя сервис, возвращать систему к жизни.
Конечно, потом мы провели пост-мортем и выработали типовые решения, которые многим знакомы:
Увеличение числа инстансов.
Вывод ноды из балансировки.
Настройка балансировки, правила распределения соединений по нодам.
Распределение нагрузки во времени по клиентам и устройствам.
Последний пункт сразу показался нам ключевым, но он требовал выпуска новой прошивки, а это процесс небыстрый. Поэтому сначала взялись за оптимизацию бэкенда.
При масштабировании добавили инстансов, чтобы при падении его влияние на систему было минимальным. Также выделили по виртуальной машине для каждого инстанса, брокера, ноды и Nginx. Расположили всё это в нескольких дата-центрах и зонах доступности.
Наладили балансировку. Сделали кластер из 8 Nginx, каждый равномерно распределял нагрузку на 8 инстансов бриджа.
На сетевом стеке улучшили постоянное соединение, чтобы при отсутствии трафика оно завершалось, браковалось и давало поработать другим. Для этого задали следующие параметры:
...
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_keepalive_intvl = 10
net.ipv4.tcp_keepalive_probes = 6
…
Пока мы занимались бэкендом, коллеги выпустили и протестировали прошивку с exponential backoff. Благодаря этому алгоритму устройство пытается подключиться к системе через определенные промежутки. При этом все попытки имеют случайный сдвиг по времени, чтобы подключения не происходили одновременно.
Воодушевленные мы выкатили прошивку и получили инцидент, потому что устройства пошли массово обновляться.
Обновление – это специальное MQTT-сообщение, а значит, и плюс к трафику. Устройства шлют свои статусы скачивания, как бы говоря серверу: «Я скачал 10%, 20%, 50%». Когда прошивка скачалась и подпись проверена, идёт установка, после девайс рапортует: «я перезагрузился, вот новая версия!». Эти сообщения создают нагрузку на Inventory – наш микросервис, который этим процессом заправляет. Также страдает сеть, ведь идёт отдача статики с Nginx, когда устройство загружает файлы. А каждая прошивка занимает от 1 до 2 Мбайт.
Когда справились с инцидентом, стали искать узкие места, из-за которых он произошел.
Сходу определили, что самая частая операция — это установка соединения, авторизация устройства. Это не одно действие, а несколько, ведь нужно:
Получить устройство из БД сервиса.
Сверить логин-пароль и авторизовать.
Вернуть CONNACK-пакет (ответ).
Авторизация происходит в режиме жёсткой конкуренции тысячи устройств за соединение, когда все они пытаются подключиться одновременно, а система их должна обслужить. Если у девайса не получилось авторизоваться сразу, он придет снова, приходит, и круг замыкается.
Мы подумали, что сможем решить эту проблему с помощью Redis. Раз там мы храним retained-сообщения, то с помощью него и сможем закешировать авторизацию устройств.
В MQTT retained-сообщение — это сообщение с последним состоянием устройства, которое оно отправляет в облако. Для этого не нужно, чтобы получатель следил за девайсом в данный момент. Сообщение придет адресату, когда тот подпишется на устройство. Благодаря этому Redis может закешировать ранее авторизованные девайсы. Сколько бы устройство не пыталось отправить сообщение и не отваливалось по тайм-ауту, на какой-то раз ему это удастся.
Мы протестировали это решение и поняли, что получается не намного лучше. С точки зрения MQTT бриджа, что пойти в базу, что пойти в Redis — это сетевой запрос, а значит проблема с перегрузкой системы осталась.
Поэтому мы стали думать дальше и вспомнили, что можно хранить закэшированное прямо в оперативной памяти (In-Memory). Тут вероятность попадания в кэш измеряется обратно пропорционально количеству инстансов. Вроде бы немного, но этого нам хватило.
Естественно, так мы создаем дополнительную нагрузку на смежные сервисы.
Во время пиков нагрузки могут достигать 3 тыс. RPS, при штатной ~ в 100 RPS. Такие скачки система должна выдерживать, поэтому мы её подстраховываем:
не используем общие методы с кучей ненужной информации, берем в базе только то, что нужно.
Сокращаем объем передаваемых данных для снижения нагрузки на сеть.
Оптимизируем индексы в базе данных.
Еще сильнее снизить нагрузку, помогает методика по деплою бриджей, которую мы разработали совместно с командой эксплуатации:
Выкатываем по одной ноде за раз.
Контролируемо, с определенной скоростью завершаем соединения.
Клиенты переходят на соседние инстансы.
Обновляем и перезапускаем сервис.
Как видите, большинство первоначальных доработок относилось к эксплуатации. Но количество проданных устройств росло и мониторинг беспокоил нас всё чаще и чаще. Система была очень хрупкая, любое неосторожное действие, нестабильность сети либо неаккуратный деплой снова могли привести к лавинообразной потере всех соединений.
В связи с этим напрашивались более качественное решение. Ведь мы не можем бесконечно масштабироваться. Как минимум это непропорционально увеличивает стоимость эксплуатации сервиса.
Чтобы понять, что мы делаем неправильно, решили добавить ещё метрик в мониторинг и пристально за ними наблюдать. Оказалось, что во время пиков, когда всё лагает, утилизация процессора находится в штатном пределе и не превышает 40%. Полезли в код и нашли там очень странную вещь: Accept вызывается один раз за итерацию event loop, что ограничивает скорость прироста новых соединений.
Так всё это выглядело в мониторинге
Стали разбираться и поняли, что в прототипе мы использовали библиотеку Evio, которая обещала работу с низким уровнем syscalls, их очень быстрый разбор, без лишних горутин, но на деле было так:
for !shutdown {
fds := p.wait(delay)
nextfd:
for _, fd := range fds {
for i, lfd := range lfds {
if lfd == fd {
fd, sa, err := syscall.Accept(lfd)
if err != nil {
if err == syscall.EAGAIN {
continue nextfd
}
panic(err)
}
// etc...
То есть один цикл в единственном потоке принимает syscalls. Цикл продолжается — все обрабатывается синхронно. При этом CPU не нагружен сильно, только одно ядро, остальные в этот момент простаивают.
Решили баг максимально просто. Взяли пример практически из учебника:
func (s *Server) acceptConnections() {
defer s.wg.Done()
for {
select {
case <-s.shutdown:
return
default:
conn, err := s.listener.Accept()
if err != nil {
continue
}
c := newTCPConn(conn)
s.connection <- c
}
}
}
Работает всё без сложностей. Запускаем горутины, в которых принимаем TCP-соединения, отправляем их в канал, его разгребает вторая горутина. Она уже инициализирует соединение с бизнес-логикой, запускает процессы чтения и записи, каждый из них с каналами, и отправляет в стандартный стек работы Go.
func (s *Server) handleConnections() {
defer s.wg.Done()
for {
select {
case <-s.shutdown:
return
case conn := <-s.connection:
s.events.Opened(conn)
go s.readPump(conn)
go s.writePump(conn)
}
}
}
Далее немного улучшили решение: добавили потокобезопасность и recovery и сделали аккуратное завершение соединений из readPump или writePump с помощью sync.once.
У нас уже больше девяти месяцев стабильной работы. Инциденты случаются редко и по внешним причинам: из-за DDoS-атак, сбоев в сети или облаке.
Система перестала быть такой хрупкой. Даже если что-то случается, мы самовосстанавливаемся за 10−15 минут, успеваем принять, обслужить и ответить всем устройствам.
Бонусом — отработали процесс безопасного деплоя. Методика оказалась рабочей. Вот так выглядит наш плавный рестарт по одной ноде:
Количество устройств продолжает расти, появляются новые категории девайсов, а команда разработки пишет новые типы прошивки.
Растет и количество онлайн-устройств, которые одновременно подключены к сервису. При этом утилизация ресурсов, затраты, которые мы несем, растет медленнее, чем прибывает количество устройств, а значит, мы все сделали правильно.
Преждевременная оптимизация – зло! Сразу будьте готовы, что когда проект разовьется, что-то пойдет не так, и вы не сразу поймете что именно.
Постоянным TCP-соединением нужно уметь работать, ведь оно сильно от типичного веб-соединения. Тщательно следите за жизненным циклом, в том числе за настройками keep-alive, отслеживайте все ping-pong, и, что особенно важно в Go, своевременно отпускайте ресурсы.
Внимательно читайте, что используете с GitHub. В readme могут обещать манну небесную, чудеса производительности. Но чудес не бывает.
Железо не решает всех проблем. Важно не то, сколько у тебя ресурсов, а как эффективно с ними работаешь. Лавину реконнектов сам брокер, может, и выдержит, но неизбежно создаст нагрузку на смежные сервисы. Они тоже должны быть к этому готовы.
Не стоит недооценивать нативные средства Golang, особенно в части системного программирования. Нас буквально спасла стандартная библиотека net.tcp.