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

Перед тем как продолжить, следует упомянуть пагинацию на стороне приложения. Некоторые приложения переносят всю (или большую часть) серверной информации в клиент и разделяют ее на странице там. Для малых объемов данных, пагинация на стороне приложения может быть хорошим выбором, сокращая количество HTTP вызовов. Этот подход становится непрактичным, когда записи начинают исчисляться тысячами. Разбиение на страницы на стороне сервера, имеет следующие достоинства:

  • Более быстрая загрузка начальной страницы
  • Более высокая точность, когда общие данные изменяются
  • Более быстрые операции на больших объемах данных
  • Инкапсуляция бизнес-логики
  • Более высокая производительность на клиентах с ограниченными ресурсами

PostgreSQL дает нам определенное количество техник пагинации на стороне сервера, которые отличаются по скорости, целостности (не теряют записи), а также поддержку шаблонов доступа к определенным страницам. Не все методы работают не во всех ситуациях, некоторые требуют специальных данных, или запросов. Рассмотрим методы в порядке общности, начиная с тех, которые работают с любыми запросами, и продолжая теми, которые требуют упорядоченных данных. Закончим же мы несколькими экзотическими методами, которые основаны на внутреннем устройстве PostgreSQL.

Разбиение произвольных запросов


Limit-Offset


Самый простой метод пагинации, limit-offset, является и самым рискованным. К сожалению, он является одной из основ учебных пособий по веб-разработке. Библиотеки объектно-реляционного отображения (ORM) делают использование этого метода легким и заманчивым, от SQLAlchemy'ого .slice(1, 3) до ActiveRecord'ого .limit(1).offset(3) и до Sequelize'ого .findAll({ offset: 3, limit: 1 }). Это не совпадение, что limit-offset используется повсеместно, вы можете прикреплять его к любому запросу без дальнейшей модификации.

ORM методы limit'а и offset'а это одно дело, вспомогательные библиотеки для пагинации могут быть еще более обманчивы. К примеру, популярная Ruby библиотека Kaminari использует limit-offset по-умолчанию, скрывая его за высокоуровневым интерфейсом.

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

Вот каким образом limit-offset пагинация может быть неконсистентной. Предположим что пользователь переходит со страницы n на страницу n + 1, пока в тот же момент новый элемент вставлен на страницу n. Это вызовет и дублирование (последний элемент со страницы n вытеснен на страницу n + 1), и пропуск (новый элемент). В качестве альтернативы, предположим что удален элемент n, в тот момент, когда пользователь перешел на страницу n + 1. Предварительно загруженный начальный элемент страницы n + 1 сдвинется на страницу n и будет пропущен.

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

-- Создаем таблицу со случайными строками меняющегося размера
CREATE TABLE medley AS
  SELECT
    generate_series(1,10000000) AS n,
    substr(concat(md5(random()::text), md5(random()::text)), 1, (random() * 64)::integer + 1) AS description;

-- Оповещаем планировщик о кардинально изменившемся размере таблицы
VACUUM ANALYZE;

-- Малые сдвиги освежающе быстры
EXPLAIN ANALYZE SELECT * FROM medley LIMIT 100;

Ориентировочная стоимость достаточно низка:

                                                     QUERY PLAN
--------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.00..1.85 rows=100 width=38) (actual time=0.008..0.036 rows=100 loops=1)
   ->  Seq Scan on medley  (cost=0.00..185460.60 rows=9999660 width=38) (actual time=0.007..0.017 rows=100 loops=1)
 Planning time: 0.040 ms
 Execution time: 0.059 ms
(4 rows)

Выбор offset = 1000, меняет его стоимость до 19 и время выполнения до 0.609 мс. Как только offset = 5000000, стоимость становится уже 92734 и время выполнения 758.484 мс.

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

Когда использовать: Limit-Offset. Приложения с ограниченной глубиной пагинации и терпимые к неконсистентности результата.

Курсоры


Несмотря на свои недостатки, метод limit-offset имеет плюс в виде отсутствия влияния на сервер. В противоположность этому подходу, имеется другой метод разделения на страницы, курсоры. Как и смещение, курсоры могут быть использованы с любыми запросами, но они отличаются тем, что требуют от сервера отдельного соединения и транзакции через HTTP клиент.

Вот каким образом могут быть использованы курсоры:

-- Мы должны находиться в транзакции
BEGIN;
-- Открываем курсор для запроса
DECLARE medley_cur CURSOR FOR SELECT * FROM medley;
-- Получаем 10 строк
FETCH 10 FROM medley_cur;
-- ...
-- Получаем еще 10 строк, с того места, где остановились в прошлый раз
FETCH 10 FROM medley_cur;
-- Все готово
COMMIT;

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

Каждый подход разделения на страницы имеет свои слабые стороны, и курсоры — не исключение: они зависимы от использования ресурсов и связки клиент-сервер. Каждая открытая транзакция потребляет выделенные ресурсы базы, и не масштабируется для большого количества клиентов. Конечно же, существуют «WITH HOLD» курсоры, которые могут существовать за пределами транзакции, но они должны материализовывать данные.Таким образом, указанные ошибки делают пагинацию курсорами подходящей только для узкого круга ситуаций, например для внутренней сети.

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

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

Пагинация упорядоченных запросов


Пагинация по набору ключей


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

К примеру, давайте вернется к нашему примеру:

-- Добавляем индекс для пагинации (btrees поддерживают неравенство)
CREATE INDEX n_idx ON medley USING btree (n);
SELECT * FROM medley ORDER BY n ASC LIMIT 5;

С моими случайными данными он возвращает:

 n |                         description
---+-------------------------------------------------------------
 1 | 74f70e009396
 2 | 8dac5a085eb670a29058d
 3 | fce303a32e89181bf5df1601487
 4 | fddcced2c12e83516b3bd6cc94f23a012dfd
 5 | f51ae548dd27f51147e53e839eeceb6b0c92922145276d668e73d4a6621
(5 rows)

Теперь пользователь может посмотреть на максимальный n из результата и использовать его для вызова следующей страницы:

SELECT * FROM medley
 WHERE n > 5
 ORDER BY n ASC
  LIMIT 5;

Даже при фильтрации с n > 5000000, это работает быстрее, чем в limit-offset примере.

                                                           QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.43..0.62 rows=5 width=38) (actual time=0.101..0.103 rows=5 loops=1)
   ->  Index Scan using n_idx on medley  (cost=0.43..185579.42 rows=5013485 width=38) (actual time=0.100..0.102 rows=5 loops=1)
         Index Cond: (n > 5000000)
 Planning time: 0.071 ms
 Execution time: 0.119 ms
(5 rows)

Такая пагинация работает быстро и при этом гарантирует целостность данных. Любые добавления/удаления до текущей страницы оставят результат неизменным. Два слабых места этого метода — это отсутствие произвольного доступа и возможного соединения между клиентом и сервером.

Вообще, нет способа переместиться на выбранную страницу, не посещая предыдущих для определения их максимальных элементов. При определенных условиях, тем не менее, мы можем сделать лучше. Если значения в проиндексированной колонке равномерно распределены (или даже лучше, смежные номера без пробелов), пользователь может произвести некоторые математические расчеты для нахождения интересующей страницы, потому что индекс делает дешевым нахождение наибольшей величины:

EXPLAIN ANALYZE SELECT max(n) FROM medley;
                                                 QUERY PLAN
------------------------------------------------------------------------------------------------------------
 Result  (cost=0.46..0.47 rows=1 width=0) (actual time=0.021..0.021 rows=1 loops=1)
   InitPlan 1 (returns $0)
     ->  Limit  (cost=0.43..0.46 rows=1 width=4) (actual time=0.018..0.018 rows=1 loops=1)
           ->  Index Only Scan Backward using n_idx on medley  (cost=0.43..284688.43 rows=10000000 width=4) (actual time=0.017..0.017 rows=1 loops=1)
                 Index Cond: (n IS NOT NULL)
                 Heap Fetches: 0
 Planning time: 0.087 ms
 Execution time: 0.042 ms
(8 rows)

Другой вопрос пагинации по ключевым значениям, соединение клиент/сервер, требует внимания. Изначально пользователь не знает какие колонки проиндексированы. Сервер, вероятно, предоставит конечную точку с фиксированным результатом, нежели позволит клиенту изменять порядок. Предоставленный клиенту код может не знать какой столбец упорядочен, сервер должен предоставить подсказку как запросить следующую страницу. RFC5988 определяет отношение предыдущей и следующей HTTP ссылок для кодирования ссылок для пользователя, по которым он должен перейти.

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

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

Диковинная, специализированная пагинация


Clustered TID Scan


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

  1. Не требуем от страниц того, чтобы они были одинакового размера
  2. Поддерживаем только единственную очередность для разделенных на страницы строк

