- PVSM.RU - https://www.pvsm.ru -

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки

«Просто так» результат SQL-запроса возвращает записи в том порядке, который наиболее удобен серверу СУБД. Но человек гораздо лучше воспринимает хоть как-то упорядоченные данные — это помогает быстро сравнивать соответствие различных датасетов.

Поэтому со временем у разработчика может выработаться рефлекс «Дай-ка я на всякий случай это вот отсортирую!» Конечно, иногда подобная сортировка бывает оправдана прикладными задачами, но обычно такой случай выглядит как в старом анекдоте:

Программист ставит себе на тумбочку перед сном два стакана. Один с водой — на случай, если захочет ночью пить. А второй пустой — на случай, если не захочет.

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

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 1

#1: Нехватка work_mem

Обычно мы просто не обращаем внимания на наличие лишнего Sort-узла в плане — он отрабатывает очень быстро по сравнению с самим извлечением данных. Но стоит чему-то пойти не так и перестать сортируемой выборке умещаться в памяти…

SELECT generate_series(1, 1e6) i ORDER BY i;

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 2 [1]

Из-за сортировки мы начали «свапаться» на диск (buffers temp written), и потратили на это порядка 70% времени!

Давайте как-то ускорять. И самый простой способ, который рекомендует нам подсказка под иконкой восклицательного знака — просто увеличить параметр work_mem [2] так, чтобы памяти на сортировку все-таки хватало:

ALTER SYSTEM SET work_mem = '128MB';

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 3 [3]

На четверть быстрее, хотя мы не трогали ни текст запроса, ни структуру базы! К сожалению, такой способ не всегда допустим — например, наш СБИС [4] обслуживает одновременно тысячи клиентов в рамках одного узла PostgreSQL, и просто так раздавать память направо-налево мы не можем себе позволить. Может, есть способ вообще избавиться от сортировки?..

#2: Сортировка уже отсортированного

Начнем с самого простого варианта — чтения данных из вложенного запроса:

SELECT
  *
FROM
  (
    SELECT generate_series(1, 1e6) i
  ) T
ORDER BY
  i;

Почти 2/3 всего времени выполнения заняла сортировка, хоть и происходила вся в памяти:
PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 4 [5]

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

SELECT
  *
FROM
  (
    SELECT generate_series(1, 1e6) i
  ) T;

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 5 [6]

Вроде мы ничего особенного не сделали, а запрос уже ускорился более чем в 2 раза.

Этим же свойством сохранения порядка записей обладают чтение из CTE (Common Table Expression, узел CTE Scan в плане), SRF (Set-Returning Function, Function Scan) или VALUES (Values Scan).

#3: Вложенная отладочная сортировка

Следующий пример обычно возникает в результате отладки, когда разработчик сначала проверял внутренний запрос, а потом вставил его в «обвязку» внешнего:

SELECT
  i
, 1e6 - i
FROM
  (
    SELECT
      *
    FROM
      generate_series(1, 1e6) i
    WHERE
      (i % 2, i % 3, i % 5, i % 7) = (1, 2, 4, 6)
    ORDER BY -- осталось от отладки
      i DESC
  ) T
ORDER BY
  i;

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 6 [7]

Мы-то понимаем, что «внутренняя» сортировка ни на что не влияет (в большинстве случаев), но СУБД — нет. Поправим:

SELECT
  i
, 1e6 - i
FROM
  (
    SELECT
      *
    FROM
      generate_series(1, 1e6) i
    WHERE
      (i % 2, i % 3, i % 5, i % 7) = (1, 2, 4, 6)
  ) T
ORDER BY
  i;

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 7 [8]

Минус одна сортировка. Но вспомним предыдущий пункт насчет упорядоченности SRF, и исправим до конца:

SELECT
  i
, 1e6 - i
FROM
  generate_series(1, 1e6) i
WHERE
  (i % 2, i % 3, i % 5, i % 7) = (1, 2, 4, 6);

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 8 [9]

Вот и минус вторая сортировка. На данном конкретном запросе мы выиграли порядка 5%, всего лишь убрав лишнее.

#4: Index Scan вместо сортировки

Одна из «классических» причин неэффективности SQL-запросов, о которых я рассказывал в статье «Рецепты для хворающих SQL-запросов» [10]:

CREATE TABLE tbl AS
SELECT
  (random() * 1e4)::integer fk -- 10K разных внешних ключей
, now() - ((random() * 1e8) || ' sec')::interval ts
FROM
  generate_series(1, 1e6) pk;  -- 1M "фактов"

CREATE INDEX ON tbl(fk); -- индекс для foreign key

То есть при разработке структуры базы мы описали FOREIGN KEY, повесили индекс на это поле, чтобы он отрабатывал быстро… А потом пошли прикладные задачи.

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

