Как стать автором
Обновить

Hibernate, JPA, N+1 и лишние запросы в БД

Уровень сложностиСредний
Время на прочтение9 мин
Количество просмотров3.6K

Введение

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

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

Down the rabbit hole

Проблема N+1 характерна для разных языков программирования. В данной статье, эта проблема будет рассматриваться в контексте Spring Boot и Hibernate. N+1 проявляется в том, что вместо одного запроса в базу на N элементов, происходит N запросов в базу +1 целевой. Формально, для лучшего представления, следовало бы записать 1+N, поскольку дополнительные N запросы происходят после основного, однако это не существенно. Концептуально, первый запрос получает из базы список элементов, а далее, для каждого элемента списка происходит дополнительный запрос в БД.

На примере это выглядит так: допустим, модель данных тестового приложения состоит из двух объектов: Автор (author), и Книга (book). Создадим базу данных и необходимые классы Entity:

Сущности:

@Entity
@Table
@Getter
@Setter
public class Author {
    @Id
    private Long id;
    private String name;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "author")
    List<Book> books;
}
@Entity
@Table
@Getter
@Setter
public class Book {
    @Id
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private Author author;
}

Добавим в приложение возможность для пользователя, запрашивать страницу со списком книг. Для этого создадим репозиторий (для запроса в БД), объекты ДТО (для формирования json ответа), примитивные мапперы (для создания ДТО из сущностей) и контроллер с набором апи, а затем добавим в базу 5 книг и 5 авторов.

Метод в репозитории:

...
    @Query("select b from Book b")
    List<Book> getBooks();
...

ДТО:

@Setter
@Getter
public class AuthorDto {
    private Long id;
    private String name;
}
@Setter
@Getter
public class BookDto {
    private Long id;
    private String name;
    private AuthorDto author;
}

Мапперы:

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class AuthorMapper {
    public static AuthorDto map(Author author){
        AuthorDto res = new AuthorDto();
        res.setId(author.getId());
        res.setName(author.getName());
        return res;
    }
}
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class BookMapper {
    public static BookDto map(Book book){
        BookDto res = new BookDto();
        res.setId(book.getId());
        res.setName(book.getName());
        res.setAuthor(AuthorMapper.map(book.getAuthor()));
        return res;
    }
}

В контроллер, для начала добавим такой эндпойнт:

...
    @GetMapping("/page")
    public List<BookDto> getBook(){
        System.out.println("до...");
        List<Book> books = bookRepository.getBooks();
        System.out.println("после...");

        return books.stream()
                .map(BookMapper::map)
                .collect(Collectors.toList());
    }
...

Этот эндпойнт будет получать из базы данных все книги, создавать из них список DTO объектов и отдавать пользователю.

Теперь, когда все готово, можно протестировать наше небольшое приложение. Вызовем созданный эндпойнт с помощью Postman. При запросе, в программе VisualVM (рисунок 1), мы можем увидеть, что в базу данных было сделано 1+5 запросов вместо одного. Первый запрос достал из базы все 5 книг. Последующие 5 запросов, для каждой отдельной книги запрашивали в БД информацию по связанному автору.

рисунок 1
рисунок 1

Если б книг было 7, то запросов было бы 1+7 и так далее. В целом, проблема 1+N или вернее N+1, проявляется именно так как мы ожидали. Однако если мы, помимо автора добавим в объект Книга еще одно поле, например Хозяин книги (Owner) и добавим хозяев только трем книгам, мы увидим дополнительные запросы на получение каждого отдельного собственника:

рисунок 2
рисунок 2

При том же запросе для списка из 5 или 7 книг, мы увидим уже не 1+5 и 1+7, а 1+5+X и 1+7+X запросов в базу, где X это дополнительное число запросов в таблицу owners. В этом месте, классическое определение проблемы N+1, перестает в полной мере отражать проблему. Еще больше это проявится, если у всех книг будет один автор, тогда пресловутая N+1 начинает превращаться в 1+Y+X, где Y это количество дополнительных запросов на получение авторов, а X это количество дополнительных запросов на получение собственников. В случае, если все пять книг будут принадлежать перу одного автора, тот же самый метод репозитория вернет уже 1+1+3 запроса в БД. Вот как это выглядит: первый запрос возвращает список из 5 книг, а последующие запросы возвращяют одного автора и трех собственников книг. Ниже указаны запросы в порятке затраченного на запрос времени:

рисунок 3
рисунок 3

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

...
    @GetMapping("/page/count")
    public Integer getCounter(){
        System.out.println("до...");
        List<Book> books = bookRepository.getBooks();
        System.out.println("после...");

        return books.size();
    }
...

Такой эндпойнт вернет цифру 5, то есть общее количество книг, при этом в базу данных будет сделан только один запрос.

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

Добавим еще один эндпойнт:

...
    @GetMapping("/page/sum")
    public Long getSum(){
        System.out.println("до...");
        List<Book> books = bookRepository.getBooks();
        System.out.println("после...");

        Long sum = books.stream()
                .mapToLong(b -> b.getAuthor().getId())
                .sum();

        return sum;
    }
...

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

рисунок 4
рисунок 4

При этом запрос в базу будет сделан только один:

рисунок 5
рисунок 5

Лишние запросы появятся только в тот момент, когда мы попробуем обратиться к любому не @Id полю связанной сущности.

Получается один и тот же метод, вызывает N+1, не из-за того, что в базу данных улетает неправильный запрос или в запросе не хватает джойнов (join), а из-за того, что полученный из БД набор данных, по разному обрабатывается на уровне бизнес логики. Отсюда, можно сделать вывод, что проблема N+1 не является независимой, изолированной проблемой, она является симптомом, следствием того, что Hibernate proxy класс, на лету инициализирует объекты по мере необходимости.

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

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class AuthorMapper {

    // метод всегда будет приводить к N+1
    public static AuthorDto map_N_PlusOne(Author author){
        AuthorDto res = new AuthorDto();
        res.setId(author.getId());
        res.setName(author.getName());
        return res;
    }

    // метод никогда не приведет к N+1
    public static AuthorDto map_NoExtraRequests(Author author){
        AuthorDto res = new AuthorDto();
        res.setId(author.getId());
        return res;
    }
}

Приведем еще один пример, допустим в соответствии с бизнес требованиями тестового приложения, пользователь может запросить страницу с книгами, выбрать книгу и посмотреть по этой книге подробную информацию. Реализация задачи предполагает обновление модели данных. У Книги, помимо Автора появится 5 полей связанных сущностей, не принципиально какие это поля, например: Жанр (genre), Хозяин (owner), Комментарии (comments), Картинки (pictures), Награды (awards). Для всех связанных полей будет указана стратегия извлечения FetchType.Lazy.

При запросе страницы с книгами, мы будем показывать пользователю список, в котором будет название книги и ее автор. Если пользователь выберет книгу, ему необходимо предоставить полную информацию.

Добавим в репозиторий новый метод, который будет доставать из БД книгу по идентификатору, но без связанных сущностей:

...
    @Query("select b from Book b where b.id = ?1")
    Book getBookById(Long id);
...

Добавим эндпойнт для получения информации по книге, по её идентификатору:

...
    @GetMapping("/{id}")
    public BookDto getBook(@PathVariable Long id){
        Book book = bookRepository.getBookById(id);

        return BookMapper.map(book);
    }
...

Также необходимо обновить BookMapper:

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class BookMapper {
    public static BookDto map(Book book){
        BookDto res = new BookDto();
        res.setId(book.getId());
        res.setName(book.getName());
        res.setAuthor(AuthorMapper.map_N_PlusOne(book.getAuthor()));

        if (book.getGenre() != null)
            res.setGenre(GenreMapper.map(book.getGenre()));
        if (book.getOwner() != null)
            res.setOwner(OwnerMapper.map(book.getOwner()));
        if (book.getComments() != null)
            res.setComments(book.getComments().stream().map(CommentMapper::map).collect(Collectors.toList()));
        if (book.getPictures() != null)
            res.setPictures(book.getPictures().stream().map(PicturesMapper::map).collect(Collectors.toList()));
        if (book.getAwards() != null)
            res.setAwards(book.getAwards().stream().map(AwardsMapper::map).collect(Collectors.toList()));


        return res;
    }
}

