Часто возникает проблема: одна из таблиц в базе данных сильно выросла и время выполнения запросов к этой таблице увеличилось. Одним из вариантов решения такой проблемы в PostgreSQL является партицирование. В статье затронем не только техническую реализацию, но и опишем этапы подготовки к партицированию.
Представим, что у нас есть батон хлеба. Порежем его на части. Каждый отрезанный кусочек — часть целого батона, но не сам батон. То есть мы поделили целое на части — это и есть партицирование. Батон как целое соответствует таблице, а кусочки батона как части — партициям этой таблицы.
Заметим, что кусочки батона не равны между собой: одни тоньше, другие толще, у одних корочки нет только с одной стороны, у других — с двух сторон и так далее. Так же и с партициями: они могут содержать разное количество строк, а значит и размер на диске будет разным. Стоит отметить, что таблица партицируется построчно.
Таблицу, которую партицируют, называют мастер-таблицей. Партиция имеет связь с мастер-таблицей и представляет собой обычную таблицу, то есть к ней можно обращаться точно так же, как к самой обычной таблице: SELECT, INSERT (если не нарушает ограничений, накладываемых на партицию), UPDATE, DELETE. Допустимы операции обслуживания (VACUUM, ANALYZE), а также операции по изменению схемы таблицы (ALTER), правда, с некоторыми ограничения (подробнее см. официальную документацию).
Забегая вперёд, отметим, что при партицировании через наследование нет необходимости переписывать код приложения: обращения на чтение и запись выполняются к мастер-таблице. PostgreSQL самостоятельно определяет, к каким партициям следует обратиться. В то же время, если есть такая необходимость и приложение поддерживает подобную логику работы, можно напрямую обращаться к конкретным партициям.
Какие проблемы может решить партицирование?
ускорение выборки данных;
ускорение вставки данных;
упрощение удаления старых данных;
упрощение обслуживания таблицы.
Следует помнить, что партицирование — не панацея. Как и с любым другим инструментом, его применение не означает автоматически, что, например, проблема ускорения выборки или вставки данных будет решена. Результат сильно зависит от структуры таблицы, используемых индексов, критерия партицирования, размера партиций и прочих условий.
Стандартный процесс при удалении старых данных: выполняем DELETE FROM по условию, а затем запускаем полную вакуумизацию (VACUUM FULL) с простоем (про полный вакуум без простоя см. pg_repack). Обе операции затратны по времени и нагрузке на сервер БД и слабо контролируемы: нельзя понять прогресс выполняемой операции. В случае партицирования (при условии, что партицирование выполнено по полю created_at либо аналогичному) удаление старых данных занимает 2-3 секунды и выполняется через удаление соответствующей партиции с помощью DROP TABLE.
В случае упрощения обслуживания тот же полный вакуум гораздо быстрее отработает на сумме всех партиций, чем на одной большой таблице того же размера.
В каких случаях партицирование не поможет либо никак не повлияет?
время создания бэкапа;
время восстановления из бэкапа;
место на диске.
В первых двух случаях время практически не изменится, так как выполняется полный перебор данных. В последнем случае: был 1 млрд строк, после партицирования имеем тот же 1 млрд. То есть занимаемое место на диске будет практически тем же.
Некоторые ограничения и возможные проблемы по итогам партицирования
партицируемая таблица должна быть достаточно большого размера; согласно документации, рекомендуется партицировать в случае, если таблица превосходит размер физической памяти;
на партицируемую таблицу нельзя ссылаться через FOREIGN KEYS (можно, начиная с PostgreSQL 12); при этом обратное (партицируемая таблица ссылается на другие) допустимо;
в некоторых случаях партицирование может ухудшить производительность на операциях чтения и записи; как указывалось выше, итоги партицирования сильно зависят от многих условий;
в идеале запрос будет выполняться против одной партиции, но в худшем случае — затронет все партиции и, в зависимости от настроек PostgreSQL, увеличит время выполнения запроса, как в предыдущем пункте.
Виды партицирования
Декларативное партицирование появилось в PostgreSQL 10. Является встроенным (built-in) и наиболее производительным решением по партицированию, но имеет существенные ограничения:
требует изначально создать таблицу, готовую к партицированию;
нельзя партицировать уже существующую таблицу через ALTER TABLE;
при добавлении и удалении партиций будет простой в работе таблицы из-за ACCESS EXCLUSIVE LOCK (начиная с PostgreSQL 12, режим блокировки более щадящий: можно использовать SELECT, но только без FOR UPDATE/SHARE);
имеет ряд других ограничений по сравнению с другим типом партицирования.
Партицирование через наследование является более гибким решением:
можно партицировать уже существующую таблицу;
нет даунтайма при добавлении и удалении партиций;
можно задать любой произвольный критерий партицирования (об этом ниже);
возможно множественное наследование (наследование схем более чем одной таблицы);
в конце концов партицирование можно безболезненно отменить.
Зачастую о партицировании задумываются, когда таблица сильно разрослась, поэтому подробнее остановимся на партицировании через наследование.
Процедура партицирования через наследование
1. Создаём таблицы-партиции с использованием ключевого слова INHERITS:
CREATE TABLE bigtable_y2021m03 (
CHECK (created_at >= '2021-03-01'::DATE AND created_at < '2021-04-01'::DATE)
) INHERITS (bigtable);
CREATE TABLE bigtable_y2021m04 (
CHECK (created_at >= '2021-04-01'::DATE AND created_at < '2021-05-01'::DATE)
) INHERITS (bigtable);
Указываем уникальное имя таблицы-партиции. Желательно в имени кратно указать критерий (условие) партицирования. Задаём критерий партицирования для каждой партиции через CHECK. Обратите внимание, условия для партиций должны быть уникальны, вставляемая строка данных должна удовлетворять условию только одной партиции. Поэтому в данном примере нижняя граница условия имеет знак нестрогого неравенства, а верхняя граница — строгого.
Если планируется разнести имеющиеся в мастер-таблице данные по партициям, то в данном шаге создаём столько партиций, сколько необходимо, чтобы покрыть ими текущие данные плюс некоторое количество партиций для будущих данных на 2-3 месяца вперёд. Иначе создаём партиции только для будущих данных.
2. Добавляем индексы, такие же, как в мастер-таблице:
ALTER TABLE ONLY bigtable_y2021m03
ADD CONSTRAINT bigtable_y2021m03__pkey PRIMARY KEY (id);
CREATE INDEX bigtable_y2021m03__created_at ON bigtable_y2021m03 (created_at);
ALTER TABLE ONLY bigtable_y2021m04
ADD CONSTRAINT bigtable_y2021m04__pkey PRIMARY KEY (id);
CREATE INDEX bigtable_y2021m04__created_at ON bigtable_y2021m04 (created_at);
3. Создаём функцию, обеспечивающую партицирование:
CREATE OR REPLACE FUNCTION
bigtable_insert_trigger()
RETURNS TRIGGER AS $$
BEGIN
IF ( NEW.created_at >= '2021-03-01'::DATE AND
NEW.created_at < '2021-04-01'::DATE ) THEN
INSERT INTO bigtable_y2021m03 VALUES (NEW.*);
ELSIF ( NEW.created_at >= '2021-04-01'::DATE AND
NEW.created_at < '2021-05-01'::DATE ) THEN
INSERT INTO bigtable_y2021m04 VALUES (NEW.);
ELSE
RAISE EXCEPTION 'Date out of range.
Fix the bigtable_insert_trigger() function!';
END IF;
RETURN NULL;
END;
$$
LANGUAGE plpgsql;
Цель данной функции — определение партиции, в которую будет вставлена новая строка, на основе заданных условий партицирования. Если такое условие найдено не будет, функция сгенерирует ошибку о невозможности выполнения подобной операции.
4. Подключаем функцию к мастер-таблице:
CREATE TRIGGER insert_bigtable
BEFORE INSERT ON bigtable
FOR EACH ROW EXECUTE FUNCTION bigtable_insert_trigger();
Ключевой шаг. Триггер обеспечивает вставку данных в правильную партицию. При выборке данных PostgreSQL самостоятельно определяет, из каких партиций брать данные.
5. Разносим данные из мастер-таблицы по партициям:
WITH x AS (
DELETE FROM ONLY bigtable
WHERE created_at BETWEEN .. AND .. RETURNING *)
INSERT INTO bigtable_y20XXmYY
SELECT * FROM x;
Итерационно по условию WHERE удаляем данные из мастер-таблицы и удалённые данные возвращаем (RETURNING) в качестве ответа на запрос DELETE FROM. Эти данные используем для вставки в нужную партицию. Обратите внимание на ключевое слово ONLY в операции удаления: без него запрос отработает не только по мастер-таблице, но и по всем партициям. Данное ключевое слово может быть полезно и при выборке данных ТОЛЬКО из мастер-таблицы, и при прочих операциях с мастер-таблицей, если не хотим, чтобы были затронуты партиции.
Если планируется полностью удалить данные из мастер-таблицы без переноса, то пропускаем данный шаг.
6. Очищаем мастер-таблицу
TRUNCATE ONLY bigtable;
Здесь снова используем ключевое слово ONLY, таким образом данные в партициях затронуты не будут. В процессе работы запроса для мастер-таблицы будут созданы новые пустые дата-файлы и пустые файлы индексов, прежние файлы мастер-таблицы будут удалены. В данном шаге можно было бы использовать полный вакуум, однако, в отличие от TRUNCATE операция полного вакуума выполняет проверку на существование данных, что даже на формально пустой таблице может вызвать значительный простой.
Отлично, партицирование выполнено, всё работает.
Проходит три месяца, новые данные перестают писаться в таблицу. Всё потому, что мы забыли добавить в крон добавление новых партиций. Быстро лечим кроном и переписываем функцию (добавляем условия проверки). Через год у нас уже 100 партиций, и функция разрослась до неимоверных размеров. Обслуживать такую таблицу со временем стало сложнее. Давайте не будем так делать и вместо этого заставим PostgreSQL самостоятельно решать подобные проблемы.
Объединим первые три шага процедуры в один. В итоге получим следующую процедуру:
1. Создаём расширенную функцию, обеспечивающую партицирование с автоматическим созданием партиций:
CREATE OR REPLACE FUNCTION bigtable_insert_trigger()
RETURNS TRIGGER AS $$
DECLARE
current_date_part DATE;
current_date_part_id TEXT;
partition_table_name TEXT;
first_day_of_month DATE;
last_day_of_month DATE;
BEGIN
current_date_part := CAST(DATE_TRUNC('month', NEW.created_at) AS DATE);
current_date_part_text := REGEXP_REPLACE(current_date_part::TEXT, '-','_','g');
partition_table_name := FORMAT('bigtable_%s', current_date_part_text::TEXT);
IF (TO_REGCLASS(partition_table_name::TEXT) ISNULL) THEN
first_day_of_month := current_date_part;
last_day_of_month := current_date_part + '1 month'::INTERVAL;
EXECUTE FORMAT(
'CREATE TABLE %I ('
' CHECK (created_at >= DATE %L AND created_at < DATE %L)'
') INHERITS (bigtable);'
, partition_table_name, first_day_of_month, last_day_of_month);
EXECUTE FORMAT(
'ALTER TABLE ONLY %1$I ADD CONSTRAINT %1$s__pkey PRIMARY KEY (id);'
, partition_table_name);
EXECUTE FORMAT(
'CREATE INDEX %1$s__created_at ON %1$I (created_at);'
, partition_table_name);
END IF;
EXECUTE FORMAT('INSERT INTO %I VALUES ($1.*)', partition_table_name) USING NEW;
RETURN NULL;
END;
$$
LANGUAGE plpgsql;
В данном шаге на основе входных данных определяем имя партиции, к которой должны относится новые данные, и условия принадлежности к этой партиции (первые три строки блока BEGIN). Проверяем существование такой партиции (TO_REGCLASS), и если её нет — создаём соответствующую партицию и индексы к ней (EXECUTE FORMAT … CREATE TABLE, ALTER TABLE, CREATE INDEX). В конце вставляем новые данные в правильную партицию. Соответственно, если партиция существует, то просто вставляем в неё новые данные.
2. Подключаем функцию к мастер-таблице.
3. Разносим данные из мастер-таблицы по партициям.
4. Очищаем мастер-таблицу.
Таким образом,
вместо 6 шагов процедура сократилась до 4;
нет необходимости в периодическом ручном, либо полуавтоматическом (через cron) создании партиций;
функция существенно упростилась (да, всё так: 100 if’ов для каждой партиции против одного if’а на проверку существования партиции — это значительное упрощение);
изменение функции требуется только при изменении схемы мастер-таблицы.
Может показаться, что функция усложнилась, появились переменные, выполняются дополнительные шаги по идентификации имени и созданию партиции — это же всё очень медленно. Но, во-первых, партиции создаются не каждую секунду, эта операция фактически выполняется раз в месяц (в случае помесячного разбиения). Во-вторых, как далее будет видно на примерах, даже вставка данных легко может быть ускорена, несмотря на дополнительные накладные расходы.
На этом можно было бы закончить статью, ведь с технической точки зрения мы всё сделали: создали функцию, прикрутили её к таблице, при необходимости перенесли данные. Однако возникает вопрос: по какому критерию мы партицировали таблицу? Как определить этот критерий? Почему выбрали шаг в один месяц? Что ещё мы не учли?
Давайте разберёмся с этими вопросами!
Собираем чек-лист партицирования
Итак, вы решили выполнить партицирование таблицы и, вероятно, таким образом хотите решить какую-то проблему. Какой итоговый результат вы ожидаете от партицирования? Почему вы выбрали для решения этой проблемы партицирование? Рассматривали ли вы другие варианты решения? Может быть стоит выполнить рефакторинг кода? Или на сервере БД поставить более мощное железо? Или просто в таблице не хватает индекса? Иными словами, есть ли у вас цель? Без цели выполнение партицирования чревато простоями и ухудшением производительности.
Если у вас есть цель — решаемая проблема, если прочие варианты решений были рассмотрены и отвергнуты по какой-либо причине, тогда можно попробовать реализовать партицирование. Повторюсь, партицирование — не панацея, и никто не даст никаких гарантий, что оно поможет.
Далее нужно определить самый часто встречающийся запрос на чтение данных из партицируемой таблицы (в некоторых случаях требуется найти самый тяжёлый запрос). В этом могут помочь разработчики.
В запросе смотрим, по какому условию выполняется выборка. Если в условии выборка выполняется по одному полю, то это поле — единственный кандидат в критерии партицирования. Если же полей в условии несколько, то либо пытаемся определить победителя, просмотрев топ запросов, либо все поля в условии считаем кандидатами в критерии партицирования. В любом спорном случае проверяем, какое из полей более всего согласуется с поставленной целью.
Например, в самом часто встречающемся запросе выборка выполняется по полям id и created_at. Топ запросов не выявил победителя. Если цель партицирования — упростить удаление старых данных, тогда в качестве критерия партицирования выбираем поле created_at. Если же цель — ускорить выборку данных, — скорее всего, поле id будет лучшим выбором.
Однако, есть и третий вариант: сделать комбинированный критерий из нескольких полей. Но, несмотря на то, что партицирование через наследование позволяет реализовать подобный сценарий, такого варианта следует опасаться.
Во-первых, это значительно усложняет код функции, обеспечивающей партицирование.
Во-вторых, это неизбежно ведёт к возрастанию накладных расходов при вставке данных и увеличивает время на выполнение операций вставки.
В-третьих, общая производительность на чтение данных из таблицы после такого партицирования скорее всего значительно снизится. Но повторюсь, результат сильно зависит от структуры таблицы, используемых индексов, критерия партицирования, размера партиций и прочих условий. Вполне возможно, в вашем случае вариант с комбинированным критерием будет наиболее оптимальным решением.
Следует помнить, что запросы, в которых выборка выполняется не по критерию партицирования, будут обращаться ко всем партициям и, следовательно, будут выполняться медленнее, чем без партицирования.
Определившись с критерием партицирования, выбираем размер партиции. Причём, размер — это не только байты, килобайты и прочее, это может быть:
количество строк в партиции;
периоды времени хранения информации (день, неделя, месяц, год);
диапазон идентификаторов записей в таблице (например, 1 млн id на партицию);
иные варианты, которые считаете приемлемыми для оценки размера партиции.
Иногда вместо размера партиции говорят о шаге партицирования: партицировали таблицу с шагом 1 месяц, 10 млн id, 100 млн строк и т.п.
В простом случае в выборе размера партиции могут помочь самый частый запрос и критерий партицирования. Например, самый частый запрос делает выборку за последний месяц, а критерий партицирования — поле created_at. Тогда размером партиции можно выбрать период времени в 1 календарный месяц.
В более сложных случаях, ответ не так очевиден. Например, в случае если критерий партицирования — поле id, то какой размер будет оптимальным: 1 млн id, 10 млн, 100 млн, 1 млрд? Если есть сомнения, выберите несколько возможных вариантов. Желательно ограничить количество таких вариантов числом 5.
Оптимальный размер партиции 5-20ГБ, но в вашем случае может быть и другой размер.
Когда вы оценили размер партиции и остановились на одном из вариантов, добавьте к нему ещё два: с бОльшим и мЕньшим шагом. Например, при партицировании с шагом в 1 месяц добавляем варианты с шагом 1 неделя и с шагом 2 месяца.
Подобьём промежуточный итог
имеется некая проблема, которую пытаемся решить;
найден самый часто выполняющийся запрос на чтение (либо топ запросов);
определен один или несколько критериев партиционирования;
подобраны несколько вариантов шага партиционирования для каждого из критериев.
Итого имеем N гипотез (К критериев x Ш шагов) партиционирования таблицы.
Теперь для каждой гипотезы нужно подготовить:
SQL-запрос на создание функции, обеспечивающей партицирование;
SQL-запрос (одинаковый для всех гипотез) на добавление триггера к таблице;
скрипт по переносу данных из мастер-таблицы по партициям;
для случая отката изменений запросы на удаление функции и триггера, а также скрипт обратного переноса данных.
Переходим к тестированию
Ни в коем случае не пропускайте и не игнорируйте данный этап: именно здесь мы должны подтвердить или опровергнуть наши гипотезы. Оценивать результаты тестирования следует с точки зрения поставленной цели. Если цель — ускорение выборки данных, то гипотезы, в которых происходит замедление выборки, точно не подходят, так как не согласуются с целью. Если же цель — упрощение удаления данных, то сам факт партицирования реализует поставленную цель. Однако, в этом случае стоит подобрать такой размер партиции, чтобы замедление выборки было минимальным.
Выполнять тестирование следует либо на таком же железе, как и на продакшн-базе, либо на максимально приближенном по характеристикам. В крайнем случае, если продакшн не нагружен, можно развернуть тестируемую базу рядом. Не следует тестировать на более мощном железе: при успешном тестировании реализация партицирования в проде может привести к отрицательным результатам.
Как тестировать? Разворачиваем из бэкапа либо всю базу, либо только партицируемую таблицу со связанными таблицами. Добавляем функцию и триггер. С помощью подготовленного скрипта переносим данные из мастер-таблицы по партициям. Прогоняем пачку топ запросов, замеряем время выполнения (в этом поможет поможет команда \timing), смотрим план выполнения, сравниваем результаты с продом.
Если тестирование показало отрицательные результаты, стоит пересмотреть критерий партицирования и шаг партицирования. Может быть, вы поставили слишком много целей: ускорить всё и вся и чтобы проще было удалять старые данные. Достигнуть таких целей возможно, но подобная комбинация схемы таблицы и логики работы приложения (а значит и структура запросов) встречается крайне редко.
Нашли расхождение, исправили, повторно протестировали, получили успешные результаты — отлично, проверяем работу скриптов отката изменений и переходим к реализации на проде. В ином случае, к сожалению, партицирование не решает поставленной задачи, необходимо найти иное решение.
Итого, получаем следующий чек-лист по партицированию:
определяем цель;
находим самый часто встречающийся запрос;
определяем критерий партицирования;
подбираем размер партиции;
-
готовим скрипты для рассматриваемых гипотез:
функцию, обеспечивающую партицирование;
подключение триггера;
скрипт переноса данных;
скрипты отката изменений;
выполняем тестирование гипотез;
выбираем подходящую гипотезу по итогам тестирования;
делаем бэкап продуктивных данных;
партицируем таблицу на проде.
Пара слов о параметрах СУБД, которые влияют на партицирование
Наиболее критичные из них:
параметр constraint_exclusion — должен быть включен, иначе план выполнения запроса будет сформирован неоптимально: операции выборки будут затрагивать все партиции без учёта налагаемых ограничений на партиции (PostgreSQL «забудет» о критерии партицирования);
параметр max_parallel_workers_per_gather, отвечающий за максимальное количество воркеров, используемых для сборки результатов поиска от разных воркеров (при выполнении операции Gather или Gather Merge); в некоторых случаях может существенно ускорить работу запросов, в других — привести к деградации производительности всей СУБД.
В следующей статье расскажу на примерах, как нам помогло партицирование в решении задач в Skyeng.
Полезные ссылки
Комментарии (29)
des1roer
15.10.2021 12:56почему не рассмотрено партицирирование через PARTITION BY RANGE
terranus Автор
15.10.2021 18:48В статье как раз рассмотрено партицирование по диапазонам. Или вы о чём-то другом спрашиваете?
des1roer
18.10.2021 07:54есть синтаксис вида
CREATE TABLE some_table ( id SERIAL, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL ) PARTITION BY RANGE (created_at);
terranus Автор
18.10.2021 17:55PARTITION BY относится к декларативному партицированию, которое не рассматривается в статье, т.к. тему "давайте спроектируем базу/таблицу" не планировалась. Изначально хотели рассказать, как в компании решали сложные кейсы через партицирование с конкретными примерами, но в одну статью всё не поместилось.
hungry_forester
15.10.2021 12:56Одним из вариантов решения такой проблемы в PostgreSQL является партицирование.
А какие есть еще варианты, помимо самого трудозатратного? И в каких случаях создание партиций оправдано?
terranus Автор
15.10.2021 18:52Например, такие:
перейти на более мощное железо
распределить данные по другим таблицам (нормализовать данные)
очистить старые данные и/или перенести их в долговременное хранилище
проверить наличие необходимых индексов
проверить, давно ли выполнялся автовакуум
hungry_forester
19.10.2021 17:20Второй, четвертый и пятый пункт должны быть всегда выполнены по умолчанию, да и третий с первым надо планировать на берегу, по-хорошему, но да, согласен.
Докину еще реплицирование на отдельную машину для аналитики, постоянный перерасчет накапливаемых данных и/или типовых выполняемых запросов, денормализацию (если это оправдано) для ускорения запросов, переписывание запросов с использованием временных таблиц (бывает куда быстрее посчитать нечто в 2-3 этапа, чем одним запросом).
fransua
15.10.2021 13:15А что с индексами, у каждой партиции могут быть свои независимые индексы или можно только один большой на мастер?
terranus Автор
15.10.2021 19:07+2А что с индексами, у каждой партиции могут быть свои независимые индексы
Настоятельно рекомендуется, чтобы у каждой партиции были одинаковые индексы, иначе запросы будут выполняться непредсказуемо.
Например, у всех партиций есть индекс по полю updated_at, а у одной партиции такого индекса нет. Тогда вместо того, чтобы быстро отфильтровать данные по индексу для поля updated_at, для этой партиции будет выполняться полный перебор данных, что очень медленно.
или можно только один большой на мастер?
Если таблицу полностью партицировали и разнесли данные по партициям, то в мастер-таблице никаких данных не будет (только если не оставили часть данных, чтобы удалить их через месяц-два как устаревшие). Соответственно, индексы мастер-таблицы будут пустыми. Это с одной стороны. С другой стороны, каждая партиция - это "самостоятельная" таблица со своим набором индексов, и наличие или отсутствие индексов в мастер-таблице никак не влияет ни на данные, ни на индексы в партициях. Ни мастер-таблица, ни индексы мастер-таблицы ничего не знают о конкретных данных, содержащихся в партициях.
akhkmed
15.10.2021 22:39+1Большого индекса на всю партицированную таблицу в pg пока нет. Поэтому засада с глобальными уникальными индексами. Но может возникнуть псевдо-глобальный индекс как объединение локальных, если включить в столбцы индекса ключ партицирования.
UScorp
15.10.2021 14:33А для чего нужно указывать чек:
CHECK (created_at >= '2021-03-01'::DATE AND created_at < '2021-04-01'::DATE)
непосредственно в самой партиции, если разноску все равно приходится делать через отдельную функцию и там дублировать эти же условия? Странно как то это выглядит, как по мне.
terranus Автор
15.10.2021 19:18Во-первых, для того, чтобы быть уверенным, что данные в партициях точно не пересекаются. А во-вторых, поскольку партиция - это обычная таблица, никто не запрещает туда вставить данные, относящиеся к другой партиции.
akhkmed
15.10.2021 22:41+2И всё-таки он ради constraint exlusion лишних партиций.
UScorp
16.10.2021 12:03+1Точно.
Наличие подобного ограничения хорошо поможет оптимизатору выполнять запросы на выборку из такой партицированной таблицы.
А исключение пересечения, на мой субъективный взгляд, уже дело второстепенное.
MikhailShatilov
15.10.2021 20:48В каких случаях партицирование не поможет либо никак не повлияет?
- время создания бэкапа;
- время восстановления из бэкапа;
- место на диске.
Партицирование в связке с внешними таблицами как раз помогает решить все эти вопросы.
pankraty
15.10.2021 23:04У нас в базе есть таблица, в которой сохраняются события перед отправкой в очередь, чтобы гарантировать, что они будут отправлены только при успешном коммтте транзакции (a.k.a. transactional outbox pattern). Проблема с такой таблицей в том, что она непрерывно растет и нуждается в периодической очистке, поскольку обработанные события хранить не обязательно.
Чтобы сделать очистку безболезненной, я придумал такое решение (но ещё не реализовывал на практике; любопытно услышать стороннее мнение, какие могут быть подводные камни, которые я не учел):
Делим таблицу на три партиции - A, B, C
Ключом партиционирования делаем (в качестве примера) текущий час, разделенный на 8 (с округлением вниз до целого) . Т.е. данные будут циклически попадать в одну из партиций A, B или C, в зависимости от времени суток. Размер можно подобрать в зависимости от нагрузки, важно, чтобы протяженность окна одной партиции была заведомо длиннее самой долгой транзакции, возможной в системе. Чтобы спалось спокойнее, можно брать хоть остаток от деления текущего месяца на 3 - принцип не меняется, только увеличивается объем хранимых данных.
По расписанию, вскоре после того, как текущей становится партиция C, партиция А очищается (через truncate), когда активной становится партиция A, чистим B, когда активной становится B, чистим C.
Перед очисткой убеждаемся, что в партиции не осталось необработанных строк. Для обработчика уже существует partial index по условию processed=false, поэтому такой запрос нересурсозатратный. Если строки найдены, копируем их в отдельную таблицу. Ввжно, что наличие "старых" необработанных строк - признак аварии, в норме их быть не должно вовсе. Поэтому таблица с копиями бесконтрольно разрастаться не будет. После устранения аварии и переотправки событий она будет очищаться.
POC, набросанный на скорую руку, показал жизнеспособность идеи, но под промышленной нагрузкой не тестировал.
Может быть, я переизобрел какой-то известный велосипед? Что думаете?
zVadim
17.10.2021 00:00Как альтернативу, могу предложить не переиспользовать 3 заранее созданные таблицы, очищая их по расписанию, а просто добавлять новую секцию и дропать старую. В этом случае не будет шанса проморгать временное окно, когда можно выполнять truncate
myz0ne
15.10.2021 23:34ОМГ, зачем индексы добавлять в триггере? Потом решите добавить индекс на таблицу, а триггер забудете обновить. Может лучше как-то так?
-- создание таблицы сразу со всеми индексами как на основной таблице CREATE TABLE bigtable_y2021m03 (LIKE bigtable including all) INHERITS (bigtable); -- добавление check constrains отдельно ALTER TABLE bigtable_y2021m03 ADD CHECK (created_at >= '2021-03-01'::DATE AND created_at < '2021-04-01'::DATE);
И еще бы сюда прикрутить автоматическую выдачу прав как на основную таблицу, иначе при расширении тоже может вылезти сюрприз. Как-то так вроде выглядит запрос на получение запроса.
SELECT 'GRANT ' || privilege_type || ' ON public.bigtable_y2021m03 ' || ' TO ' || grantee || ';' FROM information_schema.role_table_grants WHERE table_schema = 'public' AND table_name = 'bigtable';
`INCLUDING ALL` можно заменить на `INCLUDING INDEXES` если надо копировать только индексы.
terranus Автор
18.10.2021 19:02+3Ох, давайте разбираться по порядку:
Оператор LIKE предназначен несколько для других целей. В его обязанности входит здесь и сейчас создать новую несвязанную с базовой пустую таблицу с такой же структурой, как и базовая таблица. Причём для LIKE под структурой таблицы понимается только кол-во полей, имя поля и типа поля. Всё остальное: дефолтные значения полей, описания/комментарии полей, индексы, констрэйнты и прочее - не копируются в новую таблицу. При желании их можно скопировать через INCLUDING. Мы к этому ещё вернёмся :)
Зачем отдельно добавлять CHECK, когда это можно было сделать в одном запросе:
CREATE TABLE bigtable_y2021m03 (
LIKE bigtable including all,
CHECK (created_at >= '2021-03-01'::DATE
AND created_at < '2021-04-01'::DATE)
) INHERITS (bigtable);LIKE не предполагает взаимодействие с базовой таблицей, с которой была скопирована структура, после создания новой таблицы. Поэтому если какие-то время спустя потребовалось добавить новый индекс, этот индекс надо создать как для мастер-таблицы, так и для каждой партиции. Тут без вариант. И LIKE тут никак не поможет, с какими бы ключами он не вызывался. И да, INHERITS не наследует индексы, поэтому помимо добавления самих индексов, нужно будет поправить партицирующую функцию, это не такая уж и сложная операция.
LIKE, вызванный одновременно с INHERITS, сгенерит нотисы/замечания о том, что поля, скопированные через LIKE, объединяются со полями, наследованными через INHERITS. Не критично, но в то же время неприятно.
Создание таблицы с
LIKE bigtable INCLUDING INDEXES
ведёт к желаемому эффекту: созданию индексов. Однако, имена индексов будут заданы плюс-минус произвольно (читай: далеко не всегда можно предсказать, какое имя будет у индекса). Если имя индекса не критично, тогда да, можно использовать указанный вариант. Но если имена индексов контролируются и в дальнейшем планируется выполнять манипуляции с ними, INHERITS лучше не совмещать с LIKE.Использование
LIKE bigtable INCLUDING ALL
вообще опасная вещь. Например, в случае если в мастер-таблице заданы констрэйнты, то INHERITS более чем достаточно. Однако, если хотя бы один констрэйнт задан флагом NO INHERIT, то оператор INHERIT не будет его наследовать, но благодаря оператору LIKE сINCLUDING ALL
илиINCLUDING CONSTRAINTS
скопирует его в новую наследуемую таблицу. В итоге получаем, что в наследство от мастер-таблицы получили то, что не должно было наследоваться.И напоследок: партицирующая функция будет выполнятся от имени пользователя-владельца базы (если не задано иное). Владельцу базы дополнительных прав раздавать не нужно. Однако, если в базу ходят и другие пользователи, то бэст-практисом является не явная раздача прав на вновь создаваемые объекты, а настройка DEFAULT PRIVILEGES, на основе которых постгрес самостоятельно выдаст необходимые права на новые объекты.
myz0ne
18.10.2021 19:49Спасибо за развернутый ответ!
И да, INHERITS не наследует индексы, поэтому помимо добавления самих индексов, нужно будет поправить партицирующую функцию, это не такая уж и сложная операция.
Да, несложная, но это разрыв контекста и то, о чем всегда стоит помнить и явно контролировать при код ревью, т.к. есть довольно высокий риск что придет новый разработчик, добавит индексы на таблицу и дочерние, но не подумает о волшебном триггере. Т.е. в идея в том чтобы избежать разрывов контекста и последующего отложенного торможения системы из-за отсутствия индексов. Плюс, для меня лично, это контринтуитивно - при добавлении индекса думать о том что надо править какие-то триггеры.
Использование
LIKE bigtable INCLUDING ALL
вообще опасная вещь.Да, возможно. Но использование
INCLUDING INDEXES
вроде как ничего поломать не должно? С тем что "имена индексов контролируются и в дальнейшем планируется выполнять манипуляции с ними" спорить не буду, возможно есть какие-то сценарии где нужно именно имя индекса, и нельзя посмотреть его перед операцией. Но я с такими не сталкивался.Однако, если в базу ходят и другие пользователи, то бэст-практисом является не явная раздача прав на вновь создаваемые объекты, а настройка DEFAULT PRIVILEGES, на основе которых постгрес самостоятельно выдаст необходимые права на новые объекты.
Не всегда нужно выдавать права на все новые объекты. Вроде DEFAULT PRIVILEGES настраивается только на схему? Иногда всякие complience (и здравый смысл) запрещают выдачу прав по умолчанию на все новые объекты, но при этом, например было получено одобрение на выдачу прав на определенные таблицы, не содержащие защищаемых данных (реальный кейс).
Akina
16.10.2021 18:11+1Статья рассматривает случай, когда поле, по которому выполняется деление на партиции, статическое и не изменяется во времени. А как изменится обслуживание, если поле партиционирования изменяемое, и данные могут мигрировать из одной партиции в другую? Поскольку всё обслуживание тут фактически "вручную", создаётся впечатление, что получится совершенно неподъёмный монстр...
terranus Автор
18.10.2021 18:18+2Партицирование не предполагает, что критерий партицирования со временем будет изменяться. Это один из моментов, которые нужно учитывать при планирования партицирования и проведении тестирования. За всё приходится платить. Однако при необходимости в случае использования партицирования через наследование критерий партицирования можно изменить.
Более быстрый вариант, но с даунтаймом:
запретить запись в партицированную таблицу
изменить партицирующую функцию на соответствие новым критериям партицирования
перенести данные из старых партиций в новые путём запроса на вставку в мастер-таблицу (сработает триггер, который вставит данные в новые партиции)
удалить старые партиции
разрешить запись
Более долгий, но без даунтайма:
отключить триггер на вставку данных
перенести данные из партиций в мастер-таблицу
удалить старые партиции
изменить партицирующую функцию на соответствие новым критериям партицирования
подключить триггер обратно
перенести данные из мастер-таблицы по партициям путём вставки в мастер-таблицу (сработает триггер, который вставит данные в новые партиции)
транкейтим мастер-таблицу для очистки места
akhkmed
Для attach partition нужно заранее создать check constraint. Тогда не требуется длительных блокировок для проверки значений. Описано в документации.
Важно помнить случаи, когда партицирование замедляет выполнение запросов. Например, если ранее большая таблица могла быстро соединяться вложенными циклами по индексу с маленькой, то после её партицирования это работать не будет эффективно из-за необходимости сканировать все патриции.
Ну и конечно же партицирование через наследование работает значительно медленнее, в особенности если секций много.
terranus Автор
Да, всё так. Далее по пунктам :)
Декларативное партицирование упомянул, но не рассматривал в данной статье, т.к. о партицировании обычно задумываются, когда всё плохо и нужно с этим что-то делать. Да, можно применить декларативное партицирование, но формально это будет не та же самая таблица, а соседняя. В этом случае точно будет даунтайм при переключении на новую таблицу, вопрос только в продолжительности этого даунтайма.
Сильно зависит от структуры таблицы и используемых запросов. Если в запросе не используется поле, по которому проводили партицирование, тогда да, будут сканироваться все таблицы. Всё это выявляется на стадии подготовки к партицированию, одним из ключевых этапов которой является тестирование.
Спору нет, однако, партицирование через наследование реализовать проще и быстрее. За всё приходится платить.
akhkmed
Позвольте поясню.
Это был ответ против аргумента с блокировками:
То есть с точки зрения блокировок они равнозначны.
Тут вот такой пример.
При этом primary key (large_table.id). Баз партицирования план будет nested loops join и работать за миллисекнды, потому что в s мало строк, тем более после where. После партицирования large_table (хоть по l.id, хоть иначе) этот запрос умрёт, потому что nested loops по индексу уже не будет, а если и будет - по всем партициям сразу. Но всё будет работать сносно при использовании pg_pathman.
zVadim
В postgresql нельзя преобразовать обычную в секционированную декларативно, но можно подключить обычную в качестве секции. Как вариант, можно создать новую таблицу секционированную by range, подключить в нее существующую, не забыв при этом о наличии ограничений на диапазон значений ключа секционирования в подключаемой таблице. А затем, переименовать обе, таблицы. Сам так не делал, и подозреваю, что это не всегда применимо из-за зависимостей исходной таблицы.
PS: Я долго не мог определиться какой термин использовать "секционирование" или "партицирование". К первому варианту склонился после длительного чтения документации postgresql. Интересно, почему вы выбрали второй вариант? Во избежании холивара, я ваш выбор уважаю, и считаю оба варианта равноправными
terranus Автор
Декларативное партицирование существующей таблицы - довольно сложный кейс с кучей подводных камней и гарантированным даунтаймом, как минимум на время передеплоя и переименования/переключения на другое имя таблицы.
Пользовался для в основном англоязычными источниками, ни в одном не было sectioned table / sectioning и во всех было partitioned table / partitioning :) . Вполне естественно для меня использовать партицирование, а не секционирование.