Однажды в компании Tandem Computers, пионерах отказоустойчивых систем, случился неожиданный инцидент: пропажа единственного офисного кота вызвала сбой в самом критичном компоненте — настроении инженеров. Решение проблемы соответствовало корпоративной философии: было внедрено резервирование N+1 в виде сразу двух новых котов.

К сожалению, в мире больших корпоративных СУБД так просто проблема масштабирования и отказоустойчивости не решается. «Шардировать или не шардировать?» — сложный вопрос, который рано или поздно встаёт перед архитектором любой растущей системы.

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

  • Как правильно разбить данные?

  • Как избежать потерь производительности на джойнах?

  • Не превратится ли ваша отказоустойчивая СУБД в головную боль из‑за сложности управления?

В прошлой статье мы знакомились с Postgres Pro Shardman — распределенная СУБД на базе СУБД Postgres Pro, сохраняющая совместимость с реляционной моделью и ACID транзакциями. Сегодня поговорим о практической стороне:

  • В каких случаях шардирование оправдано, а когда можно обойтись без него?

  • Как спроектировать схему данных под распределенную СУБД Shardman?

  • Чем вам придётся пожертвовать при распределённых транзакциях и запросах?

Хайп или неизбежность?

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

Ни в коем случае не превышать рекомендованной дозы.

«Убик», Филип Киндред Дик, 1969

Десять с небольшим лет назад, в самый разгар NoSQL‑эпидемии, стали обретать популярность СУБД, которые, с одной стороны, обещали горизонтальную масштабируемость (требование времени), а с другой — настоящий ACID (ну или его часть) и теплый ламповый SQL на борту. Примерно так началась эпоха Distributed SQL и New SQL. Сегодня это уже суровая реальность, плюс продолжают появляться всё новые решения, которые тихо день за днем поглощают уже не только хипстерские стартапы, но и мир корпоративных данных. В красивых рекламных буклетах таких СУБД производители, конечно же, обещают «100500 млн RPS над петабайтами данных». Однако решиться на такой переезд довольно сложно, возникает много сомнений и вопросов:

  • Так ли это на практике или за красивыми бенчмарками скрываются костыли, боль и подводные камни?

  • Стоит ли гнаться за модными веяниями или всё-таки тюнить свою старенькую реляционку до последнего?

  • А может это естественный путь для любой крупной растущей БД и рано или поздно придётся переехать на что‑то распределённое?

Ответы на эти вопросы, пожалуй, всегда будут индивидуальными: одни проекты обретут второе дыхание с переходом на распределённые решения, а другие рискуют столкнуться с неожиданными сложностями. В этой статье мы постараемся разобрать тему так, чтобы прояснить эти дилеммы (или, возможно, добавить новые, что тоже ценно). Хотя основное внимание будет уделено нашей СУБД Shardman, многие аспекты могут оказаться полезными и для других распределённых СУБД. Надеемся, это поможет широкому кругу специалистов.

Для начала разберём, в каких случаях ваша БД действительно потребует шардирования/распределения или хотя бы когда стоит начать рассматривать эти подходы?

  • Исчерпаны, или крайне к этому близки, возможности прироста оборудованием. Сложные join‑операции на больших таблицах и нетривиальная бизнес‑логика на стороне БД могут полностью загружать вычислительные ресурсы. При постоянном росте данных/пользователей/фич вертикальное масштабирование за счет увеличения мощности сервера и сопутствующего оборудования работает лишь до определённого предела. Даже топовое оборудование имеет физические ограничения, в отличие от фантазии ваших аналитиков и людей из бизнеса. Современные серверные CPU редко превышают 128 ядер, а зависимость производительности от их числа нелинейна (в том числе из‑за накладных расходов на синхронизацию, специфики нагрузки и SQL‑запросов). При этом стоимость высокопроизводительных систем растёт экспоненциально, тогда как горизонтальное масштабирование на стандартных серверах часто оказывается экономически выгоднее уже после среднего ценового сегмента.

  • Постоянный рост объема оперативных данных. Технически PostgreSQL способен хранить сотни терабайт (ограничение в 32 ТБ на одну таблицу можно решить секционированием), но критически важным становится профиль нагрузки. В промышленных системах данные редко просто лежат на месте: типичная рабочая нагрузка включает тысячи разнообразных запросов на чтение и модификацию. Операции изменения данных вызывают интенсивную фоновую активность (vacuum/analyze), потребляющую значительные ресурсы и создающую конкуренцию за них. Ключевым индикатором становится динамика роста: если объем данных увеличивается быстро, а операции обслуживания (восстановление после сбоев, репликация) требуют все больше времени и ресурсов — это явный сигнал к архитектурным изменениям.

  • Количество одновременных соединений. Модель «один процесс на соединение» в PostgreSQL демонстрирует проблемы при высоких нагрузках. При 10k+ активных соединений возникают существенные накладные расходы. Особенно критично это для SOA и CQRS архитектур с высокочастотными подключениями или систем с долгоживущими соединениями (WebSocket, push‑уведомления).

