На связи Денис из команды платформы данных в Yandex Cloud. Мы занимаемся разработкой системы SPQR, которая помогает легко реализовать горизонтальное масштабирование PostgreSQL с помощью шардирования. И это не теоретическая задача на два шарда и десять таблиц. Необходимо сделать систему, которая в пределе хранит петабайты данных и выдерживает сотни тысяч запросов в секунду. 

В прошлой статье мы показывали SPQR со стороны пользователя: как выбрать ключ шардирования, как разложить таблицы на распределённые (distributed) и справочные (reference), как создать распределения и определить диапазоны ключей, а затем перевезти монолит на несколько шардов. Эта статья будет про инженерный путь: архитектуру, компромиссы и грабли, которые встретились по дороге.

Представим задачу — нужно построить систему, которая:

  • общается с приложением по протоколу PostgreSQL;

  • позволяет докидывать шарды на лету;

  • держит метаданные о распределении данных в оперативной памяти;

  • не разваливается от «селебрити», когда один ключ приносит непропорционально большую нагрузку;

  • умеет всё это делать без даунтайма.

Описание выше может звучать как набор взаимоисключающих требований, но именно из него вырос SPQR.

Далее разберём: почему роутер — не главная проблема, зачем SPQR оперирует key range, как координатор перевозит данные маленькими кусками, как балансировщик понимает, что именно надо двигать, и почему всё это в итоге собирается из довольно простых строительных блоков.

Если вам удобнее смотреть и слушать — в докладе на HighLoad 2024 мы уже рассказывали про часть идей, но в тексте самые актуальные данные. 

Роутинг не самая сложная часть

Когда речь заходит о «шардировании PostgreSQL», пожет показаться, что для этого обязательно понадобится очень «умный» роутер, который понимает SQL и знает, куда отправить запрос.

Если убрать детали, алгоритм роутинга выглядит довольно просто.

Алгоритм роутинга
Алгоритм роутинга
  1. Приходит SQL‑запрос.

  2. Роутер вытаскивает из него ключ шардирования.

  3. Если используется хеш‑шардирование, ключ сначала прогоняется через хеш‑функцию.

  4. Полученное значение ищется в таблице диапазонов.

  5. По диапазону находится шард.

  6. Запрос уезжает на нужный PostgreSQL‑кластер.

В псевдокоде это выглядит примерно так:

func route(query string) Shard {

    key := extractShardingKey(query)

    routedKey := maybeхеш(key)

    kr := metadata.lookupKeyRange(routedKey)

    return kr.Shard

}

Конечно, в реальном роутере всё сложнее: есть транзакции, hints, COPY, prepared statements, кросс‑шардовые сценарии и пограничные случаи парсинга. Но сама идея ровно такая.

Отсюда важный вывод: главное требование к маршрутизации — не сложность алгоритма, а дешевизна каждого решения. Для OLTP‑нагрузки роутер должен принимать решение за микросекунды и не ходить за каждым запросом во внешнее хранилище метаданных. Именно поэтому метаданные о распределении данных должны помещаться в память роутера.

Сам роутер можно написать буквально за три минуты.

Шаг 1. Научитесь общаться по протоколу PostgreSQL, получая SQL‑запрос как строку. 

Воспользуемся известным драйвером PGX, в котором уже всё написано. 

import "github.com/jackc/pgx"

Открываем Connect, слушаем порт 5432 и создаем специальный объект frontend. С помощью него мы будем слушать порт и принимать сообщение в бесконечном цикле. 

// Connect to the Postgres server
conn, err := net.Dial("tcp", "localhost:5432")

// Create a new pgx Frontend object using the TCP connection
frontend := pgproto3.NewFrontend(conn)
defer frontend.Close()

for {

Как только мы приняли сообщение типа pgproto3.Query — это то, что нам нужно. 

    // Receive the next message from the server
    msg, err := frontend.Receive()

    switch v := msg.(type) {
    case *pgproto3.Query:
        log.Printf("Query is %s", v.String)
        return nil // Process query message
    default:
        log.Fatalf("Unexpected message from server: %v", msg)
    }
}

Всё, там лежит строка. Успех. 

Шаг 2. Научитесь парсить синтаксическое дерево. 

Чтобы сделать шаг таким же простым, как первый, используем библиотеку pg_query_go от PGAnalyze. Эта библиотека строит синтаксическое дерево вашего запроса. 

import "github.com/pganalyze/pg_query_go"

Попробуем на примере запроса:

query := "SELECT id, name FROM users WHERE age > 18"

Чтобы его распарсить, нужно просто вызвать метод parse. 

// Parse the query
tree, err := pg_query.Parse(query)
if err != nil {
    fmt.Println("Error parsing query:", err)
    return
}

Дальше нам возвращается object tree и тут написано что‑то непонятное, но суть в следующем: есть дерево — у него есть ноды — по нодам можно гулять. На основе этого уже можно построить какую‑то логику. 

// Get WHERE clause
whereClause := tree.Stmts[0].Stmt.GetSelectStmt().GetWhereClause().GetAExpr()
column := whereClause.GetLexpr().GetColumnRef().Fields[0].GetString_().Sval
value := whereClause.GetRexpr().GetAConst().GetIval().Ival
fmt.Printf("column '%s', value '%d'", column, value) // column 'age', value '18

Шаг 3. Вы великолепны!

Почему нельзя хранить метаданные по каждому ключу

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

Поэтому SPQR оперирует не отдельными ключами, а диапазонами ключей — key range. Это центральная идея всей системы.

Key range — это минимальная сущность, с которой SPQR умеет работать как с объектом. Её можно создать, разделить, объединить с соседней, привязать к другому шарду и временно заблокировать на время перевоза.

У такого подхода сразу несколько плюсов:

  1. Меньше метаданных. В памяти роутера лежат не миллионы и миллиарды ключей, а относительно небольшой набор диапазонов.

  2. Сборка онлайн‑перевоза из key range. Чтобы перевезти часть данных, не нужно придумывать новую сущность. Достаточно уметь отрезать кусок диапазона, перевезти его и, если нужно, потом объединить обратно.

  3. Помощь в борьбе с перекосами нагрузки. Если горячим стал не весь шард, а один участок диапазона, его можно отделить и увезти отдельно.

Здесь же видно ограничение подхода. Key range помогает против горячего диапазона, но не отменяет физику горячего логического ключа. Если один пользователь или один тенант сам по себе занимает полшарда, вы уже не можете бесконечно делить его дальше, не ломая собственную модель целостности. В таком случае нужен либо другой ключ шардирования, либо другая стратегия.

Какие операции существуют над key range

Как только key range становится базовой сущностью, над ним естественным образом появляются базовые операции.

  • SPLIT KEY RANGE делит диапазон на два.

  • UNITE KEY RANGE объединяет соседние диапазоны обратно.

  • REDISTRIBUTE KEY RANGE перевозит данные из одного диапазона на другой шард маленькими частями.

Это даёт возможность разрезать и склеивать диапазоны отдельно от процесса копирования и перемещения строк. Система получает элементарные кирпичики, из которых уже собирается сложный онлайн‑флоу.

Трудности перевоза

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

Добавить новый шард — это только начало, дальше начинаются настоящие приключения в реальной эксплуатации.

После разбиения на шарды нужно:

  • перевезти часть уже существующих данных;

  • не потерять консистентность между источником и приёмником;

  • не сломать транзакции, которые идут прямо сейчас;

  • синхронно поменять метаданные у роутеров;

  • и сделать это так, чтобы приложение продолжало работать через тот же PostgreSQL‑совместимый endpoint.

То есть главная задача в SPQR — не «понять, где лежат данные», а изменить ответ на этот вопрос на живой системе. И дальше вся архитектура подчинена именно этому сценарию.

Из каких блоков собрана система

На уровне компонентов SPQR устроен довольно прямолинейно.

  1. Роутер принимает клиентские подключения, разбирает запрос, находит нужный диапазон и отправляет запрос на шард. Сам данные он не перевозит.

  2. Координатор управляет метаданными и выполняет длинные операции: создаёт задачи перевоза, ведёт их состояние, двигает данные, переключает маршрутизацию.

  3. QDB на базе etcd хранит общие метаданные: distributions, key ranges, lock state, а также промежуточное состояние длинных операций.

  4. Балансер — отдельная утилита, которая смотрит на состояние шардов, ищет перекосы нагрузки и инициирует перевозы там, где они нужны.

Если коротко, то:

Роутер маршрутизирует запросы, координатор перевозит данные, QDB хранит состояние, балансер решает, что пора двигать.

Компоненты SPQR во время перевоза
Компоненты SPQR во время перевоза

Как выглядит один цикл перевоза в актуальном SPQR

1. Сначала создаётся задача

Современный сценарий строится вокруг REDISTRIBUTE KEY RANGE. У этой команды есть режим проверки и режим применения. Режимы позволяют сначала понять, что все prerequisites на месте, и только потом запускать реальный перевоз.

После проверки координатор материализует задачу перевоза в метаданных. Создаётся отдельная сущность уровня Redistribute Task, в которой зафиксировано, что именно мы перевозим, куда, с какими параметрами и на каком шаге находимся.

2. Координатор заранее ищет границы будущих батчей — маленькие куски диапазона

Как именно определяется, что такое «маленький кусок диапазона»?

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

Грубо говоря, вместо «отрежь мне диапазон от 0 до 10» система просит PostgreSQL: «покажи значения ключа примерно через каждый N‑й шаг по фактическим данным». Далее по этим значениям строит список границ.

Поэтому BATCH SIZE в SPQR — это целевой масштаб одного шага перевоза.

Для хеш‑основанного шардирования логика остаётся той же, просто вместо исходного значения используется значение после хеш‑функции. Для composite key всё ещё интереснее: искать и индексировать приходится уже выражение над несколькими колонками.

3. Большой диапазон режется на много маленьких 

Если попробовать увезти весь диапазон сразу — система быстро упрётся в долгие блокировки, массу WAL, lag реплик и вообще во все неприятности PostgreSQL сразу. Поэтому SPQR использует идею ε‑split. Координатор отрезает не весь диапазон, а маленький кусок — тот самый ε — и работает только с ним.

Именно отсюда рождается вся дальнейшая механика: один большой REDISTRIBUTE внутри превращается в серию маленьких move‑шагов.

4. На время перевоза блокируется только текущий кусок

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

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

Это сразу делает видимой главную практическую проблему.

Долгие транзакции — враги перевоза.

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

5. Данные копируются на целевой шард и удаляются на исходном

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

Система старается перенести маленький кусок, быстро зафиксировать результат и перейти к следующему.

6. Метаданные переключаются, диапазоны склеиваются

После завершения шага координатор обновляет состояние диапазонов в QDB и меняет маршрут у роутеров. Если перевоз идёт на соседний диапазон, куски потом можно объединить и не плодить лишние key range. Чем меньше диапазонов в системе, тем быстрее lookup у роутеров и тем проще поддерживать метаданные.

Если свести весь цикл к линейной схеме, то он выглядит так:

CHECK → создать задачу → подготовить границы → SPLIT маленького куска

→ lock текущего куска → дождаться активных транзакций

→ copy на целевой шард → delete на исходном

→ обновить метаданные → UNITE при необходимости → следующий кусок

Почему BATCH SIZE — это компромисс

У любого перевоза есть две переменные величины: общая длительность и негативное влияние на систему в каждом отдельном шаге.

Маленький батч означает короткую блокировку и меньшее влияние на пользовательскую нагрузку. Правда, при этих плюсах даёт больший overhead: чаще повторяются split, ожидание транзакций, переключение метаданных и служебные запросы. 

