Цели главы. Разобраться, почему физическим часам в распределённой системе нельзя доверять упорядочение событий, и построить работающую замену: отношение «произошло раньше», часы Лэмпорта, векторные часы. В конце — две конструкции, доводящие идеи до практики: алгоритм согласованного снимка Чанди–Лэмпорта и гибридные логические часы, на которых стоят современные распределённые СУБД. Это самая «математическая» глава части I — и одновременно источник самых частых производственных ошибок (все они начинаются со слов «возьмём метку времени»).
Кварцевый генератор дрейфует — типично на десятки миллионных долей, то есть на миллисекунды в минуту и секунды в сутки. Синхронизация по NTP возвращает часы к истине с точностью от единиц до десятков миллисекунд в хорошей сети — и произвольно плохо при сетевых проблемах; более того, коррекция NTP может перевести часы назад, так что даже на одной машине последовательные вызовы «который час» могут убывать (поэтому для измерения длительностей существуют монотонные часы — они не для календаря, но хотя бы не идут вспять). Виртуализация добавляет паузы гипервизора, миграции — скачки.
Следствие для распределённой системы: если события на двух узлах помечены физическим временем с разницей меньше погрешности синхронизации (миллисекунды — а под них попадает практически вся конкурентная нагрузка), их порядок по меткам — фикция. Канонический производственный ущерб — репликация «последняя запись побеждает» (LWW): узел с часами, спешащими на 30 мс, систематически выигрывает конфликты у честного соседа, молча затирая его свежие записи; заметить это без специального аудита почти невозможно. Отсюда программа главы: заменить вопрос «когда произошло?» вопросом «что от чего зависело?».
Лэмпорт (1978) определил на событиях системы (в модели главы 1) отношение произошло-раньше, обозначаемое a → b, как наименьшее транзитивное отношение, такое что:
Содержательно a → b означает: информация о событии a могла достичь точки события b — a лежит в причинном прошлом b. Если ни a → b, ни b → a, события называются конкурентными (a ∥ b): никакая из сторон не могла знать о другой, и любой их взаимный порядок — вопрос соглашения, а не факта. Это точный смысл слова «одновременно» в распределённой системе: не «в один момент по часам» (бессмысленно — см. 2.1), а «причинно независимо». Всё дальнейшее содержание главы — способы вычислять отношение → эффективно.
Простейший механизм — скалярные часы Лэмпорта: каждый процесс держит целочисленный счётчик L.
Свойство (доказывается индукцией по определению →, упражнение 2): если a → b, то L(a) < L(b). Причинный порядок никогда не нарушается номерами. Обратное неверно: из L(a) < L(b) не следует a → b — конкурентные события тоже получают какие-то, вообще говоря различные, номера. Часы Лэмпорта упорядочивают причинность, но не умеют распознавать конкурентность.
Практическое усиление: пара (L, идентификатор процесса) со сравнением лексикографически даёт тотальный порядок, согласованный с причинностью, — все узлы, ничего не согласовывая, одинаково упорядочат любые два события. На этом строится классика: справедливые распределённые блокировки, детерминированное разрешение ничьих, упорядочение операций в реплицируемых журналах.
Чтобы распознавать конкурентность, счётчик заменяют вектором: процесс pi в системе из n процессов держит вектор V из n счётчиков, где V[j] — «сколько событий процесса pj лежит в моём причинном прошлом».
Сравнение векторов: V ≤ W, если V[k] ≤ W[k] по всем k; V < W, если вдобавок хоть где-то строго. Точная характеристика (в отличие от лэмпортовских): a → b тогда и только тогда, когда V(a) < V(b); несравнимые векторы = конкурентные события. Векторные часы — полный детектор причинности; цена — O(n) на каждое сообщение и головная боль при динамическом множестве участников.
Практика: версионные векторы (близкий родственник) в Dynamo-подобных хранилищах отличают «новая версия перекрывает старую» от «версии конкурентны — конфликт, требуется слияние» (вспомните лестницу разрешения конфликтов из статьи о надёжности: честные варианты стоят именно на этом механизме). Там, где участников слишком много, платят точностью: усечённые векторы дают ложные конфликты, LWW — молчаливые потери; инженерный выбор между ними должен быть осознанным.
Применим накопленное к практической задаче: снять глобальное состояние работающей системы — всех процессов и всех сообщений в пути — не останавливая её. Наивное «каждый снимет себя в полночь» разбивается об 2.1: полуночи, единой для всех, не существует, и снимок получится физически противоречивым (деньги, списанные в снимке A и ещё не зачисленные в снимке B, «исчезли»). Правильное требование к снимку — не одновременность, а согласованность: если событие b попало в снимок и a → b, то a тоже попало (снимок замкнут относительно причинного прошлого — «разрез» истории, который не пересекает ни одной стрелки причинности справа налево).
Алгоритм Чанди–Лэмпорта (1985), в предположении надёжных FIFO-каналов:
Маркеры, распространяясь волной, «разрезают» историю согласованным образом: FIFO гарантирует, что сообщения, отправленные до фиксации отправителя, не проскочат мимо учёта. Алгоритм красив сам по себе, но включён в курс за живучесть идеи: согласованные контрольные точки Apache Flink используют родственную схему с барьерами в потоках. Детали отличаются: выровненные checkpoints задерживают обработку за быстрыми барьерами, а невыровненные дополнительно фиксируют данные в каналах. Согласованные бэкапы шардированных систем решают ту же общую задачу.
Рис. 2.1. Согласованный разрез включает локальные состояния процессов и сообщения, находившиеся в каналах.
Инженерам хочется невозможного: чтобы метка была и причинно корректной (как Лэмпорт), и близкой к физическому времени (читаемой человеком, пригодной для TTL и диапазонных запросов). Компромисс — гибридные логические часы (HLC, 2014): метка (pt, l), где pt учитывает локальное физическое время и виденные метки, а логическая часть l разводит события с одинаковой физической компонентой. При корректном распространении меток HLC сохраняют причинный порядок и остаются близки к физическим часам; предел близости зависит от принятых ограничений на рассинхронизацию. HLC использует, например, CockroachDB. Другие СУБД решают задачу иначе: TiDB получает метки от TSO, YDB использует логическое время координаторов, а Spanner применяет TrueTime с атомными часами и GPS и отвечает о фиксации только после commit-wait. Эти конструкции встретятся нам в главе 9.
Ответы и указания. 1: причинные рёбра порождают цепочки a→c→d→e→f и a→b; b конкурентно с c, d, e и f. Метки Лэмпорта: a=1, b=2, c=2, d=3, e=4, f=5. Векторы: a=(1,0,0), b=(2,0,0), c=(1,1,0), d=(1,2,0), e=(1,2,1), f=(1,2,2). 2: для локального порядка счётчик строго увеличивается; при отправке метка сообщения равна метке события отправки, а получение берёт максимум с ней и добавляет один; транзитивность сохраняет неравенство по цепочке. Это покрывает три порождающих случая определения →. 3: b и e имеют L(b)=2 < L(e)=4, но b ∥ e; векторы (2,0,0) и (1,2,1) несравнимы. 4: в обоих сценариях побеждает запись на B: во втором случае более ранняя реальная запись получает большую метку и стирает более позднюю. LWW систематически отдаёт преимущество спешащим часам. 5: процесс P фиксирует состояние, отправляет маркер M, а затем обычное сообщение x. В не-FIFO-канале x обгоняет M. Процесс Q сначала получает и применяет x, а при последующем получении M фиксирует уже изменённое локальное состояние. Снимок P не содержит отправку x, произошедшую после его фиксации, тогда как снимок Q содержит её следствие: разрез получился несогласованным. 6: несогласованный разрез фиксирует узел 1 после списания, узел 2 до зачисления и не учитывает канал; согласованный снимок помещает перевод в состояние канала и сохраняет сумму. 7: вектор только по долговечным репликам уменьшает размер, но теряет точную причинность клиентов; усечение превращает забытые зависимости в ложную конкурентность или, при неосторожном слиянии, скрывает конфликт; dotted version vectors компактно отделяют одно новое событие от контекста, но требуют аккуратного управления идентификаторами и контекстом причинности.