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

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

Всім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник, хочу поділитися з вами своїм досвідом роботи з такою цікавою темою, як посторінковий висновок даних у застосунках, що використовують ORM -технології. У першій частині цієї статті я розповів про offset-based pagination, про те, як його використовувати в REST API і реалізувати за допомогою JPA або Spring Data JPA. Цей підхід має свої особливості та недоліки, тому я б хотів познайомити вас з альтернативними способами реалізації посторінкового виведення даних.

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

Cursor-based pagination

Думаю, що багатьом знайома така конструкція, як курсори бази даних. По суті, це іменований набір записів, отриманих після виконання SQL-запиту з можливістю пересуватися по цьому набору (в обох напрямках). Ось зразковий код на PL/pgSQL, який у Postgres оголошує курсор, а потім пересувається по ньому::

DECLARE cur_orders cursor for select * from ORDERS order by createdAt;
OPEN cur_orders;
FETCH cur_orders into id, product_id, amount;
MOVE cur_orders;
MOVE RELATIVE 5 cur_orders;

Таким чином, нам потрібно лише один раз виконати SQL-запит, а потім пересуватися по ньому за певним зміщенням. Правда, для цього потрібно оголосити курсор як scrollable за допомогою опції SCROLL. Виглядає привабливо, але при уважному вивченні можна помітити деякі мінуси:

  • При закритті транзакції курсори автоматично закриваються (і стають недоступними). Таким чином, нам потрібно тримати поточну транзакцію відкритою, поки ще відкрите HTTP-з’єднання. Цього можна уникнути опцією WITH HOLD, але тоді сервер БД повинен матеріалізувати поточний набір даних (зберегти на диску).
  • Даний підхід не дуже добре масштабуємо, оскільки чим більше одночасно запитів, тим більше ресурсів і пам’яті потрібно для зберігання таких наборів даних.
  • Такий варіант не підходить прихильникам stateless API, тому що нам доводиться зберігати в пам’яті результати попереднього запиту. Якщо у нас використовується load balancer, то потрібно обов’язково підключати клієнта до того сервера, де зберігається його набір даних.
  • Не всі СУБД підтримують курсори (наприклад, H2 або Apache Derby), тому таке рішення важко зробити універсальним.

Такий підхід годиться для невеликих наборів даних і невеликій кількості користувачів. Але ідея курсорів непогана, якщо під курсором розуміти покажчик на якийсь елемент у результуючому наборі даних, який однозначно ідентифікує цей елемент, тобто, власне, його унікальний ідентифікатор. Що це може бути за елемент?

Уявимо, що клієнт хоче отримати замовлення та виконує запит:

GET /orders?limit=20

Це призводить до створення такого SQL:

select * from ORDERS rows fetch first 20 rows only

Сервер поверне перші 20 записів. І клієнту для запиту наступної сторінки потрібно вказати покажчик на перший елемент цієї сторінки (як OFFSET у першому підході). Для такого покажчика може підійти первинний ключ, оскільки він завжди є унікальним. І тоді у відповіді клієнту на початковий запит потрібно буде повернути не лише дані, а й значення вказівника.

Але повертати його як «nextId» не дуже добре, тому що ми прив’язуємо API до реалізації. А простіше буде назвати, наприклад, «nextCursor»:

{
   data: [
      ...
   ],
   nextCursor: 21
}

І тоді клієнт для отримання наступної сторінки надішле запит:

GET /orders?limit=20&next_cursor=21

І це призведе до генерації SQL:

select * from ORDERS WHERE id >= 21 rows fetch first 20 rows only

Єдина відмінність цього запиту від offset-based — заміна OFFSET на WHERE, але такий запит буде виконуватися дуже швидко, тому що тут фільтрація за первинним ключем. Крім того, ми більше не залежимо від видалення/ додавання нових записів, оскільки орієнтуємося на ідентифікатор (а не зміщення). Але що, якщо у нас запит складніший і, наприклад, передбачає сортування (за датою додавання)?

select * from ORDERS WHERE id >= 21 ORDER BY createdAt DESC rows fetch first 20 rows only

У цьому випадку запит буде працювати некоректно, оскільки сортуючи дані за id і датою додавання, ми отримуємо різні результати. Ситуацію ускладнює те, що createdAt — це, швидше за все, кількість мілісекунд (Unix epoch) і не є унікальним значенням.

Тому, якщо ми використовуємо сортування в запиті, нам потрібно вказати в WHERE всі поля сортування:

select * from ORDERS WHERE (createdAt > 1689526152858) OR (createdAt = 1689526152858 AND id >= 21) ORDER BY createdAt, id DESC rows fetch first 20 rows only