Допустим, все или большинство известных рецептов для снижения нагрузки на БД были уже использованы, признаны неэффективными или неприменимы для конкретно вашего проекта:

  • Создание отдельных баз данных для новых сервисов действительно ограничивает рост основной БД, но порождает новые сложности. Теряется транзакционная целостность между разными БД, резко усложняется построение сводной отчетности, а реализация распределенных транзакций требует значительных усилий.

  • Попытки экономии дискового пространства за счёт сжатия файловых систем могут не дать ожидаемого результата. Дополнительная нагрузка на CPU для компрессии/декомпрессии данных может увеличивать latency запросов, нивелируя преимущества от сэкономленного места.

  • Пулы соединений, несмотря на свою популярность, могут оказаться неэффективными для множества долгих транзакций и стать узким местом в SOA‑архитектурах с сотнями сервисов.

  • Архивация «холодных» данных (разделение на hot/cold storage). Частые запросы к архивным данным, особые требования регуляторов к хранению и сложности поддержания консистентности между «горячими» и «холодными» данными могут делать этот подход малоэффективным на практике.

  • Перенос операций чтения на реплики перестает работать эффективно, когда приложение чувствительно к задержкам репликации, особенно при высоких нагрузках на запись (например, в финансовых системах и системах реального времени).

  • Хотя переход на NoSQL может показаться радикальным, но эффективным решением проблем масштабирования. Он требует полного перепроектирования модели данных, сложной миграции данных, переработки прикладного кода и переобучения команды/команд. А также сталкивается с организационными барьерами: существующая инфраструктура и экспертиза команды часто уже глубоко заточены под PostgreSQL.

Если большая часть пунктов из этого списка актуальна для вас, то, возможно, стоит задуматься о том, чтобы разбить вашу огромную монолитную БД на несколько шардов, обеспечив тем самым её прозрачную масштабируемость (хотя, уверен, вы и так уже об этом задумывались). Далее мы пройдем через все сложности, с которыми вы столкнетесь при переходе на распределенную СУБД, в частности СУБД Shardman, а в конце оценим результат наших усилий.

Виды шардирования

Можно выделить два основных подхода к тому, как распределить ваши данные по нескольким инстансам: schema‑based (разделение по схемам) и row‑based (разделение по строкам). Разберём их принципы и условия применения.

Разделение монолитной базы данных на несколько полностью независимых БД, или schema‑based sharding — один из самых древних и радикальных подходов к шардированию. Каждый шард становится отдельным инстансом БД со своей схемой, кодом, данными и со своими рутинами: бэкап, тюнинг, отказоустойчивость и вот это всё.

Прозрачно? Да.

Просто в реализации? Как бы не так.

Не всегда возможно определить сущности для разделения, не получив перекосов. Кроме того, придётся серьёзно перерабатывать приложение: оно должно точно знать, к какому шарду за какими данными обращаться. Хотите JOIN между разными шардами? Готовьтесь писать сложную логику на уровне приложения. Нужна консистентность? Придётся реализовывать самостоятельно, потому что встроенных механизмов не будет. Ребалансировка, перекосы нагрузки, внезапные горячие точки — всё это ложится на ваши плечи. Если один шард начинает гореть под нагрузкой, остальные ему не помогут (хотя вот Citus, например, предлагает прозрачную ребалансировку в том числе для schema‑based).

Как минимум, такой подход вполне жизнеспособен в SOA‑архитектурах, где данные естественным образом разделены между сервисами. Главное, чтобы не было нужды в кросс‑шардовых операциях, а сводная аналитика/отчетность либо не требовалась, либо собиралась отдельно. Такое шардирование даёт полный контроль, но взамен требует постоянного внимания. Если нужна максимальная предсказуемость и нет страха перед ручным управлением, почему бы и нет? Но если хочется «прозрачного» распределения данных, лучше посмотреть в сторону row‑based sharding.

Row‑based sharding — метод, при котором строки одной таблицы распределяются между разными шардами по определённому ключу (например, по хэшу от значений заданных колонок, ключа шардирования). Такой подход позволяет масштабировать запись и чтение за счёт параллельной обработки на разных серверах, но предъявляет особые требования к СУБД для обеспечения кросс‑шардовых запросов, поддержки консистентности и прочее. В этом случае приложение продолжает видеть базу как единое целое, при этом под капотом СУБД скрываются сложные кросс‑шардовые операции и обеспечиваются транзакционность и строгая консистентность данных. Система абстрагирует распределённую природу данных, предоставляя единый, вполне себе реляционный интерфейс.

Такой вариант стоит рассматривать, когда schema‑based шардирование невозможно или крайне затруднительно. Типичные сценарии включают работу с хорошо, но в меру нормализованными схемами данных, сложные ETL‑процессы, а также случаи, когда в данных присутствуют несколько групп сущностей со сложными взаимосвязями между собой. Важным условием является возможность выделить эффективный ключ шардирования, без этого производительность может серьёзно пострадать или даже стать еще хуже, чем было в монолитной БД. Мы рассмотрим этот вопрос более подробно в следующих главах.

