Привет, Хабр! Меня зовут Виталий Сушков, я .NET-разработчик и техлид в Т-Банке.  На конференции DotNext в 2024 году я выступал с докладом о применении механизма декларативного партицирования таблиц в PostgreSQL. 

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

Сервис «Хранилище коммуникаций»

Познакомимся с героем моего рассказа — сервисом, который называется «Хранилище коммуникаций» или «Хранилище активностей».

Этот сервис сохраняет в базу данных PostgreSQL информацию о взаимодействиях между операторами банка и клиентами по вопросам кредитных задолженностей. В число взаимодействий входят телефонные звонки и переписки в чате поддержки — все это мы называем коммуникациями или активностями.

Основной потребитель попавших в хранилище данных — система BPM (Business Process Management). Для каждого клиента она принимает решение о том, как мы будем выстраивать процесс работы. Алгоритмам BPM требуется знать историю наших взаимодействий с клиентом за последние 90 дней. Для этих целей хранилище активностей предоставляет GET-метод, который для указанного clientId возвращает список наших с ним коммуникаций за последние три месяца.

Еще одно назначение нашего сервиса — предоставлять данные для хранилища, которое заточено под задачи аналитики, то есть для DWH (Data Warehouse). Особенность интеграции с DWH в том, что мы не отправляем туда данные напрямую. В DWH есть инструмент, который позволяет им самим забирать то, что нужно, сразу из нашего PostgreSQL. Это напоминает репликацию на уровне баз данных, которая сильно зависит от схемы этих данных.

Хранилище коммуникаций и его интеграции
Хранилище коммуникаций и его интеграции

Хранилище коммуникаций реализовано как .NET-сервис, работающий с PostgreSQL через Entity Framework.

Для хранения активностей в PostgreSQL используется структура из пяти таблиц. Каждая активность всегда имеет:

  • Одну запись в таблице activity, где хранятся общие параметры для активности любого типа.

  • Одну запись в таблице communicant, где хранится информация о том человеке, с кем был диалог. Изначально была идея сопоставлять одному коммуниканту несколько коммуникаций, но она так и не была реализована, поэтому связь межд�� таблицами activity и communicant всегда один к одному.

  • Одну запись в таблице call или chat — в зависимости от типа коммуникации. В этих таблицах лежит информация специфичная либо только для звонков, либо только для чатов. 

  • От нуля до нескольких записей в таблице opt_data — некоторая дополнительная информация в виде списка пар «ключ — значение», полученная в ходе разговора с клиентом.

Таблицы в БД хранилища активностей
Таблицы в БД хранилища активностей

Когда мы выбираем из БД информацию, запрашиваемую со стороны BPM, мы читаем данные из всех этих пяти таблиц.

Несмотря на то, что сервис «Хранилище коммуникаций» разработан давно, в эксплуатацию он вводился постепенно: сначала сохранялся лишь небольшой процент от всех активностей, но по мере перевода процессов нашей бизнес-линии на новые сервисы этот процент рос. 

Мы провели нагрузочные тесты, чтобы убедиться, что сервис готов к росту. Эти тесты подавали запросы с нагрузкой 200 RPS на API-методы хранилища (в том числе на GET-метод, представляющий данные для BPM). Значение RPS выбирали с запасом, так как ожидаемая нагрузка была в несколько десятков RPS.

Cервис и его базу данных на тестовом стенде развернули на ресурсах, аналогичных продакшену:

  • PostgreSQL — 1 CPU и 2 GB оперативной памяти.

  • .NET-сервис — 4 пода по 0.125 CPU и 128 MB оперативной памяти.

Результаты тестов нас устроили: 

  • 95 и 99 перцентили максимального времени ответа GET-метода составили 130 мс и 460 мс соответственно.

  • Процент ошибок не превышал 0,06%.

Сбой и первые попытки быстрого решения

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

Сбою предшествовал резкий рост количества активностей, которые сохранялись в БД. Если за два месяца — с апреля по июнь 2023 года — количество активностей в хранилище выросло с 1,5 до 2 млн, то за последующие три месяца оно выросло уже с 2 млн до почти 12 млн. После чего в сентябре пошли первые тревожные звоночки.

Рост количества коммуникаций в базе данных
Рост количества коммуникаций в базе данных

По ночам, когда мы получали наиболее высокий RPS со стороны BPM, мониторинг стал фиксировать, что некоторые обращения к сервису отваливаются по таймауту. От ночи к ночи неуклонно росло количество этих ошибок и среднее время ответа GET-метода. 

Чтобы BPM успел обработать за ночь всех клиентов, мы должны были отвечать на GET-запрос в среднем не более чем за 300 миллисекунд. Но мы стали превышать этот порог в 20 раз и более. Это была серьезная проблема, которая угрожала работе всей бизнес-линии и требовала оперативного решения.

Первое, что удалось обнаружить по горячим следам: для получения активностей использовался не вполне оптимальный индекс. При обработке GET-запроса от BPM мы извлекали из базы данных записи для указанного клиента, созданные не более чем 90 дней назад, то есть выполнялся запрос с фильтрацией по полям client_id и created_dt такого вида: 

WHERE
    a.client_id = @__clientId_0 AND
    a.created_dt >= @__startDt_1

Индексы по полям client_id и created_dt были, но это были два отдельных индекса. Postgres для фильтрации по двум полям мог использовать только один из них, а по второму полю уже дофильтровывал перебором. Более эффективно здесь работал бы составной индекс по полям client_id и created_dt.

Вторая обнаруженная проблема: мы тянули данные из базы в два этапа. Сначала отдельно звонки, потом отдельно чаты, а полученные списки объединяли для формирования ответа GET-метода:

var calls = await dbContext.Calls
    .AsNotTracking()
    .AsSplitQuery()
    .Include(x => x.Activity)
    .Include(x => x.Activity.Communicant)
    .Include(x => x.Activity.OptData)
    .Where(x => x.Activity.ClientId == clientId
            && x.CreatedDt >= startDt);


