Pull to refresh

Comments 57

И как это решает проблему offset?

о какой проблемы вы говорите? Я не обещал решения этой проблемы в этой статье )

Проблема дублей и пропадающих записей

То есть если есть миллиард записей и надо выбрать первые 100 всегда будет делаться full table or index scan?

Пользователи будут очень довольны.

Много лет назад пришёл к мысли что пользователям не нужна "пагинация". Кого интересует конкретно 3я страница например?

Достаточно load N first + load more = load N starting from last ID. Если позволяет можно позволить "load all".

Никто ни разу не пришёл и не попросил страницы.

Вопрос о нужности/ненужности пагинации - это уже совсем другой вопрос. Если заказчик требует - куда деваться?

Донести ему, что она не нужна и предложить решение получше

Если у вас есть хороший поиск с фасетами и фильтрами, то вам пагинация не нужна как таковая.

Хотя для хорошего поиска надо всё это загнать в подходящую базу. Поиск сразу начнет отставать от реальности.

к моменту выполнения второго запроса данные могут стать неактуальными

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

Да, есть ситуации, когда нужно пагинировать данные для сервиса, разбивая большую таблицу на батчи, но здесь непременное условие - не пропустить ни одной строки. По вашему алгоритму между запросами страниц может быть удалена строка из текущей страницы, после запроса которой курсор сместится на количество удалённых записей вперёд. Это касается любых алгоритмов, основанных на row_id.

некую универсальную процедуру для демаршалинга

Под демаршаллингом подразумевается маппинг данных из таблицы в поля класса?

он. Но демаршалинг звучит круче! )

а при чем здесь го? энивей

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

1) правда ли нам нужно знать точное кол-во записей и страниц?

2) можем ли мы обойтись отображением первых n страниц?

3) готовы ли мы обрести беспонтовый ux порожденный limit/offset подходом?

4) понимаем ли мы, что эта штука положит базу?

5) разделяем ли передовую мысль человечества про пагинацию с помощью last_id?

с пятым пунктом не знаком

6) Можем ли мы убедить заказчика отказаться от пагинации в таком виде?

Скиньте, плз, ссылку - что почитать про передовую мысль и last_id. Буду благодарен!

Мысль интуитивно понятна, наврядли кто-то найдёт статью про это. Это тот кейс, про который я рассказывал: нельзя пропускать строки. Вся статья, которую бы я написал, сведётся к следующей строке:

SELECT * FROM table WHERE ID > last_id LIMIT :limit:

Мир еще не потерян

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

ps. Вы забыли order by

При order by не существует решения вменяемой пагинации вообще-то. Я про него не забыл, я его не учитываю.

При требовании "не пропускать строки" пагинация бесполезна. Тут нужно сортировать ПОСЛЕ получения данных. Причём всех.

При показе пользователю метод last_id не нужен. Всё равно пользователь видит устаревшие данные, что там пропущено - это его проблемы.

В теории можно пользоваться курсорами, но это тот ещё геморрой.

В целом если данных до пары миллионов, оффсеты работают нормально.

Можно вырезать куски id на where not in например, человеку не реально так далеко открутить что бы получить нагрузку.

Это будет работать медленнее offset, скорее всего. Потому что у вас не range, а офигевше большой список

Не, индексы решают)

Минусят явно те кто ни разу даже explain такого запроса не делал.

Минусят явно те кто ни разу даже explain такого запроса не делал.

Эксплейн на пустой базе и на большой покажет разные планы.

Логично, только не отменяет того факта, что поможет сохранить любую сортировку (а это уже просадка по скорости) и вывести данные порционно. Это рабочий вариант и много кто им пользуется. Не подскажете лимит на количество сущностей в in? А, он настолько велик что в жизни туда не дойдет пользователь.

Это рабочий вариант и много кто им пользуется


Это один из вариантов работы с информацией который удобен для человека при небольшом колличестве данных.

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

Пользовательские проблемы начнуться намого раньше и от пагинации попросит отказаться бизнес.

Если у вас есть 10 тысяч записей типа Пользователь, то вы можете сортировать и пагинировать по любым полям без дополнительныx индексов и база скушает это.

Вот только человеку работать с этим через погинацию будет неудобно.

Можно переделать на поиск с автокомлитом, который отсортирован с фиксированной сортировкой. Тогда и пользователю удобно и на базу нагрузка не большая.

