Команда Spring АйО перевела и адаптировала доклад Мацея Валковяка “Performance oriented Spring Data JPA & Hibernate”, в котором на наглядных примерах рассказывается, как существенно нарастить производительность приложения, оптимизировав его взаимодействие с БД. 

Доклад будет опубликован тремя частями. В первой части было рассказано об оптимизации управления подключениями к базе данных. Во второй части мы посмотрим на то, как оптимизировать код и настройки Spring Data и Hibernate, чтобы минимизировать количество отправляемых в базу данных SQL запросов. 


Неожиданные SQL запросы и как от них избавиться

Итак, в первой части мы сделали хорошее управление транзакциями. Теперь давайте наконец-то перейдем к правильному Spring Data и Hibernate. Для демонстрации воспользуемся упрощенным приложением для банка. 

@Entity
public class BankTransfer {

    public enum State {
        CREATED, SETTLED
    }

    @Id
    private String id;
    private String reference;

    @ManyToOne
    private Account sender;

    @ManyToOne
    private Account receiver;

    @Embedded
    private Amount amount;

    @Enumerated(EnumType.STRING)
    private State state;

    public BankTransfer() {
    }
}

У нас есть центральная, главная, самая важная сущность — банковский перевод (BankTransfer), ей соответствует единая банковская транзакция, в ней присутствует отправитель (поле sender), получатель (поле receiver), при этом оба они ссылаются на сущность счета (Account) с аннотацией @ManyToOne. Есть также поле, отвечающее за переводимую сумму денег  (amount), а также состояние (State). 

В приложении также присутствует сущность банковского счета (Account). 

@Entity
public class Account {

	@Id
	private String id;
	private String iban;
	private String firstName;
	private String lastName;

	@ElementCollection
	@CollectionTable(
    		name = "phone_number",
    		joinColumns = @JoinColumn(name = "account_id")
	)
	private List<PhoneNumber> phoneNumbers;

	public Account() {
	}

	public Account(String id, String iban, String firstName, String lastName) {
    		this.id = id;
    		this.iban = iban;
    		this.firstName = firstName;
    		this.lastName = lastName;
	}
}

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

@Service
public class RegisterBankTransferUseCase {

	private final AccountRepository accountRepository;
	private final BankTransferRepository bankTransferRepository;

	public RegisterBankTransferUseCase(AccountRepository accountRepository, BankTransferRepository bankTransferRepository){
      this.accountRepository = accountRepository;
      this.bankTransferRepository = bankTransferRepository;
	}

	public void execute(String bankTransferId,
                    	String reference,
                   		String senderId,
              			String receiverId,
               			Amount amount) {
      Account sender = accountRepository.findByIdOrThrow(senderId);
      Account receiver = accountRepository.findByIdOrThrow(receiverId);
      BankTransfer bankTransfer = new BankTransfer(bankTransferId, reference, sender, receiver, amount);
      bankTransferRepository.save(bankTransfer);
      // bankTransfer.settle();
	}
}

Если посмотреть на приведенный выше код, который написан очень неплохо, мы видим, что он загружает Account, для чего необходим один SELECT, затем он загружает еще один Account (второй SELECT), создает новый Java object и затем вызывает save(), что по логике вещей должно привести к запросу INSERT. Всего три запроса.

Однако, результаты реального прогона данного кода могут оказаться некоторым сюрпризом. Сначала все соответствует ожиданиям: выполняются два SELECT-а к сущности Account. Затем случается что-то очень странное. Появляется SELECT к таблице bank_transfer, в котором она выполняет join с таблицей account, потом еще один join с ней же и потом выполняет поиск по id.  

select btl_0.id, btl_0.value, btl_0.currency_code,
   	r1_0.id, r1_0.first_name, r1_0.iban, r1_0.last_name,
   	btl_0.reference,
   	s1_0.id, s1_0.first_name, s1_0.iban, s1_0.last_name,
   	btl_0.state
from bank_transfer btl_0
left join account r1_0 on r1_0.id = btl_0.receiver_id
left join account s1_0 on s1_0.id = btl_0.sender_id
where btl_0.id = ?