Большой батч, наоборот, ускоряет общий прогресс перевоза, но увеличивает вероятность попадания пользовательского запроса в заблокированный диапазон:

  • дольше держится блокировка;

  • больше WAL за одну итерацию;

  • выше риск роста replication lag;

  • тяжелее delete‑path и последующая уборка данных.

Поэтому BATCH SIZE нужно выбирать по тому, какая часть системы становится узким местом во время перевоза.

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

Если же пользовательская нагрузка редко конфликтует с перевозимым диапазоном — батч можно увеличивать. Но до тех пор, пока это выдерживают WAL, репликация и delete‑path. Крупная итерация ускоряет общий прогресс, но производит больше WAL за раз, потенциально увеличивает replication lagи может замедлить последующий vacuum/repack.

Как балансировщик понимает, что именно пора двигать

Ручной REDISTRIBUTE отвечает на вопрос «как перевезти известный диапазон». Балансировщик отвечает на вопрос «что именно нужно перевозить, если шард уже перегружен».

В актуальной схеме балансер — отдельная утилита. Она запускает свой алгоритм, собирает картину по шардам и при необходимости создает transport tasks для координатора.

Алгоритм можно описать так:

  1. Балансер собирает статистику по шардам и ищет перегруженный шард.

  2. Выбирает критерий, по которому перегрузка выражена сильнее всего.

  3. Пытается понять, какой именно key range внутри шарда вносит наибольший вклад в проблему.

  4. Оценивает, какой кусок диапазона надо увезти, чтобы снять избыток нагрузки (а не просто бессмысленно порезать данные пополам).

  5. И только затем выбирает шард‑назначение.

Балансер смотрит на все соседние диапазоны, помимо самого свободного. Если увезти кусок к соседу, его потом можно склеить с уже существующим диапазоном и не раздувать количество key range в системе.

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

Зачем здесь pg_comment_stats

Самый сложный вопрос в балансировке — как понять, какой диапазон внутри шарда «горячий»? Смотреть только на шард в целом мало. Нужна связь между запросом и конкретным диапазоном ключей.

Для этого в SPQR используется pg_comment_stats — модифицированный вариант pg_stat_statements, который умеет учитывать специальные ключи из комментариев к запросу. Роутер добавляет в запрос метаинформацию о том, к какому диапазону он относится. Дальше статистика собирается по сочетанию «тип запроса + диапазон», что даёт балансировщику сырьё для принятия решения.

Почему автоматическая балансировка пока не полностью автономна

В SPQR уже есть и балансер, и REDISTRIBUTE KEY RANGE, и механизм перевозки маленькими кусками. Но пока система не может полностью автономно прожевать любой большой resharing‑сценарий.

Текущая реальность выглядит так:

  • балансер умеет принимать решение и создавать транспортные задачи;

  • механизм переноса данных у балансера и у ручного REDISTRIBUTE общий;

  • вокруг длинных перевозов всё ещё нужен оркестрирующий‑слой и наблюдение со стороны инженера.

Причина простая: в реальной эксплуатации перевозы сталкиваются с тайм‑аутами etcd, сетевыми сбоями, transient‑ошибками connection pooler, lock timeout на таблицах и прочими неакадемическими событиями.

Часть из этого уже можно безопасно ретраить, для части есть явные команды повтора, но общая линия развития системы ясна: переносить всё больше логики retry и recovery внутрь координатора.

Именно поэтому корректнее так: на данный момент SPQR умеет хорошо автоматизировать онлайн‑решардинг, но всё ещё не является «магической кнопкой».

Что реально болит в проде при перевозе

У перевоза есть ловушка: начать смотреть только на координатор и прогресс тасков. Так делать не стоит.

Первые ограничения перевоза часто лежат на стороне самих шардов.