Основное достоинство — сохранение привычной модели работы с данными. Администрирование, бэкапы и обеспечение консистентности происходят централизованно, что значительно упрощает эксплуатацию. При этом система позволяет работать с данными, распределёнными между разными шардами, без необходимости реализовывать сложную логику на уровне приложения. Однако есть и обратная сторона: необходимо мигрировать схему, данные и хранимый код в распределенную систему. А приложения всё равно потребуют доработки, пусть и не такой кардинальной, как в предыдущем варианте. Кроме того, эксплуатация распределённой СУБД требует существенно более высокой квалификации инженеров по сравнению с классическими решениями, включая оптимизацию распределенных запросов и тонкую настройку сети и железок. В конечном счёте выбор этого пути означает компромисс между сложностью миграции и горизонтальным масштабированием с централизованным контролем консистентности данных и удобством разработки и сопровождения. Shardman как раз является распределенной СУБД на основе row‑based шардирования, об этом и будем говорить далее.

На картинке ниже иллюстрируем, как может выглядеть шардирование двух таблиц на два узла для обоих методов.

Первые шаги

Все ищут то, что я называю магическим параметром инициализации fast=true. Этот магический параметр заставил бы их базу данных работать быстрее. Чем раньше вы смиритесь с тем, что такого параметра нет, тем лучше для вас.

«Oracle для профессионалов», Том Кайт, 2003

Спустя 20+ лет это утверждение в полной мере справедливо и для большинства распределённых СУБД. Просто установить магический параметр distributed=true (даже если он где‑то и есть) недостаточно для создания действительно масштабируемой и производительной системы (более того, это может привести к обратному эффекту). Даже гипотетическое наличие такого параметра не отменяет необходимости построения сложной инфраструктуры, требующей тонкой настройки и постоянного сопровождения. Облачные провайдеры действительно предлагают решения для «прозрачного» масштабирования «по щелчку», но они подходят не всем (по техническим, экономическим и прочим причинам), а главное — в любом случае требуется глубокая переработка архитектуры БД и приложений.

Зачастую большие корпоративные БД представляют собой настоящие «реляционные помойки» сложные системы, где существуют тысячи таблиц и других объектов, тысячи связей, переплетены разные предметные области, бизнес-логика размазана на всех уровнях и все это покрыто толстым неравномерным слоем технического долга и практик «так исторически сложилось». Если в монолитной БД есть техдолг/костыли, то при переходе на распределенную СУБД их стоимость кратно увеличится. Поэтому необходим предварительный рефакторинг. Как перед каждой операцией, особенно большой и сложной, пациент проходит обследование и получает необходимое лечение, так и вашу БД нужно привести в порядок перед сложным процессом миграции на распределенную СУБД.

Не все архитектурные решения одинаково полезны. Некоторые подходы — это не просто «неоптимально», а полноценные «красные флаги», сигнализирующие о потенциальных проблемах. Это не значит, что нужно немедленно всё бросать и переписывать, но такие моменты требуют особого внимания при анализе системы. На что стоит обратить особое внимание:

  • Нормальные формы — это, конечно, хорошо, но всего должно быть в меру. Если ваша БД или ее часть сильно «занормализирована» (например, в случае Anchor или Vault Modeling, когда адрес контрагента нужно собирать джойном из 10 таблиц), то это может еще усложнить планы запросов и затруднить оптимизацию.

  • А вот здоровая денормализация не так уж и плохо, так как, скорее всего, всё равно придется этим заниматься при определении ключей шардирования.

  • Крупные СУБД зачастую содержат циклические связи между таблицами, причём такие зависимости могут образовывать сложные, взаимопересекающиеся цепочки из десятков таблиц. Если для таблиц‑справочников это допустимо, то в шардированных таблицах циклические связи создают серьёзные проблемы. Таблицы с разными ключами шардирования, попавшие в такие зависимости, вынуждены взаимодействовать кросс‑шардово.

  • EAV‑модель — это мощный, но опасный паттерн. Даёт гибкость ценой сложных трудночитаемых SQL‑запросов, проблем с производительностью и трёхэтажных джойнов. При хранении всех атрибутов всех сущностей в одной колонке одной таблицы со значениями (VALUES) теряется возможность вменяемого предметного шардирования, так как разные сущности требуют разных ключей распределения. А безликие спагетти‑запросы, характерные для EAV, в распределенной среде будут вести себя еще хуже чем в монолитной БД.

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

Новые типы объектов БД

При row‑based шардировании (в отличие от sharded‑based) системам обычно приходится вводить новые типы объектов (даже если системы эти основаны на каких‑то больших зрелых СУБД). И Shardman в этом плане не исключение.

Шардированная таблица — внешне обычная секционированная таблица Postgres. Но на каждом узле хранится только часть секций, а остальные доступны по сети как foreign‑таблицы. Таким образом данные распределены по всему кластеру, но при этом доступны в полном объеме с каждого узла кластера. При изменении количества узлов происходит равномерная перебалансировка секций между ними. Распределение строк по секциям происходит на основании хеш‑функции от ключа шардирования, который указывается при создании таблицы. Такой подход к распределению данных обеспечивает контролируемую горизонтальную масштабируемость, но требует пристального внимания к выбору ключа шардирования.