var chats = await dbContext.Chats
    .AsNotTracking()
    .AsSplitQuery()
    .Include(x => x.Activity)
    .Include(x => x.Activity.Communicant)
    .Include(x => x.Activity.OptData)
    .Where(x => x.Activity.ClientId == clientId
            && x.CreatedDt >= startDt);

В обоих запросах использовалась опция split query. Она разрешала Entity Framework каждый LINQ- запрос разбивать на несколько SQL-запросов, чтобы избежать большого количества JOIN-ов. В итоге на каждый вызов GET-метода в базу данных могло улетать четыре SQL-запроса.

Например, два таких запроса генерировал EF для получения звонков:

-- Получение данных звонков, но без записей таблицы opt_data
SELECT c.*, a.*, c0.*
FROM "call" AS c
INNER JOIN activity AS a ON a.id = c.activity_id
INNER JOIN communicant AS c0 ON c.id = a.communicant_id
WHERE
    a.client_id = @__clientId_0 AND
    a.created_dt >= @__startDt_1
ORDER BY c.id, a.id, c0.id;


-- Получение opt_data отдельным запросом
SELECT o.*, a.*, c.*, c0.*
FROM "call" AS c
INNER JOIN activity AS a ON a.id = c.activity_id
INNER JOIN communicant AS c0 ON c.id = a.communicant_id
INNER JOIN opt_data AS o WHERE o.activity_id = a.id
WHERE
    a.client_id = @__clientId_0 AND
    a.created_dt >= @__startDt_1
ORDER BY c.id, a.id, c0.id;

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

  • Поиск записей по условию WHERE, которое одинаково в обоих запросах.

  • Некоторые JOIN-ы во втором запросе, которые уже выполнялись ранее при выполнении первого запроса. Такая работа избыточна: например, при получении opt_data можно было бы вовсе обойтись без JOIN-а таблицы communicant, но EF его добавил.

Аналогично выглядят еще два запроса для чатов, в которых точно так же одна и та же работа выполняется по два раза.

Возникла мысль: что если мы переделаем получение данных так, чтобы Postgres-у не требовалось делать одну и ту же работу несколько раз? Вероятно, это даст какое-то ускорение.

Для этого вместо двух LINQ-запросов можно написать один к таблице activity с присоединением всех остальных сущностей и убрать опцию split query:

// Загрузка всех данных одним запросом
var activities = await dbContext.Activities
    .AsNoTracking()
    .Where(x => x.ClientId == clientId
            && x => x.CreatedDt >= startDt)
    .Include(x => x.Communicant)
    .Include(x => x.Call)
    .Include(x => x.Chat)
    .Include(x => x.OptData)
    .ToListAsync();

Тогда EF сгенерирует всего один SQL-запрос, который будет охватывать все пять таблиц:

-- Полученный единый SQL-запрос для загрузки всех данных
SELECT a.*, c0.*, c1.*, c2.*, o.*
FROM activity as a
INNER JOIN communicant as c0 ON c0.id = a.communicant_id
LEFT JOIN "call" as c1 ON c1.activity_id = a.id
LEFT JOIN chat as c2 ON c2.activity_id = a.id
LEFT JOIN opt_data as o ON o.activity_id = a.id
WHERE
    a.client_id = @__clientId_0 AND
    a.created_dt >= @__startDt_1
ORDER BY a.id, c0.id, c1.id, c2.id;

В новом запросе стал оптимальнее работать поиск:

  • активностей по критериям запроса — теперь делается один раз вместо четырех;

  • сущностей в связанных таблицах — делается по одному разу на каждую таблицу.

План выполнения единого запроса и его преимущества
План выполнения единого запроса и его преимущества

Но есть в этом плане и недостатки:

  • Для каждой найденной активности выполняется index scan и для таблицы call, и для таблицы chat, хотя активность не может быть одновременно и звонком и чатом. В таблице activity есть поле activity_type, по которому мы могли бы точно определить, в какую именно из таблиц call и chat нам надо сходить. Но здесь мы этого не делаем, и для каждой активности ходим все время в обе таблицы.

  • Обратная сторона медали решения убрать split query — JOIN-таблицы opt_data. Если у нас нашлось, допустим, 10 активностей и у каждой из них есть по 5 записей в таблице opt_data, то этот запрос вернет 50 очень широких строк, данные в которых будут избыточно продублированы.

Недостатки единого запроса
Недостатки единого запроса

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

Нагрузочные тесты показали положительный результат: перцентили времени ответа GET-метода и процент ошибок снизились.

Результаты нагрузочных тестов после добавления составного индекса и единого SQL-запроса
Результаты нагрузочных тестов после добавления составного индекса и единого SQL-запроса

На проде ситуация тоже стала лучше: время ответа снизилось в два раза, а количество ошибок — в семь раз.

Результаты на продакшене после добавления составного индекса и единого SQL-запроса
Результаты на продакшене после добавления составного индекса и единого SQL-запроса

Но среднее время ответа все еще составляло 4 секунды, что было слишком много, так как для нас верхняя граница была 300 миллисекунд.

Дальнейший анализ и поиск решения

Дальнейший анализ показал, что настолько низкая производительность проявляется из-за того, что Postgres при получении активностей очень много блоков читает с диска. Мы нашли несколько причин.

Причина 1. Иногда наш запрос возвращал очень много записей.

История взаимодействий с клиентом за 90 дней могла насчитывать десятки, а иногда и сотни активностей. Учитывая, что мы отказались от SplitQuery, запрос с JOIN-ом таблицы opt_data мог вернуть сразу несколько сотен строк за один вызов.

Было бы неплохо в наш GET-метод добавить пагинацию или ограничения на размер интервала поиска, чтобы за один вызов не поднимать так много данных из базы. Но тогда нам бы пришлось сломать контракт нашего API, то есть делать новую версию и привлекать разработчиков BPM, чтобы они на нее перешли. Одним из серьезных ограничений на тот момент была невозможность привлекать ресурсы других команд. Решение проблемы мы должны были найти самостоятельно и в кратчайшие сроки.

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

