Меня зовут Дмитрий Фатов, я разработчик в Газпромбанке — вместе с командой создаю платформу G2, на которой мы разрабатываем свои решения. Одно из решений — SaaS, система, в которой есть внешние интеграции через xml. До какого-то момента к нам приходило максимум 30 тыс. документов в одной выгрузке, но после подключения очень крупного клиента в одной выгрузке начали получать до 2 млн документов. Это около 4 млн записей в базе данных.
SLA на обмен данными со сторонними системами — меньше пяти минут. И мы в него не укладывались, нужно было срочно оптимизировать бизнес-процессы, среди которых весомой частью была именно вставка данных.
В этой статье расскажу именно про последнюю часть — как ускорить вставку данных. Покажу, какие настройки стоит применить для Spring и Hibernate, для чего они нужны и какой буст по производительности дают. Здесь же разберем, как можно создать свою собственную прослойку для вставки данных в PostgreSQL. Эта прослойка позволит нам использовать разные подходы к вставке данных, в том числе кастомные методы PostgreSQL, а также распараллелить процесс вставки. Посмотрим, как ее можно подружить со Spring, а также какой профит нам даст каждый из рассмотренных подходов.
Содержание
• Настройки Spring и Hibernate, ускоряющие вставку данных
• Создание собственной прослойки для вставки данных в БД
• Использование кастомных методов PostgreSQL
• Распараллеливание процесса вставки
Для наглядности я создал небольшое приложение, которое доступно по ссылке, — все, что будет дальше в статье, можно увидеть и в нем. Внутри есть подробное readme, поясняющее, как запустить приложение и как с ним работать. В статье я покажу код, который использовал, но если хочется все «потрогать ручками» — you are welcome.
Есть подготовленная база данных размером 32 Гб, которая содержит 100 млн строк в основной таблице с индексами. Тестируется вставка на 4 млн записей. Ссылка для скачивания образа и инструкция по его установке находятся в readme проекта.
На чем производились замеры. Оборудование: Intel(R) Core(TM) i7-10750H CPU @ 2.60GHz, 12 ядер, 32gb ОЗУ, окружение: java 17, PostgreSQL 14.5. Именно эта версия PostgreSQL использовалась у нас в продакшн на момент проведения замеров. Различия между PostgreSQL 17.4 отмечу далее по тексту.
Настройки Spring и Hibernate
Начнем с самого очевидного — с настроек Spring + Hibernate для ускорения процесса вставки данных в БД.
Базовая реализация на Spring + Hibernate
Для начала давайте посмотрим, сколько будет выполняться вставка данных в базовой конфигурации. Скачиваем пустую болванку со Spring IO, подключаем репозиторий, настраиваем Entity, подключаем базу данных и выполняем простой код в транзакции — пытаемся записать рандомные entity через метод save().

Получаем довольно скромный результат — больше 10 минут. От этой цифры мы будем в дальнейшем отталкиваться и пробовать ее улучшить.

Дополнительно я вывел потребление оперативной памяти, и оно было достаточно большим. Причина — в Hibernate, который кэширует все объекты в session-level cache до конца транзакции. При небольшом размере heap легко можно получить OutOfMemoryError.
Если залогировать процесс, то можно увидеть, что отправка данных происходит в момент вызова метода save, между началом генерации и ее окончанием.

У Hibernate есть замечательная настройка generate_statistics, которая позволяет увидеть статистику по отправленным к БД запросам. В нашем случае отправились 4 млн записей, из них батчами — ноль.

Для чего отправлять батчами? Если объяснять по-простому, это выглядит так: когда нужно перенести книги из одного шкафа в другой, то быстрее будет переносить их пачками по несколько книг, а не бегать с каждой туда-обратно. Разберемся, как это сделать.
Включаем батчинг: JDBC batch size
У Hibernate есть настройка jdbc.batch_size, но она не всегда работает. Если ее просто установить, в моем случае батчинга не будет.

В самом начале, когда я создавал базу данных, то сделал последовательность, которая должна генерировать айдишники за меня, потому что может случится так, что бэкендов окажется много, а база данных — одна, и хотелось сохранить последовательность создаваемых объектов. Эта последовательность позволила мне использовать GenerationType.IDENTITY и не заморачиваться с генерацией айдишников на стороне бэкенда. Однако если посмотреть в документацию Hibernate, то можно увидеть — если у ID стоит GenerationType.IDENTITY, то батчинг не работает.

