Recepti za bolne SQL poizvedbe

Pred nekaj meseci smo napovedali explain.tensor.ru — javno storitev za analizo in vizualizacijo načrtov poizvedb k PostgreSQL.

V preteklem času ste ga že uporabili več kot 6000-krat, toda ena izmed priročnih funkcij je morda ostala neopažena — to so strukturni namigi, ki izgledajo približno tako:

Recepti za bolne SQL poizvedbe

Sledite jim in vaše poizvedbe bodo "gladke in svilnate". 🙂

Če pa govorimo resno, številne situacije, ki poizvedbo upočasnijo in naredijo "umuljne" z vidika virov, so tipične in jih je mogoče prepoznati po strukturi in podatkih načrta.

V tem primeru se ne bo treba vsakemu posameznemu razvijalcu ukvarjati z iskanjem možnosti optimizacije samemu, zanašajoč se zgolj na svoje izkušnje — lahko mu pomagamo, da povemo, kaj se dogaja, kakšen je lahko vzrok, in kako pristopiti k rešitvi. Kar smo tudi storili.

Recepti za bolne SQL poizvedbe

Poglejmo to nekoliko podrobneje — kako jih določamo in do katerih priporočil vodijo.

Za boljše razumevanje tematike lahko najprej poslušate ustrezen del iz mojega predavanja na PGConf.Russia 2020, nato pa preidite na podrobno analizo vsakega primera:

Predvajaj video

#1: индексная «недосортировка»

Ko nastane

Prikaz zadnjega računa za stranko „OOO Kolokolčik“.

Kako prepoznati

-> Limit
   -> Sort
      -> Index [Only] Scan [Backward] | Bitmap Heap Scan

Priporočila

Uporabljeni indeks razširiti z razvrstitvenimi polji.

Primer:

CREATE TABLE tbl AS
SELECT
  generate_series(1, 100000) pk  -- 100K "dejstev"
, (random() * 1000)::integer fk_cli; -- 1K različnih zunanjih ključev

CREATE INDEX ON tbl(fk_cli); -- indeks za tuji ključ

SELECT
  *
FROM
  tbl
WHERE
  fk_cli = 1 -- izbira po posebni povezavi
ORDER BY
  pk DESC -- želimo samo eno "zadnjo" vnos
LIMIT 1;

Recepti za bolne SQL poizvedbe
[pogledajte na explain.tensor.ru]

Takoj lahko opazimo, da je bilo iz indeksa prebrano več kot 100 zapisov, ki so nato vsi razvrščeni, in obdržan je bil le en.

Odpravimo:

DROP INDEX tbl_fk_cli_idx;
CREATE INDEX ON tbl(fk_cli, pk DESC); -- dodali smo ključ za razvrščanje

Recepti za bolne SQL poizvedbe
[pogledajte na explain.tensor.ru]

Tudi na taki preprosti izbiri — 8,5-krat hitreje in 33-krat manj branj. Učinek bo toliko bolj očiten, čim več imate "dejstev" za vsako vrednost fk.

Opažam, da bo tak indeks deloval kot "prefiksni" vsaj tako dobro kot prejšnji in pri drugih poizvedbah z fk, kjer razvrščanja po pk ni bilo in ni (več o tem si lahko preberete v mojem članku o iskanju neučinkovitih indeksov). Vključno s tem bo zagotovil tudi normalno podporo za jasne tuje ključe po tem polju.

#2: пересечение индексов (BitmapAnd)

Ko nastane

Показати всі договори по клієнту «ТОВ Дзвоник», укладені від імені «НАО Лютик».

Kako prepoznati

-> BitmapAnd
   -> Bitmap Index Scan
   -> Bitmap Index Scan

Priporočila

Kreirati композитний індекс по полям з обох вихідних або розширити один із існуючих полями з другого.

Primer:

CREATE TABLE tbl AS
SELECT
  generate_series(1, 100000) pk      -- 100K "фактів"