Причина 2. Неудачный выбор типа данных для некоторых полей в БД.

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

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

Причина 3. Излишняя нормализация схемы данных.

Мы все время читали данные из всех пяти таблиц, для чего требовалось пять индексов и пять операций index scan. Чтобы подобное работало быстро, все эти пять индексов должны были хорошо влезать в кэш.

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

Столь радикальное изменение схемы нам тоже было недоступно из-за интеграции с DWH, дороговизны и сложности подобных изменений на нашей стороне.

Причина 4. Скромные ресурсы сервера PostgreSQL.

Суммарный объем пяти индексов, которые были необходимы для поиска активностей, на тот момент составлял 1350 МБ. А на сервере с PostgreSQL было всего 2 ГБ оперативной памяти, из них 512 МБ — под shared buffers. Когда индексы настолько сильно не умещаются в shared buffers, говорить о какой-то высокой производительности не приходится.

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

Не получалось найти техническое решение, которое бы нас полностью устраивало, поэтому мы попытались найти решение на уровне бизнес-требований: проанализировать требования к сервису и найти лазейку.

Лазейка нашлась. В таблице activity есть поле result — текстовое поле с ограниченным набором возможных значений из справочника. Это поле указывает на то, какой результат оператор получил в результате коммуникации с клиентом. Оказалось, что на тот момент почти у 90% всех записей это поле было заполнено одним и тем же значением «Автоответчик».

Автоответчики — звонки, в рамках которых не удалось дозвониться до клиента. Появилась гипотеза: если коммуникация с клиентом не состоялась, то она никак не влияет на расчеты, которые делает BPM. 

Гипотеза подтвердилась. Коллеги из BPM сказали, что, если мы не будем возвращать им «автоответчики», на их расчеты это никак не повлияет. Но если их не надо возвращать, значит, нам их не надо и читать из базы. Достаточно добавить всего одну строчку в код, чтобы не загружать записи с activity.result = «Автоответчик»:

var activities = await dbContext.Activities
    .AsNoTracking()
    .Where(x => x.ClientId == clientId)
    .Where(x => x.CreatedDt >= startDt)
    .Where(x => x.Result != "Автоответчик") // Убираем "автоответчики"
    .Include(x => x.Communicant)
    // .....

Посмотрим на план выполнения запроса.

План выполнения запросов с исключением автоответчиков
План выполнения запросов с исключением автоответчиков

Во внутренний index scan добавился еще один фильтр, благодаря которому он вернет в среднем на 90% меньше данных. Но эти данные подаются на вход четырем последующим операциям index scan. Значит, каждая из них тоже будет делать на 90% меньше работы. 

Когда мы это все выкатили на прод, среднее время ответа нашего сервиса упало с 4 секунд до 20 миллисекунд, а процент ошибок снизился практически до нуля. Чуть позже нам увеличили до 4 ГБ память на сервере Postgres, и ошибки исчезли полностью.

Решение проблемы будущего роста

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

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

Минимизировать и стабилизировать размеры таблиц и индексов нам могли помочь два факта:

  1. Нам не нужно отдавать данные старше 90 дней.  

  2. Нам не нужно отдавать автоответчики.

Кажется, что можно было бы просто периодически удалять из базы данных записи старше 90 дней. Подобное решение нам не подходило по трем причинам:

  1. Запрос DELETE для большого количества строк на больших таблицах создает дополнительную нагрузку.

  2. DELETE на самом деле не освобождает место ни в таблицах, ни в индексах. Это место потом можно переиспользовать новыми записями, но только после того, как отработает VACUUM, работа которого тоже создает нежелательную нагрузку.

  3. DWH не давал 100%-й гарантии сохранности всех данных, и мы должны были давать им возможность в случае чего эти данные от нас перезатянуть. Поэтому вместо обычного удаления пришлось бы делать перенос старых строк в архивные таблицы.

А что с автоответчиками? Если нам их не надо отдавать в GET-запросе — может, тогда их вообще не надо сохранять? Несмотря на то, что они не нужны BPM, они нужны были аналитикам в DWH. А поскольку наш Postgres — единственный мостик, по которому они туда могли попасть, сохранять автоответчики мы были обязаны. Причем сохранять в те же самые таблицы, где лежат и все остальные активности, иначе инструмент репликации в DWH их не увидит. 

Так мы пришли к тому, что:

  • Нужно часть данных (старые записи и автоответчики) переносить в другие таблицы.

  • Для инструмента репликации в DWH все должно остаться по-прежнему: он, как и раньше, должен видеть все данные в одной таблице.

Реализовать подобное позволяет такой механизм, ка�� партицирование. Рассмотрим подробнее, как им пользоваться в PostgreSQL.

Декларативное партицирование в PostgreSQL

Партицирование — возможность разбить множество строк одной таблицы на непересекающиеся подмножества и каждое такое подмножество хранить в отдельной таблице. Такие таблицы называются партициями, а ту таблицу, которую мы разбиваем, назовем мастер-таблицей или родительской.  

Физически строки хранятся именно в партициях. Мастер-таблица — виртуальная, данных на самом деле в ней никаких нет. Но к родительской таблице можно обращаться в SQL-запросах. Postgres при этом выполнит эти SQL-запросы на входящих в нее партициях автоматически.

Самое главное: на основании тех ограничений, которые есть в запросе, Postgres постарается понять, в каких партициях заведомо быть не может данных, интересующих пользователя. И в этих партициях запрос не будет выполняться.  

PostgreSQL запросы к мастер-таблице выполняет на нужных партициях
PostgreSQL запросы к мастер-таблице выполняет на нужных партициях

Для создания партицированной таблицы используется запрос CREATE TABLE с инструкцией PARTITION BY, после которой необходимо указать метод партицирования и ключ партицирования.