Как быть в этой ситуации? Придется запрашивать айдишники у базы данных. Для этого добавляем SequenceGenerator и ставим GenerationType.SEQUENCE.

И посмотрим, как это отражается на скорости.

Получился неплохой профит — 23%. Проблемы с высоким потреблением памяти остаются, потому что session-level cache никуда не делся.
Если снова посмотреть логи, то можно увидеть, что 40 батчей отправились, однако 40 млн стейтментов никуда не делись. Всему виной чтение айдишников из базы данных. Также стоит отметить, что данные теперь отправляются батчами, но в конце транзакции, а не во время выполнения.

Как сократить запросы на чтение ID из БД
Как вы уже могли догадаться, речь пойдет про кеширование. И тут у нас есть два варианта. Первый предоставлен самим PostgreSQL, реализуется при помощи добавления ключевого слова cache в саму последовательность. Второй вариант реализуется в Hibernate, т. е. на стороне бэкенда.

Давайте разберем, чем эти подходы отличаются друг от друга.
В первом подходе будет создан кеш на уровне сессии. Это помогает самому PostgreSQL избегать конкурентного доступа к последовательности. Т. е. при первом обращении из последовательности будет взято сразу 50 id, а на бэк будет возвращен только один, остальные 49 будут закешированы в самой сессии на стороне БД. При этом не использованные id не возвращаются обратно в последовательность. Поэтому если в одной сессии создать только одну запись, 49 id из последовательности потеряются.
Во втором подходе кеширование происходит на стороне бэкенда. В этом случае для последовательности мы должны применить конструкцию increment by 50, которая будет увеличивать счетчик на 50 при каждом обращении к последовательности. При этом бэкенд будет знать, что у него в запасе есть 50 id, которые он может использовать в рамках всего приложения без последующих обращений к БД. Давайте посмотрим, как данный подход будет выглядеть со стороны логов.

Количество запросов к последовательности сократилось в 50 раз, что довольно неплохо. Сравним эти два подхода по скорости.

Наибольший прирост по производительности я получил при использовании кеша на стороне приложения — около 30%. При использовании кеша на стороне БД прирост оказался не столь значительным, около 2%. Дополнительно я решил проверить эти два подхода под нагрузкой в 10 потоков. Получил следующие результаты:

Дельта между этими двумя подходами сократилась, однако разница все еще остается существенной. Давайте подведем итог по кешированию идентификаторов.
Кеш на стороне приложения работает быстрее за счет того, что мы реже обращаемся в базу, сокращая при этом транспортные затраты. Ибо самый быстрый запрос к БД — это тот, которого не было!
Кеш на стороне БД работает медленней, чем на стороне приложения. Выгода по скорости достигается только за счет того, что PostgreSQL приходиться реже накладывать блокировки на данную последовательность.
Также стоит отметить, что кеш на стороне БД сессионный. Т. е. неиспользованные идентификаторы пропадают после закрытия сессии. С кешированием на стороне приложения мы теряем значения из кеша только в случае перезапуска бэкенда. Что происходит значительно реже.
Данные подходы можно использовать вместе, учитывая особенности каждого из них.
Метод saveAll()
Следующая идея — собрать все в список и вызвать saveAll(). Посмотрим, какой профит будет от этого подхода. Тестовый метод будет выглядеть следующим образом:

Пользы от этого получили немного. Около 2% всего.

Дело в том, что капотом метод saveAll в цикле вызывает метод save. Поэтому большой разницы мы и не заметили.

Теперь заглянем в логи PostgreSQL и Hibernate. Можно увидеть, что Hibernate отправляет данные построчно, для каждой entity добавляет инструкцию insert into и имена колонок. А PostgreSQL записывает эти данные так же, как ему их передал Hibernate.

Помимо обычных Insert есть пакетные (многострочные) insert’ы, когда мы один раз передаем шапку, а в values через запятую передаем строки, которые нужно записать. PostgreSQL поддерживает второй тип, и он, по идее, должен быть быстрее, чем обычный insert для пакетных вставок.

