Grid orhid'ом погоняет
Grid orhid'ом погоняет

Три узла, одна база, клиент шлёт UPSERT. Нужно решить две вещи сразу: разрешить запись только на согласованном кластере и не допустить, чтобы после обрыва связи между узлами в журналах оказались разные версии одних и тех же данных.

В стеке вроде Raft логика такая: узлы голосуют за лидера, пишет только он; у каждого периода лидерства есть порядковый номер (term), чтобы отличать старые голоса от новых; лидер пропал — новые выборы. В Grid допуск записи устроен иначе. Лидер не выбирается голосованием. Вместо этого узлы обмениваются числовым параметром синхронизации (фазой, в смысле модели Курамото) и отдельно подтверждают каждую операцию контрольной суммой её содержимого. Этот протокол называется ORCHID.

Откуда взялся Grid

Проект начинался как хобби: in‑memory grid на Java (шардированная карта ключ → byte[], очередь записи, индексы в RAM) и идея согласовать узлы не через классические выборы лидера, а через биологически вдохновлённую модель связанных осцилляторов Курамото. Имена вроде ORCHID в документации — способ описать идею; в коде те же вещи называются технически (orchid, …). Протокол допуска записи вырос из этой модели.

Для высоконагруженного Java‑бэкенда держать горячие данные в памяти — типично: быстрое чтение и запись в пределах одного процесса, без сброса на диск на каждый запрос. Но как только появляются требования «после рестарта данные должны сохранятся» и «два узла не должны разъехаться по данным», одной оперативной памяти недостаточно.

Дальше поверх in‑memory grid постепенно выстроился цельный продукт:

  1. Журнал изменений (OpLog). Запись сначала подтверждается на диске; только после этого она видна другим сессиям. Если подтверждение не удалось, клиент получает ошибку, частично применённой строки в выборках нет.

  2. Файлы sealed GridMap на диске (.gmap / индексы .sbpt): неизменяемые снимки шардов с каталогом смещений. Оперативная память — ускоритель горячих данных, не единственное хранилище: холодные ключи вытесняются и при промахе читаются с диска.

  3. SQL: DDL и DML; подключение как по JDBC семантике так и по реактивному стеку (используют единый протокол с разной механикой работы передачи и получения данных).

  4. Между узлами — ORCHID: можно ли принять запись сейчас и какой узел присвоит ей номер в общем порядке.

Итог: Grid — распределённая SQL‑база с горячими данными в памяти, журналом и sealed‑файлами на диске, согласованием ORCHID между узлами.

Из чего состоит система сейчас:

Часть

Назначение

SQL, каталог таблиц

DDL/DML, схема

Память

Горячие данные

Диск (OpLog + sealed GridMap)

Пережить рестарт, не потерять коммит

Кластер (ORCHID + репликация)

Кто пишет и что зафиксировано

Клиент

grid:// (Reactor) и jdbc:grid://

Долговременное хранение на одном узле и репликация на соседей включаются разными настройками. Один узел с диском без соседей — штатный режим. Дальше в статье — как кластер решает, можно ли записать данные; про диалект SQL, устройство sealed GridMap и прочее — темы отдельных статей.

Чем обычно решают задачу консенсуса

В Raft (и аналогах):

  • узлы голосуют и выбирают лидера;

  • у периода лидерства есть порядковый номер (term);

  • в журнал пишет только лидер, остальные копируют;

  • лидер недоступен — снова выборы.

Модель понятная и проверенная. В Grid её не копировали: нужна была схема без отдельного режима выборов — постоянно оценивать, насколько узлы синхронизированы, и отдельно проверять согласие по конкретной операции.

Ограничение модели хорошо видно на двух узлах в одном ЦОД. Если между ними пропала сеть, ни у одной стороны нет большинства голосующих — то есть больше половины участников из конфигурации. Пока связь внутри ЦОД не восстановится, запись отклоняется у обоих: лучше отказ клиенту, чем два независимых журнала с разным содержимым.

Два вопроса. Orchid

1. Кластер сейчас может принимать данные, или узлы разъехались по синхронизации?

У каждого узла есть скалярный параметр фаза θ — вещественное число на окружности [0, 2π), как в модели Курамото. Это не системные часы ОС, а состояние осциллятора в протоколе. Узлы кластера периодически присылают свои фазы и подтягивают друг друга. Из набора фаз считается параметр порядка R ∈ [0, 1]: насколько фазы близки. Если R ниже порога (по умолчанию 0.85), запись отклоняется до рассылки предложения. Среди узлов с достаточной синхронизацией право предложить следующий номер операции в общем журнале получает узел с минимальным nodeId — без голосования за лидера. Этот узел ниже называется пишущим (phase‑ranked proposer).

2. Конкретная операция — та же, что видят остальные?

Считается контрольная сумма содержимого операции и её места в последовательности. Узел рассылает предложение; остальные отвечают «сумма совпала» или «нет». Нужно большинство голосов участников из конфигурации на одной и той же сумме. Высокий R сам по себе операцию не утверждает.

Синхронизация фаз

Подтверждение контрольной суммы

Зачем

Можно ли предлагать запись

Можно ли принять эту запись

Мера

R по фазам θ

Совпадение суммы

Отказ

Кластер не синхронизирован

Нет большинства или конфликт предложений

Один узел без других участников в конфигурации пишет сам. Если участники были сконфигурированы, а потом стали недоступны (обрыв связи), это не превращается в одиночную запись: иначе меньшая часть кластера продолжит принимать коммиты в изоляции.

Как считается R

Параметр порядка по фазам:

R = | (1/N) * сумма_k exp(i * θ_k) |

Чем ближе фаза, тем ближе R к 1. В коде — средние cos/sin и гипотенуза:

private double computeR() {
    double sx = Math.cos(phase);
    double sy = Math.sin(phase);
    int n = 1;
    for (PeerView peer : peers.values()) {
        if (!peer.seen || !countsForPhase(peer.id)) {
            continue;
        }
        sx += Math.cos(peer.phase);
        sy += Math.sin(peer.phase);
        n++;
    }
    return Math.hypot(sx / n, sy / n);
}

По умолчанию R считается по узлам своего дата‑центра. Узлы другого ЦОД в локальный R не входят. При необходимости с них отдельно ждут подтверждение контрольной суммы операции (режим с удалёнными голосующими); это не замена локальной синхронизации фаз.

Каждый узел по таймеру (параметр tick-ms, по умолчанию 10 мс) пересчитывает свою фазу и обменивается ею с узлами своего ЦОД: это и есть «тик» протокола.

У фазы есть собственная скорость изменения во времени — параметр natural-freq-hz (по умолчанию 1.0: за секунду угол фазы сам по себе проходит порядка одного полного круга 2π, если его никто не подтягивает) — и сила, с которой узлы сближают свои фазы, — coupling (по умолчанию 15). Чтобы узлы успевали сойтись, за один тик свободный сдвиг фазы должен быть заметно меньше единицы:

2 * π * natural-freq-hz * tick-ms / 1000  ≪  1

При значениях по умолчанию (natural-freq-hz = 1.0, tick-ms = 10) условие выполняется. Если задать natural-freq-hz = 50 при том же tick-ms, за один тик угол фазы уезжает слишком далеко: соседние узлы в ЦОД не успевают сблизить фазы, R падает ниже порога, и запись отклоняется на исправном кластере. Понижать order-threshold “чтобы ошибок не было” в такой ситуации — скрывать рассинхрон, а не чинить natural-freq-hz / tick-ms / coupling.

По умолчанию R считается только по узлам своего ЦОД. Узлы другого ЦОД в это число не входят. При необходимости с них отдельно ждут подтверждение контрольной суммы операции (удалённые голосующие, SYNC_VOTERS_ACROSS_DC); это дополнение к синхронизации фаз внутри ЦОД, а не перенос той же модели через WAN.

Flow записи

Пока R не ниже порога и текущий узел — пишущий (минимальный nodeId среди синхронизированных):

Сначала подтверждение в журнале на диске, потом видимость в памяти. Пока диск не подтвердил — другие сессии данные, как зафиксированные, не видят. Сбой на шаге — ошибка клиенту.

Размер большинства считается по списку участников в конфигурации кластера, а не по числу узлов, ответивших в данный момент. Исключить участника из конфигурации только затем, чтобы уменьшить требуемое большинство, нельзя: список голосующих от этого не уменьшается.

принять(операцию):
  если в конфигурации есть другие узлы, но ни один не доступен → отказ
  если R ниже порога → отказ
  если этот узел не пишущий → отказ / направить запрос на пишущий
  сумма = хеш(opSeq предыдущего коммита, байты операции)
  разослать предложение
  дождаться большинства с той же суммой
  дописать журнал, подтвердить запись на диск
  разослать коммит
  применить в карту в памяти

С клиента (.block() допустим в CLI или в синхронной точке входа приложения):

ConnectionFactory factory = ConnectionFactory.fromUrl(
        "grid://app:secret@127.0.0.1:15432/public"
                + "?connectTimeoutMs=1000&retryMode=FIXED&maxRetries=2");

factory.obtain()
        .flatMap(conn -> conn.begin()
                .flatMap(tx -> tx.sql(
                                "UPSERT INTO demo(id, name) VALUES (?, ?)")
                        .bind(1).bind("orchid")
                        .executeUpdate()
                        .then(tx.commit()))
                .doFinally(sig -> conn.close().subscribe()))
        .block();

Строгая согласованность (Jepsen): чтение с пишущего узла и только из уже зафиксированных данных.

Когда узлы теряют связь

Узлов в конфигурации

Ситуация

Кто принимает запись

3

Один узел недоступен

Два оставшихся (у них большинство)

3

Кластер разделился на группы 2 и 1

Только группа из двух

3

Два узла недоступны

Оставшийся один не пишет (нет большинства)

2

Связь между ними пропала

Не пишет никто

любой

Связь восстановилась

Фазы снова сходятся; отставший узел подтягивает недостающие записи из журнала; выборов лидера нет

любой

Рестарт узла

Узел читает сохранённый номер последнего коммита и применяет недостающие записи из журнала

Механизма «назначить себя пишущим, потому что остальные недоступны» в протоколе нет — иначе меньшинство продолжило бы коммитить в изоляции. Смена пишущего узла для клиента передаётся полями ServerMeta / типом PROMOTE_NOTIFY; клиент вызывает rediscoverWriter(), а не перебирает хосты в URL вручную.

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

Проверки

TLA+ / TLC. 

Спецификации одного ЦОД и сценария с голосующими в другом ЦОД входят в CI: фиксированный состав голосующих, один пишущий по фазе, без двух разных значений на одном номере операции, меньшинство при потере связи не коммитит. Удалённые голосующие подтверждают контрольную сумму, но не входят в локальный R, пока связывание фаз через WAN выключено.

Jepsen. 

На стенде гоняются нагрузки register и append с внесением сетевых и процессных сбоев (один ЦОД и схема из нескольких площадок). Прогоны показывают, что клиенты не видят рассинхрона данных, и, что записи в журнал не переставляются и не ветвятся вопреки инвариантам ORCHID. Это проверка согласованности, не измерение TPS.

Пропускная способность на стенде

Два узла (пишущий и реплика), fsync: true (каждый коммит журнала сбрасывается на диск), нагрузка Apache JMeter (не JDBC: синхронная обвязка искажает задержки). Хост: Intel Core 7 240H (10 ядер / 16 потоков), 64 ГиБ RAM, NVMe.

Что означают профили:

Профиль

Смесь

Что измеряет

WRITE_ONLY

100% UPSERT

Потолок записи: OpLog + fsync + ORCHID

READ_ONLY

EQ 90% / JOIN 10%

Потолок индексного чтения

Capacity QG

EQ 50 / UPSERT 40 / JOIN 10

Смешанная нагрузка

solo capacity

запись на одном узле без реплики

Отдельная точка без стоимости пиров

Ориентиры на хосте (порог регресса ≈95% опорных значений):

Профиль

Avg

p95

Порог ≈95%

WRITE_ONLY, пара узлов

~4922 оп/с

14 мс

~4676

READ_ONLY, пара узлов

~52–59 тыс. оп/с

1 мс

~52261

Capacity QG, пара узлов

~8.7–11 тыс. оп/с

16–31 мс

~8333

WRITE_ONLY, один узел

~7095 оп/с

8 мс

—

Цифры привязаны к железу и к fsync: true. Для облака, WAN и другого диска нужен свой прогон. Методика и файлы в репозитории (docs/ru/performance/capacity‑slo.md, сводка — grid-server-core/benchmarks/results/SUMMARY.md). Время ожидания ORCHID (orchidWaitP50/P99) входит в потолок WRITE.

Заключение

Киллер‑фича Grid — ORCHID
Допуск записи на модели связанных осцилляторов Курамото + большинство по контрольной сумме операции. Лидер не выбирается голосованием, нет отдельного режима выборов и term, как в Raft. В промышленных SQL‑ и KV‑системах такого контура обычно нет — там Raft, Paxos или их наследники. Здесь же идея из in‑memory grid доведена до рабочего протокола с формальной моделью и проверками согласованности.

Зачем
Один продукт вместо связки «дисковая СУБД + отдельный кэш»: горячие данные в памяти, журнал и sealed GridMap на диске, SQL с DDL/DML, клиент и по Reactor, и по JDBC. Коммит сначала на диске — потом видимость читателям; при сбое клиент получает отказ, вместо кривых данных. Один узел с диском без реплик — штатный режим; репликация в ЦОД и подтверждения из другого ЦОД включаются отдельно, не ломая локальную модель фаз.

Согласованность
Спецификации ORCHID доказана TLA+/TLC, на стенде/CI — Jepsen (register/append со сбоями) на одном ЦОД и на нескольких. На лабораторном хосте при fsync: true и паре узлов цифры такие: запись ~4922 оп/с, чтение ~52–59 тыс. оп/с, смесь ~8.7–11 тыс. оп/с; на одном узле запись ~7095 оп/с. Это опорные цифры конкретного железа — по ним видно: узкое место записи — журнал и сброс на диск, а не сам ORCHID; SQL из‑за протокола не становится «медленной частью» логики системы.

Ограничения модели остаются честными: без большинства запись не принимается; два узла без связи друг с другом — оба отказывают. Это осознанный обмен доступности на целостность журнала.

Куда идем

Уже есть и остаётся базой
SQL + TX, RAM и sealed GridMap / OpLog на диске, ORCHID, репликация в ЦОД и типы синхронизации между площадками, клиенты JDBC и Reactive, формальные проверки (TLA+/TLC) и стендовые (Jepsen, capacity под fsync: true).

Краткосрочные цели

  • Углубить диалект там, где это нужно приложениям (удобные формы DDL/DML, индексы, планы / AQE), не превращая его в полную копию какой‑то другой СУБД.

  • Сделать sealed GridMap и путь записи ещё прозрачнее для эксплуатации: hydrate, рабочий набор, восстановление, PITR — чтобы «память + диск» считались одним понятным контуром.

  • Довести распределённые блокировки данных (FOR UPDATE) и каркас 2PC‑lite без внешнего XA: одна модель TX, предсказуемый отказ при сбое.

  • Подтянуть эксплуатацию: TLS (сейчас терминация снаружи), удобнее promote / multi‑site, метрики и чек‑листы.

  • Держать living‑цифры: не ослаблять пороги SLO, а выжимать OpLog, sealed mmap и ORCHID.

Долгосрочные цели

  • Сделать чтение с реплик и перенос шардов (overlay / swarm) практичнее под нагрузкой: горячие данные ближе к клиенту, пишущий узел по‑прежнему один.

  • Расширить экосистему вокруг того же протокола: Spring Boot, JDBC/IDE, JOOQ в пределах диалекта — без второго wire протокола, и другое.

Куда сознательно не идём.
Два писателя с последующим слиянием журналов; общий dataDir на NFS/SAN на весь кластер; XA‑координатор поверх чужих ресурс‑менеджеров.
Цель — один стек SQL + память + диск + ORCHID, который можно объяснить и измерить, а не универсальный клон решений на рынке.

Код открыт: grid‑sql.
По теме этой статьи — docs/ru/understand/orchid-consensus.md, docs/ru/understand/bio-inspired.md, docs/spec/orchid/, benchmarks/jepsen/, docs/ru/performance/capacity-slo.md.