Создание партицированной таблицы
Создание партицированной таблицы

Ключ партицирования — какое-то поле в таблице или какое-то выражение. 

Метод партицирования указывает на то, как при определении партиций будет задаваться множество значений ключа, соответствующее каждой партиции. В Postgres доступны три метода партицирования: RANGE, LIST и HASH.  

Чтобы создать партицию, используется запрос CREATE TABLE, но вместо указания структуры таблицы нужно написать инструкцию PARTITION OF и после нее указать имя мастер-таблицы. Структура при этом будет унаследована автоматически. Затем с помощью ключевых слов FOR VALUES нужно согласно выбранному методу партицирования указать множество значений для ключа, соответствующее этой партиции.

Если выбран метод RANGE, то множество значений ключа, соответствующее партиции, указывается в виде интервала. Если у какой-то строки значение ключа партицирования входит в этот интервал, то строка будет отнесена именно к этой партиции.

-- Партицирование методом RANGE по дате создания
CREATE TABLE master_table (
    id,
    created_dt,
    ...
) PARTITION BY RANGE (created_dt);


-- В этой партиции будут записи, созданные в январе 2024 года
CREATE TABLE partition_january_2024
PARTITION OF master_table
FOR VALUES
    FROM ('2024-01-01T00:00:00')
    TO ('2024-02-01T00:00:00');

Если выбран метод партицирования LIST, то множество значений ключа в партициях указывается в виде перечисления всех конкретных значений, которые будут относиться к этой партиции.

-- Партицирование методом LIST по названиям дней недели
CREATE TABLE master_table (
    id,
    created_dt,
    ...
) PARTITION BY LIST (TO_CHAR(created_dt, 'DAY'));


-- В этой партиции будут записи, созданные по субботам и воскресеньям
CREATE TABLE partition_weekends
PARTITION OF master_table
FOR VALUES ('SATURDAY', 'SUNDAY');

В примере в качестве ключа партицирования используется выражение, которое возвращает название дня недели по дате создания. Используя метод партицирования LIST, можно создать партицию, в которую будут попадать записи, созданные только в выходные. Для этого нужно указать после FOR VALUES название дней недели субботы и воскресенья.

Партицирование может быть многоуровневым, так как партиции тоже можно создавать с опцией PARTITION BY и разбивать дальше. Такая структура образует дерево, листья которого будут таблицами, в которых физически хранятся данные. Все, что в этом дереве выше листьев, — виртуальные таблицы, к которым можно обращаться в запросах.

-- Список людей
-- Партицирован по дате рождения
CREATE TABLE master_table (
    id,
    birthday,
    sex,
    ...
) PARTITION BY RANGE (birthday);

-- Партиция для рожденных в январе 24 года
CREATE TABLE partition_january_2024
PARTITION OF master_table
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01')
PARTITION BY LIST (sex);

-- Партиция для рожденных в январе 24 года мальчиков
CREATE TABLE partition_january_2024_male
PARTITION OF partition_january_2024
FOR VALUES ('male');

-- Партиция для рожденных в январе 24 года девочек
CREATE TABLE partition_january_2024_female
PARTITION OF partition_january_2024
FOR VALUES ('female');

В примере таблица со списком людей методом RANGE партицирована по дате рождения. Затем создается партиция для людей, рожденных в январе 2024 года, а потом эта партиция разбивается на еще две партиции: на рожденных в январе мальчиков и рожденных в январе девочек.

Отметим еще несколько фактов о партицировании, которые полезно знать и которые нам пригодятся в дальнейшем:

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

  2. Если в мастер-таблицу запросом INSERT добавляется строка, то под эту строку должна существовать подходящая партиция. Если таковой не будет, запрос завершится ошибкой.

  3. При выполнении запросов SELECT/ UPDATE/ DELETE на мастер-таблице Postgres постарается на основе условия WHERE в запросе определить, в каких партициях искомые данные могут находиться, а в каких не могут. На тех партициях, в которых заведомо не может быть искомых данных, запрос выполняться не будет. Возвращаясь к примеру с партицированием по дате рождения: если мы в мастер-таблице ищем человека и среди критериев поиска указано, что у него дата рождения 10 сентября, то Postgres даже не будет пытаться найти такого человека в партиции partition_january_2024.  

  4. В партицированную таблицу можно добавить существующую таблицу в качестве партиции. Для этого используется команда ATTACH. Важно, что аттачить можно только таблицу, которая имеет точно такую же структуру, как мастер-таблица, и такую таблицу, строки которой будут удовлетворять тем ограничениям на ключ партицирования, которые мы укажем при команде ATTACH. Обратная операция тоже возможна: запросом DETACH можно какую-то партицию вывести из-под мастер-таблицы.

Вариант партицирования для хранилища активностей

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

Среди таблиц хранилища активностей есть две таблицы, в которых не бывает записей, относящихся к автоответчикам: таблицы chat и opt_data. 

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

Партицирование таблиц, не содержащих данных автоответчиков. Запрос поиска будет затрагивать максимум две самые свежии партиции
Партицирование таблиц, не содержащих данных автоответчиков. Запрос поиска будет затрагивать максимум две самые свежии партиции

Для остальных трех таблиц (activity, communicant и call) помимо исключения старых данных мы хотим исключить из активной обработки любые записи, относящиеся к автоответчикам. Для этого можно сделать партицирование в двух уровнях: 

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

  • каждую трехмесячную партицию разбить еще на две: на данные, которые важны для BPM и которые не важны (то есть автоответчики). 

Двухуровневое партицирование таблиц, которые могут содержать автоответчики
Двухуровневое партицирование таблиц, которые могут содержать автоответчики

Партиции с важными для BPM коммуникациями назовем important (суффикс i), а партиции с автоответчиками назовем not important (суффикс ni).

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