Ранее мы видели, что запрос, страницы с пятью книгами, без связанной сущности Автор, приведет к появлению пяти дополнительных запросов в БД (рисунок 1). Если же мы запросим отдельно книгу со всеми связанными сущностями, мы получим еще больше дополнительных запросов в БД:

рисунок 6
рисунок 6

Создание одного такого объекта, с большим количеством связанных полей, может привести к бОльшему числу дополнительных запросов в БД, чем например страница с пятью книгами. Даже одно такое не инициализированное поле может создавать ненужную нагрузку на БД. Необходимо в целом исключить вероятность лишних запросов. Если каждый метод будет исключать возможность обращения к не инициализированным полям прокси класса, проблема N+1 никогда не появится.

Таким образом, мы приходим к выводу, что в контексте Spring и Hibernate, проблема N+1, должна определяться как проблема при которой, вместе с основным запросом в базу данных происходит любое количество дополнительных запросов, которые пользователь не инициализировал, то есть любой запрос, который пользователь явно не инициировал в репозитории, является проблеммой N+1, поскольку при использовании единого маппера, получение книги по id, по количеству и характеру дополнительных запросов, будет равнозначно количеству и характеру дополнительных запросов для массива из одного элемента.

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

Приведем пример, допустим в приложении есть два метода: getBook и getBookWithStatistic. Первый метод getBook просто отдает книгу и ее автора. Известно, что для такого метода, получить все необходимые данные возможно использованием одного вызова метода репозитория, соответственно одного запроса в БД. Любой дополнительный запрос уже является проблемой, которую нужно немедленно устранять. Во втором методе getBookWithStatistic, помимо получения книги инициируются 2 дополнительных, целевых инсерта (insert) в разные таблицы, для сохранения личной и общей статистики по конкретной книге. Таким образом для второго метода приемлемыми являются 3 целевых запроса и любой дополнительный запрос будет негативно сказываться на быстродействии.

В таком виде, очень удобно как тестировать разрабатываемые методы, так и оптимизировать существующие, например в программе VisualVM.

И еще...

Следует так же добавить один неочевидный момент. Как видно на рисунке 5, обращение к индентификаторам связанных полей не приводит к дополнительным запросам в БД. Это связано с тем, что указанные идентификаторы можно найти в таблице основной сущности, то есть основной объект Книга является дочерним по отношению к связанному объекту Автор. Другими словами, при извлечении из БД информации по книге, в этой же таблице book есть возможность считать также внешний ключ на таблицу author. Именно поэтому мы смогли сложить все идентификаторы Авторов, без дополнительных запросов в таблицу author. Мы просто достали идентификаторы из таблицы book.

Однако если связанная сущность будет дочерней, Hibernate не сможет найти в родительской таблице соответствующий идентификатор и тогда в любом случае будет сделан дополнительный запрос в БД для инициализации связанной сущности. То есть, поскольку объект Автор является родительским по отношению к объекту Книга (в таблице author нет информации о том какие таблицы на нее ссылаются), при попытке достать из базы Автора и его книги мы всегда будем получать дополнительные запросы в БД, даже если захотим получить только идентификаторы книг.

Заключение

В контексте Spring Boot и Hibernate, N+1 представляет собой проблему, при которой в БД помимо одного, целевого запроса улетают N дополнительных, не инициированных пользователем запросов.

При такой постановке вопроса, немного парадоксальной, но самой эффективной стратегией поиска этой проблемы, будет стратегия при которой для каждого отдельного метода, целевое количество вызовов репозитория, будет обозначать максимальное число N, а каждый дополнительный вызов будет являться проблемой N+1.

Теги:
Хабы:
+6
Комментарии7

Публикации

Работа

Java разработчик
207 вакансий

Ближайшие события