Введение

Spring JPA с Hibernate – мощный инструмент для работы с базами данных, но при обработке больших объёмов данных можно столкнуться с OutOfMemoryError (OOM). Основная причина – механизм кэширования Hibernate, который хранит загруженные сущности в памяти до завершения транзакции.

Чтобы избежать утечек памяти и зависаний, важно понимать:

  • Как работает кэш Hibernate и почему он вызывает OOM.

  • Как @Transactional(readOnly = true) влияет на кэш и почему он не решает проблему полностью.

  • Какие техники загрузки данных позволяют обрабатывать миллионы записей без риска OOM.

Разберём всё по порядку.


1. Почему кэш Hibernate вызывает OutOfMemoryError?

Hibernate использует двухуровневый кэш:

  • Кэш первого уровня (L1 Cache) – автоматически включён и привязан к текущей сессии (EntityManager). Все загруженные сущности остаются в памяти до конца транзакции.

  • Кэш второго уровня (L2 Cache) – общий для нескольких сессий, требует явной настройки.

⚠️ Проблема: L1 Cache работает всегда, и если транзакция загружает много данных, то вся эта информация остаётся в памяти, что приводит к OOM.

Пример опасного кода:

@Transactional
public void processAllEntities() {
    List<Entity> entities = repository.findAll(); // Все записи загружаются в память
    for (Entity entity : entities) {
        process(entity);
    }
}

Ошибка: пока транзакция не завершится, Hibernate хранит все объекты в памяти → OOM.

Теперь разберём, помогает ли @Transactional(readOnly = true) избежать этой проблемы.


2. Как @Transactional(readOnly = true) влияет на кэш?

Один из популярных способов оптимизации – добавление readOnly = true в @Transactional.

Что даёт readOnly = true?

✔️ Hibernate не отслеживает изменения объектов (отключается Dirty Checking).
✔️ Снижает нагрузку на кэш, так как обновления в базе не выполняются.
✔️ Чуть ускоряет запросы, потому что Hibernate не фиксирует изменения.

⚠️ НО! Кэш первого уровня всё равно остаётся активным, и если запрос загружает миллионы строк, OOM всё равно возможен.

Пример:

@Transactional(readOnly = true)
public List<Entity> getAllEntities() {
    return repository.findAll(); // Все загруженные объекты сохраняются в L1 Cache
}

Теперь посмотрим, как эффективно загружать данные без риска OOM.


3. Эффективные стратегии загрузки данных

3.1 Постраничная загрузка (Pagination)

Один из самых простых способов ограничить нагрузку на память – постраничная загрузка (PageRequest).

int pageSize = 1000;
int pageNumber = 0;
Page<Entity> page;
do {
    page = repository.findAll(PageRequest.of(pageNumber, pageSize));
    process(page.getContent());
    entityManager.clear(); // Очистка кэша после каждой страницы
    pageNumber++;
} while (page.hasNext());

✔️ Экономит память, загружая фиксированное количество записей за раз.

3.2 Использование Stream с настройкой батча

Если данных слишком много для постраничной загрузки, можно использовать Stream и загружать их батчами (fetchSize).

// Запрос в репозитории
@QueryHints({ @QueryHint(name = "org.hibernate.fetchSize", value = "100") })
@Query("SELECT e FROM Entity e")
Stream<Entity> findAllStream();

// Код сервиса
@PersistenceContext
private EntityManager entityManager;

@Transactional(readOnly = true)
public void processEntities() {
    try (Stream<Entity> stream = repository.findAllStream()) {
        stream.forEach(entity -> {
            process(entity);
            entityManager.detach(entity);  // Очищаем объект из L1 Cache
        });
    }
}

✔️ Hibernate загружает данные партиями (fetchSize), а не все сразу.
⚠️ Важно: fetchSize работает не во всех базах данных (например, в MySQL поддержка ограничена).

3.3 Вложенные транзакции

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

⚠️ Важно: метод с @Transactional(REQUIRES_NEW) должен вызываться через отдельный бин либо через self-inject, иначе транзакция не создастся.

@Service
public class PageProcessorService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processPage(Pageable pageRequest) {
        Page<Entity> page = repository.findAll(pageRequest);
        for (Entity entity : page) {
            process(entity);
        }
    }
}

✔️ Hibernate сбрасывает кэш после обработки каждой страницы.
✔️ Отсутствует необходимость чистить кэш "руками" через entityManager.
✔️ Используется Pageable (см. пункт 3.1).

3.4 Автоматическая очистка через Hibernate-слушатели

Чтобы не очищать кэш вручную, можно использовать Hibernate-слушатели:

@Entity
@EntityListeners(ClearCacheListener.class)
public class Entity {
    @Id
    private Long id;
}

@Component
public class ClearCacheListener {
    @PersistenceContext
    private EntityManager entityManager;

    @PostLoad
    public void clearCache(Object entity) {
        entityManager.detach(entity);
    }
}

? Hibernate автоматически удаляет объект из L1 Cache после загрузки.
⚠️ Обратите внимание: @PostLoad не всегда срабатывает на коллекциях вложенных сущностей.


Заключение

Чтобы избежать OutOfMemoryError в Spring JPA:

  • Используйте постраничную загрузку или стримы с fetchSize.

  • Очищайте L1 Cache (clear()/detach()).

  • Используйте вложенные транзакции (@Transactional(REQUIRES_NEW)).

  • Рассмотрите Hibernate-слушатели или Spring Events для автоматической очистки.

  • Проверяйте настройки JDBC-драйвера, так как некоторые из них могут ограничивать fetchSize.

Эти техники помогут эффективно работать с большими объёмами данных в Spring JPA!

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