SELECT
  ts
FROM
  tbl
WHERE
  fk = 1 -- отбор по конкретной связи
ORDER BY
  ts DESC -- хотим всего одну "последнюю" запись
LIMIT 1;

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 9 [11]

Н-да… Прочитать почти мегабайт данных ради одного числа — это сильно. Но давайте расширим индекс полем сортировки, используемой в запросе:

DROP INDEX tbl_fk_idx;
CREATE INDEX ON tbl(fk, ts DESC);

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 10 [12]

Ух! Теперь весь наш запрос выполнился быстрее, чем одна только сортировка в предыдущем варианте.

#5: UNION ALL вместо сортировки

Но что делать, если от нас хотят такую сортировку, которая ну никак нормально на индекс не укладывается, хотя вроде и должна?

TRUNCATE TABLE tbl;

INSERT INTO tbl
SELECT
  CASE
    WHEN random() >= 1e-5
      THEN (random() * 1e4)::integer
  END fk -- 10K разных внешних ключей, из них 0.0001 - NULL'ы
, now() - ((random() * 1e8) || ' sec')::interval ts
FROM
  generate_series(1, 1e6) pk;  -- 1M "фактов"

Допустим, что нам надо показать оператору «топовые» 10 заявок — сначала все «неназначенные» заявки (fk IS NULL), начиная от самых старых, а затем все «его» (fk = 1):

SELECT
  *
FROM
  tbl
WHERE
  fk IS NULL OR
  fk = 1
ORDER BY
  fk NULLS FIRST, ts DESC
LIMIT 10;

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 11 [13]

Вроде и индекс у нас есть, вроде и сортировка по нужным ключам, но как-то все некрасиво в плане… Но давайте воспользуемся знанием, что чтение из вложенного запроса сохраняет порядок — разделим нашу выборку на две заведомо непересекающиеся и снова «склеим» с помощью UNION ALL, примерно как делали это в статье «PostgreSQL Antipatterns: сказ об итеративной доработке поиска по названию, или «Оптимизация туда и обратно»» [14]:

(
  SELECT
    *
  FROM
    tbl
  WHERE
    fk IS NULL
  ORDER BY
    fk, ts DESC
  LIMIT 10
)
UNION ALL
(
  SELECT
    *
  FROM
    tbl
  WHERE
    fk = 1
  ORDER BY
    fk, ts DESC
  LIMIT 10
)
LIMIT 10;

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 12 [15]

И — снова ни одной сортировки в плане, а запрос стал почти в 5 раз быстрее.

#6: Сортировки для оконных функций

Давайте теперь попробуем посчитать сразу по первым 10 клиентам одновременно количество заявок и время последней с помощью оконных функций [16]:

SELECT DISTINCT ON(fk)
  *
, count(*) OVER(PARTITION BY fk ORDER BY ts) -- без DESC!
FROM
  tbl
WHERE
  fk < 10
ORDER BY
  fk, ts DESC; -- хотим "последнее" значение ts

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 13 [17]

Сначала мы сортируем по (fk, ts) для вычисления «оконного» count(*), а потом еще раз по (fk, ts DESC) — для вычисления DISTINCT.

Замечу, что если просто написать сортировку как в самом запросе count(*) OVER(PARTITION BY fk ORDER BY ts DESC), то будет, конечно, быстрее. Только вот результат неправильный — везде будут одни единички.

Но ведь count, в отличие от разных first_value/lead/lag/..., вообще не зависит от порядка записей — давайте просто уберем сортировку для него:

SELECT DISTINCT ON(fk)
  *
, count(*) OVER(PARTITION BY fk)
FROM
  tbl
WHERE
  fk < 10
ORDER BY
  fk, ts DESC;

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 14 [18]

Так, от одной сортировки избавились. Правда, из-за этого теперь стали читать чуть больше buffers, обменяв Bitmap Heap Scan на Index Only Scan по нашему индексу, с которым совпадает целевая сортировка — зато быстрее!

Хм… Но ведь и оставшаяся сортировка с ним тоже совпадает. Можно ли и от нее избавиться, не нарушив корректность результата? Вполне! Для этого всего лишь укажем нужную «рамку» окна [19] «по всем записям» (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING):

SELECT DISTINCT ON(fk)
  *
