Распределённые системы Время в распределённой системе: часы, логические часы, векторные часы
0%

Время в распределённой системе: часы, логические часы, векторные часы

Время в распределённой системе: часы, логические часы, векторные часы

В однопроцессной программе время — скучная сущность. Есть монотонно растущий счётчик, есть порядок инструкций, и если событие A произошло раньше B, то timestamp(A) < timestamp(B). На этом молчаливом допущении построены логи, кэши, TTL, «последняя запись побеждает», отладка и здравый смысл инженера.

В распределённой системе это допущение — ложь. Не «неточность в пределах погрешности», а именно ложь: метка времени, полученная на узле A, и метка, полученная на узле B, могут быть упорядочены как угодно, независимо от того, что произошло раньше на самом деле. Хуже: «на самом деле» тоже перестаёт быть хорошо определённым понятием, потому что «одновременность» двух событий на разных машинах невозможно установить наблюдением.

Разберём три слоя: физические часы (что реально тикает и почему врёт), логическую причинность (happens-before, часы Лампорта, векторные часы, version vectors) и гибриды (HLC, TrueTime, PTP), а в конце — честный ответ на вопрос, почему exactly-once обычно миф. Предыдущая статья — https://courses.digitable.life/post/distributed-systems/01-failure-models/ — про то, что может сломаться. Эта про то, что сломается даже когда ничего не «сломалось»: все узлы живы, сеть работает, а система всё равно теряет данные.

1. Что физически тикает внутри машины

Прежде чем ругать часы, надо понять, какие именно часы есть у процесса. Их как минимум три, и путать их — самая частая ошибка в коде.

Wall clock (CLOCK_REALTIME) — «настенные часы», Unix-время от 1970-01-01 UTC. Это time.time() в Python, System.currentTimeMillis() в Java, time.Now() в Go. Их можно двигать: NTP подкручивает вперёд и назад, администратор выставляет руками, виртуалка после миграции или resume из снапшота получает скачок. Wall clock не монотонен.

Monotonic clock (CLOCK_MONOTONIC) — счётчик, растущий с момента загрузки машины. Его нельзя перевести назад, NTP может лишь менять скорость хода (slewing). Абсолютного смысла у значения нет: монотонные часы двух машин считают от разных нулей, сравнивать их между машинами бессмысленно. Годятся ровно для одного — измерения длительности внутри одного процесса.

Аппаратная база — TSC процессора, HPET, ACPI PM timer. TSC читается за единицы наносекунд, но исторически ломался при смене частоты ядра, миграции потока между сокетами и live migration виртуалок. Современные CPU дают constant_tsc и nonstop_tsc.

# какой источник времени реально использует ядро
cat /sys/devices/system/clocksource/clocksource0/current_clocksource
# состояние синхронизации: offset, разброс, root dispersion
chronyc tracking && chronyc sources -v
# самое важное число — не «Last offset», а Root dispersion + Root delay/2:
# это верхняя граница расхождения ваших часов с истиной

Практическое правило, из которого нет исключений:

Длительность измеряют монотонными часами. Момент во времени записывают wall clock. Никогда не вычитайте два wall-clock значения, если от результата что-то зависит.

// ПЛОХО: разность wall-clock. NTP-коррекция во время выполнения даст отрицательный latency.
start := time.Now().UnixNano()
doWork()
elapsed := time.Now().UnixNano() - start // может оказаться < 0

// ХОРОШО: time.Time в Go несёт монотонную компоненту, Sub/Since используют именно её.
start := time.Now()
doWork()
elapsed := time.Since(start) // скачок wall clock не влияет
// ЛОВУШКА: сериализация «съедает» монотонную часть — после Parse это снова wall-clock арифметика.

Тот же класс ошибок в Python (time.time() против time.monotonic() / time.perf_counter()) и в JVM (System.currentTimeMillis() против System.nanoTime()).

2. Как именно врут часы, и как это выглядит в логах

Дрейф. Кварцевый генератор обычного сервера имеет точность 10–100 ppm; 100 ppm — это 8,6 секунды в сутки, так что без синхронизации кластер из тридцати машин через неделю имеет разброс в минуты. Дрейф зависит от температуры: машина в горячем ряду ДЦ уходит быстрее соседей.