Когда стоит применять шардированные таблицы?

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

  • Высокая нагрузка на запись. Когда из‑за большого потока входящих данных они не успевают быть вставлены и обработаны за выделенное временное окно. Особенно в системах с ресурсоемкой бизнес‑логикой, которую можно эффективно распараллелить по шардам.

  • Интенсивные чтения приводят к увеличению времени отклика, которое не удовлетворяет бизнес‑требованиям. Если большинство запросов на чтение могут выполняться с использованием ключа шардирования, это минимизирует кросс‑шардовые соединения и сильно разгрузит систему.

Совместно размещённая таблица (colocated table) — это такая же секционированная таблица, но «привязанная» к некой родительской таблице таким образом, что все строки каждой её секции с тем же ключом шардирования физически находятся на тех же узлах, что и у родительской таблицы. Это позволяет выполнять тяжёлые JOIN‑операции по ключу секционирования с минимальными накладными расходами, так как данные находятся на одном узле. Совместно размещённые таблицы можно шардировать не только по FK, но и по произвольному полю/полям.

Когда использовать?

  • Частые соединения по ключу целесообразны, если JOIN выполняется по полю, по которому таблицы объединены, запросы работают так же быстро, как в монолитной СУБД.

  • Звёздные и схемы‑снежинки пригодны, когда совместное размещение уменьшает сетевые издержки при JOIN между фактами и измерениями.

Глобальная таблица — таблица, полная физическая копия которой размещается на каждом шарде, что позволяет выполнять локальные чтения без межсерверных запросов.

Ключевые особенности:

  • Полная репликация: данные синхронизированы на всех узлах, любая операция чтения выполняется локально.

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

  • Не только справочники: глобальными могут быть любые таблицы, если интенсивность обновлений невысока.

  • Обновления требуют синхронизации: каждый INSERT/UPDATE/DELETE будет применен на всех узлах, что кратно увеличивает нагрузку.

Глобальная таблица не подходит для часто изменяемых данных: если таблица активно обновляется, репликация может стать узким местом

Когда использовать?

  • Частые чтения, редкие записи: например, справочники.

  • Критичные к задержке запросы: поскольку данные всегда локальны, время отклика минимально.

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

На картинке ниже приведена иллюстрация распределения данных для разных типов таблиц на разных шардах. Так таблица Clients — обычная шардированная таблица, содержит часть данных на каждом узле. Таблица Orders — шардированная совместно размещенная с таблицей Clients (все заказы каждого клиента будут храниться физически вместе). Таблица Payment methods — классический справочник, она глобальная, на каждом узле содержится полная копия данных.

Прочие глобальные объекты. Помимо таблиц распределенными могут быть роли, табличные пространства и последовательности. Это значит, что создав такие объекты однажды, подключившись к одному из узлов, они будут доступны по всему кластеру. Также у них появятся дополнительные суперспособности для выживания в распределённом мире. Например, последовательности смогут генерировать сквозные уникальные значения независимо друг от друга.

Псевдо-глобальные объекты. Для тех объектов, у которых нет распределенного двойника, можно включить глобальный параметр broadcast_ddl, тогда такие объекты будут созданы на всех узлах кластера и их можно будет использовать с распределенными объектами, например создать новую схему или пользовательский тип (который можно использовать в том числе как тип колонки или базовый тип для шардированной таблицы).

Обычные объекты. По прежнему можно создавать и обыкновенные объекты (как в ванильном Postgres или Postgres Pro) без всякой distributed magic. Но следует помнить, что Shardman такими объектами не занимается, и вам придется следить за ними самостоятельно. К тому же, вы не можете их использовать в кросс-шардовых транзакциях. Это шаг назад в плане работоспособности решения, но если очень хочется, то можно. Как минимум, это может понадобиться при миграции схемы и данных с монолитной БД в качестве промежуточного этапа.

Неподдерживаемые объекты. Некоторые типы объектов могут и вовсе не вписаться в распределенную природу той или иной СУБД. В Shardman, например, к таким объектам относятся временные таблицы. Точнее, их можно создавать и использовать, но только до тех пор пока модифицирующие операции с временной таблицей не попадут в распределённую двухфакторную транзакцию.

Миграция схемы

Ознакомившись с основами устройства распределенной СУБД и новыми распределенными типами таблиц, пришло самое время начать миграцию схемы нашей системы, которая в дальнейшем за собой потянет последующие миграции, например данных и кода. И чем тщательнее мы выполним этот этап, тем проще будет на следующих шагах, и vise versa.

Несмотря на то что внешне СУБД Shardman, как и многие другие современные распределенные СУБД, выглядит так же, как обычная реляционка (таблички, ключики, транзакции), проектирование схемы данных для распределенных СУБД сильно отличается от проектирования для классических реляционок. Рассмотрим наглядную аналогию: классический книжный магазин против онлайн‑магазина с сетью пунктов выдачи, распределённых по городам и весям.

В традиционном книжном все издания собраны в одном месте: они тщательно рассортированы по отделам и полкам, снабжены картотекой по авторам, жанрам и годам издания. Нужна конкретная книга? Просто находите ее в каталоге по своему вкусу и настроению, идете к нужному стеллажу — и вот она, готовая к покупке. Проблемы начинаются, когда магазин становится слишком популярным: очередь на кассе, выходящая на улицу, толкучка между рядами, сотрудники не успевают обслуживать всех желающих. Как вы уже догадались, традиционный книжный — это аналогия классической реляционной СУБД.