, count(*) OVER(PARTITION BY fk ORDER BY ts DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
FROM
  tbl
WHERE
  fk < 10
ORDER BY
  fk, ts DESC;

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 15 [20]

Итого — тот же результат в 2.5 раза быстрее, и без единой лишней сортировки.

Bonus: Полезные сортировки, которые не происходят

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

SELECT DISTINCT ON(fk)
  *
FROM
  tbl
ORDER BY
  fk, ts DESC;

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 16 [21]

Прочитать почти 8GB данных ради 10K записей — как-то многовато… Давайте воспользуемся методикой «рекурсивного DISTINCT»:

WITH RECURSIVE T AS (
  (
    SELECT
      fk
    , ts
    FROM
      tbl
    ORDER BY
      fk, ts DESC
    LIMIT 1 -- первая запись с минимальным ключом
  )
UNION ALL
  SELECT
    X.*
  FROM
    T
  , LATERAL(
      SELECT
        fk
      , ts
      FROM
        tbl
      WHERE
        fk > T.fk
      ORDER BY
        fk, ts DESC
      LIMIT 1 -- следующая по индексу запись
    ) X
  WHERE
    T.fk IS NOT NULL
)
TABLE T;

PostgreSQL Antipatterns: убираем медленные и ненужные сортировки - 17 [22]

Получилось в 12 раз быстрее, потому что мы не читали ничего лишнего, в отличие от Index Only Scan. Хоть мы и использовали дважды в запросе ORDER BY, ни одной сортировки в плане так и не появилось.

Вероятно, в будущих версиях PostgreSQL для таких «точечных» чтений появится соответствующий тип узла Index Skip Scan [23]. Но скоро его ждать не стоит.

Замечу, что результат этих запросов все-таки немного отличается — второй не обрабатывает записи с fk IS NULL. Но кому надо — извлечет их отдельно.

Знаете другие способы устранения лишних сортировок? Напишите в комментариях!

Автор: Боровиков Кирилл

Источник [24]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/postgresql/357674

Ссылки в тексте:

[1] Image: https://explain.tensor.ru/archive/explain/30bf7eab25fb3dcf9fcd2e9eecf2e533:0:2020-10-06

[2] work_mem: https://postgrespro.ru/docs/postgresql/12/runtime-config-resource#GUC-WORK-MEM

[3] Image: https://explain.tensor.ru/archive/explain/0d8e23101d68874b07778f0a14c5b7e0:0:2020-10-06

[4] СБИС: https://sbis.ru/all_services

[5] Image: https://explain.tensor.ru/archive/explain/652f58b32442ab2c743a255d5ecd4491:0:2020-10-05

[6] Image: https://explain.tensor.ru/archive/explain/7260f742fb047bb453e79d729ca5d20e:0:2020-10-05

[7] Image: https://explain.tensor.ru/archive/explain/fc324748fb6ad18d659217b41bb167b5:0:2020-10-06

[8] Image: https://explain.tensor.ru/archive/explain/61a486284080d7c77d31d2bd97cd3509:0:2020-10-06

[9] Image: https://explain.tensor.ru/archive/explain/98bcb4e77152eafa93c41906a92976ce:0:2020-10-06

[10] «Рецепты для хворающих SQL-запросов»: https://habr.com/ru/post/492694/

[11] Image: https://explain.tensor.ru/archive/explain/ce4045fdb5fb26fab6faf0b2dbdaf19f:0:2020-10-06

[12] Image: https://explain.tensor.ru/archive/explain/4fab2d1b9475d443e27b7d28a1f86992:0:2020-10-06

[13] Image: https://explain.tensor.ru/archive/explain/be5f73c7ec67908f021cd3bf2a1a8564:0:2020-10-06

[14] «PostgreSQL Antipatterns: сказ об итеративной доработке поиска по названию, или «Оптимизация туда и обратно»»: https://habr.com/ru/post/491162/

[15] Image: https://explain.tensor.ru/archive/explain/fb2ce9d632223576123243b0d0a06f5c:0:2020-10-06

[16] оконных функций: https://postgrespro.ru/docs/postgresql/12/tutorial-window

[17] Image: https://explain.tensor.ru/archive/explain/24824fc871c776bea62e0152721ecf56:0:2020-10-07

[18] Image: https://explain.tensor.ru/archive/explain/467b21420f82e4c55468437548f327c3:0:2020-10-07

[19] «рамку» окна: https://postgrespro.ru/docs/postgresql/12/sql-expressions#SYNTAX-WINDOW-FUNCTIONS

[20] Image: https://explain.tensor.ru/archive/explain/f23f215664509296efd478e4139d636b:0:2020-10-07

[21] Image: https://explain.tensor.ru/archive/explain/a6aa187a6ba61e06314ea9d208857448:0:2020-10-07

[22] Image: https://explain.tensor.ru/archive/explain/1a4cf800457b57d462648df21810e121:0:2020-10-07

[23] Index Skip Scan: https://commitfest.postgresql.org/19/1741/

[24] Источник: https://habr.com/ru/post/522114/?utm_source=habrahabr&utm_medium=rss&utm_campaign=522114