Для такого двухуровневого партицирования нужно решить, как делать деление на important и not important. Колонка result текстовая, и делать по ней PARTITION BY RANGE бессмысленно. Мы могли бы сделать PARTITION BY LIST, поскольку множество возможных значений этой колонки ограничено и хорошо нам известно.

-- Каждую трехмесячную партицию будем разбивать еще на две по полю result
CREATE TABLE activity_20231201_20240301
PARTITION OF activity
FOR VALUES FROM ('2023-12-01T00:00:00') TO ('2024-03-01T00:00:00')
PARTITION BY LIST (result);

-- Для партиции not-important указываем значение «Автоответчик»
CREATE TABLE activity_20231201_20240301_ni
PARTITION OF activity_20231201_20240301
FOR VALUES ('Автоответчик');

-- Для партиции important перечисляем все остальные возможные значения для result
CREATE TABLE activity_20231201_20240301_i
PARTITION OF activity_20231201_20240301
FOR VALUES ('Результат 1', 'Результат 2', ..., 'Результат N');

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

Придется тогда обновлять ограничения на ключи партицирования, которые мы задали, чтобы подходящая партиция появилась. Для этого партицию important запросом DETACH нужно вывести из под мастер-таблицы, а затем снова завести с обновленным списком возможных результатов. Не самое приятное занятие, учитывая, что при выполнении ATTACH Postgres блокирует присоединяемую таблицу и сканирует все ее строки, чтобы убедиться, что они все удовлетворяют ограничению на ключ партицирования, которое мы указали. Это длительная операция, во время которой сервис не сможет нормально функционировать.

-- При появлении нового возможного значения для result нужно обновить ограничение
-- для партиции important

-- Для этого партицию important нужно отсоединить
ALTER TABLE activity_20231201_20240301
DETACH PARTITION activity_20231201_20240301_i;

-- А затем присоединить с обновленным списком возможных значений result.
-- Долгая операция с блокировкой таблицы
ALTER TABLE activity_20231201_20240301
ATTACH PARTITION activity_20231201_20240301_i
FOR VALUES ('Результат 1', ..., 'Новый результат N + 1');

Мы пошли другим путем и поставили каждому возможному текстовому значению поля result в соответствие целочисленный код так, что у важных результатов этот код всегда имеет неотрицательное значение, а у неважных (у Автоответчика) — отрицательное. Тогда можно сделать партицирование методом RANGE по полю result_code, и при появлении новых возможных значений для результатов нам не придется менять ограничение на ключ партицирования.

-- Каждую трехмесячную партицию будем разбивать по диапазонам числового кода result_code
CREATE TABLE activity_20231201_20240301
PARTITION OF activity
FOR VALUES FROM ('2023-12-01T00:00:00') TO ('2024-03-01T00:00:00')
PARTITION BY RANGE (result_code);

-- Партиции not-important будут соответствовать отрицательные значения result_code
CREATE TABLE activity_20231201_20240301_ni
PARTITION OF activity_20231201_20240301
FOR VALUES FROM (-100000) TO (0);

-- Партиции important будут соответствовать неотрицательные значения result_code
CREATE TABLE activity_20231201_20240301_ni
PARTITION OF activity_20231201_20240301
FOR VALUES FROM (0) TO (100000);

Методы партицирования существующих таблиц

Прежде чем осуществить наш план, нужно было разобраться с препятствиями: 

  • Колонка created_dt, по которой мы хотим партицировать каждую таблицу, есть только в таблице activity.

  • Колонка result (текстовая) тоже есть только в таблице activity.

  • А колонки result_code (числовой), по которой мы хотим партицировать вторым уровнем часть таблиц, пока еще нет нигде.

Главный вопрос: а как существующую обычную непартицированную таблицу сделать партицированной? К сожалению, никак, такой волшебной возможности в PostgreSQL нет.

Партицированную таблицу придется создавать новую и пустую, а данные в нее либо копировать из старой таблицы через SELECT + INSERT, либо присоединять старую таблицу в качестве партиции с помощью команды ATTACH.

Рассмотрим подробнее реализацию этих двух методов на примере базы данных хранилища активностей.

Метод копирования строк между таблицами. Следуя этому методу, для начала нужно:

  1. Для каждой таблицы, которую хотим партицировать, создаем аналогичную по структуре партицированную по дате создания таблицу. Если каких-то нужных полей в исходной таблице нет, то в новой таблице мы можем их создать и позже при вставке данных заполнить их нужными значениями. Например, у нас в таблице communicant нет полей created_dt и result_code — значит, при создании партицированной версии этой таблицы мы их добавим.

  2. Для созданных таблиц создаем трехмесячные партиции. Если хотим перенести из старых таблиц данные за последние 90 дней, то достаточно будет для начала создать по две партиции: одну на период в три месяца в прошлом, а другую — на период в три месяца в будущем.

  3. Для таблиц, которые могут содержать записи автоответчиков (activity communicant, call), каждую трехмесячную партицию дополнительно партицируем еще по полю result_code на important- и not-important-данные (автоответчики).

Посмотрим на примере таблицы communicant.

CREATE TABLE communicant_part (
    -- структура такая же, как у таблицы communicant
    id int8 NOT NULL DEFAULT nextval('"communicant_id_seq"'),
    -- ...,
  
    -- плюс недостающие, но нужные для партицирования поля
    created_dt timestamp NOT NULL,
    result_code int NOT NULL
) PARTITION BY RANGE (created_dt);

-- партиция под созданные в течение последних трех месяцев записи,
-- которая в свою очередь партицирована по result_code
CREATE TABLE communicant_20230901_20231201
PARTITION OF communicant_part
FOR VALUES FROM ('2023-09-01T00:00:00') TO ('2023-12-01T00:00:00')
PARTITION BY RANGE (result_code);

-- партиция под important (то есть не автоответчики) записи
-- созданные в течение последних трех месяцев
CREATE TABLE communicant_20230901_20231201_i
PARTITION OF communicant_20230901_20231201
FOR VALUES FROM (0) TO (1000000);

