Я часто видел, как в проекте начинали использовать Hibernate, не особо задумываясь о том, действительно ли он нужен. А через какое-то время, когда сервис разрастался, то появлялся вопрос — не было ли это ошибкой.
Давайте порассуждаем о плюсах и минусах Hibernate в целом, чтобы в следующий раз добавлять его в новый микросервис осознанно. Возможно, имеет смысл обойтись простым Spring JDBC без всей сложности JPA?
Куча неочевидных вещей
Hibernate может выглядеть как тонны аннотаций, которые волшебным образом делают выборки из базы данных и сохраняют объекты. Нам только нужно правильно расставить аннотации @ManyToMany, @OneToMany и другие.
Если копнуть глубже, то многие программисты на собеседованиях не могут точно сказать, как работает их приложение. Например, в приведенном ниже коде метод save()
явно нигде не вызывается, но изменения в базе данных сохраняются.
Сущность будет сохранена в БД:
@Transactional
public void processSomething(long accId) {
Account acc = accRepo.findById(accId).orElseThrow(RuntimeException::new);
acc.setLastName("new name");
}
Я с трудом понимаю такой код. Ревьюеры тоже потратят много времени на выснение того, что здесь происходит. И времени на исправление багов вы тоже потратите много.
Проблемы с ленивой загрузкой
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "company_id")
private Company company;
Другая проблема — использование ленивой загрузки (Lazy fetch) в транзакциях. Кажется неплохим решением добавить к сущности lazy-поле и обратиться к нему в транзакции для получения данных.
Но затем добавляется еще одно поле, и еще одно. И скорее всего, нам не потребуются все данные, кроме 1-2 полей нескольких объектов. А в результате приложение отправит 5–10 запросов к базе данных и получит все связанные объекты, хотя вместо этого можно написать один SELECT, запрашивающий только необходимые данные.
Также если доступ к полям происходит вне сервиса (и транзакции), то в коде могут появиться различные странные конструкции, которые передают привет ревьюверам кода. Странная конструкция при выборке lazy-объекта:
@Transactional(readOnly = true)
public Account getAccountWithCompany(long id) {
Account acc = accRepo.findById(id).orElseThrow(RuntimeException::new);
acc.getCompany().getName();
return acc;
}
Казалось бы, можно использовать Eager fetching или @EntityGraph. Но Eager повлияет на другой код, а @EntityGraph требует отдельного запроса (а если нам его нужно писать, то зачем нужен hibernate?).
Проблема N+1
Не так уж и просто написать с hibernate пакетную вставку данных. Не забудьте:
1.Добавить в application.yml свойство
spring.jpa.properties.hibername.jdbc.batch_size: 50
2. Создать последовательность, увеличивающуюся на размер пакета (batch)
CREATE SEQUENCE account_id_seq START 1 INCREMENT BY 50;
3.Настроить Sequence Generator на получение сразу нескольких значений последовательности, иначе hibernate будет обращаться к последовательности N раз.
@Entity
public class Account {
@Id
@GenericGenerator(
name = "account_id_generator",
strategy = "org.hibernate.id.enhanced.SequenceStyleGenerator",
parameters = {
@Parameter(name = "sequence_name", value = "account_id_seq"),
@Parameter(name = "increment_size", value = "50"),
@Parameter(name = "optimizer", value = "pooled-lo")
})
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "account_id_generator")
private Long id;
...
О sequence generator часто забывается, что полностью сводит на нет всю оптимизацию пакетной вставки.
Что действительно удобно в Hibernate, так это каскадная пакетная вставка (cascading batch insert), если все правильно настроено. Hibernate сначала запрашивает идентификаторы, что позволяет присвоить внешние ключи для каскадного сохранения связанных сущностей. Но вы можете реализовать то же самое и без Hibernate, используя такой же подход.
Не забудьте использовать @BatchSize
для массовой выборки объектов со связями @ManyToMany
или @OneToMany
. В противном случае будет выполнен N + 1 запрос.
Если вы все же решили использовать Hibernate, то я рекомендую использовать в тестах библиотеку QuickPerf, чтобы точно знать, сколько запросов выполняется к базе данных.
Использование QuickPerf для проверки количества SELECT:
@Test
@ExpectSelect(2)
public void shouldSelectTwoTimes() {
...
}
Кэш второго уровня
Напоследок стоит упомянуть кеш второго уровня в Hibernate. Если вы используете стандартную реализацию Ehcache, то при масштабировании приложения разные экземпляры будут содержать разные данные.
В результате ответ сервиса может зависеть от того, к какому экземпляру поступил запрос.
Чтобы этого избежать, вероятно, лучше сразу начать использовать Spring Cache. Тогда при масштабировании достаточно будет подключить распределенную реализацию (Redis, Hazelcast, Apache Ignite и т. д.).
Напомню также, что при использовании L2-кэша по-прежнему для каждого запроса происходит получение соединения к базе данных.
Получение соединения JDBC даже для кэшированных сущностей:
2021-07-26 20:20:44.479 INFO 55184 --- [ main] i.StatisticalLoggingSessionEventListener : Session Metrics {
4125 nanoseconds spent acquiring 1 JDBC connections;
0 nanoseconds spent releasing 0 JDBC connections;
0 nanoseconds spent preparing 0 JDBC statements;
0 nanoseconds spent executing 0 JDBC statements;
0 nanoseconds spent executing 0 JDBC batches;
0 nanoseconds spent performing 0 L2C puts;
12333 nanoseconds spent performing 1 L2C hits;
0 nanoseconds spent performing 0 L2C misses;
0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}
Заключение
Я не хочу сказать, что вы никогда не должны использовать Hibernate. В некоторых случаях Hibernate может быть полезен. Но вы всегда должны думать о его использовании на ранних этапах проекта, чтобы не сожалеть об этом в будущем.
Материал подготовлен преподавателем курса «Highload Architect» Александром Коженковым. Если вам интересно узнать подробнее о формате обучения и программе курса, познакомиться с преподавателем — приглашаем на день открытых дверей онлайн. Регистрация здесь.