Как превратить обычные insert`ы в мультистрочные? К сожалению, Spring не умеет этого делать, зато это умеет JDBC-драйвер.
Настройка драйвера: reWriteBatchedInserts
Есть замечательная настройка reWriteBatchedInserts, которая позволяет драйверу переделывать запросы вот в такой вид:

Посмотрим, какие изменения произошли в логах.

Hibernate продолжает отправлять insert по отдельности, но драйвер на лету их склеивает в мультистрочный insert. И это дает нам неплохой профит по скорости без изменения кода.

Время уже составляет 4 минуты 37 секунд. И это пока только на одних настройках. Дальше подумаем, как сократить объем используемой памяти.
Очистка session-level cache через EntityManager
Как я говорил ранее, Hibernate хранит все объекты в session-level cache до окончания транзакции. Однако его можно очищать, если отправленные в БД данные нам более не нужны. Для этого заинжектим EntityManager через @PersistenceContext и будем управлять всем процессом вставки вручную. Когда подходит момент батчевания, мы будем flush’ить данные в БД и очищать кеш.

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

Настройка order_inserts
Еще одна полезная штука — настройка order_inserts. Когда мы записываем несколько типов данных вперемешку в одной транзакции (например, покупатель — поставщик — покупатель — поставщик), есть риск получить столько батчей, сколько у нас всего записей. Т. к. Hibernate закрывает текущий батч и начинает новый, когда для вставки прилетает тип данных, отличающийся от предыдущего. Настройка order_inserts=true заставляет сначала сгруппировать объекты по типу, а уже потом в батчи. Это позволяет батчингу работать стабильно.

Промежуточный итог: со всеми настройками мы ушли с 10,5 минут до 4 минут и 19 секунд. На таблице ниже собрал информацию о времени Boost по каждому из подходов.

А что, если отказаться от hibernate и попробовать самостоятельно сформировать нужные нам инсерты?
Пишем свою прослойку
Нам ничего не мешает создать подобную строчку и записать ее в базу данных. Попробуем сделать для этого свою прослойку, которая будет из entity формировать нужные нам insert.

Но появляется закономерный вопрос: как формировать эту строчку?
Своя реализация вставки
Первое, что приходит в голову, это взять entity, через reflection вытащить оттуда данные, имена колонок и сформировать нужную для нас строку.

Однако все знают, что доступ к данным через рефлексию — это небыстрая операция, поэтому посмотрим, какие варианты у нас есть еще. Имена колонок для вставки данных перечислять вручную не рекомендуется, т. к. колонку можно переименовать в таблице, при этом никакой валидации по нативным запросам у нас нет. Котлин дает нам возможность указывать имена колонок через KProperty при помощи конструкции KClass::field. Также есть библиотека hibernate jpa-modelgen, которая генерирует константы с именами колонок через знак подчеркивания Entity._field.

Разница в производительности между всеми описанными способами оказалась несущественной, около 3 секунд на операцию с 4 млн entity. В статье я оставил вариант с reflection — как самый простой.

Архитектура собственной прослойки
Прослойка состоит из трех слоев:
Processor — в нем содержится логика преобразования данных для сохранения;
Saver — управляет процессом сохранения;
Factory — получает нужный saver по типу.

Если посмотреть на имплементации, то можно увидеть, что есть абстрактные классы с общей логикой и много saver`ов. Каждый saver будет отвечать за свой метод вставки. Их достаточно много в репозитории, основные из них рассмотрим в этой статье.

Как все работает внутри
Первый уровень абстракции умеет коммитить и роллбэчить, принимает connection.

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

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

Сам saver будет выглядеть следующим образом:

В нем есть небольшой кеш, куда мы будем складывать строки с данными, полученные через рефлексию в методе addDataForSave. Метод saveData будет вызываться автоматически в момент отправки батча в БД.
Получать нужный saver мы будем через фабрику. Метод принимает enum с типом saver и возвращает саму реализацию.

Как с этим работать? C помощью аннотации @Component создаем реализацию фабрики по нужной нам entity, инжектим ее в сервис. По нужному типу из фабрики получаем конкретную реализацию saver, генерируем случайные данные, затем коммитим их в БД.

Посмотрим, что у нас получается по времени и памяти.

Для начала хотелось бы обратить внимание на потребление памяти. Оно достаточно низкое, приложение работает в пределах 600–900 Мб. Нисходящие пики показывают работу минорного GC. Однако если посмотреть на время, то можно увидеть, что оно проигрывает 46 секунд последнему варианту с hibernate. Давайте попробуем переделать наш insert на вставку данных через prepared statement.