Нам довелося двічі порівнювати createdAt, так його значення неунікальне. Але в будь-якому випадку цей запит буде працювати швидко, тому що ми спочатку отримуємо 20 записів, а потім сортуємо їх за createdAt і id. Щоправда, нам необхідний індекс на поля, створеніAt і id, якщо ми хочемо, щоб пошук по їх комбінації працював без table scan.

Такий запит можна спростити, якщо ви використовуєте MySQL чи Postgres:

select * from ORDERS WHERE (createdAt, id) > (1689526152858, 21) ORDER BY createdAt, id DESC rows fetch first 20 rows only

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

GET /orders?limit=20&next_cursor=21,1689526152858

Таким чином, з точки зору API, ми реалізували cursor-based pagination, а з точки зору даних роботи з БД — keyset-based pagination, так як використовуємо набір ключів (стовпців), які однозначно характеризують наші записи.

Які недоліки такого підходу?

  • Не можна перейти на довільну сторінку (лише наступну або попередню).
  • Не можна розпаралелити запити для швидшого отримання даних.
  • Складніша реалізація на бекенді.

Це досить серйозні мінуси. Уявімо, що у вас є велика таблиця, а клієнту необхідно отримати всі дані з неї. Але ви надаєте лише paginated API, де максимальний розмір сторінки — 20 елементів. У випадку page-based pagination заздалегідь знає кількість елементів та сторінок, тому може розпаралелити отримання даних і навіть відображати прогрес усієї операції. У випадку cursor-based pagination йому доведеться послідовно (і синхронно) отримувати дані. Не факт, що сервер поверне загальну кількість елементів. Це серйозні обмеження, які говорять про те, що не завжди такий підхід застосовується.

Але є ще один мінус. Надаючи клієнту значення ключів, ми розкриваємо деталі нашої реалізації. Чому це погано? Клієнт може здогадатися про те, як утворюється значення курсору і сам генерувати посилання. Але навіть якщо він не буде цього робити, користувачі можуть зберігати цей URI. А це ускладнить зміну (рефакторинг) нашої реалізації. Тому часто застосовують encoded cursor.

Нам необхідно зашифрувати (закодувати) значення курсору, так щоб воно представляло для користувача рядок випадкових символів. Найпростіший варіант — Base64.

І тоді 21,1689526152858 перетвориться на MjEsMTY4OTUyNjE1Mjg1OA==

Можна піти далі і з метою універсалізації таких значень кодувати рядок такого типу: id=21&createdAt=1689526152858

Після Base64 кодування на виході отримаємо aWQ9MjEmY3JlYXRlZEF0PTE2ODk1MjYxNTI4NTg=

Якщо нам не підходять такі довгі значення, можна просто генерувати випадковий рядок (не більше 10 символів) як такий курсор, а відповідність між курсором і оригінальним рядком зберігати в якійсь тимчасовій базі даних. Такий курсор буде більше схожий на короткоживучий токен, тому такий тип API часто називають token-based pagination:

GET /orders?limit=20&next_token= MjEsMTY4OTUyNjE1Mjg1OA==

Ще один підхід, який іноді можна зустріти — time-based pagination. Він популярний тоді, коли необхідно отримати нові дані (наприклад, в соцмережах). Реалізують його за допомогою offset-або cursor-based pagination, але як ми вже розбирали, час (у мілісекундах) не гарантує свою унікальність. Крім того, тут потрібно коректно враховувати часові пояси та інші деталі, що стосуються роботи з часом:

GET /orders?limit=20&after=1689526152858

Перейдемо до реалізації.

Scrolling в Spring Data JPA

JPA не надає спеціального API для server-side pagination. Як і у випадку з offset-based pagination, реалізовувати cursor-based pagination вам доведеться самостійно. З Spring Data JPA ситуація цікавіша. Тут ще в 2015 році задумали реалізувати cursor-based pagination, але зрештою відмовилися від цієї ідеї, оскільки знову ж таки такий підхід не підтримується JPA.

І аж до 2023 року єдине, що було доступне розробникам — page-based pagination. Але у версії 3.1 спочатку у Spring Data Commons, а потім і в інших Spring Data проєктах з’явився новий Scrolling API.

У чому відмінність від існуючого API з Pageable, Page і PagingAndSortingRepository? Новий API зроблений універсальнішим і підтримує два підходи:

  • Offset-based pagination.
  • Keyset-based pagination.

Оскільки у другому випадку вже немає сторінки як такої (не можна перейти на довільну сторінку), то тут використовується термін вікно (Window) замість Page, а замість pagination — scrolling.

Для того, щоб використовувати цей API, потрібно пройти три етапи:

  1. Створити та ініціалізувати об’єкт ScrollingPosition.
  2. Виконати запит та отримати об’єкт Window/WindowIterator.
  3. Пройтися за результатом та вийняти отримані дані.

Для нашого прикладу із замовленнями достатньо додати новий метод до репозиторію:

Window<Order> findAllBy(ScrollPosition position);

