Використання та реалізація server-side pagination. Частина 1

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Всім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник хочу поділитися з вами своїм досвідом роботи з такою цікавою темою, як посторінковий вивід даних у застосунках, що використовують ORM-технології. Він застосовується практично в будь-якому застосунку, що має UI, але не завжди застосовується раціонально чи оптимально.

Я ж хочу детально розібрати цю тему на прикладі декількох ORM-технологій, які використовуються в Java-застосунках. Також я розберу основні типи pagination, особливості їх застосування та способи реалізації. Ну, і за останні роки у нас накопичилося достатньо досвіду роботи з таким підходом, і ми розглядаємо ці технології на тренінгах з Spring MVC та Spring Data. Сподіваюся, що ця стаття буде корисною для всіх, хто займається роботою з базами даних та ORM-системами.

Що таке посторінковий вивід даних

Думаю, що pagination відома і знайома будь-якому користувачеві інтернету, не обов’язково програмісту:

Якщо даних так багато, що вони не вміщаються на екрані, для зручності користувача ми розбиваємо їх на групи (сторінки) однакового розміру і виводимо тільки поточну (активну) сторінку. При цьому:

  1. Користувач повинен мати можливість навігації та переходу на інші сторінки (у тому числі і на наступну/ попередню).
  2. Має бути можливість побачити загальну кількість сторінок.
  3. Опціонально можна запропонувати користувачеві змінювати кількість записів на сторінці.

Для такого відображення зазвичай використовують спеціальний компонент UI, званий Paginator. Хоча на багатьох сучасних сайтах він відсутній, нові дані автоматично підвантажуються в процесі скролінгу поточної вебсторінки. Для реалізації такого компонента зазвичай застосовують два підходи:

  • У разі клієнтського pagination сервер віддає всі дані клієнту, який сам розбиває їх на сторінки. Цей варіант підходить при невеликій кількості даних і дуже популярний через його простоту реалізації як на бекенді, так і на фронтенді. Але клієнтський застосунок повинен буде зберігати в пам’яті всі сторінки, навіть ті, котрі зараз не відображаються.
  • При серверному pagination клієнт запитує дані лише однієї (поточної) сторінки. Це дозволяє мінімізувати навантаження на БД та мережевий трафік, але ускладнює реалізацію та комунікацію між клієнтом та сервером.

Про другий варіант і йтиметься у цій статті. Але перш, ніж приступити, слід визначитися з тим, що таке серверний pagination. Якщо це типове рішення якоїсь технічної проблеми, то за визначенням це design pattern. Дивно, але тут немає якоїсь спільної думки. Мартін Фаулер у своїй статті про Enterprise design patterns не згадує його. В багатьох статтях посторінковий вивід взагалі характеризується як «техніка» або «best practice», з чим можна погодитися. Але якогось офіційного статусу як design pattern я не зустрічав.

Отже, нам потрібно створити на бекенді API, який зможе видавати дані посторінково. Як це реалізувати? Таке рішення можна розбити на дві складові:

  • створення API;
  • реалізація завантаження даних із зовнішнього сховища (бази даних).

При цьому в силу його популярності скоріше за все будуть використовуватися RESTful вебсервіси, які накладають певні обмеження, зокрема, stateless характер взаємодії клієнта і сервера.

Використовуємо зміщення елементів

Найпростіший варіант — передача як параметри запиту індексу поточної сторінки та кількості записів на сторінці:

GET /orders?page_index=10&page_size=20

Такий підхід називається page-based pagination. Сервер в результаті формує запит до БД та завантажує лише вказану сторінку (frame). Оскільки перший елемент будь-якої сторінки має зміщення (offset) щодо початку загального списку даних, то можна використовувати інший підхід, передаючи зміщення (або індекс) першого елемента, який ми хочемо отримати:

GET /orders?offset=100&limit=20