Коррекция: slew против step. NTP исправляет расхождение либо плавно меняя скорость хода (обычно не больше 500 ppm, монотонность сохраняется), либо прыжком выставляя значение, если расхождение больше порога (ntpd по умолчанию 128 мс). А при расхождении больше 1000 секунд классический ntpd вообще отказывается работать (panic threshold) и умирает, оставляя часы как есть: машина с испорченным RTC после длинного даунтайма просто останется во вчерашнем дне.

Jul 16 03:14:02 api-07 chronyd[812]: System clock wrong by -4.128951 seconds, adjustment started
Jul 16 03:14:02 api-07 chronyd[812]: System clock was stepped by -4.128951 seconds
Jul 16 03:14:02 api-07 app[9911]: request_id=7c1e latency_ms=-4128 status=200
Jul 16 03:13:58 api-07 app[9911]: request_id=7c1f started

Здесь два симптома. Отрицательная латентность — разность wall clock через шаг назад. И немонотонные метки в самом файле лога: строка с 03:13:58 идёт после строки с 03:14:02. Если ваш парсер логов или SIEM предполагает возрастающие таймстемпы, он потеряет окно целиком.

Leap second. Вставка 23:59:60 в UTC 30 июня 2012 года уронила заметную часть интернета: баг в ядре Linux приводил к шквалу wakeups в hrtimer, и машины с Java и MySQL уходили в 100% CPU. Логи выглядели как «всё живо, но всё стоит»:

Jun 30 23:59:60 db-02 kernel: Clock: inserting leap second 23:59:60 UTC
Jul 01 00:00:03 db-02 mysqld: [Warning] Got timeout reading communication packets
Jul 01 00:00:11 db-02 monitoring: cpu.system=99.4 load1=210 no process progress

Индустрия пришла к leap smear: Google, AWS и Meta размазывают секунду по 24 часам, слегка замедляя ход часов. Побочный эффект — в течение суток размазывания ваши часы отличаются от «настоящего» UTC до 0,5 с, и если часть флота смотрит на смазывающий NTP-пул, а часть на публичный pool.ntp.org, разброс между узлами вырастает на полсекунды. Это регулярный источник инцидентов вида «сервисы в двух AZ разошлись по времени».

Виртуализация. Гостевая ОС не владеет процессором. При steal time часы гостя могут «замереть», при live migration и восстановлении из снапшота — прыгнуть. Классический симптом в Kubernetes: под с JVM «пропадает» на 12 секунд, при этом в GC-логе никакой паузы нет, потому что паузу дал гипервизор.

Правильная ментальная модель: системные часы — это кэш значения, которое приходит по сети от чужого сервера, с задержкой, потерями и возможностью полного отказа. Именно поэтому Google в Spanner не стал притворяться, что часы точны, а сделал API, возвращающий интервал (раздел 8).

3. Сценарии отказа, которые вызывает доверие к wall clock

Last-write-wins теряет запись. Cassandra разрешает конфликты по метке времени ячейки, которую по умолчанию проставляет координатор запроса или клиент. Два клиента пишут в один ключ; клиент на узле со спешащими часами записал раньше, но с большей меткой. Более поздняя по факту запись отбрасывается молча — ни ошибки, ни лога, ни метрики. Данные просто исчезают (документация Cassandra про Dynamo-модель). Симптом в проде: «пользователь поменял настройку, страница показала успех, через пять минут вернулось старое значение».

Удаление воскресает. В Cassandra и ScyllaDB удаление — это tombstone с меткой времени. Запись со спешащими часами «переживает» корректное удаление и снова становится видимой. В логах — ничего, в данных — удалённая строка.

TTL истекает мгновенно или никогда. Кэш пишет expires_at = now() + 300. Узел, который читает, имеет часы на 400 с впереди — ключ считается протухшим сразу, hit rate падает до нуля, база получает полный трафик. Видно как внезапный скачок QPS в БД без изменения внешнего трафика.

JWT и TLS отвергаются. nbf в токене — момент по часам эмитента. Если валидатор отстаёт, свежевыданный токен «ещё не действителен»:

2026-07-16T09:00:01Z gateway WARN jwt validation failed: token used before issued (nbf=09:00:03Z, now=09:00:01Z)

Лечится допуском clock_skew_leeway в 30–60 секунд в любой проверке exp, nbf, iat и notBefore.

