Оповещения, которые срабатывают один раз, а не по разу на каждый сервер
Одно из наших операционных оповещений — «низкий баланс кредитов» для опубликованного приложения пользователя — должно приходить нам не более одного раза в сутки. Однажды днём оно пришло пять раз. Причина: каждая копия нашего API запоминала в собственной памяти, какие оповещения уже отправила, поэтому два сервера отправляли его дважды, а каждый деплой стирал эту память и позволял отправить снова. Тот же механизм приводил к тому, что фоновая задача выполнялась дважды. Теперь обе части координируются через общее хранилище, и обе при его недоступности выбирают безопасный вариант.
Симптом
Mythex отправляет оповещения команде, когда что-то требует внимания: сервис недоступен, задача зависла, у пользователя с опубликованным приложением заканчиваются кредиты. У каждого оповещения есть окно дедупликации, период, в течение которого одно и то же оповещение отправляется только один раз. Для оповещения о низких кредитах это окно составляет 24 часа.
Пять одинаковых оповещений за один день означали, что окно не работало. Хуже того, они приходили парами, примерно с интервалом 11 минут.
Почему это произошло
Пакет оповещений хранил дедупликацию в памяти: таблицу «ключ оповещения → когда оно было отправлено в последний раз» внутри работающего процесса. Это работает, когда есть один процесс, который никогда не перезапускается. У нас в продакшене нет ни того, ни другого:
- Две реплики API. В продакшене работают две копии API. У каждой была своя таблица, поэтому каждая отправляла оповещение один раз. Это и были пары.
- Деплои. Каждый деплой заменяет обе реплики новыми процессами с пустыми таблицами. После деплоя оповещение уходило снова, как будто его никогда не отправляли.
Интервал около 11 минут между двумя оповещениями в каждой паре возникал из-за задачи, которая это оповещение создаёт. Она работает на обеих репликах и берёт блокировку, поэтому реплики выигрывают её по очереди, и каждая поднимала оповещение, когда подходила её очередь.
В том же коде был второй, более тихий баг. Чтобы таблица не росла бесконечно, она очищала себя, как только в ней набиралось 500 ключей, удаляя каждую запись старше 15 минут, независимо от её окна. Под нагрузкой дедупликация на 24 часа могла длиться четверть часа.
Исправление
Одна общая запись на оповещение
Перед отправкой оповещения API теперь захватывает ключ дедупликации в Redis, общем хранилище в памяти, с которым работают обе реплики и которое не перезапускается вместе с API. Захват выполняется одной операцией «установить этот ключ, только если его нет, и истечь через окно». Та реплика, которая захватила ключ первой, отправляет оповещение; другая видит, что ключ занят, и молчит. Новый процесс после деплоя находит ключ на месте.
Ключ и окно берутся из того же правила, которое использует пакет оповещений, и теперь оно общее для обоих слоёв, так что они не могут разойтись в том, что считать «тем же оповещением».
При сбое отправлять, а не блокировать
Самое важное решение касалось того, что делать, если Redis недоступен. Мы решили всё равно отправить оповещение. Если Redis не работает или не отвечает в течение 1,5 секунды, оповещение уходит. Дублирующее оповещение — это шум. Потерянное оповещение — это когда человека не предупредили.
Самые серьёзные оповещения, P0, дедупликация вообще не задерживает.
Каждая запись хранит своё окно
В собственной дедупликации пакета оповещений в памяти каждая запись теперь хранит момент окончания своего окна, а очистка удаляет только записи, чьё окно истекло. Ключ на 24 часа живёт 24 часа, сколько бы других оповещений ни приходило.
Тот же баг в другом месте
Тот же механизм удваивал и часть фоновой работы: задачу сверки, которая держит наши записи о статусе каждого рабочего пространства в соответствии с провайдером песочниц. Каждый проход перебирает все песочницы в аккаунте (в продакшене их тысячи, это около пяти секунд) и все запущенные или приостановленные проекты. Обе реплики запускали его каждые две минуты.
Его записи идемпотентны, то есть повторный запуск даёт тот же результат, что и однократный, поэтому ничего не ломалось. Просто работа выполнялась вдвое. Теперь каждое двухминутное окно захватывается в Redis так же, как наши счётчики использования захватывали свои, и проход выполняет только та реплика, которая его захватила. Если Redis недоступен, проход всё равно выполняется: двойная работа безвредна, а её отсутствие — нет.
Как мы это тестировали
Тесты повторяют реальную конфигурацию. Они загружают модуль оповещений дважды, как две отдельные «реплики», направляют обе на одно общее хранилище, поднимают из каждой одно и то же оповещение, а затем загружают третью свежую копию, чтобы имитировать «после деплоя», и поднимают его снова. Отправляется ровно одно оповещение. Другие тесты проверяют, что оповещение другого пользователя всё равно уходит, что P0 отправляется с обеих реплик и что оповещение по-прежнему уходит, когда хранилище недоступно или зависает дольше таймаута. У исправления очистки есть свой тест: ключ на 24 часа переживает 600 других оповещений, пришедших через 20 минут.
Для задачи сверки две реплики, запущенные одновременно, плюс третий запуск в том же окне дают один запрос списка.
Что мы из этого вынесли
- Состояние в памяти существует для каждого процесса отдельно. Всё, что должно быть правдой на всех серверах, — «отправлено один раз», «выполнено один раз» — должно храниться там, где его видят все.
- Деплой — это перезапуск. Состояние, которое переживает только до следующего перезапуска, не переживает обычный рабочий день.
- Решите, в какую сторону падать. Для оповещений недоступность общего хранилища должна означать больше оповещений, а не меньше.
- Ищите закономерность, а не только отдельный случай. Оповещение было самым заметным проявлением; удвоенная задача сверки была тем же багом, незаметно тратившим ресурсы.
Ничего из этого не видно изнутри Mythex. Подробнее о том, как мы держим платформу стабильной, читайте в статьях как мы довели деплои до нуля ошибок 502 и отсутствующий файл подкачки. Если вы впервые слышите о запуске нескольких копий сервиса, наш гайд что такое сервер объясняет основы.