, (random() *  100)::integer fk_org  -- 100 різних зовнішніх ключів
, (random() * 1000)::integer fk_cli; -- 1K різних зовнішніх ключів

CREATE INDEX ON tbl(fk_org); -- індекс для foreign key
CREATE INDEX ON tbl(fk_cli); -- індекс для foreign key

SELECT
  *
FROM
  tbl
WHERE
  (fk_org, fk_cli) = (1, 999); -- відбір по конкретній парі

Recepti za bolne SQL poizvedbe
[pogledajte na explain.tensor.ru]

Odpravimo:

DROP INDEX tbl_fk_org_idx;
CREATE INDEX ON tbl(fk_org, fk_cli);

Recepti za bolne SQL poizvedbe
[pogledajte na explain.tensor.ru]

Тут виграш менший, оскільки Bitmap Heap Scan знайшов час для ефективної роботи. Але все ж в 7 разів швидше і в 2.5 рази менше зчитувань.

#3: объединение индексов (BitmapOr)

Ko nastane

Показати перші 20 найстаріших «своїх» або неназначених заявок для обробки, причому свої в пріоритеті.

Kako prepoznati

-> BitmapOr
   -> Bitmap Index Scan
   -> Bitmap Index Scan

Priporočila

Uporabite UNION [ALL] для об’єднання підзапитів за кожним із блоків OR умов.

Primer:

CREATE TABLE tbl AS
SELECT
  generate_series(1, 100000) pk  -- 100K "фактів"
, CASE
    WHEN random() < 1::real/16 THEN NULL -- з ймовірністю 1:16 запис "нічиї"
    ELSE (random() * 100)::integer -- 100 різних зовнішніх ключів
  END fk_own;

CREATE INDEX ON tbl(fk_own, pk); -- індекс з "схожою" сортировкою

SELECT
  *
FROM
  tbl
WHERE
  fk_own = 1 OR -- свої
  fk_own IS NULL -- ... або "нічиї"
ORDER BY
  pk
, (fk_own = 1) DESC -- спочатку "свої"
LIMIT 20;

Recepti za bolne SQL poizvedbe
[pogledajte na explain.tensor.ru]

Odpravimo:

(
  SELECT
    *
  FROM
    tbl
  WHERE
    fk_own = 1 -- спочатку "свої" 20
  ORDER BY
    pk
  LIMIT 20
)
UNION ALL
(
  SELECT
    *
  FROM
    tbl
  WHERE
    fk_own IS NULL -- потім "нічиї" 20
  ORDER BY
    pk
  LIMIT 20
)
LIMIT 20; -- але всього - 20, більше й не потрібно

Recepti za bolne SQL poizvedbe
[pogledajte na explain.tensor.ru]

Ми скористалися тим, що всі 20 потрібних записів були одразу отримані вже в першому блоці, тому другий, з більш «дорожчим» Bitmap Heap Scan, навіть не виконувався — в підсумку в 22 рази швидше, в 44 рази менше зчитувань!

Більш детальна розповідь про цей спосіб оптимізації на конкретних прикладах можна прочитати в статтях PostgreSQL Antipatterns: škodljivi JOIN-i in OR in PostgreSQL Antipatterns: zgodba o iterativnem izboljšanju iskanja po imenu, ali "Optimizacija sem in tja".

Узагальнений варіант упорядкованого відбору за кількома ключами (а не тільки за парою const/NULL) розглянуто в статті SQL HowTo: пишемо while-цикл прямо в запиті, або «Елементарна трьохходовка».

#4: читаем много лишнего

Ko nastane

Як правило, виникає при бажанні «додати ще один фільтр» до вже існуючого запиту.

«А у вас немає такого ж, але з перламутровими гудзиками?» х/ф «Брильянтова рука»

Наприклад, модифікуючи задачу вище, показати перші 20 найстаріших «критичних» заявок для обробки, незалежно від їх призначення.

Kako prepoznati

