Сповіщення, які спрацьовують один раз, а не раз на сервер
Одне з наших операційних сповіщень — «низький баланс кредитів» для опублікованого застосунку конструктора — має приходити нам не більше одного разу на день. Одного разу воно прийшло п'ять разів. Причина: кожна копія нашого 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 під час деплоїв і відсутній файл swap. Якщо ідея запуску кількох копій сервісу для вас нова, наш посібник що таке сервер пояснює основи