Распределённая блокировка по TTL пропускает двоих. Клиент берёт ключ с TTL 10 с и считает, что владеет ресурсом 10 с. Но пауза GC, шаг часов или задержка сети делают так, что владение истекло, а клиент об этом не знает и продолжает писать. Разбор — заметка Мартина Клеппмана How to do distributed locking; лекарство — fencing token, монотонно растущий номер, который проверяет само хранилище. Подробнее в https://courses.digitable.life/post/distributed-systems/10-coordination/.

Лиза в etcd делает почти правильно — и всё равно ловит. TTL лизы отсчитывает лидер etcd по своим монотонным часам, а не клиент, поэтому расхождение часов клиента и кластера на корректность не влияет — это уже сильно лучше, чем «TTL по wall clock». Но остаются две ловушки: при смене лидера таймеры лиз перезапускаются с нуля, и лиза фактически живёт дольше объявленного TTL; а о самом истечении (lease expired, ключ владения исчез) клиент узнаёт на десятки-сотни миллисекунд позже события и всё это время считает себя владельцем. Вывод тот же: TTL — верхняя граница уверенности, а не факт. Писать в ресурс по такой уверенности можно только с ограждением, роль которого в etcd играет mod_revision в сравнении внутри Txn.

Rate limiter с окном по wall clock. Шаг часов назад — окно «сбрасывается», лимит обходится. Шаг вперёд обнуляет счётчики всех арендаторов разом, и на бэкенд приходит синхронный залп.

