Постраничный вывод больших объемов данных

Главная / Блог /

По различным причинам в web-интерфейсах оказывается предпочтительным ограничение максимального количества данных, отображаемых одновременно, которое достигается через постраничную разбивку этих данных. Этими причинами являются и ограниченная скорость передачи данных через интернет, и скорость отображения в браузере этих данных (которые зачастую представляются в виде достаточно сложной разметки). Но при достижении определенных объемов данных, критичной с точки зрения производительности, становится и сама выборка нужных данных из базы данных. Вопросам оптимизации постраничного вывода данных на стороне БД и будет посвящена эта статья. В ней рассматривается задача постраничного вывода больших объемов данных на примере PostgreSQL. Большая часть написанного справедлива также и для MySQL и для любой реляционной базы. Только нужно учитывать, что в MySQL планировщик запросов несколько слабее.

Для примера создадим табличку, содержащую id и некоторое числовое значение.

CREATE TABLE big_table
(
  id serial PRIMARY KEY,
  "value" double precision
)

Теперь добавим туда немного данных.

INSERT INTO big_table (value) SELECT random()*100 FROM generate_series(1,1000000)

Предположим, что мы хотим постранично выводить оттуда данные упорядоченные по столбцу value:

SELECT * FROM big_table ORDER BY value LIMIT 20

Посмотрим, какой получился план:

Limit  (cost=42015.64..42015.69 rows=20 width=12) (actual time=4047.578..4047.687 rows=20 loops=1)
  ->  Sort  (cost=42015.64..44515.64 rows=1000000 width=12) (actual time=4047.571..4047.607 rows=20 loops=1)
        Sort Key: value
        Sort Method:  top-N heapsort  Memory: 17kB
        ->  Seq Scan on big_table  (cost=0.00..15406.00 rows=1000000 width=12) (actual time=0.037..2022.398 rows=1000000 loops=1)
Total runtime: 4047.778 ms

Получилось не очень быстро. Поскольку мы сортируем по столбцу value, то нам может помочь индекс по нему.

CREATE INDEX big_table_value_idx ON big_table(value);

Посмотрим, что произойдет с нашим планом теперь.

Limit  (cost=0.00..0.95 rows=20 width=12) (actual time=0.029..0.166 rows=20 loops=1)
  ->  Index Scan using big_table_value_idx on big_table  (cost=0.00..47603.17 rows=1000000 width=12) (actual time=0.023..0.087 rows=20 loops=1)
Total runtime: 0.240 ms

Стало куда лучше. Но по мере листания

SELECT * FROM big_table ORDER BY value LIMIT 20 OFFSET 100

результат ухудшется

Limit  (cost=4.76..5.71 rows=20 width=12) (actual time=0.583..0.716 rows=20 loops=1)
  ->  Index Scan using big_table_value_idx on big_table  (cost=0.00..47603.17 rows=1000000 width=12) (actual time=0.022..0.443 rows=120 loops=1)
Total runtime: 0.785 ms

А когда дойдём по середины

SELECT * FROM big_table ORDER BY value LIMIT 20 OFFSET 500000

то будет примерно также как и без индекса

Limit  (cost=23801.59..23802.54 rows=20 width=12) (actual time=4397.397..4397.591 rows=20 loops=1)
  ->  Index Scan using big_table_value_idx on big_table  (cost=0.00..47603.17 rows=1000000 width=12) (actual time=0.022..3409.592 rows=500020 loops=1)
Total runtime: 4397.673 ms

Почему так происходит? Дело в том, что когда PostgreSQL оптимизирует запрос с конструкцией вида "LIMIT n OFFSET m" с помощью индекса то ему приходится искать по индексу первые n+m записей а из них первые m пропускать. При больших значениях m это оказывается не лучше, чем сканировать всю таблицу. Выход один - заменить OFFSET на WHERE. Предположим, что мы получили первую страницу данных следующего вида:

   id   |         value