Такий підхід називає offset-based pagination. Дуже важливо розуміти, що їхня відмінність тільки в API для клієнтських запитів, реалізація буде ідентична. У будь-якому випадку всі підходи, що розглядаються, поєднує те, що ми обов’язково передаємо кількість потрібних нам елементів, а відмінність буде в тому, як передавати позицію (або індекс) першого елемента.

Перейдемо до реалізації. Ми не будемо розглядати JDBC, тому що це досить низько-рівневий підхід, який зараз мало використовується. Почнемо з JPA (або Jakarta Persistence). У ньому відсутня пряма підтримка pagination, але є інтерфейс Query, де з самого початку присутні два дуже корисні методи:

  • setMaxResults — вказує максимальну кількість записів для повернення (за замовчуванням Integer.MAX_VALUE);
  • setFirstResult — вказує позицію (або усунення) першого запису, який потрібно повернути (за замовчуванням нуль).

Щоб розібратися, як це працює, перевіримо роботу JPA на практиці. Створимо запис PageRequest:

public record PageRequest(int page, int size) {}

та утилітний метод findPageable:

       public <T> List<T> findPageable(String jpql, PageRequest page, Class<T> clz) {
              TypedQuery<T> query = em.createQuery(jpql, clz);
              query.setFirstResult(page.page() * page.size());
              query.setMaxResults(page.size());
              return query.getResultList();
       }

Якщо виконати цей запит, він призведе до такого SQL, який буде згенерований нашим JPA-провайдером (в цьому випадку Hibernate 6):

select * from ORDERS o1_0 offset ? rows fetch first ? rows only

Тут є два цікаві ключові слова:

  • offset — вказує усунення першого запису, яку сервер БД повинен повернути з того результату, яку він отримав. Тобто за фактом це аналог методу skip у Streams API, оскільки сервер має проігнорувати перші N записів із результату;
  • rows fetch first — цей набір операторів може деяких здивувати, оскільки вони звикли до ключового слова limit. Понад те, ранні версії JPA/Hibernate використовували limit, а не fetch. У чому між ними різниця? Хоча зустрічається пояснення, що вони еквівалентні, але це зовсім так. По-перше, limit не входить до стандарту SQL, а fetch увійшов до SQL 2008. Крім того, не гарантується, що з limit будуть повернуті перші N записів результату (хоча швидше за все так і буде). Тому грамотніше використовувати fetch, тим більше що він вже підтримується такими СУБД як Oracle, Postgres, DB2, SQL Server, H2 і Derby.

Тут може виникнути закономірне питання. А що, якщо поточний сервер БД не підтримує конструкцію fetch first (як наприклад, MySQL)? Чи він взагалі не підтримує pagination (теоретично таке може бути)?

Розробники Hibernate підстрахувалися та створили спеціальний внутрішній інтерфейс LimitHandler:

public interface LimitHandler {
 
       boolean supportsLimit();
 
       boolean supportsOffset();
 