Вот просто давайте представим ленту фото приложения. Там мало того что сортировка (по дням я думаю), так еще и ранжирование. Ну или тот же Пикабу с проблемой сокрытия прочитанных постов, тоже знаете ли)

Ну или тот же Пикабу с проблемой сокрытия прочитанных постов, тоже знаете ли)

Не знаю.

Чтобы скрывать просмотренные посты нужно делать матрицу пользователь - пост. Выглядит как дорогое удовольствие если данных много.

Чтобы пагинация работала быстро нужно для строить индекс под каждый случай. То есть в этом случае нужно строить для каждого пользователя по всем видам доступной сортировки.

Без индекса будет не быстро.
Нам нужно 50 картинок. Вытащили первые 100, отфильтровали, если осталось мало, вытащили еще и так пока не наберётся или таймаут не случиться.

Мне по работе приходится с таким сервисом интегрироваться, только там оптимизировали до одного запроса, то есть вытаскивают из базы данные, отфильтровывают и что осталось возвращают. У меня ушло порядочно времени понять, почему если запросить сразу всё то есть данные, а если только первую страницу то там пусто.



Почему нет, даже термин придумали: Курсорная пагинация (Cursor-Based Pagination)

Это называется курсорная пагинация.

Но что бы её использовать с ид, сортировка по ид тоже нужна.

Вопрос не в том - делать или не делать пагинацию. Его решили без меня. Вопрос в данном случае - как сделать проще и удобнее, раз уж требуют

Не уверен, что пагинацию через limit и offset в принципе делать правильно.

Допустим, пользуясь пагинацией через limit и offset кто-нибудь начнёт табличку целиком "постранично забирать".

Т.е. представьте, пусть у вас таблица на миллиард-десяток элементов.

пришли к вам один раз с

SELECT * FROM client_accounts WHERE <условия> LIMIT 100 OFFSET 0;

Потом пришли с

SELECT * FROM client_accounts WHERE <условия> LIMIT 100 OFFSET 100;

Пришли к вам ещё раз с

SELECT * FROM client_accounts WHERE <условия> LIMIT 100 OFFSET 200;

И т.д.

Мне кажется, или нам придётся из базы забрать n^2 строчек суммарно таким образом, чтобы читающий код мог исходные n строчек выгрести?

Давайте я вам предложу способ получше:

возьмите приблизительное количество строк из статистики

select 
  *,
  (
    select n_live_tup 
    from pg_stat_user_tables 
    where relname = 'clients'
    /*добавь схему ещё*/
  )
from clients
offset 10
limit 10

UPD

Если вы хотите очень приблизительный результат ( допустим, у вас условия по выборке идёт и вы не можете применить выше ) - запустите explain и вытяните кол-во записей из планировщика. ( конечно, если у вас данные нормально распределены )

Ну ещё кэши запросов есть на всякий ( внешний кэш имею в виду )

https://habr.com/ru/articles/301044/

Мне понравился способ из комментариев, где человек ищет по индексу (например, по идентификатору) через JOIN или временную таблицу, а далее из неё по идентификатору получает эти самые необходимые данные. Правда издержка очевидная - сам индекс.

Ну это всё прекрасно, но как и писали выше - это всё ерунда, главная проблема лимит-оффсет в производительности. Если вы делаете это в лоб, то чем дальше страница тем медленнее будет выполняться запрос.

Где-то когда-то нашел в комментариях решение, которое на удивление дает около константное время запроса на любую страницу. Концепция простая:

SELECT * FROM mega_table AS m0
INNER JOIN (
    SELECT id FROM mega_table
    WHERE <conditions>
    ORDER BY <conditions>
    LIMIT <i>
    OFFSET <j>
) AS fast_filter ON m0.id = fast_filter.id
<conditions if need>

Суть заключается в том, чтобы избежать некоторых тяжелых операции, если делаете и оффсет и условия и сортировку на одной таблице. Ну и главное, чтобы на поле id из примера был повешен индекс, а так же на те поля по которым вы подразумеваете фильтрацию и сортировку.
Хак в том, чтобы использовать только индексные поля, тем самым не заставляя БД тащить тяжелые тосты и вот это всё, а уже в самом конце, когда нам известны id выборки, то их сильно проще забрать, чем перечитывать страницы для оффсета еще и с сортировкой и условиями.

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