Затем выполняется еще одна загрузка из  таблицы account. И только после этого мы получаем тот INSERT, который ожидали.

Давайте разбираться, что происходит, и начнем с этого подозрительного длинного SELECT-а. Откуда он взялся и какой цели служит?

Когда мы вызываем метод save(), Spring Data вызывает простой JPA репозиторий, а также метод save(). И в зависимости от того, считается ли сущность новой, внутри метода save() вызывается либо entityManager.persist(), либо entityManager.merge().

if (entityInformation.isNew(entity)) {
	entityManager.persist(entity);
	return entity;
} else {
	return entityManager.merge(entity);
}

Откуда метод save() узнает, новая сущность или нет? По умолчанию он ищет поле, аннотированное @Id.

@Id
private String id;

Если id установлено, это означает, что сущность не новая. Если id равно null, тогда будет выполнен метод persist().

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

public void execute(String bankTransferId,
                	String reference,
                	String senderId,
                	String receiverId,
                	Amount amount) {

	Account sender = accountRepository.findByIdOrThrow(senderId);
	Account receiver = accountRepository.findByIdOrThrow(receiverId);
	BankTransfer bankTransfer = new BankTransfer(bankTransferId, reference, sender, receiver, amount);
	bankTransferRepository.save(bankTransfer);
	// bankTransfer.settle();
}

Поэтому Spring Data рассуждает следующим образом: “О, это сущность, которая, как утверждает callee, уже существует! Однако мне надо удостовериться, существует ли она на самом деле. Поэтому я сделаю SELECT в базу данных, чтобы убедиться, есть ли там эти данные.” Именно так и появился тот длинный SELECT, который поставил нас в тупик и существенно замедлил работу нашего приложения.

Эту ситуацию можно исправить посредством добавления колонки version, и это лишь один из способов. 

@Version

private Long version;

Как только колонка version добавлена, метод entityInformation.isNew() начинает рассуждать следующим образом: “окей, я посмотрю на version, и если version равно null, тогда это новая сущность”:

Но это, конечно, означает, что мы должны добавить колонку к базе данных. Ничего плохого тут нет, потому что версия — это очень удобная вещь, например, когда вы хотите сделать optimistic locking. 

Альтернатива состоит в том, чтобы реализовать интерфейс Persistable.

public interface Persistable<ID> {

	/**
	 * Returns the id of the entity.
	 *
	 * @return the id. Can be {@literal null}.
	 */
	@Nullable
	ID getId();

	/**
	 * Returns if the {@code Persistable} is new or was persisted already.
	 *
	 * @return if {@literal true} the object is new.
	 */
	boolean isNew();
}

Этот интерфейс содержит метод isNew(), где мы можем задать свою собственную кастомизированную логику, чтобы сообщить Spring Data, следует ли считать эту сущность новой или нет и, соответственно,  вызывать ли persist() или merge()

Для нашего примера можно ограничиться использованием колонки version. Что еще мы можем сделать, чтобы улучшить наши результаты?

Если посмотреть на исходный код повнимательнее, можно увидеть, что у класса RegisterBankTransferUseCase нет аннотации @Transactional

Это имеет определенные последствия. Поэтому, когда мы вызываем репозиторий accountRepository, чтобы получить сущность sender, происходит обращение к базе данных, но кроме того, поскольку мы используем JPA, создается сессия Hibernate, она загружает сущность, и после этого сессия Hibernate заканчивается, так что сущностью никто не управляет. То же самое происходит, когда мы получаем receiver. И затем мы хотим выполнить persist() на сущности BankTransfer, с использованием в качестве аргументов двух сущностей, которыми никто не управляет, так что Spring Data опять необходимо убедиться, что эти две сущности реально существуют, и поэтому мы получаем еще два SELECT-а.

Account sender = accountRepository.findByIdOrThrow(senderId);
Account receiver = accountRepository.findByIdOrThrow(receiverId);
BankTransfer bankTransfer = new BankTransfer(bankTransferId, reference, sender, receiver, amount);

Если добавить аннотацию @Transactional и прогнать этот тест снова, останется только два SELECT-а и один INSERT, как мы и ожидали в самом начале. 

Возможны ли дальнейшие улучшения для данного сценария?

Ответ — да! Мы можем избавиться от всех SELECT запросов и оставить только INSERT. 

Потому что если вы посмотрите на то, что происходит с этими Account объектами, то увидите, что ничего реально полезного не происходит. Мы загружаем их только чтобы угодить JPA, ради следующих отношений:

@ManyToOne
private Account sender;

@ManyToOne
private Account receiver;

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

Репозиторий AccountRepository не является стандартным CRUD репозиторием, он на самом деле расширяет JpaRepository:

public interface AccountRepository extends JpaRepository<Account, String> {

	default Account findByIdOrThrow(String id) {
    		return findById(id).orElseThrow();
	}
}

то есть является “специализированным” репозиторием только для JPA. JpaRepository имеет один интересный метод, который называется getReferenceById(). И getReferenceById() — это хитрый трюк, позволяющий избежать появления каких-либо запросов к базе данных.

Вместо этого он создает своего рода прокси-объект с нужным нам id, который служит именно для того, чтобы получить reference. 

@Transactional
public void execute(String bankTransferId,
                	String reference,
                	String senderId,
                	String receiverId,
                	Amount amount) {

	Account sender = accountRepository.getReferenceById(senderId);
	Account receiver = accountRepository.getReferenceById(receiverId);
	BankTransfer bankTransfer = new BankTransfer(bankTransferId, reference, sender, receiver, amount);
	bankTransferRepository.save(bankTransfer);
	//bankTransfer.settle();
}

Если теперь перезапустить тест, останется только один INSERT. 

У такого трюка есть и побочный эффект, потому что прежде у нас был метод findByIdOrThrow(), что означает, что если бы этот счет не существовал, мы получили бы соответствующее исключение. При использовании getReferenceById(), если такой счет не существует, операция потерпит неудачу только после вызова save(). Однако, всеми этими проблемами можно управлять. Идем дальше.

У нас есть другой сценарий использования, в котором банковский перевод финализируется методом settle()

@Transactional
public void execute(String bankTransferId) {
  BankTransfer bankTransfer = bankTransferRepository.findByIdOrThrow(bankTransferId);
  bankTransfer.settle();
  bankTransferRepository.save(bankTransfer);
}

Давайте запустим его, чтобы посмотреть, какие запросы будут выполнены. Должен быть один SELECT, затем метод settle() просто меняет состояние, и затем мы вызываем save() и это должно привести к появлению UPDATE. Именно это и происходит. Приятный сюрприз.

Однако, мы снова получаем очень длинный SELECT, который берет информацию из bank_transfer, затем выполняет join с account, чтобы получить receiver, еще один, чтобы получить sender. Это происходит потому, что эти записи о банковских счетах помечены аннотацией @ManyToOne, которая по умолчанию дает FetchType.EAGER. Это означает, что каждый раз, когда нам нужен банковский перевод, относящиеся к нему счета тоже загружаются полностью.

В данном случае загрузка таких агрегированных данных может иметь смысл с точки зрения бизнес-логики. Но проблема состоит в том, что иногда случаются и более сложные случаи EAGER загрузки. Посмотрим на еще один пример. У нас есть   BankTransferController, и этот контроллер ищет все банковские переводы по senderId.

@RestController
public class BankTransferController {
	private final BankTransferService bankTransferService;
  
	public BankTransferController(BankTransferService bankTransferService) {
    		this.bankTransferService = bankTransferService;
	}

	@GetMapping("bank-transfers")
	List<BankTransferDto> getBankTransfers(@RequestParam String senderId) {
    		return bankTransferService.findBySenderId(senderId);
	}
}

Имеется также репозиторий:

@Component
public class BankTransferService {

	private final BankTransferRepository bankTransferRepository;

	public BankTransferService(BankTransferRepository bankTransferRepository) {
    		this.bankTransferRepository = bankTransferRepository;
	}