Трюк заключается в том, чтобы выбрать возвращенные страницы которые связаны со страницами из базы данных на диске, или с определенными частями этих страниц на диске. Каждая таблица в базе данных PostgreSQL содержит в себе секретную колонку, которая называется ctid и идентифицирует ее строку:

SELECT ctid, * FROM medley WHERE n <= 10;
  ctid  | n  |                         description
--------+----+-------------------------------------------------------------
 (0,1)  |  1 | 74f70e009396
 (0,2)  |  2 | 8dac5a085eb670a29058d
 (0,3)  |  3 | fce303a32e89181bf5df1601487
 (0,4)  |  4 | fddcced2c12e83516b3bd6cc94f23a012dfd
 (0,5)  |  5 | f51ae548dd27f51147e53e839eeceb6b0c92922145276d668e73d4a6621
 (0,6)  |  6 | eb9fe1dfe1e421903f96b3b5c5dfe1ee1253582d728c35b4ee7330b
 (0,7)  |  7 | e95202d7f5c612f8523ae705d
 (0,8)  |  8 | 6573b64aff262a2b940326
 (0,9)  |  9 | a0a43
 (0,10) | 10 | 82cdc134bd249a612cfddd3088dd09e32de5f4fa33
(10 rows)

Каждый ctid представляет из себя следующий вид: (страница, строка). PostgreSQL может получать строки очень быстро по ctid'у, на самом деле, так работают индексы — они связывают значения колонок с ctid'ми.

Следует обратить внимание, хоть PostgreSQL и определяет отношение порядка на основе tid типа, он не может эффективно получить ctid'ы из неравенства

EXPLAIN ANALYZE SELECT count(1) FROM medley WHERE ctid >= '(0,1)'::tid AND ctid < '(1,0)'::tid;
                                                      QUERY PLAN
----------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=235589.00..235589.01 rows=1 width=0) (actual time=1241.851..1241.852 rows=1 loops=1)
   ->  Seq Scan on medley  (cost=0.00..235464.00 rows=50000 width=0) (actual time=477.933..1241.802 rows=116 loops=1)
         Filter: ((ctid >= '(0,1)'::tid) AND (ctid < '(1,0)'::tid))
         Rows Removed by Filter: 9999884
 Planning time: 0.047 ms
 Execution time: 1241.889 ms
(6 rows)

Запрос диапазонов не работает, но все еще существует способ эффективного запроса всех строк со страницы на диске. Каждая страница содержит current_setting('block_size') байтов данных (обычно 8к). Строки связаны 32-битным указателем, так что, в большинстве своем, приходится block_size/4 строк на страницу. (На самом деле строки, как правило, шире, чем минимальный размер и четверть размера блока обеспечивает верхнюю границу строк на странице.) Следующая последовательность будет генерировать все возможные ctid'ы на j-ой странице

SELECT ('(' || j || ',' || s.i || ')')::tid
 FROM generate_series(0,current_setting('block_size')::int/4) AS s(i);

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

SELECT * FROM medley WHERE ctid = ANY (ARRAY
  (SELECT ('(0,' || s.i || ')')::tid
    FROM generate_series(0,current_setting('block_size')::int/4) AS s(i)
  )
);

Планировщик определил стоимость выполнения этого запроса равной cost=25.03..65.12 и выполняет его за 2.765мс. Запрос страницы под номером 10000 имеет такую же стоимость. Таким образом мы получаем реально произвольный доступ, почему бы его не любить?

Тут имеется три узких места:

  1. Когда строки удалены — они оставляют дыры в страницах.
  2. Порядок строк не может быть значимым. База данных вставляет строки в дыры, оставленные от удаления строк, что приведет к выпадению строк из последовательности.
  3. «Where» не поддерживается

В некоторых ситуациях это не проблема. Один случай — это данные, естественный порядок которых соотносится с порядком добавления в базу, такие как только возрастающие данные интервалов времени. Другой — это данные, которые не меняются часто. Это связано с тем, что мы имеем контроль над расположением строк на страницам через команду CLUSTER.

Давайте вернемся к нашему примеру. Его строки на диске упорядочены по n колонке в возрастающем порядке, так как это порядок, в котором мы добавляли их в базу. Но что если мы хотим отсортировать их по столбцу description? Для этого нам придется физически перестроить таблицу, создав индекс на колонке description и выполнить кластеризацию.

CREATE INDEX description_idx ON medley USING btree (description);
CLUSTER medley USING description_idx;