И первое, с чем я столкнулся при таком подходе, — это превышение максимального количества параметров в запросе (ссылка на ограничения: https://postgrespro.ru/docs/postgresql/17/limits).

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

В моем случае самым эффективным оказался размер батча в 5 тыс. записей. На что также хочется обратить внимание: вставка данных через prepared statement быстрее, чем через обычный statement. Многие знают, что использование обычного statement небезопасно из-за sql-инъекций, но давайте разберемся, почему данный подход получился более быстрым. Для этого включим логи на стороне PostgreSQL и соберем информацию о времени выполнения этапов запроса parse, bind и execute.

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

Из данной таблицы мы видим, что parse у prepared statement выполняется только первые пять раз. Кроме того, если внимательно посмотреть на bind, заметно, что первые 10 раз он выполняется дольше. Давайте посмотрим, почему.

Всему виной так называемая server prepare — оптимизация, которая позволяет переиспользовать планы запросов, избегая при этом время на парсинг и планирование. Вот документация.
Как это работает. Jdbc драйвер понимает, что данный запрос не мусорный и что его неплохо бы закешировать. Он назначает алиас, например S_1, далее отдает команду в PostgreSQL, чтобы тот рассчитал по этому запросу оптимальный план и закешировал его. После чего jdbc-драйвер начинает передавать в PostgreSQL не весь запрос, а только алиас, а сам PostgreSQL использует уже закешированный план запроса.

То, сколько раз должен повториться запрос, чтобы он не считался мусорным, задается в параметре prepareThreshold, и по дефолту его значение равно пяти. Вот почему мы видим, что у prepared statement парсинг выполнился только пять раз. Далее запрос был закеширован и следующие пять раз bind выполнялся дольше, т. к. PostgreSQL по нему рассчитывал оптимальный план запроса.
Итак, давайте двигаться дальше. Подумаем, что еще может мешать вставке данных в PostgreSQL.
Индексы и триггеры
С триггерами все сложно — там может быть бизнес-логика, избавление от которой требует согласований с бизнесом, поэтому на моей таблице триггеров нет. А вот с индексами проще. Их можно удалить перед вставкой, а затем вернуть.
Многие пользуются этим, когда восстанавливают базы с нуля. Удаляют индексы, загружают данные, потом индексы возвращают. Но я все-таки не рекомендовал бы использовать этот способ на работающих системах, потому что, во-первых, что-то может пойти не так, обработчик упадет, можно остаться без индексов. Во-вторых, во время больших вставок пользователи живут без индексов, что тоже не очень хорошо.
Однако коллеги не только из моей компании, но и из других организаций говорят, что рассматривают такие варианты. Поэтому я решил попробовать.
У PostgreSQL есть вьюшка, которая называется pg_indexes. Она возвращает данные, в которых есть имя индекса, а также скрипт, показывающий, как его можно создать.

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

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

Здесь мы, наоборот, провалились по времени. Хотели получить профит, но не вышло. 7 минут 19 секунд — это слишком много.
Разберемся, почему так произошло. Смотрим логи.

Видно, что и drop index, и вставка проходят достаточно быстро. А на восстановление индексов мы тратим 5 минут 11 секунд. Плюсом ко всему прочему грузим процессор.
Давайте посмотрим, что еще умеет PostgreSQL.
Использование кастомных методов PostgreSQL
Помимо обычного INSERT, в PostgreSQL есть метод Copy, который позволяет копировать данные между файлом и таблицей. Давайте попробуем применить его для вставки данных и посмотрим, какой профит мы получим.
Синтаксис у метода copy достаточно простой. Передаем имя таблицы, колонки и источник данных. В самом драйвере есть copyAPI, который мы можем использовать для работы с этим методом.

Copy через csv и файл
Раз уж метод copy создан для копирования данных между файлом и таблицей, давайте протестируем, сколько будет стоить эта операция. Сформируем csv-файл с нужными данными и передадим его драйверу.

Посмотрим на результаты:

Несмотря на то, что данные перед вставкой мы записывали на диск, этот метод оказался быстрее, чем мультистрочный insert. Также приятно выглядит потребление памяти. Все примерно в рамках 100 Мб, потому что данные для вставки записываются на диск, а не хранятся в оперативной памяти. Данный подход был бы удобен, когда есть необходимости вставлять данные из внешних источников, например из XML. Можно написать потоковый процессор, который большую XML будет преобразовывать в csv файл, а затем передавать эти данные для вставки в БД.
Давайте посмотрим, что произойдет, если хранить данные для вставки в памяти.
Copy с хранением буфера в памяти
В следующем замере используем тот же метод copy, только наш кеш будем хранить в памяти при помощи StringWriter.

Давайте посмотрим на результат.

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

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

Еще сложнее с BigDecimal. Его я также взял из драйвера PostgreSQL.

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

Видно, что в целом код работает быстро. Если посмотреть разницу между reflection и превращением данных через reflection в binary, то можно заметить, что она уже не такая большая, около 300 мс.
Теперь посмотрим замеры. Здесь нас ждет неприятный сюрприз.

Вроде бы мы подготовили все, как PostgreSQL любит. Но по времени у нас получилось больше.
Сравним csv-файл с binary. На картинке ниже можно увидеть, что файлики отличаются, csv явно весит меньше, по времени создания файла у меня получились одинаковые цифры, 56 секунд, а вот запись binary на 5 секунд больше.

Copy binary в памяти
Ниже привел пример бинарного copy с сохранением буфера в памяти приложения.

Как можно заметить, разница приблизительно такая же, как и с сохранением через файл (1–2%).
Примечание: я пробовал сравнить тесты бинарного copy vs csv формат на PostgreSQL с версией 17.4. В этой версии copy бинарный стал обгонять csv-формат на те же 1–2%. PostgreSQL явно не стоит на месте и проводит некоторые оптимизации в этом направлении.
Интеграция Copy со Spring
Разберемся, как все это добро прикрутить к транзакциям Spring, чтобы можно было использовать наши подходы с saver’ами внутри этих транзакций и наш код выглядел бы следующим образом:

Что сделаем для этого:
создадим отдельный метод
saveByCopy()в нашем абстрактном репозитории;из Spring’овского контекста вытащим
Connectionс помощьюTransactionSynchronizationManager;

зарегистрируем saver как ресурс текущей транзакции;

перед коммитом вызовем метод
saveData() у saver, чтобы доотправить данные в БД.
Т. к. мы используем connection из Spring, то и коммитить вручную нам ничего не нужно. За нас это сделает Spring.
Так выглядит наш метод saveByCopy(). Мы получаем наш saver, затем через метод addDataForSave добавляем данные для сохранения. Далее все за нас сделает Spring.

Теперь дополним наш JPA-репозиторий функциональностью нашего абстрактного репозитория. Таким образом, в custom JPA-репозитории будут все функции, которые есть в JPA-репозитории, плюс еще дополнительно наша функция.

Посмотрим на результаты.

Результат получился практически тот же, что и через фабрику, только теперь данный метод мы можем вызывать в транзакциях Spring.
Теперь давайте попробуем распараллелить процесс вставки. Посмотрим, какой профит даст нам этот подход.
Параллельная вставка
Перед тем как приступить к реализации, я зафиксировал в логах, сколько времени мы тратим на то, чтобы отправить данные в БД. Получилось 2 минуты 53 секунды. Это две трети от общего времени. В это время наш поток спит и ждет, пока данные отправятся в БД.

Давайте отдадим отправку данных в БД другим потокам, при этом наш основной поток не будет уходить в сон и продолжит генерировать данные для вставки. Что для этого сделаем:
Создадим отдельный
ThreadPoolExecutorдля управления задачами. На самом деле можно было обойтись обычными thread`ами, просто я считаю, чтоThreadPoolExecutorпозволяет лучше контролировать потоки приложения.Создадим
ConcurrentSaverHandler. Он будет управлять процессом сохранения и распределять нагрузку между несколькими saver`ами. То есть как только Handler поймет, что текущий saver начнет сейчас отправлять данные в БД, он переключится на другой saver и будет сохранять данные туда.Расширим функциональность
CopyByEntitySaverдля неблокирующей отправки в базу данных.
Как это будет выглядеть на схеме:

Какие минусы у этого подхода:
Большее потребление коннектов при больших нагрузках. Нам теперь нужен будет не один коннект на транзакцию, а пять.
Транзакция для параллельных saver`ов перестанет быть атомарной.
Мы будем коммитить данные наших saver`ов перед коммитом основного потока, однако в момент коммита что-то может отвалиться, и часть данных останется незаписанной, — это нужно учитывать.
Посмотрим, как это можно реализовать. Создадим CopyByEntityConcurrentSaver, который будет расширять функциональность CopyByEntitySaver. Он будет принимать ExecutorService и записывать во внутреннюю переменную saveDataJob текущую задачу по отправке данных в БД.

Если вдруг основной поток пройдет все saver`ы и догонит saver, который не успел отправить данные в БД, нам нужно будет дождаться завершения отправки.
Как это работает: на каждом этапе работы с БД мы проверяем, завершилась ли текущая задача, через переменную saveDataJob, где метод get ожидает завершения и возвращает результат. Если задача завершилась, то в методе saveData через executorService мы создаем новую задачу с вызовом родительского метода saveData из copyByEntitySaver.

Также у нас есть счетчики созданных Entity и количества переключений saver’ов. Первый мы будем использовать для определения наполненности батча, чтобы понять, когда текущий saver пойдет отправлять данные в БД и нам потребуется переключиться на другой saver. Второй нужен нам для того, чтобы определять наш текущий saver, тот, с которым мы работаем сейчас.

Определять, когда у saver`а наполнилась пачка, и наш текущий saver проще по остатку от деления (через %).

Важно отметить, что при коммите и при rollback нужно обязательно закрывать connection. Для кого-то это очевидно, для кого-то нет. Если не закрыть connection, мы его просто потеряем из пула. Он будет висеть, и в конце концов connection`ы станут расходоваться очень быстро.
Теперь давайте попробуем подружить ConcurrentSaverHandler с транзакциями Spring. Для этого также будем использовать TransactionSynchronizationManager, через него мы будем получать наш handler и добавлять его в ресурсы при первом обращении.

Перед коммитом мы будет выполнять commit у handler, который, в свою очередь, сделает commit у всех saver. Через хук afterCompletion проверяем, что все прошло успешно, если нет, откатываем незакоммиченные транзакции.

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

Посмотрим на замерах, что получилось.

Результат очень хороший. Удалось существенно ускорить операцию за счет ее распараллеливания. По памяти тоже все очень неплохо выглядит.
Что в итоге
Spring и Hibernate дают свои overhead на вставку данных, скорость вставки можно увеличить. На своих данных у меня получилось поднять скорость вставки приблизительно на 40%.
В своей прослойке можно написать все так, как нам удобно, использовать кастомные методы PostgreSQL. Работать будет быстрее, можно подружиться со Spring и распараллелить процесс вставки. Однако есть большой минус — плюшки Spring не будут работать. На многих проектах на Spring написаны аудиты и прочие функции. Если они вам не нужны, то можно использовать такой подход. Если нет, то придется оптимизировать с помощью Spring.
Метод Copy работает быстрее мультистрочного Insert приблизительно на 10%. По крайней мере, на моем оборудовании были такие показатели. Но работает только с PostgreSQL, с другими базами данных не прокатит.
Copy binary дает сомнительный профит по скорости в сравнении с csv. Во всяком случае, я его не увидел. В версии 17.4 PostgreSQL он работает немного быстрее, чем csv-формат, в 14.5 — немного медленней.
И самый главный результат — удалось ускорить процесс вставки в шесть раз: с 10 минут 28 секунд до 1 минуты 47 секунд.
Вы можете протестировать все на своих данных — рассказывайте потом в комментариях, что получилось. Обратная связь и идеи очень приветствуются :-)
Комментарии (7)

chesser69
20.11.2025 13:49SLA вещь непростая)
У нас партнёры, при работе с нами, всегда хотели дать ответ только после полной обработки. Долго не соглашались на простой паттерн, который можно использовать и тут. Данные сбрасываются в буферную таблицу максимально простую без индексов. По факту сброса клиент получает ответ. Потом либо в самой бд, либо в приложении периодически запускается джоба. Из бд в ту же бд. Всё прозрачно и SLA достичь проще
kmatveev
Не совсем понял, бинарный copy собирает результат в буфер и флашит в базу? Какой размер пачки получался?
FatOFF Автор
Не совсем так. Этим процессом мы управляем вручную. Т.е. сначала складываем в буфер то что мы хотим сохранить методом copy, затем отправляем в БД. На моем примере я делал вставки батчами по 5_000 записей.