Хватит использовать Spring Data saveAll как молоток

spring data jdbc performance postgresql java

saveAll(...) удобен. Очень удобен.
Именно поэтому он так часто оказывается в местах, где ему не место.

Проблема начинается, когда через него пытаются загрузить > 1_000+ строк за раз.
saveAll(...) универсальный, но не всегда оптимальный для массовой вставки: в итоге вылезают лишние SQL-операции,
лишние round-trip и просто заметная задержка на ровном месте.

Я как раз уперся в это на практике и не захотел оставлять массовые вставки на “авось оптимизируется само”.

В чем проблема с saveAll(...) на больших объемах

  • saveAll(...) в первую очередь про удобство API, а не про максимальную пропускную способность;
  • saveAll(...) не только вставляет: если у сущности уже есть id, он идет в update-сценарий;
  • в базовом сценарии это обработка коллекции в обычном цикле, то есть при N объектах вы получаете около N операций
    записи в БД;
  • на больших пачках он часто проигрывает выделенной batch-стратегии;
  • чем больше данных, тем дороже становится эта разница.

Если у вас обычный CRUD с небольшими наборами, это вообще не проблема.
Но когда у вас импорты, синхронизации или технические джобы на тысячи записей, “просто saveAll” быстро становится узким
местом.

Как я это решал

Я использовал подход Spring Data Repository Fragment:

  1. BulkRepository<T> с методом insertBatch(...)
  2. BulkRepositoryImpl<T> с JdbcTemplate.batchUpdate(...)
  3. регистрация фрагмента через META-INF/spring.factories
  4. подключение в UserRepository

Короткая идея в коде:

public interface UserRepository
        extends ListCrudRepository<User, Long>, BulkRepository<User> {
}

То есть обычный репозиторий остается обычным, но получает отдельный bulk-метод под массовые операции.

Внутри реализации:

  • SQL собирается из metadata Spring Data (таблица/колонки);
  • план вставки кэшируется на тип сущности;
  • параметры передаются через prepared statement (?);
  • сущности с уже заполненным id пропускаются с WARN.

Важный нюанс про JPA

Если вы на Spring Data JPA, batching для saveAll(...) часто можно включить Hibernate-настройками (например,
hibernate.jdbc.batch_size, hibernate.order_inserts).
Но там есть ограничения: IDENTITY-генерация id часто ломает эффективный batching, плюс поведение сильно зависит от
persistence context, flush/clear и жизненного цикла сущностей.

В остальных Spring Data модулях обычно нет одного универсального “включателя”, который магически сделает массовые
операции быстрыми. Поэтому отдельные bulk-методы остаются рабочим и прозрачным подходом.

Куда это развивать дальше

Самое полезное в таком подходе: это не одноразовый трюк под insertBatch(...).

  • можно добавить updateAll(...) с batch update;
  • можно сделать deleteBatch(...) для массовых удалений;
  • можно вынести общие bulk-паттерны в переиспользуемый слой для нескольких сервисов.

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

Небольшие бенчмарки

Мой текущий локальный прогон для 5 000 записей:

СценарийПользователейГенерация (ms)Вставка (ms)Итого (ms)
saveAll50005232238
insertBatch500055867

Прогон на удалённой БД. Сервер находится в моём городе:

СценарийПользователейГенерация (ms)Вставка (ms)Итого (ms)
saveAll1000017919936
insertBatch1000017171188

Репозиторий с полной реализацией

Если хотите посмотреть все целиком (Flyway, PostgreSQL, endpoint’ы, fragment implementation):
github Ulllie/spring-data-ext