Не очень понял, почему SELECT id FROM mega_table будет существенно быстрее, чем SELECT * FROM mega_tabl

https://stackoverflow.com/a/27169302
https://dba.stackexchange.com/a/205286

Простите, но слишком лень гуглить более техническое описание того, почему чистый оффсет такой тяжелый.
СОВСЕМ ПРИМИТИВНОЕ ОБЪЯСНЕНИЕ: если мы применяем оффсет на что-то, что в конечном итоге использует только индексы (в данном случае возвращается только таблица с полем id), то pg не нужно заглядывать целиком в реальную таблицу с данными, читать байтики там чтобы не ошибиться с оффсетом, попутно проверяю условия и т.д. Т.к. мы используем чистое индексное поле на выходе, то pg может на много быстрее посчитать оффсет. По сути мы упираемся только в то, сколько условных байт нам надо прочитать, чтобы корректно отсчитать строки оффсета. ЭТО ОЧЕНЬ ПРИМИТИВНОЕ ОБЪЯСНЕНИЕ! Возможно тут появится гуру pg и объяснит более корректно.

Индекс это еще одна таблица с данными. В некоторых базах он хранится точно так-же как и основая таблица.

SELECT id FROM mega_table

В запросе база сама выбирает читать из таблицы или из индекса. Если в индексе есть все нужные поля, подходящая сортировака и он меньше по размерам, то база выберет его для чтения.

Да, именно так, спасибо что сократили мою мысль )))

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

А как же условия WHERE, в котором может быть много разных полей? Например, отображение товара в интернет магазине с фильтрацией по куче разных параметров (полей)? Как СУБД осуществит фильтрацию, читая только таблицу первичного индекса? Ничего не понимаю...

Я же написал, просто используйте индексы на те поля которые вам нужны. Индексы хранятся в отдельной таблицы и со своей магией, все что вам нужно от этого джойна - это получить id, а все остальные данные вы получаете уже "наверху".
Вы можете обернуть это ещё раз в под запрос и присоединить что то ещё - это все равно будет быстро, т.к. у вас уже будет ограниченный список выборки.
И да, джойн тут играет магическую роль, не знаю почему, но планировщик мне показывал бОльшую эффективность, чем с where. Но тут не ручаюсь, стоит 15 версия если что.

Конечно, есть крайний случай, когда у вас фильтр подразумевается по всем полям выводимых данных. Ну, тут уже сами прикидывайте что вам важнее, скорость или место на диске/памяти.

Если данные хранятся отсортированные по id, то возможно просто вычитать их из таблицы будет даже проще. Но вот если порядок другой, то через индекс с объединением будет быстрей. В идеале построить индекс отсортированный по каждому возможному типу сортировки.

Мне не приходилась делать пагинацию с сортировкой по большим данным, мы данные копировали в Solr (брат ElasticSearch и OpenSearch). Там уже из коробки одним запросом и фильтрация и полное количество записей и фасеты с количеством попаданий.

https://www.elastic.co/guide/en/app-search/current/facets.html

Копья ломаются по поводу того, что пагинация по limit/offset - плохо или вообще - пагинация зло. Но, по предложенному способу вроде замечаний особо нет )). Дело в том, что есть случаи, когда и объем и нагрузка небольшие и заказчик хочет вот простейшую пагинацию. Это мой случай как раз. ИМХО, с ума сходить не стоит и лучше сделать простейшим способом. Ну а когда/если понадобится оптимизация - тогда и будем морщить лоб )). Спасибо, коллеги!

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


Это противоречит тому, что написанно в статье:

Хочу поделиться лайфхаком по реализации универсального способа пагинации в произвольных запроса к БД для получания списков

Ни одного противоречия нет. Вы придираетесь )

ИМХО, с ума сходить не стоит и лучше сделать простейшим способом. Ну а когда/если понадобится оптимизация - тогда и будем морщить лоб ))


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

Вся соль в том, что крайне нежелательно делать это в лоб двумя запросами к БД.

Под морщить лоб я понимаю либо отказ от пагинации либо оптимизация для снижения нагрузки )

Немного о пагинации в реальности.

https://www.amazon.com/s/ref=nb_sb_noss?url=search-alias%3Daps&field-keywords=key

Вверху страницы:

На странице 4:
145-192 of over 200,000 results for "key"
На странице 5:
193-240 of over 100,000 results for "key"