-> Seq Scan | Bitmap Heap Scan | Index [Only] Scan [Backward]
   && 5 × rows 80% прочитанного
   && loops × RRbF > 100 -- и при этом больше 100 записей суммарно

Priporočila

Создать [более] специализированный индекс s WHERE-условием или включить в индекс дополнительные поля.

Если условие фильтрации является «статичным» для ваших задач — то есть не предполагает расширения перечня значений в будущем — лучше использовать WHERE-индекс. В эту категорию хорошо укладываются разные boolean/enum-статусы.

Если же условие фильтрации может принимать разные значения, то лучше расширить индекс этими полями — как в ситуации с BitmapAnd выше.

Primer:

CREATE TABLE tbl AS
SELECT
  generate_series(1, 100000) pk -- 100K "фактов"
, CASE
    WHEN random() < 1::real/16 THEN NULL
    ELSE (random() * 100)::integer -- 100 разных внешних ключей
  END fk_own
, (random() < 1::real/50) critical; -- 1:50, что заявка "критичная"

CREATE INDEX ON tbl(pk);
CREATE INDEX ON tbl(fk_own, pk);

SELECT
  *
FROM
  tbl
WHERE
  critical
ORDER BY
  pk
LIMIT 20;

Recepti za bolne SQL poizvedbe
[pogledajte na explain.tensor.ru]

Odpravimo:

CREATE INDEX ON tbl(pk)
  WHERE critical; -- добавили "статичное" условие фильтрации

Recepti za bolne SQL poizvedbe
[pogledajte na explain.tensor.ru]

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

#5: разреженная таблица

Ko nastane

Разнообразные попытки сделать собственную очередь обработки задач, когда большое количество обновлений/удалений записей на таблице приводят к ситуации большого количества «мертвых» записей.

Kako prepoznati

-> Seq Scan | Bitmap Heap Scan | Index [Only] Scan [Backward]
   && loops × (rows + RRbF)  64

Priporočila

Регулярно вручную проводить VACUUM [FULL] или добиться адекватно частой отработки autovacuum с помощью тонкой настройки его параметров, в том числе для конкретной таблицы.

В большинстве случаев подобные проблемы оказываются вызваны плохой компоновкой запросов при вызовах с бизнес-логики вроде тех, которые были рассмотрены в PostgreSQL Antipatterns: сражаемся с ордами «мертвецов».

Но надо понимать, что даже VACUUM FULL может помочь не всегда. Для таких случаев стоит ознакомиться с алгоритмом из статьи DBA: когда пасует VACUUM — чистим таблицу вручную.

#6: чтение с «середины» индекса

Ko nastane

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

Kako prepoznati

-> Index [Only] Scan [Backward]
   && loops × (rows + RRbF)  64

Priporočila

Pozorno preglejte strukturo uporabljenega indeksa in ključna polja, določena v poizvedbi — verjetno, del indeksa ni določen. Verjetno boste morali ustvariti podoben indeks, vendar brez predpona polj ali se naučiti iterirati njihove vrednosti.

Primer:

USTVARI TABELO tbl AS
IZBERI
  generate_series(1, 100000) pk      -- 100K "dejstev"
, (random() *  100)::integer fk_org  -- 100 različnih zunanjih ključev
, (random() * 1000)::integer fk_cli; -- 1K različnih zunanjih ključev

USTVARI INDeks NA tbl(fk_org, fk_cli); -- vse skoraj kot v #2
-- le ta ločen indeks po fk_cli smo že ocenili za odvečen in ga odstranili

IZBERI
  *
IZ
  tbl
KJER
  fk_cli = 999 -- fk_org pa ni določen, čeprav je prej v indeksu
LIMIT 20;

Recepti za bolne SQL poizvedbe
[pogledajte na explain.tensor.ru]

Zdi se, da je vse v redu, celo po indeksu, vendar je nekako sumljivo — za vsako od 20 prebranih zapisov je bilo treba prebrati 4 strani podatkov, 32KB na zapis — ali ni preveč? Tudi ime indeksa tbl_fk_org_fk_cli_idx spodbuja razmišljanje.

