Введение
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!