-- и под not-important (автоответчики)
CREATE TABLE communicant_20230901_20231201_ni
PARTITION OF communicant_20230901_20231201
FOR VALUES FROM (-100000) TO (0);

-- аналогично создаются партиции на следующие три месяца

Когда партицированная таблица и партиция созданы, остается перенести строки за нужный нам период (в нашем случае — последние 90 дней) из оригинальной таблицы в новую. В процессе переноса мы можем заполнить значения новых полей created_dt и result_code, взяв их из связанной записи таблицы activity и справочника возможных результатов. 

Пример такого переноса для таблицы communicant:

INSERT INTO communicant_part (
    id,
    ...,
    created_dt,
    result_code
)
SELECT
    -- Копируем значения полей из communicant
    c.id,
    ...,

    -- created_dt берем из activity
    a.created_dt,

    -- result_code берем из справочника по текстовому значению из activity
    (SELECT code FROM result_dictionary r WHERE r.name = a.result)
FROM activity a
INNER JOIN communicant c ON c.id = a.communicant_id
WHERE a.created_dt >= '2023-09-01T00:00:00';

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

У метода копирования есть существенный недостаток: в нашем случае запрос INSERT FROM SELECT для всего объема переносимых данных был тяжелый и долгий, выполнить его без простоя сервиса было невозможно. Чтобы избежать простоя, понадобилось бы реализовать фоновый процесс, который делает копирование по частям, не создавая лишней нагрузки на базу. Для фонового процесса нужны ощутимые вложения в разработку, поэтому мы решили повнимательнее изучить альтернативный метод — присоединение существующих таблиц как партиций.

Метод присоединения таблиц как партиций в начале настраивается так же, как метод копирования:

  1. Создаем партицированные версии существующих таблиц и добавляем недостающие поля.

  2. В них создаем трехмесячные партиции, но теперь уже начиная с некоторого дня в будущем — назовем его день X.

  3. Трехмесячные таблицы там, где нужно, партицируем дальше по result_code.

Потом вместо копирования строк из оригинальных таблиц мы их целиком присоединим к созданным мастер-таблицам в качестве партиций через команду ATTACH на интервал от начала времен до дня X. Она останется не разбитой на автоответчики и не-автоответчики, но это нестрашно, так как начиная с дня X для новых записей это разбиение заработает.

После присоединения останется создать индексы, переименовать таблицы — и сервис будет готов работать на партицированной базе.

Пример реализации метода присоединения для таблицы communicant:

CREATE TABLE communicant_part (
    id int8 NOT NULL DEFAULT nextval('"communicant_id_seq"'),
    ...,
    created_dt timestamp NOT NULL,
    result_code int NOT NULL
) PARTITION BY RANGE (created_dt);


-- Партиция на будущее после дня X
CREATE TABLE communicant_20231201_20240301
PARTITION OF communicant_part
FOR VALUES FROM ('2023-12-01T00:00:00') TO ('2024-03-01T00:00:00')
PARTITION BY RANGE (result_code);

-- Ее, как и в примере выше, делим на importat / not-importnat
CREATE TABLE communicant_20231201_20240301_i ...
CREATE TABLE communicant_20231201_20231201_ni ...

-- Оригинальную таблицу присоединяем на интервал до дня X
ALTER TABLE communicant_part
ATTACH PARTITION communicant
FOR VALUES FROM ('0001-01-01T00:00:00') TO ('2023-12-01T00:00:00');

Выглядит красиво и просто, но работать не будет :) В присоединяемой таблице нет колонок, которые есть в новых мастер-таблицах (все те же created_dt и result_code).

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

ALTER TABLE communicant
ADD COLUMN result_code int NOT NULL DEFAULT 0;

ALTER TABLE communicant
ADD COLUMN created_dt timestamp '0001-01-01T00:00:00';

Такие значения по умолчанию будут удовлетворять ограничению на ключ партицирования, которые мы будем указывать при ATTACH.

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

Секреты эффективного выполнения ATTACH

Я упоминал, что команда ATTACH берет эксклюзивную блокировку на присоединяемую таблицу и сканирует все ее записи. Сканирование нужно, чтобы проверить, соответствует ли таблица ограничению, которое накладывается на ключ партицирования. Чем больше размер таблицы, тем дольше будет идти сканирование под блокировкой. Оно отберет у сервиса возможность какое-то время добавлять активности и снизит производительность для операций чтения.

Но если на присоединяемой таблице уже есть CONSTRAINT, аналогичный ограничению на ключ партицирования, то ATTACH выполнится мгновенно.

У нас такого CONSTRAINT-а не было. Если его создавать командой ADD CONSTRAINT, Postgres точно так же будет брать блокировку на таблицу и сканировать все ее строки. Но для команды ADD CONSTRAINT существует опция NOT VALID, благодаря которой можно провалидировать CONSTRAINT позже отдельным запросом VALIDATE CONSTRAINT.

Особенность команды VALIDATE CONSTRAINT в том, что она на таблицу берет более мягкую блокировку, которая позволяет добавлять в нее новые записи в процессе выполнения. Все равно придется ждать, пока Postgres перелопатит и проверит все записи, но зато мы не потеряем возможность в нашем сервисе создавать новые записи. Какую-то деградацию производительности на время валидации получим, но полного отказа сервиса не будет. И затем, имея валидный CONSTRAINT, мы сможем мгновенно выполнить ATTACH.

-- Выполнится быстро, то есть есть опция NOT VALID
ALTER TABLE activity
ADD CONSTRAINT activity_old CHECK (
    created_dt >= '0001-01-01T00:00:00' AND
    created_dt < '2023-12-01T00:00:00'
)
NOT VALID;

-- Долгая операция, зато без блокировки
ALTER TABLE activity VALIDATE CONSTRAINT activity_old;

-- Теперь ATTACH выполнится мгновенно
ALTER TABLE activity_part
ATTACH PARTITION activity
FOR VALUES FROM ('0001-01-01T00:00:00') TO ('2023-12-01T00:00:00');