       boolean supportsLimitOffset();

У Hibernate використовується концепція діалект-класів, які інформують Hibernate про можливості використовуваної СУБД. У кожному з них вказується та реалізація LimitHander, яка властива цій базі даних. Для MySQLDialect буде додано оператор LIMIT, для Postgres — FETCH FIRST, а ось для Sybase — TOP.

Здавалося б, все легко і просто, але лише на перший погляд. Якщо ми використовуємо SQL без додаткових налаштувань, він працюватиме досить швидко, оскільки дані завантажуватимуться у порядку, де вони перебувають у таблиці.

Уявімо реалістичнішу ситуацію, коли у нас використовується сортування або фільтрація:

SELECT * FROM ORDERS WHERE state='pending' ORDER BY createdAt DESC

У такому разі щоразу, коли ми запитуємо нову сторінку, сервер БД буде змушений виконати весь запит, отримати всі записи, відфільтрувати і відсортувати дані і тільки потім знайти потрібні елементи за індексом. У разі таблиці з мільйонами записів це призведе до дуже великого навантаження на базу даних.

Можлива інша проблема. Якщо ми отримали якусь сторінку, потім додали новий запис на цій сторінці, то при переході на нову сторінку ми отримаємо дублікати, які ми вже бачили. Аналогічно з видаленням елементів.

Є й інші проблеми. Згадаймо, що нам потрібно обов’язково повернути клієнту у першому запиті загальну кількість елементів, тому для відповіді можна використовувати такий запис:

public record PageContent<T>(List<T> rows, int totalElements) {}

Але як отримати цю загальну кількість? Якщо ми хочемо створити загальне рішення, то можна взяти оригінальний SQL-запит і використовувати на початку COUNT. Якщо спочатку у нас було:

SELECT * FROM ORDERS WHERE state='pending' ORDER BY createdAt DESC

то тепер буде:

SELECT COUNT(*) FROM ORDERS WHERE state='pending' ORDER BY createdAt DESC

Якщо придивитися до цього запиту, можна зрозуміти, що в ньому не потрібен ORDER BY, який не впливає на результат, і можна спростити запит як:

SELECT COUNT(*) FROM ORDERS WHERE state='pending'

Складніше, якщо ви використовуєте joins між таблицями для eager fetching дочірніх сутностей:

SELECT * FROM PRODUCTS p left join ORDERS o ON p.ID=o.PRODUCT_ID;

У такому разі просте використання COUNT поверне кількість замовлень (а не товарів):

SELECT COUNT(*) FROM PRODUCTS p left join ORDERS o ON p.ID=o.PRODUCT_ID;

Тому потрібно видалити join та використовувати лише таблицю PRODUCTS:

SELECT COUNT(*) FROM PRODUCTS;

Але і тут ховається каверза. Якщо ми шукаємо тільки ті товари, на які є замовлення, потрібно використовувати inner join, а в COUNT вказати не *, а стовпець з таблиці PRODUCTS:

SELECT COUNT(p.id) FROM PRODUCTS p inner join ORDERS o ON p.ID=o.PRODUCT_ID;

Таким чином, для загального випадку потрібно написати спеціальний парсер, який перетворюватиме загальний SQL запит на COUNT-запит, а це потребує додаткового ресурсів та хорошого покриття тестами. Але вихід є, якщо ви використовуватимете не чистий JPA, а Spring Data JPA.

Pagination в Spring Data JPA

У проєктах Spring Data базовим елементом є маркер-інтерфейс Repository, а його спадкоємцем — PagingAndSortingRepository:

@NoRepositoryBean
public interface PagingAndSortingRepository<T, ID> extends Repository<T, ID> {

у якому вже є повноцінна підтримка pagination:

       Page<T> findAll(Pageable pageable);

Pageable — це базовий інтерфейс для page-based pagination, у якого головна реалізація — клас PageRequest, у якому ви вказуєте всю необхідну інформацію для pagination: індекс сторінки, розмір сторінки та опціональне сортування. А Page — це об’єкт-контейнер, що містить результат (у вигляді списку) і загальна кількість записів.

Тепер для того, щоб використовувати цей метод, потрібно лише створити об’єкт Pag-eRequest:

       PageRequest pageRequest = PageRequest.of(4, 5);

І передати його в метод findAll:

       Page<Product> productPage = productRepository.findAll(pageRequest);

Цей код створить і викликає два SQL-запити (загальний та COUNT). Складніше, якщо ви вказуєте ваш власний JPQL запит для такого методу:

       @Override
       @Query(value = "SELECT p FROM Product p left join fetch p.orders "
                     + "WHERE p.active=true")
       Page<Product> findAll(Pageable page); 

До Spring Data 3.x такий запит викликав помилку:

Caused by: org.hibernate.QueryException: query specified join fetching, but the owner of the fetched association was not present in the select list

[select count(p) FROM model.Product p left join fetch p.orders WHERE p.active=true]

І доводилося явно вказувати COUNT запит в окремому атрибуті:

@Query(value = "SELECT p FROM Product p left join fetch p.orders "
       + "WHERE p.active=true", countQuery = "SELECT COUNT(p) FROM Product p WHERE p.active=true")

Але в Spring Data JPA 3 з’явився спеціальний JPQL-парсер, який може сам правильно згенерувати COUNT-запит.

Spring інженери виявили винахідливість при реалізації pagination. Якщо сервер поверне відповідь, де кількість елементів менше, ніж розмір сторінки, це означає, що отримана остання сторінка. І виклик COUNT зроблено не буде, а загальна кількість елементів буде розрахована як offset + кількість елементів на сторінці.

У Spring Data JPA, як і в будь-яких налаштуваннях, є і свої накладні витрати. Тепер будь-який виклик findAll призводить до двох SQL-запитів, навіть якщо нам це не потрібно. Коли ми використовували JPA, ми самі контролювали, що і як ми викликаємо. Тому тут можна або змиритися з цим, або використовувати як результат List або об’єкт Slice:

       Slice<Product> findBy(Pageable page);

Інтерфейс Slice схожий на Page (більше того, Slice це базовий інтерфейс для Page), але тут немає загальної кількості елементів, тому тепер COUNT-запит не виконуватиметься. Але як тепер передати загальну кількість клієнту? Адже один раз (вперше) це потрібно зробити. Можна або написати свій власний метод, або скористатися Spring Data JPA і навіть закешувати його, щоб він не звертався до бази щоразу:

       @Cacheable
       @Query("SELECT p FROM Product p join fetch p.orders")
       Page<Product> geTotalElements(Pageable page);

А що з native (SQL) запитами?

       @Query(value = "SELECT * FROM PRODUCT", nativeQuery = true)
       Page<Product> findAll(Pageable page);

Тут не все так просто. Ні Hibernate, ні Spring Data JPA не можуть парсити SQL запити, тому як COUNT тут буде виконано такий запит:

SELECT * FROM PRODUCT

А потім Spring просто підрахує кількість елементів у його результаті. Зрозуміло, це абсолютно неефективно, тому слід явно вказати свій запит COUNT:

       @Query(value = "SELECT * FROM PRODUCT", nativeQuery = true, countQuery = "SELECT COUNT(*) FROM PRODUCT")
       Page<Product> findAll(Pageable page);

Клас PageRequest бiльш функціональний, ніж наш запис PageRequest, тому що там є методи next і previous:

       @Override
       public PageRequest next() {
              return new PageRequest(getPageNumber() + 1, getPageSize(), getSort());
       }
 
       @Override
       public PageRequest previous() {
              return getPageNumber() == 0 ? this : new PageRequest(getPageNumber() - 1, getPageSize(), getSort());
       }

Ці методи можуть стати в нагоді при генерації відповіді для клієнта. Зараз клієнт має сам знати про те, як надіслати посторінковий запит на сервер. Якщо ви чули про Hypermedia і використовували HATEOAS, то швидше за все дотримуєтеся іншого принципу, коли сервер відправляє клієнту не тільки дані, але й посилання на операції (включаючи навігацію).

Таким чином, коли клієнт відправляє початковий запит на сервер, а сервер підраховує, що не всі дані помістяться на поточну сторінку, він поверне клієнту додатково посилання для переходу на наступну сторінку:

"_links" : {
    "next" : {
      "href" : "http://localhost:8080/orders?limit=10&page=1"
    }
  }

Це дозволить нашому клієнту не прив’язуватись до особливостей API, а використовувати ті URI, які генерує сервер.

Висновки

Якщо підвести підсумки, то до плюсів offset-based pagination можна віднести простоту реалізації та можливість перейти на будь-яку сторінку за її індексом, а до недоліків:

  • погана продуктивність для великих обсягів даних;
  • можливість появи фантомних даних при видаленні/ вставці елементів.

Тому доцільно розібрати інші підходи, які не використовують абсолютне усунення елементів, ми це зробимо у другій частині.

Що стосується підтримки з боку Java-спільноти, то JPA надає тільки базові можливості, а ось Spring Data JPA (як і інші підпроєкти Spring Data) дозволяють організувати page-based pagination out-of-the-box з підтримкою сортування.

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
При серверному pagination клієнт запитує дані лише однієї (поточної) сторінки. Це дозволяє мінімізувати навантаження на БД

— цей момент хочу уточнити. При використанні pagination завжди дані впорядковуються за якимсь критерієм. Так от навантаження дуже залежить від наявності індексів по колонках, за якими виконується впорядкування, від вартості запиту загалом. Запит однієї сторінки може коштувати майже стільки ж, скільки запит на вибірку усіх даних.

Так і звичайна поведінка клієнта це виставляти потрібні фільтри щоб знайти усі потрібні данні на одній сторінці. Фільтрами бавляться більше ніж пагінацією (якщо вони звісно є). Це теж варто мати на увазі

Реалізувати пагінацію без фантомів просто — варто запитувати замість page index айді останнього переглянутого елемента, далі віддавати стільки наступних елементів, скільки було запрошено через page size

Хм...

> Використовуємо зміщення елементів

Тут можна було б пропустити критику, тому що анонсована друга частина з «більш ефективними підходами», але... залишу: зміщення це неефективно. Подивився 20 записів зі сторінки, перейшов на наступну (старішу)... а за цей час додалось 50. Потім продивився їх, перейшов далі... і бачиш знову ті що бачив перед цим. Інколи навіть Алісиного «треба бігти в два рази скоріше щоб тільки залишитись на місці» не вистача.

Ну і ціна великих зміщень росте що не квадратично. Для MySQL взагалі колись був рекомендований хак — спочатку через limit+offset витащити тільки ключі, а далі за множиною ключей витащити решту. Не знаю, може, і для інших серверів таке на користь.

Там, де реально думають про навантаження (Reddit, FB (в мобільній версії), ще багато де) замість зміщення використовують значення первинного індексу, тоді не треба базі починати всю ітерацію с початку. Запит виглядає в дусі GET /resources?before=8388123&n=20.

Ну, зачекаємо обіцяну частину 2 з іншими підходами :)

> rows fetch first — цей набір операторів може деяких здивувати, оскільки вони звикли до ключового слова limit.

Дивує не це. Дивує те, що взагалі не враховується, наскільки різні SQL сервери потребують різних підходів. І те, як формулювати те саме limit+offset — чи «limit L offset S», чи «limit S,L», чи «offset S rows fetch first L rows only» — це мала проблема порівняно з тим, як викликати merge... чи insert or update чи upsert чи ще якусь локальну забаганку.

> Крім того, не гарантується, що з limit будуть повернуті перші N записів результату (хоча швидше за все так і буде).

Це як? Чому? І що, у випадку fetch first L rows only — гарантується, а тут ні? Поясніть.

> Уявімо реалістичнішу ситуацію, коли у нас використовується сортування або фільтрація:
> У такому разі щоразу, коли ми запитуємо нову сторінку, сервер БД буде змушений виконати весь запит, отримати всі записи, відфільтрувати і відсортувати дані і тільки потім знайти потрібні елементи за індексом.

Ні. «Весь» запит він не зобовʼязаний виконувати, і у більшости є оптимізації проти цього — щонайменше коли запит може напряму використати існуючий індекс. Але коли, наприклад, іде запит з offset=2000 — він не може не вибрати попередні 2000 записів і відкинути їх — і це те, чому я вище казав про первинний ключ. Але ті 100500000 записів що йдуть після нього — якщо індекс відповідає сортировці запита, то вони вибиратись не будуть.

> Згадаймо, що нам потрібно обов’язково повернути клієнту у першому запиті загальну кількість елементів

А я ось не розумію, чому. Яка цінність в точній цифрі тут? Ну показали ви спочатку що буде 102 сторінки, а потім клієнт долистав до 105й. Бо за цей час хтось щось додав аж на три сторінки списка. Або до 90й і воно скінчилось. Наскільки це взагалі важливо і чому не можна, наприклад, тримати орієнтовні кількості в окремій таблиці (коли вони не параметризовані)?

> Таким чином, для загального випадку потрібно написати спеціальний парсер, який перетворюватиме загальний SQL запит на COUNT-запит

Щось мені здається, що тут спочатку зробили самі собі проблему вибором інструмента який занадто рано формує запит у вигляді фінального SQL, а потім героїчними зусиллями переборюєте це, вводячи якісь допоміжні парсери. Чому запит не може бути представлений у вигляді внутрішніх обʼєктів як ланцюг всіх цих from-where-orderby-groupby-having... і тільки потім фінально перетворюватись у текстовий вигляд?

Про парсер (ну, у більшості випадків небагато парсити) і виконання COUNT перед самим запитом — це граблі з грабель. Можна легко подвоїти час видачи даних юзеру.
На жаль, універсальних рецептів тут нема. Дуже залежить бази даних, самих даних та того UX який бажано мати.

Це перша частина. Може, якийсь суперінсайт буде далі :)

