Распределенные системы: что может пойти не так
21 октября 2019
Большинство современных приложений представляют собой распределенные системы. Допустим, ваша компания делает «просто» приложение для мобильных устройств. Но помимо самого приложения, с которым работает пользователь, наверняка есть и какой-то сервер-сайд. Он состоит из балансировщиков нагрузки (например, Nginx), некоторого количества микросервисов, а те в свою очередь ходят в некие СУБД (PostgreSQL), кэши (Redis, Memcached) и service discovery (Consul). СУБД скорее всего крутится не на одном сервере, а имеет энное количество реплик — для распределения нагрузки, аналитики и снятия бэкапов. По моему скромному опыту, многие люди не сильно задумываются над проблемами, которые могут возникать в подобных системах. Давайте же выясним, что это за проблемы.
Итак, вот примерный список:
- Машины падают. Это самый простой сценарий, и большинство людей, к счастью, про него в курсе. Как следствие, исполнение любой программы или скрипта может быть прервано в произвольном месте. Отсюда в свою очередь есть ряд других следствий, например, что запись логически связанных данных должна производиться в базу в одной транзакции. Когда одна из машин в системе недоступна, система должна либо продолжать работать нормально, либо переходить в режим read only (если упал мастер СУБД), либо как-то иначе корректно обрабатывать эту ситуацию. После поднятия машины система должна приходить в консистентное состояние.
- Вообще, железо дохнет. Падение машины является частным, и наиболее простым, случаем этого пункта. Все становится чуточку интереснее, когда железо дохнет частично. Например, зафиксированны случаи [PDF], когда на жестких дисках портились отдельные байты, и файловая система этого не ловила. (Кстати, если вы используете «облачные» диски типа EBS, то там вообще нет каких-либо гарантий в отношении поведения диска.) Отсюда следует, что из базы данных нужно периодически вычитывать «холодные» данные и проверять их целостность. По той же причине бэкапы должны храниться больше, чем в одном месте, а их целостность и вообще возможность восстановится должны регулярно проверяться. Если в бэкапе испорчен случайный байтик, процедура восстановления должна завершаться с соответствующей ошибкой. Аналогичным образом нельзя полагаться на контрольные суммы TCP-пакетов.
- Нетсплиты. Смерть роутера может приводить к ситуации, когда один или несколько серверов оказываются отрезаны от остальной сети. Типичная стратегия на этот случай заключается в том, что часть системы, оставшаяся в меньшинстве, должна немедленно завершиться, а остальная часть системы продолжить работу. После восстановления работы сети система должна приходить в консистентное состояние.
- Сообщения теряются. Компоненты системы в том или ином виде обмениваются какими-то сообщениями. Любое сообщение может потеряться, и эта ситуация должна корректно обрабатываться. Возможных причин потери сообщений много — уже упомянутые падения машин и нетсплиты, перезапуск/обновление приложения на принимающей стороне, высокая нагрузка на приложение, и так далее. Для UDP потеря пакета вообще является штатной ситуацией. Если на отправленное сообщение ожидается ответ, на его получение должен стоять некий таймаут. Потеря сообщения не должна приводить к неконсистентности приложения.
- Порядок и время доставки сообщений не гарантируется. Если только это как-то явно не реализовано в приложении. Рассмотрим простейший сценарий. Сервис A шлет сообщение M1 сервису B, при получении которого B шлет M2 сервису C. Одновременно A шлет сообщение M3 напрямую С. Нет абсолютно никаких гарантий касаемо порядка или времени доставки M2 и M3 (ровно как и самого факта доставки, см выше). Если же мы говорим о UDP, в нем изменение порядка сообщений является совершенно штатной ситуацией.
- Сообщения могут дублироваться. Опять же, это совершенно штатная ситуация для UDP. Если же используется иной протокол, одно и то же сообщение может приходить больше одного раза как часть стратегии ретрая (если ответ на первое сообщение не был получен в заданный таймаут — он мог просто потеряться, см выше) или как результат действий злонамеренного пользователя. Система должна обеспечивать идемпотентность обработки сообщений. То есть, повторная обработка сообщения не должна приводить к каким-либо новым эффектам по сравнению с первой обработкой. Часто для этого используют уникальные request id и реализуют механизм дедупликации сообщений.
- Malicious ноды. Если модель угроз системы включает в себя наличие мелишес (злонамеренных) узлов, и система должна сохранять корректную работу при наличии таких узлов, то система называется византийской системой. Мелишес нода может творить любую фигню — посылать сообщения с мусором, обрабатывать сообщения очень медленно, дублировать сообщения, и так далее. Большинство реальных систем не являются византийскими. В них предполагается, что всем узлам системы можно доверять. Тем не менее, даже в таких системах может возникнуть мелишес нода, например, если одну из нод забудут обновить вместе с остальными. На тестовом окружении мелишес нодой является QA. Любая система, византийская или нет, должна быть готова к получению сообщений (или чтению данных с диска) в старом формате, получению испорченных данных, и так далее.
- Дэдлоки. В распределенных системах часто возникают сценарии, при которых нода A шлет сообщение ноде B, та шлет ноде C, эта нода шлет сообщение ноде A, а нода A висит в ожидании ответа от B. Классический дэдлок. Это решается реализацией механизма deadlock detection или loop detection. В простейшем случае можно просто отваливаться по таймауту, но такое решение подходит не для всех систем. Более сложное решение заключается в передаче с каждым сообщением уникального trace id запроса. Если ноде приходит сообщение с trace id, который нода уже обрабатывает, это является признаком дэдлока. Возможны и иные подходы, в зависимости от специфики приложения.
- Несколько ДЦ. Если система работает в нескольких датацентрах, то возникает ряд проблем. Как минимум, это большие сетевые задержки между ДЦ, порядка сотен миллисекунд. Как следствие, если пользователь ходит с запросами на запись сразу в несколько ДЦ, то возникает проблема консистентности данных. Кроме того, возможно падение сразу целого ДЦ, например, в результате отключения питания. В зависимости от специфики приложения могут быть и другие нюансы. Например, юридические — где территориально по закону должны хранится персональные данные конкретного пользователя.
- Консистентное видение данных. Рассмотрим классический пример. Есть интернет-магазин. Пользователь кладет товар в корзину (запрос на запись). Затем открывает корзину (запрос на чтение) и не видет в ней товара. Происходит это по той причине, что запросы на запись идут в мастер PostgreSQL, а запросы на чтение — в асинхронную реплику. Репликация затупила, товара в корзине не видно. При дизайне системы необходимо четко определить, какие уровни изоляции (serizlizable, snapshot isolation, …) и в каких случаях она предоставляет. Проблема особенно актуальна для систем с шардингом и распределенными транзакциями.
- Время относительно. Вы не можете полагаться на то, что часы на всех нодах показывают одно и то же время, идут с примерно одинаковой скоростью и вообще, что время идет линейно. Внезапный перевод часов вперед или назад, замедление или ускорение часов никак не должно сказываться на функционировании системы.
- Каскадные сбои. Все описанное выше может происходить одновременно или в короткий промежуток времени, в случайном порядке. Например, сеть мигает и какая-то из машин в то же время перезагружается. Не такой уж невероятный случай при сбое питания. Или другой пример — сервер СУБД перезапускается, начинается процедура recovery. Во время работы этой процедуры машина снова перезагружается. Если система устойчива к одной из описанных выше проблем, это еще не значит, что она устойчива к их комбинациям. Таким образом, имеем комбинаторный взрыв с точки зрения тестирования и документирования поведения системы. Отсюда возникают Jepsen-подобные тесты, гоняемые на стендах по несколько месяцев, а также Erlang’овый принцип let it crash.
Приведенный список, безусловно, не претендует на полноту. Однако он позволяет составить общее представление о происходящем. Важно понимать, что все описанные случаи не являются чем-то из ряда вон выходящим. Они случаются относительно редко, это правда. Тем не менее, система должна обрабатывать данные сценарии штатным образом, и иметь соответствующие тесты. Конечно, если только вы не хотите однажды потерять важные данные и вместе с ними ваших клиентов.
А что бы вы добавили к приведенному списку, и с чем из перечисленного вам доводилось сталкиваться на практике?
Метки: Разработка, Распределенные системы, Тестирование.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.