	@Transactional
	public List<BankTransferDto> findBySenderId(String senderId) {

    		return bankTransferRepository.findBySenderId(senderId)
        		.stream()
        		.map(it -> new BankTransferDto(
            		it.getId(),
            		it.getAmount(),
            		it.getReceiver().getId(),
            		it.getSender().getId()
        		))
        		.toList();
	}
}

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

Что мы можем ожидать от такого кода в плане SQL запросов? Здесь должен быть один запрос для загрузки из bank_transfer, и затем будет выполнен JOIN и запрос по sender_id. Будет загружаться account на стороне sender, только для того, чтобы мог отработать оператор WHERE. В дальнейшем для каждого элемента в списке найденых переводов будут выполняться еще по два запроса, так что у нас в конце концов появляется ситуация, когда чем больше результат выполнения первоначального запроса, тем больше запросов мы выполняем по сумме. Это называется проблема N+1. Она давно известна и может полностью убить производительность вашего приложения, даже когда в системе нет большого трафика. Способ исправить эту ситуацию — поменять маппинг. Когда у нас есть отношения @ManyToOne или @ManyToMany, необходимо всегда менять FetchType на LAZY. Кроме того, SQL запросы лучше писать самим и указывать все нужные нам данные в явном виде, чтобы не загружать лишнего. Пример такого кода приведен ниже.

public interface BankTransferRepository extends ListCrudRepository<BankTransfer, String> {

	default BankTransfer findByIdOrThrow(String id) {
    	return findById(id).orElseThrow();
	}

	@Query("from BankTransfer bt join fetch bt.sender join fetch bt.receiver where bt.sender.id = :senderId")
	List<BankTransfer> findBySenderId(String senderId);
}

Теперь при прогоне теста у нас останется только один запрос, как и ожидалось с самого начала. 

Немного о @DynamicUpdate

Теперь вкратце расскажем об аннотации @DynamicUpdate.  Когда у вас появляется этот сценарий использования, SettleBankTransferUseCase, мы обновляем только состояние.

public class SettleBankTransferUseCase {

	private final BankTransferRepository bankTransferRepository;

	public SettleBankTransferUseCase(BankTransferRepository bankTransferRepository) {
    		this.bankTransferRepository = bankTransferRepository;
	}

	@Transactional
	public void execute(String bankTransferId) {
    		BankTransfer bankTransfer = bankTransferRepository.findByIdOrThrow(bankTransferId);
    		bankTransfer.settle();
    		bankTransferRepository.save(bankTransfer);
	}
}

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

Это может быть проблемой, а может и не быть. Если мы хотим сказать Hibernate — просто обнови то, что необходимо обновить — и в целом просто написать запрос, который мы написал бы сами, если бы просто писали SQL, мы можем пометить эту сущность аннотацией @DynamicUpdate. И тогда оно не только будет отслеживать, изменилась ли сущность, но также какие именно поля изменились. 

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

Комментарий от команды Spring АйО

На деле, если вы мапите свои поля в сущности на CLOB/JSON или просто довольно большой текст, то, как правило, имеет смысл вообще поставить @Basic(fetch = LAZY) аннотацию, чтобы данное поле грузить только по мере надобности. Поле, которое загружается лениво и которое не было загружено в рамках текущей сессии тоже не будет обновлено в рамках dynamic update. 

Однако, для ленивой подгрузки @Basic полей Hibernate-у необходимо модифицировать байткод. Сделать это можно с помощью подключения плагина:

https://docs.jboss.org/hibernate/orm/7.0/userguide/html_single/Hibernate_User_Guide.html#tooling-maven-enhancement

Иногда аннотация @DynamicUpdate может быть очень удобной. 

Суммируя сказанное

Чтобы не замедлять наши приложения лишними запросами к базе данных:

  • Избегаем появления SELECT перед INSERT при помощи аннотации @Version или кастомизированной реализации Persistable.

  • Используем getReferenceById(), когда нам достаточно ссылки на энтити.

  • Всегда используем FetchType.LAZY для @ManyToOne и @ManyToMany.

  • Явным образом загружаем ленивые поля и коллекции с помощью fetch join или EntityGraph

  • Используем @DynamicUpdate для таблиц с большим количеством колонок.