Не буде )
У більшості статей Сергія «частина друга» складається зі складних і шкідливих рецептів типу як вистрілити собі в ногу з танка, загорнув його у декоратор інтерфейсу «пістолет».

Для однієї таблиці це ще проходить але я дуже рідко з такой працював.
У моїх застосунках пагінація була на досить складний запит з 2-3-5 таблиць та ще й дозволів на читання окремих рядків з них. Тож у таблицях близько 500К рядків а користувач бачить ну максимум 150 −200 з них ( адміни звісно бачать більше але не все)

пагінація була на досить складний запит з 2-3-5 таблиць

Обʼєднання чи join?

та ще й дозволів на читання окремих рядків з них.

Він не може бути представлений як Where?

звісно використовува join та через where limit offset фільтрував дані. потім зробили view та через нього робили але проста пагінація це не складно а щось більш складне потребує деяких незначних зусиль щоб не напрягати сервер.

По перформансу звичайна пагінація використовуючи offset не ок. Для користувача на сайті може і ок, бо він далі 10 сторінок не йде, а якщо в нас пагінація для опрацювання даних батчами, і коли у базі більше пів мільйона записів, а то й кілька мільйонів, то стає повільно і використовує більше ресурсів (може доходити до 100% cpu usage на AWS RDS mariadb особисто це спостерігав).
Тому є хороший спосіб використовуючи id і не використовуючи offset (для mariadb):

@Query(value = «SELECT * FROM PRODUCT where id > :id order by id limit :limit», nativeQuery = true)

:id передаємо максимальне значення з попередньої сторінки.

Тут деталі: mariadb.com/...​/pagination-optimization

Мабудь краще created_at, бо id різні бувають

І взагалі хто сказав що потрібне для виводу сортування даних співпадає з id.

1. Як воно зветься — id, created... — це не настільки важливо. Важливо, що змінна в порівнянні у WHERE повинна бути початковою частиною ключа для ORDER BY (або просто співпадати з ним, бо зазвичай нема потреби в інших полях для сортування) — це як раз дає можливість вибрати до limit+offset перших записів після сортування.

А список полів в ORDER BY, у свою чергу, дає ефективний пошук, якщо є ключ у якому цей список співпадає з ключем чи є його початком. Якщо це не виконується, скоріш за все буде full scan, а це дуже дорого.

2. Якщо цей id по порядку створення, і саме id, то він унікальний і проблем нема. Якщо це, як ви кажете, created_at, можливі дублі. У цьому випадку є імовірність пропустити якийсь рядок видачі просто через нестабільність порядку рівних ключів — це зветься ties problem. Зауважте, що у прикладах у Моренця було select first L rows only. Є ще select first L rows with ties, при невеличкій імовірности таких співпадінь воно спрацює нормально, але може дати наприклад 21 запис на сторінку замість 20:)

Цей метод не працює, коли в грідах зявляється сортування по колонкам.

І як воно псує справу?

І як воно псує справу?

Або відрегували запит в першому пості, або я не побачив. З order by буде працювати з сортуванням по іншим колонкам.

Підписатись на коментарі