І тепер його можна викликати, вказавши, що ми хочемо використовувати offset-based pagination:

Window<Order> orders = orderRepository.findAllBy(ScrollPosition.offset());

У об’єкта Windows є методи hasNext і positionAt, і якщо нам потрібно відразу отримати наступні «вікна», то для цього потрібно викликати їхню комбінацію:

while(orders.hasNext()) {
orders = orderRepository.findAllBy(orders.positionAt(orders.size() -1));
}

Але що робити, якщо ми хочемо реалізувати «pagination»? Адже нам потрібно буде щоразу в запиті встановлювати нове усунення? Для цього потрібно передати його в метод offset:

Window<Order> orders = orderRepository.findAllBy(ScrollPosition.offset(20));

Якщо ви хочете повністю перейти на новий Scrolling API, тобто можливість сконвертувати об’єкт PageRequest в ScrollPosition:

       PageRequest request = PageRequest.of(0, 20);
       Window<Order> orders = orderRepository.findAllBy(request.toScrollPosition());

Цікавіша ситуація з keyset-based pagination. Якщо ми хочемо його використовувати, необхідно вказати набір ключових полів з їх значеннями та напрямок scrolling (вперед або назад):

orders = orderRepository.findAllBy(
              ScrollPosition.forward(Map.of("id", id, "createdAt", LocalDateTime.now())), Sort.by("id", "createdAt"));

У результаті ми отримаємо такий SQL:

select * from ORDERS o1_0 where o1_0.id>? or o1_0.id=? and o1_0.createdAt>? order by o1_0.id,o1_0.createdAt

Якщо ж ви хочете перетворити дані значення в токен/курсор, це потрібно робити самостійно. Але як передати розмір вікна, як ми це робили з PageRequest? Тут можливі два варіанти. Можна вказати його за допомогою слова Top у назві query method:

       Window<Order> findTop5By(ScrollPosition position);

А в Spring Data Commons 3.2, який вийде в жовтні 2023 року, вже з’явився новий інтерфейс Limit, в якому розробники Spring вдало використовували нове ключове слово sealed:

public sealed interface Limit permits Limited, Unlimited{
 
       int max();
 
       default boolean isUnlimited() {
              return Unlimited.INSTANCE.equals(this);
       }
 
       static Limit unlimited() {
              return Unlimited.INSTANCE;
       }
 
       static Limit of(int max) {
              return new Limited(max);
       }

Таким чином, ви тепер можете оголосити в будь-якому методі вашого репозиторію, що використовує scrolling, аргумент типу Limit:

       Window<Order> findAllBy(ScrollPosition position, Limit limit);

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