Трассировка со спаном в отрицательное время. Дочерний span заканчивается «раньше», чем начался родительский, потому что они на разных машинах. Jaeger и Tempo рисуют такие спаны криво или отбрасывают. Восстанавливать порядок надо не временем, а причинностью через контекст трассировки (https://courses.digitable.life/post/distributed-systems/12-observability/).

Общий вывод: wall clock допустим для отображения человеку и для грубых метрик и недопустим как источник упорядочивания решений.

4. Happens-before: порядок без часов

В 1978 году Лесли Лампорт опубликовал Time, Clocks, and the Ordering of Events in a Distributed System — вероятно, самую цитируемую работу в области. Ключевая идея: перестать спрашивать «когда» и начать спрашивать «могло ли одно повлиять на другое».

Отношение happens-before () — наименьшее отношение, для которого выполняются три правила: если a и b — события одного процесса и a раньше b в программном порядке, то a → b; если a — отправка сообщения, а b — его приём, то a → b; и оно транзитивно, то есть из a → b и b → c следует a → c.

Если ни a → b, ни b → a, события конкурентны: a ∥ b. Конкурентность — не «одновременность». Она означает, что информация об одном не могла достичь другого, и потому никакой наблюдатель не вправе утверждать, какое было первым. Отсюда главное: частичный порядок, и это принципиально. Физическое время даёт полный порядок, но фальшивый; happens-before даёт неполный, но честный. Всё дальнейшее — поиск компромисса между этими двумя.

Пространственно-временная диаграмма: причинность и физические метки расходятся

На схеме событие c1 причинно предшествует a3 (между ними сообщение m3), но физическая метка c1 больше. Любой алгоритм, сортирующий события по wall clock, построит здесь порядок, противоречащий реальности. А a2 и c1 конкурентны — и система, которая заявляет, что «знает», какое было раньше, просто выдумывает.

5. Часы Лампорта: минимальный счётчик

Логические часы Лампорта — целое число C на каждом процессе и три правила:

1. Перед каждым локальным событием:       C = C + 1
2. При отправке сообщения:                C = C + 1;  отправить (msg, C)
3. При получении сообщения (msg, Cm):     C = max(C, Cm) + 1

Свойство (clock condition): если a → b, то C(a) < C(b). Критически важно, что обратное неверно: из C(a) < C(b) не следует a → b, события могут быть конкурентными. Часы Лампорта обнаруживают потенциальную причинность, но не отличают её от конкурентности. Это их единственный, но серьёзный недостаток.

from dataclasses import dataclass


@dataclass
class LamportClock:
    """Скалярные логические часы. O(1) по времени и памяти, 8 байт на сообщение."""
    counter: int = 0

    def tick(self) -> int:            # локальное событие или отправка
        self.counter += 1
        return self.counter

    def receive(self, incoming: int) -> int:   # приём: подтягиваемся и делаем шаг
        self.counter = max(self.counter, incoming) + 1
        return self.counter

Сложность. O(1) по времени и памяти на процесс, одно целое на сообщение. Часы Лампорта практически бесплатны — их можно вешать на всё.

Где применяют. Там, где нужен произвольный, но согласованный полный порядок. Частичный порядок доводят до полного ключом сортировки (counter, node_id): он детерминирован и одинаков на всех узлах, хотя реальное время не отражает. Так устроен алгоритм взаимного исключения Лампорта. Offset в партиции Kafka — по сути те же логические часы одного писателя-лидера: монотонный счётчик, к времени отношения не имеющий.

Где ломается. Как только нужно ответить «это конфликт или обновление?», скаляра не хватает. Именно этот вопрос стоит перед любой multi-leader репликацией (https://courses.digitable.life/post/distributed-systems/05-replication/).

6. Векторные часы: отличить конфликт от обновления

Векторные часы (Colin Fidge и Friedemann Mattern, независимо, 1988) — вектор из N счётчиков, по одному на процесс. Процесс i увеличивает V[i] на своих событиях, а при приёме берёт поэлементный максимум:

1. Локальное событие на процессе i:    V[i] = V[i] + 1
2. Отправка процессом i:               V[i] = V[i] + 1;  отправить (msg, V)
3. Приём процессом i вектора Vm:       V = поэлементный max(V, Vm);  V[i] = V[i] + 1

Сравнение: V ≤ W, если V[k] ≤ W[k] для всех k; V < W (строго раньше), если V ≤ W и хотя бы в одной позиции строго меньше; иначе — конкурентны. И теперь эквивалентность работает в обе стороны: a → b тогда и только тогда, когда V(a) < V(b). Векторные часы характеризуют причинность, а не дают лишь необходимое условие.

from enum import Enum


class Ord(Enum):
    BEFORE = "before"
    AFTER = "after"
    EQUAL = "equal"
    CONCURRENT = "concurrent"


class VectorClock:
    """Векторные часы. Словарь вместо массива — узлы приходят и уходят."""

    def __init__(self, node_id: str, vec: dict[str, int] | None = None) -> None:
        self.node_id = node_id
        self.vec: dict[str, int] = dict(vec or {})

    def tick(self) -> dict[str, int]:
        self.vec[self.node_id] = self.vec.get(self.node_id, 0) + 1
        return dict(self.vec)

    def merge(self, incoming: dict[str, int]) -> dict[str, int]:
        """Приём: поэлементный максимум, затем собственный шаг."""
        for node, value in incoming.items():
            if value > self.vec.get(node, 0):
                self.vec[node] = value
        return self.tick()

    @staticmethod
    def compare(a: dict[str, int], b: dict[str, int]) -> Ord:
        """O(N) по числу узлов, встречающихся хотя бы в одном векторе."""
        a_less = a_greater = False
        for k in set(a) | set(b):
            av, bv = a.get(k, 0), b.get(k, 0)
            a_less |= av < bv
            a_greater |= av > bv
            if a_less and a_greater:
                return Ord.CONCURRENT
        return Ord.BEFORE if a_less else Ord.AFTER if a_greater else Ord.EQUAL


assert VectorClock.compare({"A": 2, "B": 1}, {"A": 1, "C": 3}) is Ord.CONCURRENT

Сложность. Сравнение O(N), память на версию O(N), накладные расходы на сообщение O(N), где N — число узлов, когда-либо писавших. Вот это и есть главная проблема: для кластера из пяти узлов вектор занимает десятки байт, а для системы с миллионом мобильных клиентов, каждый из которых пишет напрямую, — мегабайты метаданных на объект.

Ниже — обмен, в котором векторные часы обнаруживают конфликт, а часы Лампорта его бы «сгладили».

Это ровно механизм из Dynamo: Amazon’s Highly Available Key-value Store (SOSP 2007, раздел 4.4). Там же знаменитый пример с корзиной: «добавленный товар не должен исчезать», поэтому конфликт разрешается объединением. Riak сделал эту модель публичным API (X-Riak-Vclock), Voldemort и Cassandra пошли разными путями.

6.1. Version vectors — не то же самое, что vector clocks

Терминология исторически запутана, и путаница дорого стоит. Vector clock отслеживает события процессов, по счётчику на процесс. Version vector отслеживает версии реплик объекта, по счётчику на реплику. Разница проявляется, когда клиентов много, а реплик мало: если наивно заводить позицию на каждого клиента, вектор растёт без границ. Решение — Dotted Version Vectors (Preguiça и др., 2010): точка (dot) фиксирует конкретную запись конкретной реплики, а размер метаданных остаётся O(числа реплик).

6.2. Как выбирать механизм

Приёмы борьбы с ростом векторов на практике: ограничить число позиций числом реплик, а не клиентов; обрезать старые записи по размеру и времени (Riak делал это через small_vclock/big_vclock — ценой ложных конфликтов, что безопаснее потерянной записи); использовать Interval Tree Clocks с явными операциями fork/join для динамического состава участников; либо уйти от порядка вовсе к CRDT, где слияние ассоциативно, коммутативно и идемпотентно (https://courses.digitable.life/post/distributed-systems/05-replication/).

7. Гибридные логические часы (HLC)

Логические часы честны, но бесполезны для человека и для запросов вида «дай состояние на 12:00». Wall clock понятен, но лжив. HLC (Kulkarni, Demirbas и др., 2014) склеивает оба: метка — пара (l, c), где l близка к wall clock, а c разрывает ничьи внутри одной миллисекунды.

class ClockDriftError(RuntimeError): ...


class HLC:
    """Гибридные логические часы: метка (l, c), сравнение лексикографическое."""

    MAX_DRIFT_MS = 500  # бюджет расхождения; больше — отказываемся принимать сообщение

    def __init__(self, wall_ms) -> None:
        self.wall_ms = wall_ms   # функция: текущее физическое время в мс
        self.l = 0
        self.c = 0

    def now(self) -> tuple[int, int]:
        pt = self.wall_ms()
        if pt > self.l:
            self.l, self.c = pt, 0   # физические часы обогнали — берём их
        else:
            self.c += 1              # физические часы отстали — растим счётчик
        return (self.l, self.c)

    def update(self, m_l: int, m_c: int) -> tuple[int, int]:
        pt = self.wall_ms()
        if m_l - pt > self.MAX_DRIFT_MS:
            # чужие часы слишком далеко впереди: принять — значит утащить свои вперёд
            raise ClockDriftError(f"remote HLC ahead by {m_l - pt} ms")
        prev_l, self.l = self.l, max(self.l, m_l, pt)
        if self.l == prev_l == m_l:
            self.c = max(self.c, m_c) + 1
        elif self.l == prev_l:
            self.c += 1
        elif self.l == m_l:
            self.c = m_c + 1
        else:
            self.c = 0
        return (self.l, self.c)

Свойства. HLC сохраняет clock condition (a → b влечёт HLC(a) < HLC(b)), при этом l не уходит от wall clock дальше максимального перекоса в кластере. Размер метки — 64 бита (типично 48 на l, 16 на c), сложность O(1) по времени и памяти. Это делает HLC почти бесплатной заменой часов Лампорта там, где нужна ещё и человекочитаемость.

Кто использует. CockroachDB (метки транзакций, max_offset по умолчанию 500 мс), MongoDB (clusterTime и afterClusterTime в causal consistency sessions), YugabyteDB.

Сценарий отказа, специфичный для HLC. Узел с часами, ушедшими на час вперёд, отправляет сообщение. Соседи либо утаскивают свою l на час вперёд — и весь кластер «живёт в будущем», пока реальное время не догонит, — либо отвергают сообщение. CockroachDB выбирает третий вариант: узел с расхождением больше max_offset совершает самоубийство.

F260716 09:12:44.113 clock synchronization error: this node is more than 500ms away from
at least half of the known nodes (0 of 4 nodes are within the offset)
F260716 09:12:44.113 *** node exiting to avoid serving stale or inconsistent data

Это правильное поведение: узел выбирает недоступность вместо неверных данных. Смысл такого выбора — тема следующей статьи, https://courses.digitable.life/post/distributed-systems/03-cap-and-pacelc/.

8. TrueTime: заплатить временем за правду

Spanner (Corbett et al., OSDI 2012) пошёл другим путём: вместо того чтобы притворяться, будто часы точны, Google построил инфраструктуру из GPS-приёмников и атомных часов в каждом ЦОД и сделал API, где TT.now() возвращает интервал [earliest, latest], гарантированно содержащий истинное время. Ширина интервала — , на практике 1–7 мс.

TrueTime: интервал неопределённости и commit-wait в Spanner

Механизм commit-wait: транзакция выбирает метку s = TT.now().latest, а затем специально ждёт, пока TT.now().earliest > s, и только потом отпускает замки. Ожидание в среднем равно . Ценой этой задержки Spanner получает внешнюю согласованность: если транзакция T1 завершилась до начала T2, то s(T1) < s(T2) — глобально, между континентами, без всякой координации между этими двумя транзакциями.

Три вывода, которые стоит унести. Точность часов конвертируется в латентность: уменьшили ε вдвое — сократили commit-wait вдвое, поэтому инвестиции Google в железо есть буквально экономия миллисекунд на каждой транзакции. Интервал честнее точки: любое API времени, возвращающее точку без оценки погрешности, заставляет вызывающего врать самому себе. Это воспроизводимо не только в Google: AWS Time Sync Service с PTP даёт микросекундную точность в EC2, Aurora DSQL строит на ней аналогичный механизм, Meta развернула сопоставимую инфраструктуру — разрыв «Google может, вы нет» существенно сократился.

9. Почему «exactly-once» обычно миф

Тема формально относится к https://courses.digitable.life/post/distributed-systems/09-idempotency-and-delivery/, но корень её здесь, во времени.

9.1. Откуда берётся невозможность

Отправитель послал сообщение и не получил ответа. Причины: (а) сообщение не дошло, (б) дошло и было обработано, но потерялся ответ, (в) дошло и ещё обрабатывается. Отправитель не может их различить — это тот же аргумент, что в Two Generals Problem и в FLP-невозможности (Fischer, Lynch, Paterson, 1985). Отличить «медленно» от «мертво» без синхронных часов нельзя, а часы у нас, как мы выяснили, не синхронны.

Значит, у отправителя ровно два варианта: не повторять (at-most-once, теряем сообщения) или повторять (at-least-once, получаем дубликаты). Третьего варианта на уровне доставки не существует.

9.2. Почему дедупликация «по окну времени» ломается

Соблазнительная идея: хранить обработанные message_id за последние пять минут и отбрасывать дубликаты. Что ломается:

  • Повтор пришёл через шесть минут. Сеть держала пакет, брокер перебалансировал партиции, потребитель перезапустился и переиграл лог с чекпоинта часовой давности.
  • Часы дедупликатора шагнули. Окно очистилось раньше срока (дубликат проходит) или позже (память растёт до OOM, рестарт, полная потеря окна).
  • Окно на репликах дедупликатора разное, потому что часы разные. Дубликат проходит через ту реплику, у которой окно уже истекло.
2026-07-16T12:03:11Z worker-3 INFO  dedup hit key=order-8871 age_s=41 -> skipped
2026-07-16T12:41:52Z worker-1 INFO  processed key=order-8871 -> charge created   # тот же ключ
2026-07-16T12:41:52Z billing  WARN  duplicate charge for order-8871, amount 4990 RUB

Окно по времени — эвристика оптимизации, а не гарантия. Гарантию даёт только состояние, живущее столько же, сколько живёт бизнес-смысл операции.

9.3. Что делают вместо

Идемпотентность на стороне получателя. Ключ идемпотентности приходит от клиента и сохраняется в той же транзакции, что и эффект:

-- Ключ и эффект — в ОДНОЙ транзакции.
-- Никаких «сначала проверим, потом запишем»: это гонка.
BEGIN;
INSERT INTO idempotency_keys (key, request_hash, created_at)
VALUES ('order-8871:charge', 'sha256:...', now())
ON CONFLICT (key) DO NOTHING;
-- 0 строк => это повтор: откатываемся и возвращаем сохранённый ответ
INSERT INTO charges (order_id, amount_minor, currency) VALUES (8871, 499000, 'RUB');
COMMIT;

Монотонные последовательности вместо времени. Kafka idempotent producer выдаёт продюсеру PID, epoch и монотонный sequence number на партицию; брокер помнит последние номера и отбрасывает повторы. Работает не потому, что есть часы, а потому что есть монотонный счётчик и явная сессия (KIP-98).

Fencing tokens. Ресурс принимает запись только от владельца с номером не меньше последнего виденного. Устаревший владелец — задержавшийся после GC-паузы или шага часов — отвергается самим хранилищем, без всяких предположений о часах.

Транзакционные границы вместо доставки. То, что Kafka называет exactly-once, точнее назвать effectively-once внутри замкнутого контура: read → process → write атомарен, потому что и offset потребителя, и результат пишутся в один лог одной транзакцией. Как только результат уходит во внешнюю систему, гарантия рассыпается: у чужого API нет вашей транзакции.

Короткая формулировка: exactly-once delivery невозможен; exactly-once processing достижим, если эффект идемпотентен или транзакционно связан с фиксацией позиции. Всё остальное — маркетинг. Практические детали — в https://courses.digitable.life/post/distributed-systems/11-messaging/.

10. Эволюция подхода

11. Практический чеклист

В коде. Длительности — только монотонными часами; проверьте каждое место, где вычитаются таймстемпы. Любая проверка exp, nbf, notBefore — с допуском 30–60 с. Не сортируйте события из разных источников по wall clock, если от порядка зависит корректность: используйте sequence number источника, offset, HLC или явный причинный контекст. Не полагайтесь на TTL как на механизм взаимного исключения — только fencing token. Записывайте в события две метки: event_time (когда произошло, по часам источника) и ingest_time (когда система узнала); watermark-и в Flink и Beam построены ровно на этом различии (https://courses.digitable.life/post/data-engineering/04-streaming/).

В инфраструктуре. Один источник времени на весь флот: смешивать смазывающие и несмазывающие NTP-пулы нельзя. Мониторить не «время на узле», а offset и root dispersion каждого узла, с алертом на превышение бюджета. Алерты на stratum > 3, на потерю всех источников, на step больше порога. Проверить, что сервисы переживают шаг часов назад — это отдельный chaos-эксперимент (https://courses.digitable.life/post/distributed-systems/13-testing-distributed/): date -s "-5 seconds" на узле под нагрузкой.

В проектировании. Определитесь заранее, нужен вам порядок или момент — это разные задачи и разные инструменты. Если система допускает конкурентные записи в один ключ, выбирайте между version vectors с явным разрешением конфликтов, CRDT и консенсусом (https://courses.digitable.life/post/distributed-systems/07-consensus/). LWW по wall clock — осознанный выбор «мы согласны терять записи», и его надо записать в ADR именно такими словами.

12. Типичные ошибки

Ошибка Что происходит Чем лечится
now() - start на wall clock отрицательные латентности, ломаные SLO монотонные часы
LWW по клиентским меткам молчаливая потеря записей version vectors, CRDT, консенсус
Дедупликация по временному окну дубликаты после долгих ретраев идемпотентный ключ в той же транзакции
Блокировка по TTL два владельца одновременно fencing token, проверка на стороне ресурса
Позиция в векторе на каждого клиента метаданных больше, чем данных DVV, version vectors по репликам
Сортировка логов кластера по времени восстановленная «причинность» неверна trace и span context, causal metadata
Смешанные NTP-источники до 0,5 с расхождения в дни leap smear единый пул на весь флот
Игнорирование max_offset узел отдаёт неконсистентные данные принудительное самоустранение узла

Мини-итог

  • Wall clock — ненадёжный сетевой кэш чужого значения: годится для показа человеку и грубых метрик, но не для решений о порядке. Монотонные часы измеряют длительность, wall clock фиксирует момент; смешивать нельзя.
  • Happens-before — единственный порядок, существующий объективно. Он частичный, и это не дефект, а свойство реальности.
  • Часы Лампорта дают O(1) и полный порядок, но не отличают конкурентность от причинности; векторные часы отличают, но платят O(N) метаданных, а DVV и CRDT — способы эту цену снизить.
  • HLC даёт причинность плюс читаемость в 64 битах и стал промышленным стандартом де-факто.
  • TrueTime показывает, что точность часов буквально конвертируется в латентность транзакций, а честный API возвращает интервал, а не точку.
  • Exactly-once delivery не существует; существует at-least-once плюс идемпотентность — и это не компромисс, а правильный дизайн.

Источники

Что дальше

Мы выяснили, что узел не может знать «когда» и вынужден довольствоваться «после чего». Следующий шаг — понять, чем именно приходится жертвовать, когда сеть разрывается, и почему знаменитая формула «выбери два из трёх» описывает реальность хуже, чем её продолжение PACELC.

CAP и PACELC: что теорема на самом деле говорит и чего не говорит

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов