
Три узла, одна база, клиент шлёт UPSERT. Нужно решить две вещи сразу: разрешить запись только на согласованном кластере и не допустить, чтобы после обрыва связи между узлами в журналах оказались разные версии одних и тех же данных.
В стеке вроде Raft логика такая: узлы голосуют за лидера, пишет только он; у каждого периода лидерства есть порядковый номер (term), чтобы отличать старые голоса от новых; лидер пропал — новые выборы. В Grid допуск записи устроен иначе. Лидер не выбирается голосованием. Вместо этого узлы обмениваются числовым параметром синхронизации (фазой, в смысле модели Курамото) и отдельно подтверждают каждую операцию контрольной суммой её содержимого. Этот протокол называется ORCHID.
Откуда взялся Grid
Проект начинался как хобби: in‑memory grid на Java (шардированная карта ключ → byte[], очередь записи, индексы в RAM) и идея согласовать узлы не через классические выборы лидера, а через биологически вдохновлённую модель связанных осцилляторов Курамото. Имена вроде ORCHID в документации — способ описать идею; в коде те же вещи называются технически (orchid, …). Протокол допуска записи вырос из этой модели.
Для высоконагруженного Java‑бэкенда держать горячие данные в памяти — типично: быстрое чтение и запись в пределах одного процесса, без сброса на диск на каждый запрос. Но как только появляются требования «после рестарта данные должны сохранятся» и «два узла не должны разъехаться по данным», одной оперативной памяти недостаточно.
Дальше поверх in‑memory grid постепенно выстроился цельный продукт:
Журнал изменений (OpLog). Запись сначала подтверждается на диске; только после этого она видна другим сессиям. Если подтверждение не удалось, клиент получает ошибку, частично применённой строки в выборках нет.
Файлы sealed GridMap на диске (
.gmap/ индексы.sbpt): неизменяемые снимки шардов с каталогом смещений. Оперативная память — ускоритель горячих данных, не единственное хранилище: холодные ключи вытесняются и при промахе читаются с диска.SQL: DDL и DML; подключение как по JDBC семантике так и по реактивному стеку (используют единый протокол с разной механикой работы передачи и получения данных).
Между узлами — ORCHID: можно ли принять запись сейчас и какой узел присвоит ей номер в общем порядке.
Итог: Grid — распределённая SQL‑база с горячими данными в памяти, журналом и sealed‑файлами на диске, согласованием ORCHID между узлами.
Из чего состоит система сейчас:
Часть | Назначение |
|---|---|
SQL, каталог таблиц | DDL/DML, схема |
Память | Горячие данные |
Диск (OpLog + sealed GridMap) | Пережить рестарт, не потерять коммит |
Кластер (ORCHID + репликация) | Кто пишет и что зафиксировано |
Клиент |
|
Долговременное хранение на одном узле и репликация на соседей включаются разными настройками. Один узел с диском без соседей — штатный режим. Дальше в статье — как кластер решает, можно ли записать данные; про диалект SQL, устройство sealed GridMap и прочее — темы отдельных статей.
Чем обычно решают задачу консенсуса
В Raft (и аналогах):
узлы голосуют и выбирают лидера;
у периода лидерства есть порядковый номер (term);
в журнал пишет только лидер, остальные копируют;
лидер недоступен — снова выборы.
Модель понятная и проверенная. В Grid её не копировали: нужна была схема без отдельного режима выборов — постоянно оценивать, насколько узлы синхронизированы, и отдельно проверять согласие по конкретной операции.
Ограничение модели хорошо видно на двух узлах в одном ЦОД. Если между ними пропала сеть, ни у одной стороны нет большинства голосующих — то есть больше половины участников из конфигурации. Пока связь внутри ЦОД не восстановится, запись отклоняется у обоих: лучше отказ клиенту, чем два независимых журнала с разным содержимым.
Два вопроса. Orchid
1. Кластер сейчас может принимать данные, или узлы разъехались по синхронизации?
У каждого узла есть скалярный параметр фаза θ — вещественное число на окружности [0, 2π), как в модели Курамото. Это не системные часы ОС, а состояние осциллятора в протоколе. Узлы кластера периодически присылают свои фазы и подтягивают друг друга. Из набора фаз считается параметр порядка R ∈ [0, 1]: насколько фазы близки. Если R ниже порога (по умолчанию 0.85), запись отклоняется до рассылки предложения. Среди узлов с достаточной синхронизацией право предложить следующий номер операции в общем журнале получает узел с минимальным nodeId — без голосования за лидера. Этот узел ниже называется пишущим (phase‑ranked proposer).
2. Конкретная операция — та же, что видят остальные?
Считается контрольная сумма содержимого операции и её места в последовательности. Узел рассылает предложение; остальные отвечают «сумма совпала» или «нет». Нужно большинство голосов участников из конфигурации на одной и той же сумме. Высокий 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 + |
READ_ONLY | EQ 90% / JOIN 10% | Потолок индексного чтения |
Capacity QG | EQ 50 / UPSERT 40 / JOIN 10 | Смешанная нагрузка |
solo | запись на одном узле без реплики | Отдельная точка без стоимости пиров |
Ориентиры на хосте (порог регресса ≈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.