       orders = orderRepository.findAllBy(ScrollPosition.offset(), Limit.of(10));

Використання в production

У Paypal застосовується page-based pagination, де ви передаєте параметр page (індекс сторінки) та page_index (кількість елементів на сторінці).

Cisco використовує offset-based pagination у своєму API, де ви передаєте параметри offset та limit.

Twitter застосовує token-based paginaton, де ви передаєте параметр pagination_token, а у відповіді отримуєте поля next_token та prev_token для навігації. А ось для Ads API використовується cursor-based pagination, де ви передаєте параметр cursor.

Attlassian використовує offset-based pagination, де ви передаєте параметри startAt (індекс елемента) і maxResults.

Slack називає page-based pagination архаїчною (хоча і підтримує її), але для більшості операцій застосовує все-таки cursor-based pagination, де ви передаєте в запиті cursor і limit параметри.

LinkedIn використовує offset-based pagination, де ви можете вказати параметри start (індекс елемента) і count (кількість елементів).

Github використовує page-based pagination, де ви використовуєте у запиті параметри per_page (кількість елементів на сторінці) та page (індекс сторінки).

Google Play використовує token-based pagination, де кожен запит повертає вам токени для переходу на наступну або попередні сторінки.

Stripe використовує cursor-based pagination, де в API ви вказуєте параметри limit (кількість елементів) і опціонально starting_after, який дорівнює ідентифікатору першого об’єкта, який ви хочете отримати. Або ви можете вказати параметр ending_before і це буде ідентифікатором останнього об’єкта, який ви хочете отримати.

IBM Cloud API підтримує offset- та token-based pagination.

Facebook підтримує кілька типів pagination, починаючи з cursor-based, де курсор — це рядок, що генерується, який посилається на певний елемент з результату. Якщо елемент буде видалено, курсор стає невалідним і більше його не можна використовувати. У відповідь від сервера будуть посилання, які дозволять перейти на попередню або наступну сторінку:

{
  "data": [
     ...
  ],
  "paging": {
    "cursors": {
      "after": "MTAxNTExOTQ1MjAwNzI5NDE=",
      "before": "NDMyNzQyODI3OTQw"
    },
    "previous": "https://graph.facebook.com/{your-user-id}/albums?limit=25&before=NDMyNzQyODI3OTQw"
    "next": "https://graph.facebook.com/{your-user-id}/albums?limit=25&after=MTAxNTExOTQ1MjAwNzI5NDE="
  }
}

Також тут підтримується tme-based pagination, де дані відсортовані за часом створення, а параметром буде кількість Unix секунд:

{
  "data": [
     ... 
  ],
  "paging": {
    "previous": "https://graph.facebook.com/{your-user-id}/feed?limit=25&since=1364849754",
    "next": "https://graph.facebook.com/{your-user-id}/feed?limit=25&until=1364587774"
  }
}

Якщо вам не підходять ці два підходи, то третій варіант — offset-based pagination, де ви передаєте запит параметри offset і limit.

Висновки

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

З точки зору REST API та його використання клієнтами посторінковий висновок може бути page-, offset, cursor-і token-based pagination. З точки зору реалізації ви використовуєте або offset-або keyset-based pagination. Перший варіант найпростіший (з точки зору реалізації та використання), але вимагає більшого споживання ресурсів на сервері.

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

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному3
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

Тема актуальна для high load специфічних систем. Для більшості проектів, дешевше буде купити дорожчий інстанс бази ніж тратити людиногодини для «покращення» пагінації.

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

та його друзі — «пекельні борошна», пішли у врятовано-перетворювальний собор.

Мабуть варто глянути першу частину. А взагалі, то пагінація з offset дуже багато ресурсів забирає, і це може бути не дані для фронтенду, а якийсь батч процесінг який має опрацювати кілька мільйонів записів батчами по 50 штук, то ж після 200к записів offset в запиті буде тормозити, і описано способи як цього уникнути.

Та я лиш приклад навів, все залежить від потреби, суть в тому що offset з великим числом повільний і багато ресурсів жере (бачив на 100% cpu usage AWS RDS mariadb через великий offset при обробці мільйонів записів батчами)

Може і велике.
Багато запитів (і від людини і від робота) робляться так — тягне дані поки не вирішить що досить.
Але тягнути їх треба порціями.

а це на чому?

пагінація з offset дуже багато ресурсів забирає

Бо я зараз перевірив на MySQL — таблиця на 6 млрд записів, розмір 700Гб, батч процессінг літає.

AWS RDS mariadb, запити типу select * from message where .... limit 5000000, 500

може сам запит неправильні індекси використовує? бо у мене то все за долі секунди виконується в проді.

індексів було достатньо, індекси не допомагали саме з великим offset, для offset <100k все було ок, а от при зростанні виникала проблема, якщо бути точнішим то запит був з order by:
select * from message where .... order by id limit 5000000, 50
у вас точно є умови в where і order by (його рекомендується мати якщо пагінацію робиш) ?

Таких запитів було багато, один за одним, витягувались по 50 записів, з великим offset час зростав до 20 сек, в той самий час коли offset був кілька тисяч то все до секунди займає.

Аж цікаво стало. Ось, що у мене вийшло:
select * from calls_split where calls_split_id > 100 order by calls_split_id LIMIT 100, 50;
50 rows in set (0.00 sec);

select * from calls_split where calls_split_id > 100 order by calls_split_id LIMIT 2000000, 50;
50 rows in set (1.09 sec);

select * from calls_split where calls_split_id > 100 order by calls_split_id LIMIT 5000000, 50;
50 rows in set (3.60 sec)

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

У вас тут дуже проста у where умова, додати ще кілька умов, повинен змінитись час виконання. При моїх тестах, наявні індекси при великих offset переставали працювати (в explain можна було помітити, що раніше писало що використовуються, а при великих офсетах вже ні)

Взагалі проблематика не висосана з пальця (чи мого проекту) ось тут описано mariadb.com/...​/pagination-optimization

Так там у статті і згадується ID.

По іншому полю трохи повільніше, але, можливо, треба перевіряти на тестовому сервері, бо на проді постійно щось крутиться:
select * from calls_split where call_id > 100 order by call_id LIMIT 100, 10;
10 rows in set (0.01 sec)
select * from calls_split where call_id > 100 order by call_id LIMIT 1000000, 10;
10 rows in set (4.80 sec)
select * from calls_split where call_id > 100 order by call_id LIMIT 5000000, 10;
10 rows in set (9.60 sec)

ID згадується щоб пофіксити проблему офсету, і позбутись його)

було: select * from message where .... order by id limit 5000000, 50
стало: select * from message where .... and id > max_id_from_previous_batch order by id limit 50

То це виходить, що оцей непотріб в Doctrine, що називається «eager_loading» разом із FilterEagerLoadingExtension якраз із метою «оптимізувати пагінацію» ввели.

Взагалі, сам опис проблеми інтригує і добре, що ті проблеми не у мене.

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