Типичные боли:

  • рост replication lag, когда мастер не успевает переваривать объём удалений;

  • накопление WAL и риск упереться в место на репликах;

  • нехватка свободного ресурса по CPU/IOPS на шардах;

  • тайм‑ауты и ошибки записи в etcd, особенно если параллельно идёт много служебных операций;

  • прикладные всплески ошибок и latency на фоне блокировок батчей.

Что происходит, если перевоз прерывается

Ещё один важный вопрос: насколько вся эта конструкция вообще живучая? Поскольку состояние задач материализуется в QDB, перевоз не сводится к «в памяти одной горутины что‑то происходит». Это уже хорошо.

Но пока не все сбои переживаются без участия человека. Во многих сценариях задачу можно перезапустить: если шаг упал, координатор видит состояние task group, и повторяет операцию.

Самый болезненный из сценариев — когда данные уже перевезены, а операция UNITE KEY RANGE не успела пройти везде одинаково. Например, координатор обновил метаданные у себя, а один из роутеров в этот момент не принял команду из‑за сбоя сети. Тогда перевозка данных уже сделана, но финальный «склей» диапазонов надо доводить руками.

Что особенно важно для разработчика приложения

Эта статья в основном про инфраструктуру, но и для разработчика есть несколько полезных выводов.

  1. Удачное шардирование начинается не с балансировщика, а с хорошего ключа. Если ключ шардирования выбран так, что данные естественно группируются и почти все запросы несут его в себе — перевоз и балансировка становятся решаемой задачей.

  2. Долгие транзакции и тяжёлые запросы — это и проблема базы, и препятствие для онлайн‑решардирования. Чем чище и короче транзакционный профиль сервиса, тем легче потом двигать данные.

  3. Глобально монотонные сущности в распределённой системе не возникают бесплатно. Это видно на примерах перевоза и последовательностей. Для reference‑таблиц в SPQR есть рабочий сценарий с AUTO INCREMENT, но строго монотонный sequence для шардированных таблиц остается отдельной и нетривиальной задачей.

Шардирование почти всегда заставляет пересмотреть старые архитектурные привычки.

Куда идёт SPQR 

Нам хотелось бы, чтобы пользователь как можно реже думал в терминах key range и как можно чаще в терминах «система сама знает, как разложить и подвигать данные под капотом».

Для этого нужны как минимум три улучшения:

  1. Более зрелое версионирование и более тонкие режимы блокировок.

  2. Автоматический restart и recovery для известных сбоев, чтобы длинные перевозы не требовали ручного дежурства на каждом неприятном тайм‑ауте.

  3. Более высокоуровневые стратегии решардинга поверх нынешних примитивов. Не просто «раздели диапазон и увези кусок», а «разложи данные вот по такой схеме и доведи операцию до конца».

То есть вектор развития такой: не заменять текущие элементарные блоки, а подняться над ними на уровень выше.

Главное, что стоит вынести из этой истории

Если смотреть на SPQR со стороны, самой сложной частью может показаться роутер: он говорит по PostgreSQL‑протоколу, разбирает SQL и выбирает шард по sharding key. Но основная сложность проявляется не в самом факте маршрутизации.

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

Поэтому перевоз и балансировка в SPQR строятся как композиция простых механизмов:

  • хранить не отдельные ключи, а диапазоны;

  • кешировать метаданные в роутерах, оставляя QDB источником истины;

  • хранить состояние долгих операций в QDB;

  • переносить данные не одним большим куском, а ограниченными батчами;

  • принимать решения по статистике конкретных диапазонов, а не только по шарду в целом.

Так онлайн‑решардирование превращается в последовательность небольших контролируемых и понятных шагов, которые помогают выстроить четкий процесс. 

Буду рад вашим комментариям и вопросам. Если вы хотите оставаться на связи, вот два ресурса: Yandex Cloud про обновления сервисов, кейсы и новости рынка, и Inside Yandex Cloud для тех, кому интереснее посмотреть, что у нас под капотом.

Комментарии (0)