Во время репетиции процесса на клоне продовой базы, несмотря на CONSTRAINT, команда ATTACH все равно выполнялась недопустимо долго с высоким потреблением ресурсов сервера. Выполняя ATTACH, Postgres зачем-то строил уникальный составной индекс по трем полям: id, created_dt, result_code.

Оказалось, что при создании партицированных таблиц я скопировал DDL от оригинальных таблиц, поправил название и добавил нужные колонки, но не обратил внимания, что скопировал еще и объявление первичного ключа на колонке id. А в партицированных таблицах любое ограничение уникальности, в том числе и первичный ключ, обязано включать в себя все ключи партицирования. 

При создании таблицы я получал соответствующую ошибку и, не подумав о последствиях, правил определение PRIMARY KEY, добавляя в него ключи партицирования. Так я создал на мастер-таблице ограничение уникальности по трем столбцам, которого не было в оригинальной таблице, присоединяемой командой ATTACH. Поэтому при выполнении этой команды Postgres должен был проверять, что все строки присоединяемой таблицы удовлетворяют ограничению уникальности. Для проверки Postgres создавал уникальный составной индекс, что для больших таблиц недешевая и небыстрая операция.

Чтобы такого не происходило, не нужно создавать никаких первичных ключей на мастер-таблице. Первичный ключ можно добавить уже на партициях самого нижнего уровня, причем будет достаточно, чтобы он включал в себя только столбец id.

Оптимизация запросов под партиции

Будут ли SQL-запросы, которые делает наш сервис, эффективно работать на партицированных таблицах? Напомню, что наш сервис делает запрос с JOIN-ом всех пяти таблиц, в условии WHERE в котором есть фильтрация по client_id, created_dt и result для таблицы activity:

SELECT a.*, c0.*, c1.*, c2.*, o.*
FROM activity as a
INNER JOIN communicant as c0 ON c0.id = a.communicant_id
LEFT JOIN "call" as c1 ON c1.activity_id = a.id
LEFT JOIN chat as c2 ON c2.activity_id = a.id
LEFT JOIN opt_data as o ON o.activity_id = a.id
WHERE
    a.client_id = @__clientId_0 AND
    a.created_dt >= @__startDt_1 AND
    a.result != 'Автоответчик'
ORDER BY a.id, c0.id, c1.id, c2.id;

Благодаря ��словию a.created_dt >= @__startDt_1 Postgres поймет, что для таблицы activity не нужно выполнять поиск в старых партициях, которые заведомо не могут содержать записи, созданные более 90 дней назад. 

Если мы добавим условие a.result_code >= 0, то не будут посещаться партиции с автоответчиками.

Для остальных четырех таблиц в запросе подобных ограничений нет, поэтому Postgres, выполняя JOIN-ы, будет искать связанные записи во всех партициях. Это неэффективно, поэтому нам в запрос нужно добавить ограничения на ключи партицирования для всех партицированных таблиц.

SELECT a.*, c0.*, c1.*, c2.*, o.*
FROM activity as a
INNER JOIN communicant as c0 ON c0.id = a.communicant_id
LEFT JOIN "call" as c1 ON c1.activity_id = a.id
LEFT JOIN chat as c2 ON c2.activity_id = a.id
LEFT JOIN opt_data as o ON o.activity_id = a.id
WHERE
    a.client_id = @__clientId_0 AND
    a.created_dt >= @__startDt_1 AND
    a.result != 'Автоответчик' AND a.result_code >= 0 AND
    -- добавляем ограничения на все ключи партицирования
    -- во всех остальных партицированных таблицах
    c0.created_dt >= @__startDt_1 AND
    c0.result_code >= 0 AND
    c1.created_dt >= @__startDt_1 AND
    c1.result_code >= 0 AND
    c2.created_dt >= @__startDt_1 AND
    o.created_dt >= @__startDt_1
ORDER BY a.id, c0.id, c1.id, c2.id;

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

Планы выполнения запросов на партицированной и на непартицированной базах были эквивалентны. На партицированной базе было в два раза больше операций index scan, так как запрос обычно затрагивает две самые свежие партиции. Но это были очень легкие index scan, и маловероятно, что они могли замедлить запрос в 50—100 раз.

Ответ нашелся в самом конце плана выполнения. На партицированной базе появляется информация про just-in-time компиляцию запроса. Именно она и отъедала все время.

JIT-компиляция в плане выполнения запроса на партицированных таблицах
JIT-компиляция в плане выполнения запроса на партицированных таблицах

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

Может, Postgres прав и без JIT-компиляции будет еще хуже? Легко убедиться, что это не так, если принудительно выключить JIT командой SET jit = OFF. После этого запрос на партицированной базе начинает выполняться так же быстро, как и на непартицированной. Проблема не в партицировании как таковом, а в том, что планировщик боится нашего запроса, рисует для него неадекватно высокую сложность и включает JIT, который все портит.

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

Удалось выяснить, что причиной чрезмерно завышенной оценки сложности запроса была неактуальная статистика по таблицам сразу после партицирования. VACUUM ANALYZE на партициях ситуацию не исправлял, но после VACUUM ANALYZE на мастер-таблице Postgres начинал адекватно оценивать запрос и не переставал включать JIT. Подробнее об этом я писал в своем телеграм-канале.

Мы решили полностью переделать запрос получения активностей и вернуться к идее split query, но реализовать ее самим, а не полагаться на Entity Framework.

Идея такая:

  • Сначала запрашиваем только записи из таблицы activity и присоединяем к ним записи из таблицы communicant. Один JOIN работает нормально, включение JIT не провоцируется.

  • Среди полученных записей берем идентификаторы только тех, про которые точно знаем, что они являются звонками. Мы можем это определить по полю activity.activity_type. И затем только для идентификаторов звонков запрашиваем записи из таблицы call. 

  • Чаты получаем так же, как и звонки.

  • И потом еще одним запросом подтягиваем записи по идентификаторам активностей из таблицы opt_data.