Odpravimo:

USTVARI INDeks NA tbl(fk_cli);

Recepti za bolne SQL poizvedbe
[pogledajte na explain.tensor.ru]

Nenadoma — 10-krat hitreje in 4-krat manj brati.!

Druge primere neučinkovite rabe indeksov lahko vidite v članku DBA: najdemo nepotrebne indekse..

#7: CTE × CTE

Ko nastane

V poizvedbi smo sestavili "debela" CTE iz različnih tabel, nato pa smo se odločili, da jih povežemo JOIN.

Primer je relevanten za različice pod v12 ali poizvedbe z S MATERIALIZIRANO.

Kako prepoznati

-> CTE Scan
   && zanke > 10
   && zanke × (vrstice + RRbF) > 10000
      -- prevelik kartesian izdelek CTE

Priporočila

Temeljito analizira poizvedbo — ali so CTE sploh potrebni? Если все-таки да, то uporabi "slovarstvo" v hstore/json po modelu, opisanem v PostgreSQL Antipatterns: udarimo s slovarjem po težkem JOIN.

#8: swap на диск (temp written)

Ko nastane

Enkratna obdelava (sortiranje ali unikatizacija) velikega števila zapisov ne vstopa v dodeljeno pomnilnik za to.

Kako prepoznati

-> *
   && začasno zapisano > 0

Priporočila

Če uporabljena količina pomnilnika operaciji ne presega nastavljene vrednosti parametra work_mem, je smiselno, da jo prilagodite. Lahko takoj v konfiguraciji za vse, ali pa prek SET [LOCAL] za določeno poizvedbo/transakcijo.

Primer:

PREDSTAVITE work_mem;
-- "16MB"

IZBERI
  random()
IZ
  generate_series(1, 1000000)
NAROČILNO
  1;

Recepti za bolne SQL poizvedbe
[pogledajte na explain.tensor.ru]

Odpravimo:

SET work_mem = '128MB'; -- pred izvedbo poizvedbe

Recepti za bolne SQL poizvedbe
[pogledajte na explain.tensor.ru]

Iz jasnih razlogov, če se uporablja samo pomnilnik, ne pa disk, se bo poizvedba izvajala veliko hitreje. Pri tem se tudi del obremenitve s HDD odstrani.

Vendar je treba razumeti, da dodelitev preveč pomnilnika nikoli ne bo možna — preprosto ga ne bo dovolj za vse.

#9: неактуальная статистика

Ko nastane

V zbirko podatkov so vnesli kar precej, vendar niso uspeli opraviti ANALYZE.

Kako prepoznati

-> Sec Scan | Bitmap Heap Scan | Indeks [Samo] Scan [Nazaj]
   && razmerje >> 10

Priporočila

Opraviti. ANALYZE.

Več informacij o tem je zapisano v PostgreSQL antipatterni: statistika vodi.

#10: «что-то пошло не так»

Ko nastane

Prišlo je do čakanja na zaklep, ki ga je nalagal konkurenčni poizvedbi, ali pa so primanjkovali strojnih virov CPU/hypervisorja.

Kako prepoznati

-> *
   && (shared hit / 8K) + (shared read / 1K) < time / 1000
      -- RAM hit = 64MB/s, HDD read = 8MB/s
   && time > 100ms -- brali smo malo, vendar predolgo

Priporočila

Uporabite zunanji sistem za spremljanje strežnikov glede na prisotnost zaklepov ali nenormalne porabe virov. O našem načrtu organizacije tega procesa za stotine strežnikov smo že poročali tukaj in tukaj.

Recepti za bolne SQL poizvedbe
Recepti za bolne SQL poizvedbe

Izvor: habr.com

Kupite zanesljivo gostovanje za spletne strani z zaščito pred DDoS, VPS VDS strežniki 🔥 Kupite zanesljivo gostovanje za spletne strani z zaščito pred DDoS, VPS VDS strežniki | ProHoster