А вот онлайн‑магазин устроен принципиально иначе. Книги физически разбросаны по разным складам и пунктам выдачи. На сайте они могут отображаться как единая коллекция, но на деле: издание «А» находится в Москве, издание «Б» в Уфе, а издание «В» вообще еще не напечатано (print on demand). Покупатель просто выбирает удобный пункт выдачи и получает заранее доставленные туда книги, минуя очереди и толкотню. Однако есть важные ограничения: нельзя прийти в ПВЗ и запросить «всю фантастику за 2024 год» или «все сборники рассказов Стивена Кинга» — можно забрать только то, что уже доставлено в этот конкретный пункт заранее. Ошибка в выборе города при заказе означает, что придется ехать за заказом в другой город. А крупный заказ может потребовать посещения нескольких ПВЗ, если один пункт не может обработать весь объем. И это прямая аналогия с распределенными СУБД.

Первый подход предлагает универсальность запросов, второй — горизонтальную масштабируемость, но с жесткими ограничениями на типы возможных операций. Таким образом, при проектировании схемы данных для распределенной БД нужно заранее хорошо знать, кто и как будет пользоваться данными (когда будет дешево, а когда — дорого) и заранее предусмотреть, где и в каком виде будут находиться данные. Достигается это путём выбора подходящего ключа шардирования.

Ключ шардирования — это специальный атрибут данных (столбец или набор столбцов), определяющий физическое размещение записей между шардами распределённой СУБД и критически влияющий на производительность за счёт минимизации кросс‑шардовых операций (или, наоборот, за счет увеличения, если ключ выбран или используется неверно). Ключ может быть естественным или искусственным (он же суррогатный). Естественный использует существующие колонки таблицы (даже если среди них есть суррогатный PK):

-- Если ключ шардирования совпадает с имеющимся PK, то оператор создания
-- шардированной таблицы почти не отличается от создания обычной таблицы
-- Необходимо только добавить опции распределения в блок with
create table persons (
  id serial primary key,
  name text,
  department_id int)
with (distributed_by = 'id', num_parts = 32);
 
-- Если мы хотим использовать естественный ключ шардирования,
-- не совпадающий с колонками PK (например шардировать данные по департаментам)
-- То его необходимо добавить в PK
create table persons (
  id serial,
  name text,
  department_id int,
  primary key (id, department_id))
with (distributed_by = 'department_id', num_parts = 32);

Распространенной практикой является использование суррогатных ключей шардирования. Такие ключи оперируют специальными служебными колонками (либо совмещают их с обычными колонками), чтобы более тонко управлять маршрутизацией данных. В нашем случае это некая колонка skey, заполнением которой управляет уже приложение, а не СУБД:

-- Колонки суррогатного ключа шардирования также необходимо добавить в PK
create table persons (
  id serial,
  skey uuid not null,
  name text,
  primary key (id, skey),
  deprartment_id int)
with (distributed_by = 'skey', num_parts = 32);

При миграции монолитной схемы на распределённую архитектуру (или проектировании её с нуля) критически важно сразу правильно определить ключи шардирования, последующее изменение этого выбора в production потребует значительных затрат. В редких случаях можно использовать существующие колонки, но чаще приходится добавлять новые поля и идти на денормализацию (это может понадобиться для того, чтобы данные из связанных таблиц были объединены общими колонками и была возможность хранить связанные данные на одном шарде). Хотя выбор ключа для отдельной таблицы тривиален, реальная сложность возникает из‑за взаимосвязей между десятками (иногда сотнями) таблиц и сотнями запросов, где каждая таблица играет разные роли. Создание слаженно работающей распределённой схемы, особенно при перепроектировании устоявшейся системы с терабайтами данных и тысячами пользователей, представляет собой нетривиальную инженерную задачу, которой мы посвятим одну из следующих статей.

Зачастую миграция схемы приводит не только к прагматичной денормализации для определения ключей шардирования, но и к общему рефакторингу схемы, лечению старых болячек, которые, может, и не связаны напрямую с процессом миграции на распределенную СУБД, но их устранение сделает миграцию более простой. Плюс почему бы не воспользоваться моментом, так как рефакторинг схем проводится достаточно редко и следующего шанса можно ждать годами. Например, случай из жизни: во время миграции одной схемы БД было принято решение вообще избавиться от нескольких дочерних таблиц в пользу того, чтобы превратить их во вложенные структуры данных в родительской таблице (пали жертвами овернормализации на заре проектирования БД). Таким образом, сильно упростились запросы и код. Тут главное — не увлекаться и не забывать, что именно у вас в приоритете.

И напоследок пару слов о миграторах схемы. В мире СУБД инструменты вроде Liquibase и Flyway давно стали стандартом де‑факто. При работе с распределёнными СУБД SQL‑based миграции остаются наиболее надёжным вариантом: достаточно дополнить стандартные DDL‑операторы специфичными директивами вроде global или distributed_by. Однако при использовании декларативных XML/JSON‑миграций возникают принципиальные сложности: абстракция мигратора, скорее всего, не учитывает распределённую природу целевой СУБД, что неизбежно приводит к необходимости либо глубокой кастомизации инструмента, либо болезненного перехода на SQL‑миграции. Последнее, впрочем, всегда считалось хорошей инженерной практикой, особенно в контексте сложных распределённых систем.

Миграция кода

Миграция на распределённую СУБД неизбежно потребует доработки кода, работающего с базой, как хранимых процедур, так и внешнего прикладного кода. В данной главе мы разберём ключевые аспекты адаптации кодовой базы при переходе на шардированную архитектуру, выделив наиболее критичные точки, требующие особого внимания.

Расширения — это то, без чего сложно представить современный PostgreSQL. Они добавляют функциональность, упрощают работу с данными и часто становятся неотъемлемой частью инфраструктуры. Но когда речь заходит о распределённой СУБД, практически для всех используемых расширений, как встроенных, так и сторонних, придётся полностью пересмотреть подход к их использованию. Одни расширения придётся отключить, другие — серьёзно доработать, а третьи — применять с оговорками и ограничениями. И при работе с каждым из них неизбежно возникает вопрос: «А как оно поведёт себя в распределённой среде? Выполнит операцию на текущем шарде, на удаленном шарде, или затронет весь кластер?» Возьмём, например, старое доброе расширение uuid‑ossp. В обычном PostgreSQL вероятность коллизии UUID ничтожно мала. Но что, если в кластере работают десятки инстансов, генерирующих UUID одновременно? Увеличивается ли риск дублей, и если да, то насколько? Или pgpro_scheduler — должен ли он запускать задачи только на текущем узле или распределять их по всему кластеру? Если распределять, то как: параллельно, «лесенкой» (rolling scheduler) или случайным образом, избегая пересечений?

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

Последовательности — фундаментальный механизм реляционных СУБД, и их роль выходит далеко за рамки банальной генерации суррогатных ключей. В хитрых бизнес‑логиках они могут отвечать за нумерацию документов, управление версионностью или даже работу с временными интервалами, — всё благодаря гарантированной уникальности и монотонности. Но в распределённых системах этот проверенный инструмент превращается в головную боль. Ценность сквозной уникальности значений резко возрастает, равно как и сложность её обеспечения. Классические последовательности, привязанные к одному узлу, в распределённом кластере становятся узким местом и источником конфликтов.

Существует множество алгоритмов и технологий обеспечения генерации уникальных значений в распределенных системах — от классического UUID до ULID, Snowflake ID и hash‑based экзотики. В Shardman для этого реализован механизм выделения диапазонов: каждый шард получает свой отрезок значений, а при исчерпании последних запрашивает новый. Это позволяет продолжать пользоваться стандартными sequence‑генераторами Postgres, даёт железобетонную гарантию уникальности в масштабах всего кластера, но при этом генерируемые значения теряют монотонность. Такие компромиссы критично учитывать при миграции:

  • где‑то потребуется переписать запросы, завязанные на строгую монотонность;

  • где‑то существенно изменить способы или даже виды сортировки;

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

Запросы на чтение. Рефакторинг схемы, включая добавление новых колонок, неизбежно потребует модификации существующих запросов. Хотя запросы на чтение могут продолжать работать, их производительность может снизиться по сравнению с монолитной БД, особенно если в условиях WHERE отсутствуют колонки ключа шардирования, что приводит к неоптимальному кросс‑шардовому выполнению.

Допустим, таблица persons стала шардированной по суррогатному ключу. В таком случае даже самые простые запросы по PK (коих могут быть тысячи) продолжат выполняться и возвращать нужные данные, но это приведет к полному сканированию всех секций таблицы, включая удалённые, находящиеся на других узлах. Такой запрос будет выполняться дольше и потреблять больше ресурсов, чем в монолитной БД:

select name
from persons
where id = 42
 
Append  ...
  ->  Async Foreign Scan on persons_1_fdw persons_2  ...
  ->  Async Foreign Scan on persons_2_fdw persons_3  ...
  ...
  ->  Async Foreign Scan on persons_31_fdw persons_32  ...

А вот если явно указать ключ шардирования, то всё обойдется единственным Foreign Scan на конкретный узел (а если волею судеб искомая строчка уже находится на текущем узле-координаторе, то будет вообще простое локальное чтение):

select name
from persons
where id = 42 and skey = 'cc00ffee-6dd6-44cf-8f40-0c306e308b3d'
 
Foreign Scan on persons_18_fdw persons  ...
  Foreign EXPLAIN:
    Index Scan using persons_18_pkey on persons_18  ...
      Index Cond: ((id = 42) AND (skey = 'cc00ffee-6dd6-44cf-8f40-0c306e308b3d'::uuid))

Особую опасность в распределённых СУБД представляет антипаттерн 'SELECT *' (известный как «неявные столбцы» у Билла Карвина). Хотя такой подход упрощает написание запросов, он приводит к избыточному чтению данных. Особенно этим грешат разнообразные ORM, часто загружающих все колонки модели по умолчанию. В монолитных системах это увеличивает сетевой трафик и нагрузку, тогда как в распределённых архитектурах негативный эффект может усиливаться пропорционально количеству шардов из‑за необходимости транспортирования избыточных данных со всех узлов.