// Получаем активности и коммуникантов
var activities = await dbContext.Activities
    .AsNoTracking()
    .Include(a => a.Communicant)
    .Where(a => a.ClientId == clientId
        && a.CreatedDt >= startDt
        && a.Result != "Автоответчик" && a.ResultCode >= 0
        && c.CreatedDt >= startDt
        && c.ResultCide >= 0)
    .ToListAsync();

// Из полученных записей берем ids звонков
var callActivityIds = activities
    .Where(a => a.ActivityType == "Звонок")
    .Select(a => a.Id);

// И только для звонков догружаем записи из call
var calls = await dbContext.Calls
    .AsNoTracking()
    .Where(c => callActivityIds.Contains(c.ActivityId)
        && c.CreatedDt >= startDt
        && c.ResultCide >= 0)
    .ToListAsync();

// аналогично для чатов и opt_data

Мы убрали JOIN-ы, которые пугают планировщик Postgres. А еще такой подход показал очень хорошие результаты на нагрузочных тестах, даже на непартицированной базе. Больше не выполняется поиск в таблицах call и chat для активностей, у которых заведомо не может быть связанной сущности. Больше нет JOIN-а активностей с таблицей opt_data, который приводил к большому количеству широких строк в результате.

Результаты нагрузочных тестов нового способа получения активностей. Первая колонка — старый запрос с джойном пяти таблиц. Вторая колонка — новый подход на непартицированной базе. Третья колонка — новый подход на партицированной базе
Результаты нагрузочных тестов нового способа получения активностей. Первая колонка — старый запрос с джойном пяти таблиц. Вторая колонка — новый подход на непартицированной базе. Третья колонка — новый подход на партицированной базе

Релиз и результаты работы

На проде описанный переход к партицированным таблицам выполняли в три этапа.  

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

Второй этап был самый ответственный и опасный. Мы создавали CONSTRAINT-ы на существующих таблицах. Хоть и нашелся способ делать это без блокировки, тем не менее запросы были тяжелыми, занимали много времени и забирали много ресурсов на сервере базы данных. 

Мы проанализировали, в какое время наш сервис получает самый низкий RPS. Такое окошко на 10—15 минут обнаружилось вечером, за 15 минут до полуночи. Именно в это время мы на наших таблицах постепенно создавали и валидировали CONSTRAINT-ы.

Третьим этапом создавали новые партицированные таблицы и партиции на будущее. Потом ненадолго отключали репликацию в DWH, делали ATTACH и переименовывали таблицы.

После этого сервис начинал работать уже на новых, партицированных таблицах.

Спустя почти год после этих работ получили такие результаты:

  • Хранилище насчитывает больше 170 млн активностей.

  • Суммарный объем всех таблиц и индексов чуть больше 160 ГБ.

  • Весь последний год база данных не просто росла, а росла с ускорением: каждая последующая трехмесячная партиция была на 30—40% больше предыдущей, потому что в этот год как раз активно переводились на новые сервисы все оставшиеся процессы.

  • Несмотря на это, время работы GET-метода у нас стабильно держится около 20 или чуть больше миллисекунд при 60 RPS.

За этот год у нас не было ни одного сбоя и ни одного всплеска ошибок. 

Что касается ресурсов: один раз нам подняли память на сервере с Postgres до 4 ГБ и увеличили CPU до двух — на этих ресурсах мы и живем. Никаких предпосылок к тому, что нам понадобится что-то более мощное, пока нет.

Сейчас размер хранилища активностей приближается к 800 ГБ, и все по-прежнему эффективно работает без сбоев на тех же самых ресурсах. 

Работа над ошибками

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

1. Не стоит допускать интеграций с другими системами, завязанных на схему БД. Много умных людей до меня в своих умных книжках неоднократно писали, что давать доступ к одной и той же базе данных разным системам, особе��но если эти системы разрабатываются совсем разными командами, — плохая идея.

Наша интеграция с DWH послужила неплохой иллюстрацией в пользу этого тезиса. Cначала нам казалось очень удобным, что ничего не нужно разрабатывать, DWH сами придут в нашу базу и заберут то, что им нужно. Но потом, когда мы что-то захотели изменить в своей схеме базы данных, оказались связанными по рукам и ногам и ничего изменить уже не могли. К счастью, этот подход в компании признан устаревшим, и для новых сервисов мы так интеграцию с DWH не делаем. Теперь данные к ним отправляем через Kafka по заранее оговоренным контрактам.  

2. Не стоит использовать TEXT под поля с ограниченным набором значений. Откровенно ошибочным решением было использовать поля текстового типа для полей, имеющих ограниченный набор различных значений из справочника. Если бы мы использовали вместо текста числа, база данных сейчас могла бы весить не 160 ГБ, а 100—110. В будущем эта экономия была бы все более и более ощутимой.

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

4. Профиль нагрузки и структура БД в нагрузочных тестах должны соответствовать ситуации на проде. Наши нагрузочные тесты не смогли предсказать сбой, который случился.

В них мы давали достаточно высокий RPS на сервис и создавали в тестовой базе данных очень много клиентов, но на каждого клиента создавали всего по одной активности. Не было такого, что у одного клиента много активностей и много автоответчиков, а ведь именно это послужило причиной сбоя на проде. Тесты оказались такими, что этот кейс вообще не покрывали. Именно поэтому не смогли предсказать сбой.  

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

Выводы о партицировании

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

Плюсы партицирования:

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

  • Позволяет эффективно, не провоцируя VACUUM, удалять большие объемы данных через DROP партиции вместо запроса DELETE.

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

Минусы партицирования:

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

  • Нужно вовремя создавать новые партиции. Но этот процесс можно автоматизировать. Об одном из способов сделать это я рассказывал в этом посте.

Заходите в комментарии, если появились вопросы или хотите поделиться своим опытом работы с партицированными таблицами.

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