Шардинг

Партиционирование governance-состояния по шардам для горизонтального роста пишущей нагрузки. Customer (со всеми его командами и виртуальными ключами) — единица назначения; миграция между шардами поддерживается без потери in-flight запросов.

Enterprise

Обзор

В кластерном режиме строгая координация 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 ещё до проверки бюджета.

vkIndex hit

неизвестный VK

Запрос с x-bf-vk

Router

ShardStore целевого шарда

shard0 fallback

CheckBudget local

RESERVE через Raft

Вызов провайдера

FINALIZE через Raft

Резолюция, от быстрой к медленной:

  1. In-process vkIndexsync.Map с ключом VK value → shard_id. Заполняется при гидратации и поддерживается обработчиками VK-уведомлений. Hot path — O(1).
  2. 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-часовая TeamRedirect tombstone — она ловит самые поздние стрэглеры (например, FINALIZE от вызовов, начатых до миграции) и перенаправляет их на приёмник. Periodic GC чистит просроченные tombstone'ы.

Примеры применения:

  • Балансировка нагрузки. Customer с экстремальной нагрузкой переносится со «своего» шарда на свежесозданный, чтобы разгрузить шард с другими арендаторами.
  • Изоляция арендатора. Premium-клиент изолируется на отдельный шард под выделенный SLA по latency / availability.
  • Подготовка к удалению шарда. Перед удалением шарда все его customer'ы переносятся на другие шарды, после чего шард можно безопасно удалить через DELETE-эндпоинт.

Сейчас миграция инициируется внутренними механизмами — публичная management-ручка для запуска миграции на roadmap. Сама механика переноса работает в production-кластере; недостаёт только удобного триггера через HTTP API. Если вам нужно мигрировать customer'а в текущей версии — обратитесь в поддержку.


Режимы развёртывания

РежимШардыЗамечания
In-memory unit-test1 (или N через cfg.TestShards)Без БД, без gRPC, без снапшоттера; используется только в тестах.
Single-node SQLiteТолько shard0Многошардовость на SQLite запрещена и завершает старт ошибкой; миграция и шардинг требуют PG-only примитивов.
Single-node PostgreSQLNОператор может предзаполнить rb_shards в схеме или вызывать POST /api/cluster/shards в рантайме.
Cluster PostgreSQLN, реплицированы по cluster.peersТот же кодовый путь, что и single-node PG. Реплики берутся из карты пиров; на каждом узле работает примерно N/M лидеров.

Подробнее о выборе backend'а и конфигурации кластера — на странице Кластеризация.


Связанные ресурсы

Содержание