--------+----------------------
 880439 |  1.2572854757309e-006
 860116 | 6.20726495981216e-005
 699877 | 7.89295881986618e-005
 345070 |  0.000164657831192017
 614704 |  0.000217463821172714
 276126 |  0.000326475128531456
 225441 |  0.000362470746040344
 159893 |  0.000384915620088577
 288870 |  0.000771228224039078
 660139 |  0.000779842957854271
 605310 |  0.000936398282647133
 928812 |  0.000983802601695061
 276966 |    0.0010006595402956
 778343 |    0.0010328833013773
 908325 |   0.00132769346237183
 463928 |   0.00134636647999287
 550207 |   0.00150990672409534
 248902 |   0.00154105946421623
 556308 |   0.00168662518262863
 329455 |   0.00169374980032444
(20 rows)

Мы видим, что последняя из выбранных строк имеет значение 0.00169374980032444 в столбце value. Теперь, можно было бы заменить конструкцию "OFFSET 20" на "WHERE value > 0.00169374980032444".

SELECT * FROM big_table WHERE value > 0.00169374980032444 ORDER BY value LIMIT 20

План в этом случае будет такой:

Limit  (cost=0.00..1.00 rows=20 width=12) (actual time=0.047..0.193 rows=20 loops=1)
  ->  Index Scan using big_table_value_idx on big_table  (cost=0.00..50101.42 rows=999900 width=12) (actual time=0.040..0.113 rows=20 loops=1)
        Index Cond: (value > 0.00169374980032444::double precision)
Total runtime: 0.269 ms

Но что если значение 0.00169374980032444 не уникально, тогда мы пропустим больше строк, чем нужно. Чтобы это учесть нужно сделать, чтобы упорядочивание было однозначным, например добавить id к ORDER BY: "ORDER BY value, id". Тогда можно будет использовать следующую конструкцию: "WHERE value = 0.00169374980032444 AND id > 329455 OR value > 0.00169374980032444". К счастью, она имеет более удобную запись: "WHERE (value, id) > (0.00169374980032444, 329455)". Т.к. мы упорядочиваем по паре (value, id) то для БД будет более удобно использовать следующий индекс:

CREATE INDEX big_table_value_id_idx ON big_table(value, id);

И запрос на получение второй страницы будет иметь следующий вид:

SELECT * FROM big_table WHERE (value, id) > (0.00169374980032444, 329455) ORDER BY value, id LIMIT 20

и план:

Limit  (cost=0.00..1.09 rows=20 width=12) (actual time=0.041..0.178 rows=20 loops=1)
 ->  Index Scan using big_table_value_id_idx on big_table  (cost=0.00..54533.99 rows=999900 width=12) (actual time=0.035..0.099 rows=20 loops=1)
       Index Cond: (ROW(value, id) > ROW(0.00169374980032444::double precision, 329455))
Total runtime: 0.249 ms

Как видно, при таком подходе листание будет происходить очень быстро. Однако пролистывание сразу на много страниц вперёд остаётся ресурсоёмкой задачей, поэтому интерфейс высоконагруженного приложения лучше организовать таким образом, чтобы пользователь не мог запросить сразу страницу №10000.

Помимо непосредственно листания, нужно также выводить пользователю общее число строк в таблице. К сожалению, выполнение COUNT потребует сканирования всех строк (даже в простых запросах вида "SELECT COUNT(*) FROM table", если говорить о базах с полноценной реализацией MVCC). Спасает в этом случае одно - выводить не точное число строк, а приблизительное. Его можно оценить с помощью EXPLAIN.

EXPLAIN SELECT * FROM big_table
Seq Scan on big_table  (cost=0.00..15406.00 rows=1000000 width=12)

Полученный план можно распарсить regexp'ом и получить из него оценку количества строк. (В PostgreSQL 9.0 обещают возможность вывода плана в форматах JSON, XML и YAML, что устранит необходимость в специальном парсинге плана)

Примечение 1.

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

Примечание 2

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

SELECT * FROM big_table WHERE id % 2 = 0 ORDER BY value, id

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

PostgreSQL, оптимизация Написал Александр Коротков ‧ 14 / 05 / 2010

Комментарии

Категории

Облако тегов

Подписка

Для того, что бы получать информацию о свежих обзорах интернет-магазинов и новости отрасли, подпишитесь.
* — поля, обязательные для заполнения