Теперь выборка всех строк с первой страницы возвращает нам данные, отсортированные в алфавитном порядке по колонке description. Если таблица изменится, то новые строки выпадут из алфавитного списка, но пока таблица неизменна — возвращаемые объекты будут в полном порядке. Кроме того, она может быть периодически перекластеризована после изменений, несмотря на то, что эта операция блокирует таблицу и не может быть выполнена когда людям нужен к ней доступ.

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

SELECT pg_relation_size('medley') / current_setting('block_size')::int;

Когда использовать: TID Scan. Когда требуется быстрый произвольный доступ и не требуется фильтрация. Особенно хорошо работает с только увеличивающимися данными времени, с практически не изменяющейся шириной строки.

Набор ключей с оценочными закладками


Как мы видели, обычная пагинация по наборам ключей не позволяет перемещаться на конкретные страницы, кроме случаев с догадками пользователя. Тем не менее, сборщик статистики PostgreSQL поддерживает гистограммы распределения значений по столбцам. Мы можем использовать эти оценки в сочетании с ограничениями и небольшими смещениями, чтобы получить быструю пагинацию с произвольным доступом через гибридный подход.

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

SELECT array_length(histogram_bounds, 1) - 1
  FROM pg_stats
 WHERE tablename = 'medley'
   AND attname = 'n';

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

{719,103188,193973,288794, … ,9690475,9791775,9905770,9999847}


Обратите внимание, что значения приблизительные. Первое число это не ровно 0, и последнее это не ровно десять миллионов. Диапазоны разделяют нашу информацию в блок размера B = 10,000,000 / 100 = 100,000 строк.

Мы можем использовать диапазоны гистограмм сборщика статистики PostgreSQL для получения вероятностно правильных страниц. Если мы выбираем размер страницы на клиентской стороне, равный W, то как мы получим i-ую страницу? Она будет находиться в блоке IW / B, со смещением IW% B.

Выбирая W = 20, давайте запросим страницу 270000 из нашей тестовой таблицы.

WITH bookmark AS (
    SELECT (histogram_bounds::text::int[])[((270000 * 20) / 100000)+1] AS start,
           (histogram_bounds::text::int[])[((270000 * 20) / 100000)+2] AS stop
    FROM pg_stats
    WHERE tablename = 'medley'
    AND attname = 'n'
    LIMIT 1
  )
SELECT *
FROM medley
WHERE n >= (select start from bookmark)
AND n < (select stop from bookmark)
ORDER BY n ASC
LIMIT 20
OFFSET ((270000 * 20) % 100000);

Это выполняется сверхбыстро (обратите внимание что сдвиг происходит за время, близкое к нулю в данном случае). Запрос возвращает строки с n=5407259 до 5407278. Истинные значения на странице 270000 равны с n = 5400001 до 5400020. Промах составляет 7239, или приблизительно 0.1%.

Нам повезло с выбором страницы в этом случае. Для контраста, страница 74999 требует смещения на 99980. Мы знаем, что наше смещение будет не более 100000. Верхняя граница находится у нас под контролем, если мы хотим достичь компромисса. Настраивая сборщик статистики PostgreSQL, мы можем получить более точную гистограмму по столбцам.

ALTER TABLE medley ALTER COLUMN n SET statistics 1000;
VACUUM ANALYZE;

Теперь мы имеем 1000, вместо 100 значений гистограммы. В моей базе данных это выглядит следующим образом:

{10,10230,20863, …, 9980444,9989948,9999995}

При этом шаг нашего смещения будет не более 10000. Компромисс заключается в том, что планировщик теперь просматривает больше величин, но замедляется. Так что это что-то среднее между неэффективностью смещения и накладными расходами сборщика статистики.

Этот гибридный «набор ключей/смещение» метод вероятно не подходит для многих применений пагинации в реальной жизни. И тут не будет работать условие «where». Кроме того, он неточен и становится только более и более неточным при изменении таблицы и если сборщик статистики давно не запускался.

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

Выводы


Как и многие другие инженерные решения, выбор техники пагинации требует компромиссов. Можно с уверенностью сказать, что пагинация по набору ключей наиболее применима для среднего сайта с упорядоченным линейным доступом. Однако даже limit/offset метод имеет свои сильные стороны, и более экзотические методы обеспечивают специальные эксплуатационные характеристики для определенных видов данных. Как вы видите, есть довольно много возможностей. Выберите правильный инструмент для работы и не позволяйте пагинации быть закрытой книгой.
Поделиться с друзьями
-->