Запросы на запись

Миграция на распределённую СУБД также требует кардинального пересмотра запросов на запись: все INSERT, UPDATE и прочие операторы должны явно включать новые колонки ключей шардирования, значения которых необходимо вычислять перед каждой операцией. Хотя технически возможно делегировать эту задачу на уровень БД (например, в триггерную логику), такое решение создаёт существенную нагрузку на систему. Предпочтительный, но более затратный подход — модификация прикладного кода для подготовки полного набора данных, включая ключи шардирования, что может вызвать определённые трудности при интеграции со сторонними системами, не рассчитанными на распределённую архитектуру.

Проблемы отображения/поиска/пагинации данных

В мире баз данных seqscan (он же fullscan, он же table scan) считается злом во плоти, которое разработчики пытаются изгнать из всех своих запросов (кстати, не всегда оправданно). Но в распределённых СУБД есть вещи похуже. Здесь возможен «сэкскан сэксканов», когда запрос при этом полностью сканирует все удалённые шарды с последующей фильтрацией на координаторе (так как нужно предварительно всё это еще затащить на координатор по сети). Как мы уже успели убедиться, даже безобидный 'SELECT * FROM table_foo', пусть даже и с фильтром по PK, может обернуться полным сканированием всех партиций, если не указан ключ шардирования. Либо условие поиска может быть достаточно сложным или непредсказуемым, чтобы явно определить конкретный шард с нужными данными (например, при использовании mutable‑функций, результат которых может отличаться на разных узлах).

Особо остро эта проблема встает при отображении данных, например, с использованием пагинации и поиска по произвольным колонкам различными системами (приложения с пользовательским интерфейсом, веб‑сервисы и прочие). Классическая offset‑пагинация (LIMIT X OFFSET Y) является самым простым и излюбленным методом пагинации, и она же особенно губительна как для монолитных СУБД, так и тем более для распределенных, так как потребует полного сканирования данных со всех шардов и сортировки данных на координаторе. Гораздо эффективнее keyset‑пагинация (также известная как «пагинация по курсору»), где каждая следующая страница строится на основе последнего значения предыдущей. Однако и здесь важно учитывать ключ шардирования, иначе запросы будут вынуждены обращаться ко всем узлам, сводя на нет преимущества этого подхода.

Бизнес‑логика на стороне БД. Зачастую БД содержат какую‑либо бизнес-логику: от встроенного контроля целостности до сложных бизнес‑сценариев, реализованных в хранимых процедурах и триггерах, что требует также отдельного внимания при работе в распределенных СУБД.

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

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

ORM. Классические ORM десятилетиями культивировали миф о том, что различные СУБД можно безболезненно менять «как перчатки». Вплоть до того, что тестировать приложение можно на карманном SQLite (быстро и дёшево), в продакшене все это работает на монструозной Oracle DB, а завтра мы заменим все это на PostgreSQL и никто ничего не заметит. В мире распределённых систем эти мечты (и без того не очень осуществимые) разбиваются о суровую реальность: каждая адаптация запросов под шардинг требует явного учёта ключей распределения и ограничений кросс‑шардовых операций.

ORM продолжают работать с распределённой СУБД как с single‑instance, генерируют запросы, игнорирующие физическую локацию данных. В результате разработчикам приходится вручную «обучать» ORM: декларировать колонки шардирования в моделях, переопределять методы генерации запросов и явно контролировать точки выполнения. Без такой настройки приложение либо окажется неработоспособным, либо столкнётся с катастрофическим падением производительности из‑за неоптимальных SQL‑запросов, агрегирующих данные со всех узлов.

Конечно, может произойти чудо: все ключи шардирования в вашей БД совпадут с уже существующими первичными ключами, но «вероятность крайне мала»: такое обычно бывает на учебных демо-базах, и то не всегда.

Миграция данных