Где-то внизу плашка пагинации, там 1, 2, 3, ..., 7. Всего семь страниц на эти over дофига результатов.

Сортировка такого объёма выдачи не имеет смысла логически, я так никогда стану делать, потому что перед тем как начать пользоваться пагинацией и сортировкой я добавлю больще фильтров и уточнений запросов.


Я не понимаю ваш запрос. Он будет на каждую страницу делать либо seq scan либо index scan

Правильный запрос такой

select * from table where id > lastpage order by id limit pagesize;

Детали этого запроса оставляю за кадром

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

В общем пагинация у вас получилась для очень мелких таблиц, но рабочая.

А выяснять количество записей вообще не надо. Нет следующей страницы значит это конец. Если надо отобразить общее количество страниц, то можно быстро из индекса вытащить. Данные один чёрт будут весь сеанс меняться.

" id > lastpage" - очаровательно :). Особенно учитывая то, что это разные типы данных в моём случае

Я вам просто скелет написал, как правильно страницу выбирать

Это не скелет, увы. Это просто непонятная строчка ))

как можно сравнивать с чем-то id, когда это UUID?

UUID сравнивать можно.

Когда я говорю, что мой запрос - это скелет, то это именно скелет, по крайней мере потому что там присутствует псевдокод.

Ниже план вашего запрос (в таблице 1 млн записей)

Limit (cost=43884.04..43888.43 rows=100 width=46) (actual time=1379.551..1379.575 rows=100 loops=1)
Buffers: shared hit=5405 read=2735, temp read=7954 written=5616
-> WindowAgg (cost=0.42..43888.43 rows=1000000 width=46) (actual time=1168.296..1337.128 rows=1000000 loops=1)
Buffers: shared hit=5405 read=2735, temp read=7954 written=5616
-> Index Scan using page_idx on page a (cost=0.42..31388.42 rows=1000000 width=38) (actual time=4.623..817.783 rows=1000000 loops=1)
Buffers: shared hit=5405 read=2735
Planning:
Buffers: shared hit=32 read=1
Planning Time: 0.432 ms
Execution Time: 1387.984 ms

А вот моего селета

Limit (cost=0.42..10.09 rows=95 width=38) (actual time=0.026..0.096 rows=100 loops=1)
Buffers: shared hit=6
-> Index Scan using page_idx on page a (cost=0.42..10.09 rows=95 width=38) (actual time=0.024..0.084 rows=100 loops=1)
Index Cond: (id > 999900)
Buffers: shared hit=6
Planning:
Buffers: shared hit=6 read=1
Planning Time: 0.321 ms
Execution Time: 0.139 ms

Что называется найдите 10 отличий.

А тут наверно и сами догадаетесь что происходит

Limit (cost=0.42..302.27 rows=100 width=66) (actual time=0.023..0.288 rows=100 loops=1)
Buffers: shared hit=103
-> Index Scan using page_uidx on page a (cost=0.42..15095.77 rows=5001 width=66) (actual time=0.022..0.276 rows=100 loops=1)
Index Cond: (u_id >= 'fff90f55-49ff-4e75-88b6-8cb6b029a0e6'::uuid)
Buffers: shared hit=103
Planning:
Buffers: shared hit=11
Planning Time: 0.231 ms
Execution Time: 0.341 ms

Нет, не догадаюсь и догадываться не хочу, уж простите. Если вы не хотите быть понятым, с чего вдруг мне прикладывать к этому усилия ))) И кстати, никакого псевдокода в вашем примере нет. Это скорее недоразумение, а не пример ))

Все это как-то странно... Ведь догадываться ни о чем не надо, если вы умеете читать планы выполнения запросов, конечно же. Пара минут у меня есть. Ваш запрос отрабатывает на 1 млн записей за 1.4 секунды. Мой за 0 секунд. С применением uuid тоже за 0 секунд. Вам понятно?

Сравнивать можно, просто не ожидать человечности от этой сортировки.

Если нужно отстортировать по дате создания то нужно использовать два параметра.

Сначала сортируем по времени, потом для записей созданных в одно время по UUID

SELECT * 
FROM table 
WHERE uuid > last_uuid AND created_at > last_created_at 
ORDER BY created_at, uuid
LIMIT pagesize;

Оконные функции вас спасут. Все делается одним count(*) over () Q ,.... далее ваш запрос с ограничениями и сортировками

Sign up to leave a comment.

Articles