Распределённые системы
Время и порядок: почему таймстемпы по настенным часам врут
Пользователь меняет адрес доставки, видит тост об успехе, делает рефреш — и старый адрес вернулся. Ни ошибки, ни строки в логе, ни исключения. Кластер Cassandra использовал last-write-wins по клиентскому таймстемпу, а часы на узле, обработавшем апдейт, отставали на 1.2 секунды. Поэтому «новая» запись пришла со штампом раньше, чем строка, которую должна была заменить. База сравнила два числа, оставила большее и выбросила изменение пользователя как устаревшее. Про таймстемпы она говорила правду, про реальность — врала.
Настенные часы — не те часы, по которым можно упорядочивать
У каждой машины есть кварцевый кристалл, который дрейфует — обычно на десятки частей на миллион, поэтому свободные часы уплывают на миллисекунды в минуту и секунды в сутки. NTP это правит, периодически подгоняя (slew) или прыгая (step) к эталону. Прыжок — опасная часть: когда локальные часы достаточно сильно ушли, ntpd прыгает ими, и этот прыжок может пойти назад. На несколько сотен миллисекунд «сейчас» оказывается раньше, чем «сейчас», которое ты уже прочитал. По флоту из десятков узлов даже здоровый NTP оставляет skew порядка миллисекунд; упавший демон NTP или VM, приостановленная гипервизором, разгоняют это до секунд или минут.
Поэтому таймстемп, сгенерированный на машине A, и таймстемп с машины B несравнимы как порядок. У них общие единицы измерения и больше ничего. В тот момент, когда твоя корректность зависит от того, что tsA < tsB означает «A произошло раньше B», ты построил на песке.
Last-write-wins — потеря данных в ожидании сдвига часов
Баг с адресом — каноничный продакшен-провал, и он встроен в любую систему, которая разрешает конфликты сравнением настенных таймстемпов: Cassandra, Riak, хранилища в стиле DynamoDB в режиме LWW. Правило — «оставить запись с наибольшим таймстемпом». Когда часы разъехались, наибольший таймстемп — это не самая поздняя запись, а запись с узла, чьи часы спешат. По-настоящему более новая запись проигрывает, и, что критично, клиент получает успешный ответ. Нет ошибки, которую можно поймать, нет метрики для алерта. Данные просто исчезли, и узнаёшь ты об этом, когда жалуется клиент.
С удалениями хуже. Tombstone, записанный с таймстемпом в далёком будущем — баговый клиент, узел с часами, прыгнувшими вперёд, — подавит любую реальную запись ниже себя, пока compaction не соберёт tombstone мусором, а это могут быть дни. Один сдвинувшийся узел способен стереть ключ на неделю. Анализ Jepsen сформулировал прямо: настенные таймстемпы — фундаментально небезопасный конструкт для упорядочивания.
Почему это работает
«Просто запусти NTP» тебя не спасёт. NTP держит skew малым в среднем, но LWW — это не среднее, это худший случай. Ты теряешь данные на том одном узле, в ту одну минуту, когда часы сдвинулись. И провал тихий: запись возвращает 200 OK, строка выброшена, и ничто в логах не отличает её от записи, которой просто никогда не было.
Логические часы: упорядочивать события без доверия к стене
Фикс — перестать измерять физическое время и начать измерять причинность. Таймстемп Лампорта — это один счётчик на процесс: инкремент на каждое локальное событие, а при получении сообщения выставляешь счётчик в max(local, received) + 1. Это гарантирует: если событие A happens-before B (A причинно предшествует B), то L(A) < L(B). Получаешь согласованный полный порядок бесплатно, за O(1) памяти на событие.
Чего часы Лампорта не умеют — это обратное: L(A) < L(B) не означает, что A вызвало B. Два события на узлах, которые никогда не общались, могут быть по-настоящему конкурентными, но Лампорт всё равно навязывает им произвольный порядок — он не умеет детектировать конкурентность, только навязать порядок. Для системы разрешения конфликтов эта слепота — и есть вся проблема: тебе нужно знать, что две записи конкурентны, чтобы их слить, а не молча выбрать одну.
Векторные часы это возвращают. Каждый узел несёт вектор — по счётчику на каждый узел системы — и шлёт весь вектор с каждым сообщением. Теперь можно сравнить два события: если каждая компонента A ≤ компоненты B и хотя бы одна строго меньше, то A happens-before B; если ни один вектор не доминирует над другим, события конкурентны, и система может поднять обе версии (siblings), чтобы приложение их слило. Цена — вот загвоздка: O(n) памяти на штампованное значение, где n — число узлов, плюс метаданные едут с каждой записью. На кластере в 200 узлов это 200 счётчиков, прицепленных к данным, и вектор растёт вместе с кластером.
| Подход | Детектирует конкурентность? | Цена на штамп | Продакшен-подвох |
|---|---|---|---|
| Настенный таймстемп (LWW) | Нет | O(1), одно число | Молчаливая потеря данных при skew; ошибка не возвращается |
| Таймстемп Лампорта | Нет — только полный порядок | O(1), один счётчик | Упорядочивает конкурентные записи произвольно; не слить конфликты |
| Векторные часы | Да — помечает конкурентные события | O(n), счётчик на каждый узел | Метаданные растут с кластером; нужна обрезка |
| TrueTime (Spanner) | Н/Д — даёт реальный порядок | GPS + атомные часы на датацентр | Commit-wait добавляет латентность; нужно спецжелезо |
TrueTime: заплати железом, чтобы настенные часы стали честными
Spanner от Google делает противоположную ставку: вместо отказа от физического времени — сделать его надёжным и квантифицировать своё незнание. TrueTime ставит в каждый датацентр GPS-приёмники и атомные часы, синхронизируемые примерно каждые 30 секунд, и отдаёт TT.now() не как один момент, а как интервал [earliest, latest]. Ширина этого интервала — неопределённость ε — обычно около 1мс (меньше 1мс на 99-м перцентиле в опубликованных Google числах, иногда расширяясь до единиц миллисекунд, когда time master отвечает медленно).
Хитрая часть — commit-wait. Spanner назначает таймстемп коммита s, затем намеренно спит, пока TT.now().earliest > s — пока не станет уверен, что таймстемп лежит в прошлом везде. Это ожидание — размером с ε, несколько миллисекунд, и оно идёт параллельно с репликацией Paxos, поэтому часто стоит мало настенного времени. Выигрыш — внешняя согласованность (линеаризуемость по всей базе): если транзакция T1 закоммитилась до старта T2, таймстемп T1 гарантированно меньше таймстемпа T2 — глобально, без оговорок про skew. Spanner превращает неопределённость часов из тихого бага корректности в явную ограниченную цену по латентности.
Мультирегиональному key-value хранилищу нужно разрешать конкурентные записи в один ключ, не теряя молча апдейт пользователя. Какой механизм упорядочивания подходит?
Кластер Cassandra использует last-write-wins по клиентским таймстемпам. Часы одного узла отстают на 1.5с. Апдейт пользователя, прошедший через этот узел, исчезает после рефреша. Почему клиент не увидел ошибки?
Что на самом деле делает commit-wait в Spanner и что он покупает?
Расставь цепочку событий, превращающую skew часов в молчаливую потерю данных при last-write-wins:
- 1 Часы одного узла дрейфуют (или NTP прыгает ими) и отстают от остального флота
- 2 По-настоящему более новая запись идёт через этот узел и штампуется таймстемпом из прошлого
- 3 Разрешение конфликта сравнивает таймстемпы и оставляет существующую (с бóльшим таймстемпом) строку
- 4 Более новая запись выброшена — но клиент всё равно получает успешный ответ
- 5 Через недели клиент сообщает, что изменение пропало; ни лог, ни метрика этого не пометили
- 01Коллега говорит: «у нас на каждом узле работает NTP, значит last-write-wins по таймстемпу безопасен». Объясни, почему это рассуждение неверно и что на самом деле происходит.
- 02Когда ты возьмёшь векторные часы вместо таймстемпов Лампорта и чего это стоит?
По всему флоту настенное время — не примитив упорядочивания: дрейф кварца плюс прыжки NTP (которые могут сдвинуть часы назад) оставляют skew в миллисекунды в норме и в секунды-минуты, когда что-то ломается. Любая система, разрешающая конфликты сравнением клиентских таймстемпов — last-write-wins в Cassandra, Riak, режиме LWW DynamoDB — превращает этот skew в молчаливую потерю данных, потому что наибольший таймстемп принадлежит самым быстрым часам, а не самой поздней записи, и клиент всё равно получает успех. Логические часы чинят порядок, измеряя причинность вместо физического времени: таймстемпы Лампорта дают дешёвый полный порядок за O(1), но не детектируют конкурентность, поэтому навязывают конкурентным записям произвольный порядок; векторные часы несут счётчик на каждый узел, детектируют конкурентность и позволяют хранилищу держать конфликтующие siblings — ценой O(n) метаданных, растущих с кластером. Google Spanner идёт третьим путём: TrueTime квантифицирует неопределённость часов как ограниченный интервал ε (около 1мс через GPS и атомные часы), а commit-wait намеренно пережидает это окно, чтобы дать внешнюю согласованность, превращая тихий баг корректности в явную ограниченную цену по латентности. Инстинкт сеньора прост: никогда не доверяй стене упорядочивание распределённых записей.