Комментарии (33)


  1. the_unbridled_goose
    18.05.2016 16:19
    +1

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


  1. ZOXEXIVO
    18.05.2016 17:03
    -2

    Раньше думал, что только в MongoDB offset тормозит


  1. Shannon
    18.05.2016 17:04
    +12

    Если цель просто сделать оптимизацию пагинации, избавиться от оверхеда limit offset, а не изучить возможности postgesql, то достаточно использовать JOIN
    Вместо:

    SELECT * FROM test_table ORDER BY id LIMIT 100000, 30
    

    Написать:
    SELECT * FROM test_table JOIN (SELECT id FROM test_table ORDER BY id LIMIT 100000, 30) as b ON b.id = test_table.id
    


    image


    1. Throwable
      18.05.2016 17:57
      +1

      Можете объяснить откуда берутся такие результаты? Что за движок и какие планы выполнения обоих запросов?


      1. Shannon
        18.05.2016 19:29
        +3

        В общем немного упрощенно, но смысл примерно такой:
        Допустим у нас есть нужные (простые или составные) индексы по полям для сортировки

        Для первого случая подстава в том, что LIMIT работает не до, а после того как была произведена выборка. Допустим, было бы условие «WHERE id > 100000 AND id < 100030», использовался бы индекс, и в лапы LIMIT уже попали бы только 30 записей (и лимит был бы уже не нужен, но такой простой подход для пагинации обычно не удобен)

        Но так как никакого условия конкретизирующего нет (а если и есть, то всё равно обычно результат выборки большой), то сначала выбирается вся таблица, и в LIMIT попадает огромное кол-во срок, при этом он не знает где находится 100000 строка, поэтому вынужден пролистывать, пока не отсчитает 100000, чтобы взять лишь 30 строк

        Для случая с JOIN в запросе всего лишь «SELECT id», то есть выборка id будет производится не из таблицы, а из оптимизированного индекса, и даже если будет какое-то условие, то мы всё равно в результате получим оптимизированную выборку индекса, в которой более менее точно знаем на какой позиции будет находится 100000 запись, поэтому просто перескакиваем туда и берем 30 записей

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


        1. Throwable
          19.05.2016 11:55

          Вообще говоря, странное поведение для планировщика. Обычно запрашиваемые поля никак влияют на план выполнения запроса, а только поля, участвующие в предикатах, сортировках, джоинах, etc… Цель планирования — получить keyset, по которому можно будет сделать fetch — достать все остальные данные из таблицы. То есть по-сути, планировщих должен всегда неявно трансформировать первый запрос во второй.


          1. kshvakov
            19.05.2016 14:01
            +2

            Не совсем так, цель планирования — получить самый «дешевый» план выполнения, запрашиваемые поля в этом случае влияют на стоимость «в районе» доступа к хранилищу (нужно нам с диска читать и сколько), поэтому это достаточно нормальное поведение.
            И, да, планировщик не должен всегда трансформировать подобные запросы, т.к. данный «финт ушами» во многих случаях дороже



      1. kshvakov
        18.05.2016 19:38
        +1

        С join он использует index only scan, что даёт выигрыш на выборку. Больше сложностей с получением общего числа строк в выборке, иногда помогает создание дополнительной таблицы со счетчиками и параметрами, тогда получение общего количества это просто сумма по параметрам, а вот с выборкой по подстроке уже сложности


      1. LAV45
        19.05.2016 08:20

        ~ $ psql --version
        psql (PostgreSQL) 9.5.2

        =# EXPLAIN ANALYZE SELECT * FROM medley LIMIT 30 OFFSET 5000000;
        Limit (cost=91666.88..91667.43 rows=30 width=37) (actual time=501.497..501.502 rows=30 loops=1)
        -> Seq Scan on medley (cost=0.00..183334.29 rows=10000029 width=37) (actual time=0.006..341.246 rows=5000030 loops=1)
        Planning time: 0.053 ms
        Execution time: 501.523 ms

        =# EXPLAIN ANALYZE SELECT * FROM medley t1 JOIN (SELECT n FROM medley ORDER BY n LIMIT 30 OFFSET 5000000) as t2 ON t2.n = t1.n;
        Nested Loop (cost=129844.71..130099.23 rows=30 width=41) (actual time=600.520..600.580 rows=30 loops=1)
        -> Limit (cost=129844.28..129845.06 rows=30 width=4) (actual time=600.506..600.507 rows=30 loops=1)
        -> Index Only Scan using medley_n_idx on medley (cost=0.43..259688.87 rows=10000029 width=4) (actual time=0.013..446.107 rows=5000030 loops=1)
        Heap Fetches: 0
        -> Index Scan using medley_n_idx on medley t1 (cost=0.43..8.45 rows=1 width=37) (actual time=0.001..0.001 rows=1 loops=30)
        Index Cond: (n = medley.n)
        Planning time: 0.222 ms
        Execution time: 600.607 ms


        1. Shannon
          19.05.2016 10:18

          В первом запросе забыли ORDER BY n, поэтому seq scan (результат хоть и быстрее, но выборка будет не та, которая нужна)

          https://habrahabr.ru/post/301044/#comment_9615682


    1. nightwolf_du
      18.05.2016 19:38
      +1

      Извините, не подскажете, как вы этого добились? Протестировал ваш пример,
      pg 9.3, debian
      16GB таблица, primary key на id. Примерно 71кк строк. Нагрузка равномерная, при 100к вне зависимости от смещения 20-21 секунда оба запроса.
      При 200к 38-40. Значимой разницы в производительности не замечено.
      Строки большие, в кэш базы/диска не влезают.
      Результат значительно при повторном прогоне не менялся.
      Планы:
      1) IndexScan -> Limit
      2) IndexOnlyScan -> limit ->NestedLoop
      IndexScan->


      1. Shannon
        18.05.2016 20:41
        +1

        На последнем postgresql для 10кк строк результаты такие:

        postgres=# EXPLAIN ANALYZE SELECT * FROM medley ORDER BY n OFFSET 500000 LIMIT 30;
                                                                       QUERY PLAN                                                               
        -------------------------------------
         Limit  (cost=17258.34..17259.37 rows=30 width=37) (actual time=202.280..202.292 rows=30 loops=1)
           ->  Index Scan using n_idx on medley  (cost=0.43..345158.43 rows=10000000 width=37) 
                (actual time=0.034..176.281 rows=500030 loops=1)
         Planning time: 0.123 ms
         Execution time: 202.323 ms
        (4 rows)
        
        postgres=# EXPLAIN ANALYZE SELECT * FROM medley JOIN 
                           (SELECT n FROM medley ORDER BY n OFFSET 500000 LIMIT 30) 
                           as b ON b.n = medley.n;
                                                                                QUERY PLAN                                                                         
        ------------------------------------------
         Nested Loop  (cost=12985.27..13239.79 rows=30 width=41) (actual time=132.953..133.150 rows=30 loops=1)
           ->  Limit  (cost=12984.83..12985.61 rows=30 width=4) 
                (actual time=132.897..132.912 rows=30 loops=1)
                 ->  Index Only Scan using n_idx on medley medley_1  (cost=0.43..259688.43 rows=10000000 width=4) 
                      (actual time=0.032..107.005 rows=500030 loops=1)
                       Heap Fetches: 0
           ->  Index Scan using n_idx on medley  (cost=0.43..8.45 rows=1 width=37) 
                 (actual time=0.007..0.007 rows=1 loops=30)
                 Index Cond: (n = medley_1.n)
         Planning time: 0.426 ms
         Execution time: 133.200 ms
        (8 rows)
        

        То есть по сути разница хоть и есть, но всё равно результат далек от того, что хотелось бы


        1. Arkham
          20.05.2016 04:29
          +2

          Есть ещё один трюк, попробуйте его:

          WITH temp_rows AS (
                  SELECT n 
                  FROM medley 
                  ORDER BY n OFFSET 500000 
                  LIMIT 30
          )
          
          SELECT * FROM medley WHERE medley.n = ANY(ARRAY(SELECT n FROM temp_rows)::uuid[])
          


          1. Arkham
            20.05.2016 04:52

            Забыл добавить, вместо «uuid[]» должен быть тип который соответствует массиву типа колонки «n».

            Вот результат для моей таблицы, с ~500к записей.

            Чистый order: 5338.266 ms
            EXPLAIN ANALYZE
            SELECT *
            FROM product
            ORDER BY guid
            OFFSET 200000
            LIMIT 10

            "Limit (cost=53081.04..53083.70 rows=10 width=427) (actual time=5338.193..5338.218 rows=10 loops=1)"
            " -> Index Scan using "pk-product" on product (cost=0.42..107495.58 rows=405026 width=427) (actual time=0.026..5201.130 rows=200010 loops=1)"
            "Planning time: 0.679 ms"
            "Execution time: 5338.266 ms"


          1. Shannon
            20.05.2016 11:28
            +1

            Для Int на той же таблице по сути разницы нет:
            Просто OFFSET: Execution time: 202.323 ms
            Вариант с WITH: Execution time: 138.158 ms
            Вариант с JOIN: Execution time: 133.200 ms

            explain
            postgres=# EXPLAIN ANALYZE WITH temp_rows AS (
                    SELECT n 
                    FROM medley 
                    ORDER BY n OFFSET 500000 
                    LIMIT 30
            )
            SELECT * FROM medley WHERE medley.n = ANY(ARRAY(SELECT n FROM temp_rows)::Int[]);
                                                                                     QUERY PLAN                                                                          
            -------------------------------------------------------------------------------------------------------------------------------------------------------------
             Index Scan using n_idx on medley  (cost=12986.44..13034.53 rows=10 width=38) (actual time=138.009..138.107 rows=30 loops=1)
               Index Cond: (n = ANY ($1))
               CTE temp_rows
                 ->  Limit  (cost=12984.63..12985.41 rows=30 width=4) (actual time=137.945..137.953 rows=30 loops=1)
                       ->  Index Only Scan using n_idx on medley medley_1  (cost=0.43..259694.04 rows=10000374 width=4) (actual time=0.038..110.982 rows=500030 loops=1)
                             Heap Fetches: 0
               InitPlan 2 (returns $1)
                 ->  CTE Scan on temp_rows  (cost=0.00..0.60 rows=30 width=4) (actual time=137.950..137.968 rows=30 loops=1)
             Planning time: 0.219 ms
             Execution time: 138.158 ms
            (10 rows)
            


          1. the_unbridled_goose
            20.05.2016 11:46
            +1

            У меня с более сложных подзапросом внутри WITH, с сортировкой по двум полям результаты следующие:

            Обычный limit-offset:

            cost=725946.11..725946.11 rows=1 width=1147) (actual time=1554.900..1554.915 rows=50 loops=1

            JOIN:
            cost=725954.67..725962.71 rows=1 width=2041) (actual time=1200.102..1201.138 rows=50 loops=1

            WITH:
            cost=1654277.66..1654362.74 rows=10 width=2041) (actual time=2729.149..2729.608 rows=50 loops=1


            1. the_unbridled_goose
              20.05.2016 12:08
              +1

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


            1. Arkham
              20.05.2016 12:11

              А можно запрос с эксплейном with'а?


              1. the_unbridled_goose
                20.05.2016 13:01

                Это проблематично, так как тестировалось на боевом контуре. В таком виде недостаточно?

                Index Scan (cost=1310619.54..1310704.63 rows=10 width=2041) (actual time=1906.651..1907.164 rows=50 loops=1)
                  CTE temp_rows
                    ->  Limit (cost=1310617.85..1310617.97 rows=50 width=32) (actual time=1906.502..1906.517 rows=50 loops=1)
                          ->  Sort (cost=1310542.85..1311220.85 rows=271201 width=32) (actual time=1899.475..1904.427 rows=30050 loops=1)
                                ->  Bitmap Heap Scan (cost=399032.63..1289016.16 rows=271201 width=32) (actual time=821.209..1723.774 rows=218436 loops=1)
                                      ->  Bitmap Index Scan (cost=0.00..398964.83 rows=305711 width=0) (actual time=705.200..705.200 rows=320530 loops=1)
                  InitPlan 2 (returns $1)
                    ->  CTE Scan on temp_rows  (cost=0.00..1.00 rows=50 width=16) (actual time=1906.506..1906.544 rows=50 loops=1)
                Planning time: 0.966 ms
                Execution time: 1911.184 ms
                


                1. Arkham
                  20.05.2016 17:09
                  +1

                  Да, тут уже дальше оптимизировать проблематично:
                  1. Bitmap Heap Scan — постгресу пришлось перепроверять версионность строк, это нормально для таблиц в которых часто происходят update/delete — если же таблица не из таких, то надо проверить когда был последний vacuum/analyze таблицы.
                  2. Sort — совсем грошёвые cost'ы, максимум что можно сделать — проверить сортировки в индексах, в том ли порядке они, как в запросе.

                  P.S. Можно попробовать избавиться от Bitmap Index Scan — разбив на несколько CTE, что бы получить Index Only Scan'ы.


                  1. the_unbridled_goose
                    20.05.2016 17:18
                    +1

                    Там вообще все очень непросто, увы. Но спасибо за совет.

                    1. Изменения там регулярные, кроме того, долгие хождения по вектору, тяжелому вектору. С вакуумом и статистикой особых проблем нет. Все регулярно, по мере необходимости. Помимо автовакуума, само собой.
                    2. Обязательно проверю еще раз что там с индексами.



                    1. the_unbridled_goose
                      20.05.2016 19:00
                      +1

                      Да, индекса не было нужного.

                      Было:

                      (cost=1365076.05..1365161.13 rows=10 width=2501) (actual time=2078.275..2078.763 rows=50 loops=1)
                      

                      Стало:
                      (cost=2589.09..2674.17 rows=10 width=2501) (actual time=59.720..60.222 rows=50 loops=1)
                      

                      И сам explain:
                      Index Scan (cost=2589.09..2674.17 rows=10 width=2501) (actual time=59.720..60.222 rows=50 loops=1)
                          ->  Limit  (cost=1294.04..2587.51 rows=50 width=32) (actual time=23.419..59.575 rows=50 loops=1)
                                ->  Index Scan (cost=0.56..11409104.75 rows=441025 width=32) (actual time=0.579..59.549 rows=100 loops=1)
                          ->  CTE Scan on temp_rows  (cost=0.00..1.00 rows=50 width=16) (actual time=23.423..59.661 rows=50 loops=1)
                      Planning time: 0.647 ms
                      Execution time: 60.287 ms
                      

                      О — оптимизация)


                      1. Arkham
                        21.05.2016 05:19
                        +1

                        Отличный результат!


      1. Shannon
        18.05.2016 22:24

        Проверил на mysql для таблицы 6кк строк, разница примерно такая же


        1. Shannon
          18.05.2016 22:29

          Всмысле хоть с join намного легче запрос, но это просто потому что без join намного тяжелее чем тут


    1. Shannon
      18.05.2016 23:52

      Окончательный вердикт. Эта информация немного преувеличена. Этот трюк работает редко и только с mysql, так как в индексе хранится видимость, и можно легко перепрыгнуть. В psql видимость хранится по другому, поэтому подобный трюк с JOIN не сработает так же хорошо, как для mysql, но при этом обычный LIMIT OFFSET будет оптимизирован и сработает намного лучше, чем в mysql.

      При этом добавляя условие в JOIN (без чего пагинация редко обходится), и добавляя составной индекс, в обоих случаях результат становится схожим и для mysql и для postgresql

      Разница в том, что для psql лучше не использовать join, а для mysql без join тяжеловато выходит


    1. franzose
      19.05.2016 02:05

      На MySQL подобный выигрыш по производительности тоже можно получить?


      1. kshvakov
        19.05.2016 07:50
        +2

        В MySql подобный выигрыш ощутимие и стабильнее по причине того, что даже при index only scan постгрес может «бегать» на диск что в explain будет показано в heap fetches, тут все дело в visibility map и такие «фокусы» в постгресе будут работать не на всех таблицах/данных особенно у тех кто «сознательно отключает autovacuum», хотя, стоит сказать, что с «глубокими» limit/offset постгрес справляется вполне достойно


  1. Tatikoma
    18.05.2016 21:43
    +2

    Полагаю следует уточнить, что метод limit… offset… в postgresql может давать неожиданные результаты без сортировки, т.н. одна и та же запись может оказаться на нескольких страницах (и, соответственно, часть записей не будет отображена), кажется это связано с особенностями оптимизации базой выполнения запросов.
    Во избежание таких неожиданностей при реализации этого метода в postgresql следует использовать сортировку по уникальному ключу (что делает запрос немного дороже).


    1. asm0dey
      18.05.2016 23:31
      +2

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


    1. kshvakov
      19.05.2016 07:55
      +1

      Крайне не рекомендуется использовать LIMIT без ORDER BY т.к. это может помешать в выборе правильного плана оптимизатором. В отличии от MySql у постгреса первичный ключ не кластер и он не «выравнивает» данные по индексу постоянно, отсюда и «неожиданный» результат т.к. при последовательном вычитывании данные будут расположены в порядке изменения (mvcc)


  1. kuzin_mv
    19.05.2016 08:20

    Хорошая презентация, с более сложным примером для случая «Пагинация по набору ключей» www.slideshare.net/MarkusWinand/p2d2-pagination-done-the-postgresql-way.