На одном компьютере программа работает предсказуемо: одна и та же операция даёт один и тот же результат, а сбой обычно фатален — упало всё сразу. Как только процессы общаются по сети, эта определённость исчезает. Появляется частичный отказ: одни узлы работают, другие — нет, а третьи работают, но никто не знает наверняка. Это определяющая черта распределённых систем, и почти вся эта статья — про то, чему в них нельзя доверять: сети, часам и даже тому, что твой собственный процесс не был поставлен на паузу. Отсюда прямая дорога к алгоритмам консенсуса, но сперва нужно понять, от чего они защищают.
Частичный отказ: тайм-аут — единственный инструмент
Отправил узел запрос и не получил ответа — что случилось? Потерялся запрос; узел упал; узел жив, но отвечает медленно; узел обработал запрос, а потерялся уже ответ. Различить эти случаи невозможно — у отправителя есть только факт «ответа нет». Единственный способ вообще что-то решить — тайм-аут: подождать некоторое время и объявить узел неработающим.
Но какой выбрать тайм-аут? Короткий — быстро замечаешь сбои, но рискуешь объявить мёртвым узел, который просто притормозил под нагрузкой; тогда его работу передадут другим, добавив нагрузки уже перегруженной системе — и это путь к каскадному отказу. Длинный — долго ждёшь, прежде чем среагировать. «Правильного» значения нет, потому что в асинхронной сети задержки не ограничены: пакет может застрять в очереди перегруженного коммутатора, у занятого CPU, у гипервизора, приостановившего виртуальную машину. Телефонная сеть с гарантированной задержкой возможна (выделенный канал на звонок), но интернет и сети ЦОД оптимизированы под пиковый трафик через очереди — ценой предсказуемости. Поэтому тайм-ауты подбирают экспериментально, а лучше — измеряют разброс задержек и подстраивают динамически (Phi Accrual в Cassandra и Akka).
Три вещи, которым нельзя доверять
Сеть. Пакеты теряются и задерживаются на произвольное время, соединение может работать в одну сторону и не работать в обратную, сетевые сбои случаются даже в управляемых ЦОДах чаще, чем кажется. Вывод простой: любой сетевой обмен подвержен сбоям, и обработку этих сбоев надо проектировать и тестировать (идея Chaos Monkey — намеренно рвать сеть в проде).
Часы. У каждой машины свои кварцевые часы, и они расходятся (clock drift). Есть два вида часов, и путать их опасно:
- Часы истинного времени (time-of-day,
System.currentTimeMillis()) синхронизируются по NTP и могут прыгнуть назад при коррекции, спотыкаются о секунды координации, зависят от неизвестной погрешности сервера. Для измерения интервалов — непригодны. - Монотонные часы (
System.nanoTime()) гарантированно идут вперёд, но их абсолютное значение бессмысленно. Только ими и меряют длительности.
Отсюда главная ловушка — упорядочивать события по меткам времени истинных часов. Именно так работает разрешение конфликтов «выигрывает последний» (LWW): у двух конкурентных записей сравнивают timestamp, побеждает поздняя. Но если часы узлов разошлись на 100 мс, «поздняя» запись может физически произойти раньше, и та, что клиент реально записал последней, молча пропадёт. Метки времени не гарантируют причинно-следственный порядок — для него нужны логические часы и версии (Google Spanner идёт на GPS и атомные часы в каждом ЦОДе именно чтобы сузить погрешность до нескольких мс).
Паузы процессов. Поток может быть вытеснен в любой момент на произвольное время: всеобъемлющая пауза сборщика мусора (stop-the-world, иногда минуты), приостановка виртуальной машины (suspend/live-migration), украденное время CPU, свопинг страницы, даже Ctrl+Z. Узел этого не замечает — для него между двумя строками кода прошло «мгновение», а на деле минута, и его давно объявили мёртвым.
Истина определяется большинством, а не узлом
Из паузы вырастает коварнейший баг. Представь узел, который держит распределённый лок или считает себя ведущим. Он проверяет: «лок ещё мой?» — да, и идёт писать. Но между проверкой и записью его заморозила GC-пауза; за эту минуту lock истёк, лок захватил другой узел, а очнувшийся первый — всё ещё уверен, что владеет им — и пишет, разрушая данные. Два владельца одновременно.
Мораль: узел не может полагаться на собственное мнение о своём статусе. В распределённой системе истину определяет кворум — решение большинства (обычно > половины) узлов. Если кворум объявил узел мёртвым, тот считается мёртвым, пусть даже прекрасно работает. Кворум по большинству безопасен, потому что два большинства не пересекаются — конфликтующих решений быть не может. Именно так узлы решают, кто ведущий, и предотвращают split brain (два ведущих сразу).
Но кворум решает, кто должен писать, а не мешает опоздавшему всё испортить. Практическая защита — ограждающий маркер (fencing token): сервис блокировок при каждой выдаче лока возвращает монотонно растущий номер, клиент прикладывает его к каждой записи, а хранилище отклоняет запись с маркером меньше уже виденного. Очнувшийся после паузы узел приходит со старым номером — и его запись отвергают. Ключевое: проверять маркер должен сам ресурс, а не клиент (клиенту, считающему себя «в порядке», доверять нельзя). Подробный разбор с Redis и кодом — в задаче про лок без fencing.
Византийские сбои и модели системы
До сих пор мы считали узлы «добропорядочными»: они могут молчать, тормозить, отдавать устаревшее — но если отвечают, то честно. Если узел способен лгать (слать произвольные или поддельные сообщения — из-за порчи памяти, бага или злого умысла), это византийский сбой, а согласование в такой среде — задача византийских генералов. Защита от неё дорога и нужна там, где нет доверия: авиакосмос (радиация портит память), блокчейны (участники не доверяют друг другу). Внутри своего ЦОДа византийских сбоев обычно нет, и защита не окупается — но контроль входных данных от внешних клиентов (валидация, защита от SQL-инъекций) нужен всегда.
Чтобы рассуждать о корректности, алгоритмы формулируют через модель системы — набор допущений. По хронометражу: синхронная (задержки ограничены — нереалистично), частично синхронная (обычно ведёт себя хорошо, иногда нет — реалистичный дефолт), асинхронная (никаких допущений о времени). По отказам: отказ-остановка, отказ-восстановление (узел падает и поднимается, надёжное хранилище переживает сбой), византийская. И два вида свойств: safety («ничего плохого не произойдёт» — нарушение имеет конкретный момент и необратимо) и liveness («со временем что-то хорошее случится» — например, конечная согласованность). Хорошие алгоритмы держат safety всегда, а liveness — при разумных допущениях.
Где это применяется
Как только сервисов больше одного и они ходят друг к другу по сети — вы уже в распределённой системе, даже если не планировали. Любой сетевой вызов может потеряться, зависнуть или уткнуться в тайм-аут; любой узел может замолчать посреди операции. Практическая рамка: не тяните распределённость раньше времени (три условия, когда она нужна), но если она есть — проектируйте под частичный отказ: тайм-ауты и повторы (идемпотентные!), никакого упорядочивания по стенным часам, решения через кворум, fencing на разделяемых ресурсах.
Где спотыкаются начинающие:
- Считают, что «нет ответа» значит «узел упал» — это неотличимо от потери ответа или медленного узла. Отсюда двойные списания и потерянные данные без единой ошибки в логах.
- Упорядочивают события по
currentTimeMillis()— часы узлов разошлись, и LWW молча теряет запись, которую клиент считал сохранённой. - Проверяют lock и сразу пишут — GC-пауза между проверкой и записью даёт двух владельцев. Нужен fencing token, проверяемый ресурсом.
- Узел верит собственному «я ещё ведущий» — за время его паузы кворум уже выбрал другого. Истину определяет большинство.
- Тянут микросервисы «ради масштаба» — и получают все проблемы распределённых систем там, где хватило бы одного узла.
Что почитать дальше: модели репликации — кворумы w+r>n и конфликты записи; строительные блоки — где в системе живут очереди, репликация и координация; распределённый лок без fencing — механизм ограждающего маркера с кодом; когда нужны распределённые паттерны — три условия и три альтернативы. Первоисточник — Мартин Клеппман, «Высоконагруженные приложения», глава 8.