Продолжение следует. 

Комментарий от команды Spring АйО

Статью хочется дополнить, отметив, что @Version и реализация Persistable - не самые часто используемые способы не делать select перед insert. Select не будет сделан, если у энтити есть идентификатор, генерируемый на стороне приложения, равный null. То есть идентификатор это, например UUID с указанием, что он делается генератором или целое число, следующее значение которого Hibernate берёт из Sequence.

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

DynamicUpdate - штука не бесплатная и в случае с JSON её уместно использовать, если вы всегда читаете JSON поле. Если вы читаете его в редких случаях, то лучше сделать его ленивым. Basic поле сразу сделать ленивым не получится, для этого надо будет подключить библиотеку.


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

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


  1. JavaWizard
    26.06.2025 17:01

    На сколько помню там ещё от стратегии идентификатор тоже зависит если он uuid, не помню как называется и там если идентификатор не указан то будет инсерт а если указан то апдейт

    И с энтити граф ещё одна проблема может возникнуть если сортировать по этим сущностям то сортировку хибер начнёт делать в памяти, т. Е. Дёргает всю бд и сортирует, слава богу об этом ворнинг летит... Лучше всего лейзи листы всегда вытягивать подзапросом, а в некоторых командах даже принимают правило защиты от дурака: запрещают указывать onetomany


  1. Slobodator
    26.06.2025 17:01

    Ох, парни, что ж вы такое переводите!

    Суммируя сказанное

    Это была рубрика "вредные советы", ничего из перечисленного в статье делать не надо. В хибернейте разумные дефолты (OSIV не в счёт, это Spring Data JPA, а не сам хибер), что-то tweak-ать надо с полным пониманием.

    Кто такой вообще этот Мацей Валковяк и почему его постят?

    Поехали разбираться.

    Поэтому Spring Data рассуждает следующим образом: “О, это сущность, которая, как утверждает callee, уже существует! Однако мне надо удостовериться, существует ли она на самом деле. Поэтому я сделаю SELECT в базу данных, чтобы убедиться, есть ли там эти данные.”

    Чушь! Это не так.

    Во-первых, Spring Data навязал репозиторный метод save() в том числе и для Spring Data JPA (для единообразия), оказав тем самым медвежью услугу. В абсолютном большинстве случаев нужны em.persist() для новых entities и "ничего" для изменения managed ones.

    Хорошо бы понимать, для чего em.merge() (спойлер -- в нормальных кейсах он не нужен) и какой у него сайд-эффект при вручную установленных ID, вот эта самая лишняя загрузка.

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

    Скорость работы селекта определяется не его длиной)

    Лишний селект, это, конечно, никогда не хорошо, но в данном конкретном случае -- join по primary key -- существенно замедлить работу не должен.

    Альтернатива состоит в том, чтобы реализовать интерфейс Persistable.

    Вот такого точно не надо, пожалуйста.

    И @Version тоже добавляйте для Optimistic Lock, а не для каких-то других целей.

    Смущает лишний select при "сохранении" энтити с предустановленными ID -- просто используйте em.persist()!

    добавить аннотацию @Transactional

    Ну, хорошо что про это всё таки вспомнили, хорошо бы с этого начать, а заодно понимать, как оно до этого работало.

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

    И как управлять-то?

    Прежде чем "ускорять" приложение с помощь getReferenceById() хорошо бы понимать трейд-офф между ним и обычным findById(id).orElseThrow(...).

    Обычный findById делает запрос в БД и кидает указанный экспешн, который -- обычная практика -- переоборачивается в 400 или 404.

    getReferenceById() ничего этого не делает, id используется как есть в надежде, что есть foreign key. Парсить ForeignKeyConstraintViolationException тоже можно (а возможно и нужно в сильно конкуретной среде), но строить на нём BadRequest/ResourseNotFoundException несколько сложнее.

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

    Почему это не очень хороший код?! Вот как раз правильно сделано!

    > В дальнейшем для каждого элемента в списке найденых переводов будут выполняться еще по два запроса, так что у нас в конце концов появляется ситуация, когда чем больше результат выполнения первоначального запроса, тем больше запросов мы выполняем по сумме. Это называется проблема N+1.

    Откуда тут N+1?! Видимо, автор запутался в своих тестах и получил N+1, как раз когда поставил `@ManyToOne(fetch = LAZY)`

    Когда у нас есть отношения @ManyToOne или @ManyToMany, необходимо всегда менять FetchType на LAZY

    Не нужно.
    Во-первых, @ManyToMany и так LAZY по дефолту.

    А что касается исправления @ManyToOne на LAZY то, опять же, надо понимать трейд-офф.

    C EAGER мы `left join`-ом загружаем данные всегда (как, собственно, автор и указал вначале)

    from bank_transfer btl_0
    left join account r1_0 on r1_0.id = btl_0.receiver_id
    left join account s1_0 on s1_0.id = btl_0.sender_id

    Они могут нам понадобиться для бизнес-логики/маппинга в ДТО, а могут и не понадобиться. Если у нас инкапсулированная модель, то в общем случае мы этого заранее не знаем, и знать не можем.

    JOIN по первичному ключу, естественно, не бесплатен, но, будем считать, относительно дешёв.

    В противном случае, с `@ManyToOne(fetch = LAZY)` на любом запросе коллекции энтити как раз-то и есть риск получать N+1 проблему. Обычно это существенно медленнее, и поэтому дефолтное значение как раз EAGER.

    Короче говоря, дефолтные `@ManyToOne(fetch = EAGER)` и `@OneToMany(fetch = LAZY)` -- это нормально и менять их не надо.

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

    Обычно лучше не писать. Но вообще это философский спор между инкапсуляцией и быстродействием.

    когда мы выполняем UPDATE, PostgreSQL в любом случае вставляет полностью новую колонку.

    СТРОКУ, а не колонку!

    Select не будет сделан, если у энтити есть идентификатор, генерируемый на стороне приложения, равный null

    А, вижу, добавили. Может, поднимите наверх, где "проблема" обсуждается и спойлер уберите, чтобы сразу в глаза бросалось?

    @DynamicUpdate

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

    Самое время задуматься, чем руководствовались разработчики Хибернейта, и почему сделано именно так. Разобраться с `JDBC PreparedStatement` и кешем запросов.

    По мелочи.

    Бизнес-конструкторы -- это хорошо и правильно, пустой конструктор нужен хибернейту, но его лучше сделать protected, чтобы запретить вызывать из кода.


  1. Slobodator
    26.06.2025 17:01

    Теперь правильные советы.

    Основные причины "тормозящего" хибернейта -- это, конечно, не сам хибернейт, а разработчики вокруг него.

    Основные проблемы:

    1. Отсутствие индексов!

      Если для @ManyToOne primary key забыть невозможно, то вот для

      class Parent {
        @OneToMany(mappedBy = "parent")
        List<Child> children;
      }

      ... parent_id в таблице children -- вполне. Проверьте!

    2. Явно установленный EAGER для `@OneToMany(fetch = EAGER)`. То ли для борьбы с LazyInitializationException, то ли для "загрузки" данных, то ли ещё для чего-то. Так делать не надо, источник N+1 проблемы. Правильно: маппить энтити в responseDto.

    3. Переопределённый `@ManyToOne(fetch = LAZY)`. То ли после прочтения этой статьи, то ли аналогичных. Тоже потенциальный источник N+1 проблемы. Можно менять только полном понимании всех трейдоффов.


  1. Slobodator
    26.06.2025 17:01

    Отдельный комментарий для "зачем нужен это ваш JPA/Hibernate, одни только проблемы, лучше возьмите JOOQ/Spring JDBC/учите чистый SQL".

    Hibernate нужен вот для этого, соберусь с силами -- разверну в статью.