Шардинг
Партиционирование governance-состояния по шардам для горизонтального роста пишущей нагрузки. Customer (со всеми его командами и виртуальными ключами) — единица назначения; миграция между шардами поддерживается без потери in-flight запросов.
Обзор
В кластерном режиме строгая координация governance — резервирование бюджета и rate limit'ы — реализована поверх Raft. У каждой Raft-группы один лидер, и каждое запись (RESERVE / FINALIZE) идёт через него: батчится в WAL лидерского узла, делает один fsync, реплицируется. Пропускная способность по записи в одной Raft-группе ограничена скоростью fsync на её лидере — это фундаментальное ограничение, не настраиваемое.
Шардинг снимает это ограничение горизонтально. Шард — это одна Raft-группа плюс срез governance-состояния, который ей принадлежит. Лидерство по разным шардам распределяется между узлами, поэтому каждый узел ведёт примерно N/M шардов (N — число шардов, M — число узлов). Параллельно работают несколько лидеров — пропускная способность кластера по записи растёт линейно с числом шардов, пока нагрузка по customer'ам распределена равномерно.
Ключевые свойства:
- Customer — единица назначения шарда. Все объекты под одним customer'ом (Teams, Virtual Keys, Budgets, Rate Limits) живут на одном шарде, чтобы иерархическое RESERVE (VK + Team + Customer) выполнялось одной атомарной Raft-проводкой.
shard0особенный. Всегда существует, держит default-customer и владеет provider/model-состоянием для всего кластера.- Топология в Postgres. Postgres — источник истины: какие шарды есть, какой customer привязан к какому шарду, какие узлы — реплики. На SQLite шардинг недоступен (только
shard0). - Изменения топологии прозрачны. Создание, удаление и миграция шардов происходят в работающем кластере; in-flight запросы корректно ретраятся при изменениях.
Что лежит на шарде
| Состояние | Где живёт |
|---|---|
| Customer | На своём шарде (по rb_shard_assignments.customer_id → shard_id) |
| Team | На шарде своего customer'а |
| Virtual Key | На шарде своего customer'а (через team) |
| Budget | На шарде своего владельца (VK / Team / Customer) |
| Rate Limit | На шарде своего владельца (только VK) |
| Provider, Model Config | Только на shard0 |
| Routing Rule, User | Глобально (вне шардов) |
Распределение по шардам делает иерархический debit (VK → Team → Customer) единственной атомарной записью на одном шарде. Если бы customer и его teams лежали на разных шардах, единичный RESERVE требовал бы распределённой транзакции — это лишило бы шардинг смысла.
┌── shard0 ──┐ ┌── shardA ──┐ ┌── shardB ──┐
node1 │ *LEADER* │ │ follower │ │ follower │
node2 │ follower │ │ *LEADER* │ │ follower │
node3 │ follower │ │ follower │ │ *LEADER* │
└────────────┘ └────────────┘ └────────────┘
customers c0..c5 customers c6..c8 customers c9..c11
+ provider/model (только свои (только свои
state для всех customer'ы) customer'ы)
запросовКак запрос находит свой шард
Каждый inference-запрос приходит с виртуальным ключом. Плагин governance в PreLLMHook превращает значение VK в нужный ShardStore ещё до проверки бюджета.
Резолюция, от быстрой к медленной:
- In-process
vkIndex—sync.Mapс ключомVK value → shard_id. Заполняется при гидратации и поддерживается обработчиками VK-уведомлений. Hot path — O(1). - Fallback на
shard0— для неизвестных VK (control-plane ключи, гонки в момент гидратации, устаревший кэш). Безопасный fallback: наshard0живёт default-customer, и попытка списать чужой бюджет завершается чистым отказом (budget_not_found).
Если topology поменялась прямо во время запроса (например, customer мигрировал на другой шард), state machine отвергает proposal с явным reason'ом (customer_epoch_not_match, team_moved, migrating), и ReserveBudget / FinalizeBudget атомарно ретраятся на корректном шарде — клиент получает успешный ответ, не 5xx.
Согласованность топологии между узлами
Шард, созданный на одном узле, должен материализоваться на каждом узле, который держит его реплики. Используется стандартный паттерн «push + poll»:
- Push (EventBus). Изменение топологии публикуется в шину как событие
ShardTopologyChanged. Подписчики на других узлах получают его за миллисекунды и применяют изменения. - Poll (60 с reconciler). Периодический тикер сверяет локальный набор шардов с источником истины в Postgres и устраняет расхождения.
Событие — это оптимизация, периодический reconciler — контракт. Архитектурно система корректна и без EventBus: poll'а достаточно. EventBus сокращает время сходимости с десятков секунд до миллисекунд, но не является обязательным условием корректности.
Reasons, по которым EventBus может пропустить событие, — overflow канала, рестарт узла между публикацией и pickup'ом, partition сети, падение издателя. Все они покрываются 60-секундным reconciler'ом, поэтому пропуск события не делает узел постоянно «слепым» к шарду.
Аналогичный entity-уровневый reconciler работает per-shard для VK / Team / Customer / Budget / Rate Limit — он защищает от потерянных уведомлений по конкретным сущностям. Topology reconciler и entity reconciler независимы и оба обязательны.
Управление шардами через API
Шарды управляются через эндпоинты /api/cluster/shards. Все операции требуют разрешения cluster:update / cluster:view / cluster:delete соответственно.
Список всех шардов:
curl http://localhost:8080/api/cluster/shardsОтвет:
{
"shards": [
{
"id": "shard0",
"replica_nodes": ["node1", "node2", "node3"],
"leader_node": "node1",
"customer_count": 6,
"conf_ver": 1
},
{
"id": "shardA",
"replica_nodes": ["node1", "node2", "node3"],
"leader_node": "node2",
"customer_count": 3,
"conf_ver": 1
}
]
}Получить конкретный шард:
curl http://localhost:8080/api/cluster/shards/shardAСоздать шард:
curl -X POST http://localhost:8080/api/cluster/shards \
-H "Content-Type: application/json" \
-d '{"id": "shardB"}'Создание шарда — атомарная операция: запись в Postgres и runtime-инициализация (Raft-группа + ShardStore + per-shard reconciler) либо обе успешны, либо обе откатываются. После успеха публикуется ShardTopologyChanged, и пиры получают шард через push или поднимают его через 60-секундный reconciler.
Удалить шард:
curl -X DELETE http://localhost:8080/api/cluster/shards/shardBУдаление возможно только если на шарде нет активных customer'ов (иначе вернётся 409 Conflict). shard0 удалить нельзя ни при каких условиях — он всегда существует.
При удалении сначала шард убирается из Router'а (новые запросы перестают попадать на него), потом ShardStore дренирует фоновые задачи и останавливает Raft-группу, и только после этого удаляются строки в Postgres. Этот порядок гарантирует, что ни один запрос не попадёт в шард, который уже выключается.
Создание и удаление шардов — операция cluster-wide. Ошибочное удаление невозможно откатить «бесплатно»: чтобы вернуть шард, нужно создать его заново и мигрировать туда нужных customer'ов. Применяйте только в плановом окне обслуживания.
Миграция между шардами
Кластер поддерживает атомарную миграцию customer'а с одного шарда на другой. Перенос захватывает всю иерархию — самого customer'а, его команды (Teams), виртуальные ключи (Virtual Keys), бюджеты и rate limits — и происходит как единая распределённая операция.
Ключевые гарантии:
- In-flight запросы не теряются. Запросы, которые попали на источник во время миграции, корректно ретраятся: state machine отвергает их с одним из reason'ов (
customer_epoch_not_match,team_moved,migrating), аReserveBudget/FinalizeBudgetповторяют операцию с обновлённой топологией — на корректном шарде. Клиент видит обычный успешный ответ. - Никакого overspend и underspend. Миграция — пятифазный процесс (Freeze → Snapshot → Apply → Commit → RedirectGC); на каждом шаге обе стороны (источник и приёмник) согласованы через Raft, поэтому ни одно списание не теряется и не дублируется.
- «Хвост» учитывается. После Commit на источнике ставится 24-часовая
TeamRedirecttombstone — она ловит самые поздние стрэглеры (например, FINALIZE от вызовов, начатых до миграции) и перенаправляет их на приёмник. Periodic GC чистит просроченные tombstone'ы.
Примеры применения:
- Балансировка нагрузки. Customer с экстремальной нагрузкой переносится со «своего» шарда на свежесозданный, чтобы разгрузить шард с другими арендаторами.
- Изоляция арендатора. Premium-клиент изолируется на отдельный шард под выделенный SLA по latency / availability.
- Подготовка к удалению шарда. Перед удалением шарда все его customer'ы переносятся на другие шарды, после чего шард можно безопасно удалить через DELETE-эндпоинт.
Сейчас миграция инициируется внутренними механизмами — публичная management-ручка для запуска миграции на roadmap. Сама механика переноса работает в production-кластере; недостаёт только удобного триггера через HTTP API. Если вам нужно мигрировать customer'а в текущей версии — обратитесь в поддержку.
Режимы развёртывания
| Режим | Шарды | Замечания |
|---|---|---|
| In-memory unit-test | 1 (или N через cfg.TestShards) | Без БД, без gRPC, без снапшоттера; используется только в тестах. |
| Single-node SQLite | Только shard0 | Многошардовость на SQLite запрещена и завершает старт ошибкой; миграция и шардинг требуют PG-only примитивов. |
| Single-node PostgreSQL | N | Оператор может предзаполнить rb_shards в схеме или вызывать POST /api/cluster/shards в рантайме. |
| Cluster PostgreSQL | N, реплицированы по cluster.peers | Тот же кодовый путь, что и single-node PG. Реплики берутся из карты пиров; на каждом узле работает примерно N/M лидеров. |
Подробнее о выборе backend'а и конфигурации кластера — на странице Кластеризация.
Связанные ресурсы
- Кластеризация — peer-to-peer-развёртывание Meridian, gossip и базовая координация бюджетов через Raft.
- Виртуальные ключи — иерархия governance: customers, teams, VKs.