Когда речь заходит о переносе сотен терабайт данных в распределённую СУБД, процесс требует тщательной подготовки и поэтапного подхода. Давайте разберём ключевые шаги миграции, основанные на реальном опыте подобных переходов:

  • На подготовительном этапе всё начинается с определения точки консистентного состояния системы. Идеальным кандидатом часто становится свежий бэкап. Параллельно необходимо выбрать решение для CDC, которое будет отслеживать изменения в исходной системе. Этот выбор критически важен и напрямую зависит от используемой СУБД.

  • Этап преобразования и загрузки (собственно TL‑часть из аббревиатуры ETL) обычно занимает от одной до двух недель. Обычно недостаточно просто залить старые данные в новую таблицу, их придется трансформировать, например, заполняя новые колонки, созданные под ключи шардирования. Если вставлять данные в лоб, прямо в таблицы на текущем узле, то это приведет к тому, что координатору придется парсить такие батчи с данными и рассылать отдельные запросы на каждый подходящий узел, что приведет к дополнительным расходам ресурсов. Поэтому стоит подумать о том, чтобы вставлять данные напрямую на каждом узле, в Shardman для этого уже есть специальный инструмент — команда shardmanctl load. На этот период в исходной системе продолжают накапливаться изменения, что требует особого внимания к механизмам синхронизации. Настоятельно рекомендуется предусмотреть возможность создания trunk‑версии базы, совместимой со старой схемой данных. Это ваш страховочный трос на случай необходимости отката.

  • После завершения первоначальной загрузки наступает этап синхронизации актуальных изменений через выбранное CDC‑решение. Теперь Shardman становится ведомой системой. Крайне важно дождаться полного совпадения данных и тщательно проверить их целостность на всех системах: новой, старой и промежуточной trunk‑версии. Практика показывает, что многие «надёжные» методики проверки при детальном анализе оказываются не столь совершенными.

  • На этапе тестового переключения роли меняются: Shardman становится ведущей системой, а старая — подчинённой. Идеальным решением будет реализация механизма переключения «по кнопке» с возможностью мгновенного отката. Это ваш последний рубеж перед полным переходом. Стоит отметить, что текущая версия Shardman не включает встроенного CDC‑решения. Придётся работать с ванильным механизмом репликации через репликационные слоты, что, признаем, не самый удобный вариант. Мы активно работаем над улучшением этой функциональности в будущих версиях.

  • Завершение миграции — отключение старой системы — кажется простым только на бумаге. На практике это момент истины, когда проверяется вся проделанная работа. Убедитесь, что все процессы стабильно работают в новом окружении, прежде чем окончательно выводить старую систему из эксплуатации. Такой поэтапный подход, хотя и требует значительных временных затрат, значительно снижает риски и позволяет избежать катастрофических последствий в случае возникновения проблем. Главное — не торопиться и тщательно проверять каждый шаг.

Топология

В монолитных СУБД топология достаточно прозрачна и буднична: есть единый мастер‑сервер, набор реплик и отработанные механизмы failover. Однако распределённые системы требуют принципиально иного подхода. Здесь изначально существует множество равноправных шардов, каждый со своим набором реплик, что создаёт сложную сеть взаимосвязей. Ключевая задача — обеспечить слаженную работу этой распределённой топологии, где баланс между доступностью, согласованностью и отказоустойчивостью достигается за счёт тщательно продуманной архитектуры. Рассмотрим основные варианты таких решений.

Прямая топология — надёжный подход к организации отказоустойчивого кластера. Суть проста: каждый узел (мастер и его N standby‑реплик) размещается на отдельных физических серверах. Ключевое преимущество — обеспечивает бесперебойную работу кластера даже при одновременном отказе до N-1 узлов в рамках каждого шарда.

Кросс‑топология — бюджетный, но эффективный вариант. Реплики подсаживаем на машины к мастерам, особенно учитывая то, что такие реплики не будут создавать большую нагрузку на машину (так как чтение с них не производится, а только синхронизация с мастером и ожидание подмены в случае его падения). Но чтобы выход из строя одной машины не убил весь кластер — реплики можно подсадить на машины к чужим мастерам. Такая архитектура обеспечивает сохранение работоспособности при полном выходе из строя отдельной машины.

Data Replication Service. Оптимальным решением для обеспечения отказоустойчивости кластера является использование специализированного сервиса репликации, основанного на переключении целых кластеров. При этом топология резервного кластера должна строго соответствовать production‑окружению. Механизм реализует асинхронную пересылку WAL‑логов на удалённый кластер с идентичной топологией, что обеспечивает:

  • работу между разными дата‑центрами;

  • умеренные требования к пропускной способности сети;

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

Вместо заключения

В результате после всех испытаний мы получаем полноценную БД с SQL, ACID‑транзакциями и масштабированием, сильно стремящимся к линейному — идеальный фундамент для высоконагруженных систем. Все сложности миграции с лихвой окупаются, когда видишь такие показатели на графике.

На первом этапе мы развернули кластер из двух мастер‑узлов без репликации. Затем масштабировали систему до трех мастеров, получив ожидаемый прирост производительности за счет горизонтального масштабирования, результат вполне предсказуем.

Следующим шагом мы обеспечили отказоустойчивость, добавив к каждому мастеру синхронную реплику. Это дало нам нулевой RPO (Recovery Point Objective), но, как видно из графиков, привело к закономерному снижению производительности (плата за гарантированную сохранность данных).

Для чистоты эксперимента мы повторили тесты с конфигурацией из четырех мастеров без реплик, а затем — с шестью мастерами. В финальной серии тестов каждый мастер получил синхронную реплику, обеспечив полную отказоустойчивость: при падении любого узла система продолжает работу без потери данных (RPO=0). Цену такой надежности наглядно демонстрируют результаты тестирования.

Рекомендации по конфигурации:

  • определите оптимальное количество шардов для вашей нагрузки;

  • выберите баланс между количеством реплик и требованиями к отказоустойчивости;

  • для секционирования таблиц используйте степень двойки (2^n), начиная с двух партиций на шард;

  • избегайте избыточного секционирования, это создаст чрезмерную нагрузку на планировщик.

Что дальше?

Мы разобрали основные моменты работы с нашей СУБД, но это лишь вершина айсберга. В официальной документации можно найти детальное описание всех аспектов работы СУБД Shardman, в том числе:

Статья подготовлена на базе доклада Михаила Сумского «Архитектурные практики применения Shardman» на конференции Core Systems Dev Conf 2025.

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