Sql

SQL трюки від DBA. Небанальні поради для розробників БД

Alex Alex 08 серпня
SQL трюки від DBA. Небанальні поради для розробників БД

Коли я починав свою кар'єру розробника, моєю першою роботою стала DBA (адміністратор бази даних, АБД). У ті роки, ще до AWS RDS, Microsoft, Google Cloud та інших хмарних сервісів, існувало два типу АБД:

  • АБД інфраструктури відповідали за налаштування бази даних, конфігурування сховища і турбувалися за резервні копії та реплікацію. Після налаштування БД інфраструктурний адміністратор час від часу «налаштовував екземпляри», наприклад, уточнював розміри кешей.
  • АБД програм отримував від АБД інфраструктури чисту базу і відповідав за її архітектуру: створення таблиць, індексів, обмежень і налаштування SQL. АБД також реалізовував ETL-процеси та міграцію даних. Якщо команда використовували збережені процедури, то АБД також їх підтримував.

АБД програм зазвичай були частиною команд розробки. Вони володіли глибокими знаннями з конкретної теми, тому зазвичай працювали тільки над одним-двома проєктами. Інфраструктурні адміністратори баз даних зазвичай входили в ІТ-команду і могли одночасно працювати над кількома проєктами.

Я адмін бази даних програм

У мене ніколи не було бажання возитися з бэкапами або налаштовувати сховище (упевнений, це цікаво!). Донині мені подобається говорити, що я адмін БД, який знає, як розробляти програми, а не розробник, який розбирається в базах даних.

У цій статті я поділюся хитрощами про розробку баз даних, які дізнався за свою кар'єру.

Зміст:

Оновлюйте лише те, що потрібно оновити

Операція UPDATE споживає досить багато ресурсів. Для її прискорення найкраще оновлювати тільки те, що потрібно оновити.

Ось приклад запиту на нормалізацію колонки email:

db=# UPDATE users SET email = lower(email);
UPDATE 1010000
Time: 1583.935 ms (00:01.584)

Виглядає невинно, так? Запит оновлює адреси пошти для 1 010 000 користувачів. Але чи потрібно оновлювати всі рядки?

db=# UPDATE users SET email = lower(email)
db-# WHERE email != lower(email);
UPDATE 10000
Time: 299.470 ms

Потрібно було оновити 10 000 рядків. Зменшивши кількість оброблюваних даних, ми знизили тривалість виконання з 1,5 секунд до менш ніж 300 мс. Це також заощадить нам надалі сили на супровід бази даних.

SQL трюки від DBA. Небанальні поради для розробників БД

Оновлюйте лише те, що потрібно оновити.

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

При великих завантаженнях відключайте обмеження та індекси

Обмеження — важлива частина реляційних баз даних: вони зберігають дані консистентними та надійними. Але у всього є своя ціна, і найчастіше доводиться розплачуватися при завантаженні або оновленні великої кількості рядків.

Давайте задамо схему невеликого сховища:

DROP TABLE IF EXISTS product CASCADE;
CREATE TABLE product (
    id serial PRIMARY KEY,
    name TEXT NOT NULL,
    price INT NOT NULL
);
INSERT INTO product (name, price)
    SELECT random()::text, (random() * 1000)::int
    FROM generate_series(0, 10000);


DROP TABLE IF EXISTS customer CASCADE;
CREATE TABLE customer (
    id serial PRIMARY KEY,
    name TEXT NOT NULL
);
INSERT INTO customer (name)
    SELECT random()::text
    FROM generate_series(0, 100000);


DROP TABLE IF EXISTS sale;
CREATE TABLE sale (
    id serial PRIMARY KEY,
    created timestamptz NOT NULL,
    product_id int NOT NULL,
    customer_id int NOT NULL
);

Тут визначаються різні типи обмежень, такі як «not null», а також обмеження на унікальність значень

Щоб встановити базову лінію, почнемо додавати в таблицю sale зовнішні ключі, після чого завантажемо туди дані

db=# ALTER TABLE sale ADD CONSTRAINT sale_product_fk
db-# FOREIGN KEY (product_id) REFERENCES product(id);
ALTER TABLE
Time: 18.413 ms

db=# ALTER TABLE sale ADD CONSTRAINT sale_customer_fk
db-# FOREIGN KEY (customer_id) REFERENCES customer(id);
ALTER TABLE
Time: 5.464 ms

db=# CREATE INDEX sale_created_ix ON sale(created);
CREATE INDEX
Time: 12.605 ms

db=# INSERT INTO SALE (created, product_id, customer_id)
db-# SELECT
db-#    now() - interval '1 hour' * random() * 1000,
db-#    (random() * 10000)::int + 1,
db-#    (random() * 100000)::int + 1
db-# FROM generate_series(1, 1000000);
INSERT 0 1000000
Time: 15410.234 ms (00:15.410)

Після визначення обмежень та індексів завантаження в таблицю мільйона рядків зайняла близько 15,4 с.

Тепер спочатку завантажимо дані в таблицю, і тільки потім додамо обмеження та індекси:

db=# INSERT INTO SALE (created, product_id, customer_id)
db-# SELECT
db-#    now() - interval '1 hour' * random() * 1000,
db-#    (random() * 10000)::int + 1,
db-#    (random() * 100000)::int + 1
db-# FROM generate_series(1, 1000000);
INSERT 0 1000000
Time: 2277.824 ms (00:02.278)

db=# ALTER TABLE sale ADD CONSTRAINT sale_product_fk
db-# FOREIGN KEY (product_id) REFERENCES product(id);
ALTER TABLE
Time: 169.193 ms

db=# ALTER TABLE sale ADD CONSTRAINT sale_customer_fk
db-# FOREIGN KEY (customer_id) REFERENCES customer(id);
ALTER TABLE
Time: 185.633 ms

db=# CREATE INDEX sale_created_ix ON sale(created);
CREATE INDEX
Time: 484.244 ms

Завантаження пройшло набагато швидше, 2,27 секунд замість 15,4. Індекси та обмеження створювалися після завантаження даних помітно довше, але весь процес виявився набагато швидше: 3,1 с. замість 15,4.

На жаль, в PostgreSQL з індексами так само вчинити не вийде, їх можна лише видаляти і перестворювати. В інших базах, наприклад, Oracle, можна відключати і включати індекси без переcтворення.

Для проміжних даних використовуйте UNLOGGED-таблиці

Коли ви міняєте дані в PostgreSQL, зміни записуються в журнал  (write ahead log (WAL)). Він використовується для підтримки цілісності, швидкої переіндексації в ході відновлення і підтримки реплікації.

Запис в WAL часто потрібен, але є деякі обставини, при яких ви можете відмовитися від WAL заради прискорення процесів. Наприклад, у випадку з проміжними таблицями.

Проміжними називають одноразові таблиці, в яких зберігаються тимчасові дані, використовувані для реалізації якихось процесів. Приміром, у ETL-процесах дуже часто завантажують дані з CSV-файлів в проміжні таблиці, очищають інформацію, а потім вантажать її в таблицю. У такому сценарії проміжна таблиця — одноразова і не використовується в резервних копіях або репліках.

SQL трюки від DBA. Небанальні поради для розробників БД

UNLOGGED-таблиця.

Проміжні таблиці, які не потрібно відновлювати у разі збою і які не потрібні в репліках, можна задати як UNLOGGED:

CREATE UNLOGGED TABLE staging_table ( /* table definition */ );

Увага: перш ніж використовувати UNLOGGED, переконайтеся, що ви повністю усвідомлюєте всі наслідки.

Реалізуйте процеси за допомогою WITH і RETURNING

Припустимо, у вас таблиця користувачів, і ви виявили, що в ній є дані що повторюються :

Table setup
db=# SELECT u.id u.email, o.id as order_id
FROM orders o JOIN users u ON o.user_id = u.id;

 id | email | order_id
----+-------------------+----------
 1 | [email protected] | 1
 1 | [email protected] | 2
 2 | [email protected] | 3
 3 | [email protected] | 4
 3 | [email protected] | 5

Користувач haki benita зареєстрований двічі, з поштою [email protected] та [email protected]. Оскільки ми не нормалізуємо адреси електронної пошти при внесенні в таблицю, тепер нам доведеться розібратися з дублями.

Нам потрібно:

  1. Визначити дублі за адресами у верхньому регістрі, і зв'язати дубльованих користувачів один з одним.
  2. Оновити замовлення, щоб вони посилалися тільки на один з дублів.
  3. Прибрати дублі з таблиці.

Зв'язати дубльованих користувачів можна за допомогою проміжної таблиці:

db-#     SELECT
db-#         lower(email) AS normalized_email,
db-#         min(id) AS convert_to_user,
db-#         array_remove(ARRAY_AGG(id), min(id)) as convert_from_users
db-#     FROM
db-#         users
db-#     GROUP BY
db-#         normalized_email
db-#     HAVING
db-#         count(*) > 1;
CREATE TABLE

db=# SELECT * FROM duplicate_users;
 normalized_email  | convert_to_user | convert_from_users
-------------------+-----------------+--------------------
 [email protected] |               2 | {3}

У проміжної таблиці містяться звязок між дублями. Якщо користувач з нормалізованою адресою пошти з'являється більше одного разу, ми присвоюємо йому мінімальний ID користувача, у до якого звернемо всі дублі. Інші користувачі зберігаються в array column і всі посилання на них будуть оновлені.

З допомогою проміжної таблиці оновимо посилання на дублі у таблиці orders:

db=# UPDATE
db-#    orders o
db-# SET
db-#    user_id = du.convert_to_user
db-# FROM
db-#    duplicate_users du
db-# WHERE
db-#    o.user_id = ANY(du.convert_from_users);
UPDATE 2

Тепер можна безпечно видалити дублі з users:

db=# DELETE FROM
db-#    users
db-# WHERE
db-#    id IN (
db(#        SELECT unnest(convert_from_users)
db(#        FROM duplicate_users
db(#    );
DELETE 1

Зверніть увагу, що для «перетворення» масиву ми використовували функцію unnest, яка перетворює кожен елемент на рядок.

Результат:

db=# SELECT u.id, u.email, o.id as order_id
db-# FROM orders o JOIN users u ON o.user_id = u.id;
 id |       email       | order_id
----+-------------------+----------
  1 | [email protected]       |        1
  1 | [email protected]       |        2
  2 | [email protected] |        3
  2 | [email protected] |        4
  2 | [email protected] |        5

Відмінно, всі появи користувача 3 ([email protected]) перетворені в користувача 2 ([email protected]).

Можемо перевірити, що дублі видалені з таблиці users:

db=# SELECT * FROM users;
 id |       email
----+-------------------
  1 | [email protected]
  2 | [email protected]

Тепер можна позбутися від проміжної таблиці:

db=# DROP TABLE duplicate_users;
DROP TABLE

Все добре, але занадто довго і потрібна очистка! Чи є спосіб краще?

Узагальнені табличні вирази (CTE)

За допомогою узагальнених табличних виразів, також відомих як вираз WITH, ми можемо виконати всю процедуру з допомогою єдиного SQL-запиту:

WITH duplicate_users AS (
    SELECT
        min(id) AS convert_to_user,
        array_remove(ARRAY_AGG(id), min(id)) as convert_from_users
    FROM
        users
    GROUP BY
        lower(email)
    HAVING
        count(*) > 1
),

update_orders_of_duplicate_users AS (
    UPDATE
        orders o
    SET
        user_id = du.convert_to_user
    FROM
        duplicate_users du
    WHERE
        o.user_id = ANY(du.convert_from_users)
)

DELETE FROM
    users
WHERE
    id IN (
        SELECT
            unnest(convert_from_users)
        FROM
            duplicate_users
    );

Замість проміжної таблиці ми створили узагальнений табличний вираз і багаторазово його використовували.

Повернення результатів з CTE

Одна з переваг виконання DML всередині виразу WITH полягає в тому, що ви можете повернути з нього дані за допомогою ключового слова RETURNING. Припустимо, нам потрібен звіт про кількість оновлених і видалених рядків:

WITH duplicate_users AS (
    SELECT
        min(id) AS convert_to_user,
        array_remove(ARRAY_AGG(id), min(id)) as convert_from_users
    FROM
        users
    GROUP BY
        lower(email)
    HAVING
        count(*) > 1
),

update_orders_of_duplicate_users AS (
    UPDATE
        orders o
    SET
        user_id = du.convert_to_user
    FROM
        duplicate_users du
    WHERE
        o.user_id = ANY(du.convert_from_users)
    RETURNING o.id
),

delete_duplicate_user AS (
    DELETE FROM
        users
    WHERE
        id IN (
            SELECT unnest(convert_from_users)
            FROM duplicate_users
        )
        RETURNING id
)

SELECT
    (SELECT count(*) FROM update_orders_of_duplicate_users) AS orders_updated,
    (SELECT count(*) FROM delete_duplicate_user) AS users_deleted
;

Результат:

 orders_updated | users_deleted
----------------+---------------
              2 |             1

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

УвагаЧитач Reddit вказав мені на можливу непередбачувану поведінку виконання DML в узагальнених табличних виразах:

Суб-вирази  в WITH виконуються конкурентно один з одним і з основним запитом. Тому при використанні в WITH  виразів що модифікують дані, фактичний порядок оновлень буде непередбачуваним

Це означає, що ви не можете покладатися на порядок виконання незалежних суб-виразів. Виходить, що якщо між ними є залежність, як у прикладі вище, ви можете покладатися на виконання залежних підвиразів до їх використання.

В колонках з низькою вибірковістю уникайте індексів

Припустимо, у вас є процес реєстрації, при якому користувач входить за адресою пошти. Щоб активувати акаунт, потрібно верифікувати пошту. Таблиця може виглядати так:

db=# CREATE TABLE users (
db-# id serial,
db-# username text,
db-# activated boolean
db-#);
CREATE TABLE

Більшість ваших користувачів — громадяни свідомі, вони реєструються з коректною поштовою адресою і негайно активують обліковий запис. Давайте заповнимо таблицю даними користувача, і будемо вважати, що 90 % користувачів активовано:

db=# INSERT INTO users (username, activated)
db-# SELECT
db-#     md5(random()::text) AS username,
db-#     random() < 0.9 AS activated
db-# FROM
db-#     generate_series(1, 1000000);
INSERT 0 1000000

db=# SELECT activated, count(*) FROM users GROUP BY activated;
 activated | count
-----------+--------
 f         | 102567
 t         | 897433

db=# VACUUM ANALYZE users;
VACUUM

Щоб вибрати кількість активованих і неактивованих користувачів, можна створити індекс по колонці activated:

db=# CREATE INDEX users_activated_ix ON users(activated);
CREATE INDEX

І якщо ви виберете кількість неактивованих користувачів, база скористається індексом:

db=# EXPLAIN SELECT * FROM users WHERE NOT activated;
                                      QUERY PLAN
--------------------------------------------------------------------------------------
 Bitmap Heap Scan on users  (cost=1923.32..11282.99 rows=102567 width=38)
   Filter: (NOT activated)
   ->  Bitmap Index Scan on users_activated_ix  (cost=0.00..1897.68 rows=102567 width=0)
         Index Cond: (activated = false)

База вирішила, що фільтр видасть 102 567 позицій, приблизно 10 % таблиці. Це узгоджується з завантаженими нами даними, так що таблиця добре впоралася.

Однак якщо ми вибиремо кількість активованих користувачів, то виявимо, що база вирішила не використовувати індекс:

db=# EXPLAIN SELECT * FROM users WHERE activated;
                       QUERY PLAN
---------------------------------------------------------------
 Seq Scan on users (cost=0.00..18334.00 rows=897433 width=38)
 Filter: activated

Багатьох розробників це збиває з пантелику, коли база даних не використовує індекс. Пояснити, чому вона так робить, можна наступним чином: якби вам потрібно було прочитати всю таблицю, ви скористалися б індексом?

Ймовірно що ні, навіщо це потрібно? Читання з диска — операція дорога, тому ви захочете читати якомога менше. Наприклад, якщо таблиця розміром 10 Мб, а індекс розміром 1 Мб, то для зчитування всій таблиця доведеться зчитати з диска 10 Мб. А якщо додати індекс, то вийде 11 Мб. Це марнотратно.

Давайте тепер подивимося на статистику, яку PostgreSQL зібрав по таблиці:

db=# SELECT attname, n_distinct, most_common_vals, most_common_freqs
db-# FROM pg_stats
db-# WHERE tablename = 'users' AND attname='activated';
------------------+------------------------
attname           | activated
n_distinct        | 2
most_common_vals  | {t,f}
most_common_freqs | {0.89743334,0.10256667}

Коли PostgreSQL проаналізував таблицю, він з'ясував, що у колонці activated є два різних значення. Значення t в колонці most_common_vals відповідає частоті 0.89743334 в колонці most_common_freqs, а значення f відповідає частоті 0.10256667. Після аналізу таблиці база даних визначила, що 89,74 % записів — це активовані користувачі, а решта 10,26 % — неактивовані.

На основі цієї статистики PostgreSQL вирішив, що краще сканувати всю таблицю, ніж припускати, що 90 % рядків задовольнять умові. Поріг, після якого база може вирішувати, чи використовувати індекс, залежить від багатьох факторів, і жодного емпіричного правила тут немає.

SQL трюки від DBA. Небанальні поради для розробників БД

Індекс для колонок з низькою і високою вибірковістю.

Використовуйте часткові індекси

У попередній главі ми створили індекс для колонки з булевими значеннями, в якій близько 90 % записів були true (активовані користувачі).

Коли ми вибрали кількість активних користувачів, база не використовувала індекс. А коли запросили кількість неактивованих, база використовувала індекс.

Виникає питання: якщо база не збирається користуватися індексом для фільтрування активних користувачів, навіщо нам індексувати їх в першу чергу?

Перш ніж відповісти на це питання, давайте подивимося на вагу повного індексу по колонці activated:

db=# \di+ users_activated_ix

 Schema |      Name          | Type  | Owner | Table | Size
--------+--------------------+-------+-------+-------+------
 public | users_activated_ix | index | haki  | users | 21 MB

Індекс важить 21 Мб. Просто для довідки: таблиця з користувачами займає 65 Мб. Тобто вага індексу ~32 % ваги бази. При цьому ми знаємо, що ~90 % вмісту індексу навряд чи буде використовуватися.

В PostgreSQL можна створювати індекс тільки для частини таблиці — так званий частковий індекс:

db=# CREATE INDEX users_unactivated_partial_ix ON users(id)
db-# WHERE not activated;
CREATE INDEX

З допомогою виразу WHERE ми обмежуємо рядки які охоплюються індексом . Давайте перевіримо, чи спрацює:

db=# EXPLAIN SELECT * FROM users WHERE not activated;
 QUERY PLAN
------------------------------------------------------------------------------------------------
 Index Scan using users_unactivated_partial_ix on users (cost=0.29..3493.60 rows=102567 width=38)

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

У такого підходу є ще одна перевага:

db=# \di+ users_unactivated_partial_ix
 List of relations
 Schema | Name | Type | Owner | Table | Size
--------+------------------------------+-------+-------+-------+---------
 public | users_unactivated_partial_ix | index | haki | users | 2216 kB

Повний індекс по колонці важить 21 Мб, а частковий — всього 2,2 Мб. Тобто 10 %, що відповідає частці неактивованих користувачів в таблиці.

Завжди завантажуйте відсортовані дані

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

Припустимо, у вас є величезна таблиця з конкретними продажами:

db=# CREATE TABLE sale_fact (id serial, username text, sold_at date);
CREATE TABLE

Кожну ніч в ході ETL-процесу ви завантажуєте дані в таблицю:

db=# INSERT INTO sale_fact (username, sold_at)
db-# SELECT
db-#     md5(random()::text) AS username,
db-#     '2020-01-01'::date + (interval '1 day') * round(random() * 365 * 2) AS sold_at
db-# FROM
db-#     generate_series(1, 100000);
INSERT 0 100000

db=# VACUUM ANALYZE sale_fact;
VACUUM

Щоб зімітувати завантаження, скористаємося випадковими даними. Вставили 100 тис. рядків з випадковими іменами, а дати продаж в періоді з 1 січня 2020 року і на два роки вперед.

Здебільшого таблиця використовується для підсумкових звітів про продажі. Найчастіше фільтрують по даті, щоб подивитися продажі за певний період. Щоб прискорити сканування діапазону, створимо індекс sold_at:

db=# CREATE INDEX sale_fact_sold_at_ix ON sale_fact(sold_at);
CREATE INDEX

Поглянемо на план виконання запиту на вибірку всіх продажів в червні 2020:

db-# SELECT *
db-# FROM sale_fact
db-# WHERE sold_at BETWEEN '2020-07-01' AND '2020-07-31';

                            QUERY PLAN
-----------------------------------------------------------------------------------------------
 Bitmap Heap Scan on sale_fact  (cost=108.30..1107.69 rows=4293 width=41)
   Recheck Cond: ((sold_at >= '2020-07-01'::date) AND (sold_at <= '2020-07-31'::date))
   Heap Blocks: exact=927
   ->  Bitmap Index Scan on sale_fact_sold_at_ix  (cost=0.00..107.22 rows=4293 width=0)
         Index Cond: ((sold_at >= '2020-07-01'::date) AND (sold_at <= '2020-07-31'::date))
 Planning Time: 0.191 ms
 Execution Time: 5.906 ms

Прогнавши запит кілька разів, щоб прогріти кеш, тривалість виконання стабілізувалася на рівні 6 мс.

Сканування за бітовій карті (Bitmap Scan)

У плані виконання ми бачимо, що база використовувала сканування за бітовій карті. Воно проходить у два етапи:

  • Побудова бітової карти (Bitmap Index Scan): база проходить по всьому індексу sale_fact_sold_at_ix і знаходить всі сторінки таблиці, що містять релевантні рядка.
  • Сканування за бітовій карті (Bitmap Heap Scan): база зчитує сторінки, що містять релевантні рядки, і знаходить ті з них, що задовольняють умову.

Сторінки можуть містити багато рядків. На першому етапі індекс використовується для пошуку сторінки. На другому етапі шукаються рядки в сторінках, звідси випливає операція Recheck Cond в плані виконання.

На цьому моменті багато адміністраторів баз даних і розробників закруглятся і перейдуть до наступного запитом. Але є спосіб поліпшити цей запит.

Індексне сканування (Index Scan)

Внесемо невеликі зміна в завантаження даних.

db=# TRUNCATE sale_fact;
TRUNCATE TABLE

db=# INSERT INTO sale_fact (username, sold_at)
db-# SELECT
db-#     md5(random()::text) AS username,
db-#     '2020-01-01'::date + (interval '1 day') * round(random() * 365 * 2) AS sold_at
db-# FROM
db-#     generate_series(1, 100000)
db-# ORDER BY sold_at;
INSERT 0 100000

db=# VACUUM ANALYZE sale_fact;
VACUUM

На цей раз ми завантажили дані, відсортовані за sold_at.

Тепер план виконання того ж запиту виглядає так:

db=# EXPLAIN (ANALYZE)
db-# SELECT *
db-# FROM sale_fact
db-# WHERE sold_at BETWEEN '2020-07-01' AND '2020-07-31';

                           QUERY PLAN
---------------------------------------------------------------------------------------------
 Index Scan using sale_fact_sold_at_ix on sale_fact (cost=0.29..184.73 rows=4272 width=41)
   Index Cond: ((sold_at >= '2020-07-01'::date) AND (sold_at <= '2020-07-31'::date))
 Planning Time: 0.145 ms
 Execution Time: 2.294 ms

Після декількох прогонів тривалість виконання стабілізувалася на рівні 2,3 мс. Ми отримали стійку економію приблизно в 60 %.

Також ми бачимо, що в цей раз база не стала використовувати сканування по бітовій карті, а застосувала «звичайне» індексне сканування. Чому?

Кореляція

Коли база аналізує таблицю, вона збирає всю можливу статистику. Одним з параметрів є кореляція:

Статистична кореляція між фізичним порядком рядків і логічним порядком значень в колонках. Якщо значення близьке до -1 або +1, індексне сканування по колонці вважається вигіднішим, ніж коли значення кореляції близько 0, оскільки знижується кількість випадкових звернень до диску.

Як пояснюється в офіційній документації, кореляція є мірою того, як «відсортовані» значення в конкретній колонці на диску.

SQL трюки від DBA. Небанальні поради для розробників БД

Кореляція = 1.

Якщо кореляція дорівнює 1 або близько того, це означає, що сторінки зберігаються на диску приблизно в тому ж порядку, що й рядок таблиці. Таке зустрічається дуже часто. Наприклад, у автоінкрементних ID кореляція зазвичай близька до 1. У колонок з датами і тимчасовими мітками, які показують, коли був створений рядок, кореляція теж близька до 1.

Якщо кореляція дорівнює -1, сторінки відсортовані в зворотному порядку щодо колонок.

SQL трюки від DBA. Небанальні поради для розробників БД

Кореляція ~ 0.

Якщо кореляція близька до 0, це означає, що значення в колонці не корелюють або майже не корелюють з порядком сторінок в таблиці. 

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

db=# SELECT tablename, attname, correlation
db-# FROM pg_stats
db=# WHERE tablename = 'sale_fact';

 tablename | attname  | correlation
-----------+----------+--------------
 sale      | id       |            1
 sale      | username | -0.005344716
 sale      | sold_at  | -0.011389783

У автоматично згенерованого ID колонки кореляція дорівнює 1. У колонки sold_at кореляція дуже низька: послідовні значення розкидані по всій таблиці.

Коли ми завантажили відсортовані дані в таблицю, вона вирахувала кореляції:

tablename | attname  |  correlation
-----------+----------+----------------
 sale_fact | id       |              1
 sale_fact | username | -0.00041992788
 sale_fact | sold_at  |              1

Тепер кореляція sold_at дорівнює 1.

Так чому база використовувала сканування по бітовій карті, коли кореляція була низькою, а при високій кореляції застосувала індексне сканування?

  • Коли кореляція була рівна 1, база визначила, що рядки потрібного діапазону, ймовірно, будуть у послідовних сторінках. Тоді для читання декількох сторінок краще використовувати індексне сканування.
  • Коли кореляція була близька до 0, база визначила, що рядки потрібного діапазону, ймовірно, будуть розкидані по всій таблиці. Тоді доцільно використовувати сканування по бітовій карті тих сторінок, в яких є потрібні рядки, і тільки потім витягати їх із застосуванням умови.

Коли наступного разу будете завантажувати дані в таблицю, подумайте про те, скільки інформації буде вибиратися, та сортування так, щоб індекси могли швидко сканувати діапазони.

Команда CLUSTER

Інший спосіб «сортування таблиці на диску» по конкретному індексу полягає у використанні команди CLUSTER.

Наприклад:

db=# TRUNCATE sale_fact;
TRUNCATE TABLE

-- Insert rows without sorting
db=# INSERT INTO sale_fact (username, sold_at)
db-# SELECT
db-#     md5(random()::text) AS username,
db-#     '2020-01-01'::date + (interval '1 day') * round(random() * 365 * 2) AS sold_at
db-# FROM
db-#     generate_series(1, 100000)
INSERT 0 100000

db=# ANALYZE sale_fact;
ANALYZE

db=# SELECT tablename, attname, correlation
db-# FROM pg_stats
db-# WHERE tablename = 'sale_fact';

  tablename | attname  |  correlation
-----------+-----------+----------------
 sale_fact | sold_at   | -5.9702674e-05
 sale_fact | id        |              1
 sale_fact | username  |    0.010033822

Ми завантажили дані в таблицю у випадковому порядку, тому кореляція sold_at близька до нуля.

Щоб «перекомпонувати» таблицю sold_at, використовуємо команду CLUSTER для сортування таблиці на диску у відповідності з індексом sale_fact_sold_at_ix:

db=# CLUSTER sale_fact USING sale_fact_sold_at_ix;
CLUSTER

db=# ANALYZE sale_fact;
ANALYZE

db=# SELECT tablename, attname, correlation
db-# FROM pg_stats
db-# WHERE tablename = 'sale_fact';

 tablename | attname  | correlation
-----------+----------+--------------
 sale_fact | sold_at  |            1
 sale_fact | id       | -0.002239401
 sale_fact | username |  0.013389298

Після кластеризації таблиці кореляція sold_at стала дорівнювати 1.

SQL трюки від DBA. Небанальні поради для розробників БД

Команда CLUSTER.

Що потрібно відзначити:

  • Кластеризація таблиці по конкретній колонці може вплинути на кореляцію іншої колонки. Наприклад, погляньте на кореляцію ID після кластеризації за sold_at.
  • CLUSTER — важка і блокуюча операція, тому не застосовуйте її до живої таблиці.

З цих причин краще вставляти вже відсортовані дані і не покладатися на CLUSTER.

Колонки з високою кореляцією індексуйте з допомогою BRIN

Коли мова заходить про індекси, багато розробників думають про В-дерева. Але PostgreSQL пропонує і інші типи індексів, наприклад, BRIN:

BRIN спроектований для роботи з дуже великими таблицями, в яких деякі колонки володіють природною кореляцією зі своїм фізичним місцем розташування всередині таблиці

BRIN означає Block Range Index. Згідно документації, BRIN найкраще працює з колонками, які мають високу кореляцію. Як ми вже бачили в попередніх главах, автоінкрементні ID і тимчасові мітки природним чином корелюють з фізичною структурою таблиці, тому для них вигідніше використовувати BRIN.

При певних умовах BRIN може забезпечити краще «співвідношення ціни та якості» з точки зору розміру та продуктивності в порівнянні з аналогічним індексом типу B-дерево.

SQL трюки від DBA. Небанальні поради для розробників БД

BRIN.

BRIN діапазон значень в межах кількох сусідніх сторінок в таблиці. Припустимо, у нас в колонці є такі значення, кожне в окремій сторінці:

1, 2, 3, 4, 5, 6, 7, 8, 9

BRIN працює з діапазонами сусідніх сторінок. Якщо задати три суміжні сторінки, індекс розділить таблицю на діапазони:

[1,2,3], [4,5,6], [7,8,9]

Для кожного діапазону BRIN зберігає мінімальне і максимальне значення:

[1-3], [4-6], [7-9]

Давайте з допомогою цього індексу пошукаємо значення 5:

  • [1-3] — тут його точно немає.
  • [4-6] — може бути тут.
  • [7-9] — тут його точно немає.

З допомогою BRIN ми обмежили зону пошуку блоком 4-6.

Візьмемо інший приклад. Нехай значення у колонці будуть мати кореляцію близькою до нуля, тобто вони не відсортовані:

[2,9,5], [1,4,7], [3,8,6]

Індексування трьох сусідніх блоків дасть нам такі діапазони:

[2-9], [1-7], [3-8]

Пошукаємо значення 5:

  • [2-9] — може бути тут.
  • [1-7] — може бути тут.
  • [3-8] — може бути тут.

У цьому випадку індекс взагалі не звужує область пошуку, тому він марний.

Розбираємося з pages_per_range

Кількість суміжних сторінок визначається параметром pages_per_range. Кількість сторінок в діапазоні впливає на розмір і точність BRIN:

  • Велике значення pages_per_range дасть маленький і менш точний індекс.
  • Маленьке значення pages_per_range дасть великий і більш точний індекс.

За замовчуванням значення pages_per_range дорівнює 128.

SQL трюки від DBA. Небанальні поради для розробників БД

BRIN з більш низьким значенням pages_per_range.

Для ілюстрації створимо BRIN з діапазонами по дві сторінки і пошукаємо значення 5:

  • [1-2] — тут його точно немає.
  • [3-4] — тут його точно немає.
  • [5-6] — може бути тут.
  • [7-8] — тут його точно немає.
  • [9] — тут його точно немає.

При діапазоні в дві сторінки ми можемо обмежити зону пошуку блоками 5 і 6. Якщо діапазон буде на три сторінки, індекс обмежить зону пошуку блоками 4, 5 і 6.

Іншою відмінністю між двома індексами є те, що коли діапазон дорівнював трьом сторінкам, нам потрібно було зберігати три діапазони, а при двох сторінках у діапазоні ми отримуємо вже п'ять діапазонів і індекс збільшується.

Створюємо BRIN

Візьмемо таблицю sales_fact і створимо BRIN по колонці sold_at:

db=# CREATE INDEX sale_fact_sold_at_bix ON sale_fact
db-# USING BRIN(sold_at) WITH (pages_per_range = 128);
CREATE INDEX

За замовчуванням значення pages_per_range = 128.

Тепер виберемо період дат продажів:

db=# EXPLAIN (ANALYZE)
db-# SELECT *
db-# FROM sale_fact
db-# WHERE sold_at BETWEEN '2020-07-01' AND '2020-07-31';
                                    QUERY PLAN
--------------------------------------------------------------------------------------------
 Bitmap Heap Scan on sale_fact  (cost=13.11..1135.61 rows=4319 width=41)
   Recheck Cond: ((sold_at >= '2020-07-01'::date) AND (sold_at <= '2020-07-31'::date))
   Rows Removed by Index Recheck: 23130
   Heap Blocks: lossy=256
   ->  Bitmap Index Scan on sale_fact_sold_at_bix  (cost=0.00..12.03 rows=12500 width=0)
         Index Cond: ((sold_at >= '2020-07-01'::date) AND (sold_at <= '2020-07-31'::date))
 Execution Time: 8.877 ms

База з допомогою BRIN отримала період дат, але в цьому нічого цікавого...

Оптимізуємо pages_per_range

Згідно з планом виконання, база прибрала зі сторінок 23 130 рядків, які знайшла за допомогою індексу. Це може говорити про те, що заданий нами для індексу діапазон занадто великий для цього запиту. Створимо індекс з удвічі меншою кількістю сторінок в діапазоні:

db=# CREATE INDEX sale_fact_sold_at_bix64 ON sale_fact
db-# USING BRIN(sold_at) WITH (pages_per_range = 64);
CREATE INDEX

db=# EXPLAIN (ANALYZE)
db- SELECT *
db- FROM sale_fact
db- WHERE sold_at BETWEEN '2020-07-01' AND '2020-07-31';
                                        QUERY PLAN
---------------------------------------------------------------------------------------------
 Bitmap Heap Scan on sale_fact  (cost=13.10..1048.10 rows=4319 width=41)
   Recheck Cond: ((sold_at >= '2020-07-01'::date) AND (sold_at <= '2020-07-31'::date))
   Rows Removed by Index Recheck: 9434
   Heap Blocks: lossy=128
   ->  Bitmap Index Scan on sale_fact_sold_at_bix64  (cost=0.00..12.02 rows=6667 width=0)
         Index Cond: ((sold_at >= '2020-07-01'::date) AND (sold_at <= '2020-07-31'::date))
 Execution Time: 5.491 ms

При 64 сторінках в діапазоні база видалила менше рядків, знайдених з допомогою індексу — 9 434. Значить, їй довелося робити менше операцій введення-виведення, а запит виконався трохи швидше, за ~5,5 мс замість ~8,9.

Протестуємо індекс з різними значеннями pages_per_range:

pages_per_range Прибрано рядків при повторній перевірці індексу
128 23 130
64 9 434
8 874
4 446
2 446

При зменшенні pages_per_range індекс стає точнішим, а зі знайдених з його допомогою сторінок видаляється менше рядків.

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

Оцінка розміру індексу

Іншою важливою перевагою BRIN є його розмір. У попередніх розділах ми для поля sold_at створили індекс на основі В-дерева. Його розмір був 2 224 Кб. А розмір BRIN з параметром pages_per_range=128 всього 48 Кб: в 46 разів менше.

Schema | Name | Type | Owner | Table | Size
--------+-----------------------+-------+-------+-----------+-------
 public | sale_fact_sold_at_bix | index | haki | sale_fact | 48 kB
 public | sale_fact_sold_at_ix | index | haki | sale_fact | 2224 kB

На розмір BRIN також впливає pages_per_range. Приміром, BRIN з pages_per_range=2 важить 56 Кб, трохи більше 48 Кб.

Робіть індекси «невидимими»

В PostgreSQL є класна фіча transactional DDL. За роки роботи з Oracle я звик в кінці транзакцій використовувати такі DDL-команди, як CREATEDROP та ALTER. Але в PostgreSQL виконувати DDL-команди можна усередині транзакції, а зміни будуть застосовані тільки після коміта транзакції.

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

Наприклад, в таблиці sale_fact ми створили індекс по колонці sold_at. План виконання запиту на вибірку липневих продажів виглядає так:

db=# EXPLAIN
db-# SELECT *
db-# FROM sale_fact
db-# WHERE sold_at BETWEEN '2020-07-01' AND '2020-07-31';
                                         QUERY PLAN
--------------------------------------------------------------------------------------------
 Index Scan using sale_fact_sold_at_ix on sale_fact  (cost=0.42..182.80 rows=4319 width=41)
   Index Cond: ((sold_at >= '2020-07-01'::date) AND (sold_at <= '2020-07-31'::date))P

Щоб побачити, як виглядав би план, якщо б індексу sale_fact_sold_at_ix не було, можна помістити індекс всередину транзакції і негайно відкотитися:

db=# BEGIN;
BEGIN

db=# DROP INDEX sale_fact_sold_at_ix;
DROP INDEX

db=# EXPLAIN
db-# SELECT *
db-# FROM sale_fact
db-# WHERE sold_at BETWEEN '2020-07-01' AND '2020-07-31';
                                   QUERY PLAN
---------------------------------------------------------------------------------
 Seq Scan on sale_fact  (cost=0.00..2435.00 rows=4319 width=41)
   Filter: ((sold_at >= '2020-07-01'::date) AND (sold_at <= '2020-07-31'::date))

db=# ROLLBACK;
ROLLBACK

Спочатку почнемо транзакцію за допомогою BEGIN. Потім видалемо індекс і згенеруємо план виконання. Зверніть увагу, що план тепер використовує повне сканування таблиці, немов би індексу не існує. У цей момент транзакція все ще виконується, тому індекс поки не видалений. Для завершення транзакції без видалення індексу відкатемо її за допомогою команди ROLLBACK.

Перевіримо, що індекс ще існує:

db=# \di+ sale_fact_sold_at_ix
 List of relations
 Schema | Name | Type | Owner | Table | Size
--------+----------------------+-------+-------+-----------+---------
 public | sale_fact_sold_at_ix | index | haki | sale_fact | 2224 kB

Інші бази, які не підтримують транзакційний DDL, дозволяють досягти мети інакше. Наприклад, Oracle дозволяє вам позначити індекс як  невидимий і тоді оптимізатор буде його ігнорувати.

Увага: якщо видалити індекс всередині транзакції, це призведе до блокування конкурентних операцій SELECTINSERTUPDATE та DELETE в таблиці, поки транзакція буде активна. Обережно застосовуйте в тестових середовищах і уникайте застосування в експлуатаційних базах.

Не плануйте початок тривалих процесів на початок будь-якої години

Інвестори знають, що можуть відбуватися дивні події, коли ціна акцій досягає гарних круглих значень, наприклад, 10$, 100$, 1000$. Ось що про це пишуть:

[...] ціна активів може непередбачувано змінюватися, перетинаючи круглі значення кшталт $50 або $100 за акцію. Багато недосвідчених трейдерів люблять купувати або продавати активи, коли ціна досягає круглих чисел, тому що їм здається, що це справедливі ціни.

З цієї точки зору розробники не надто відрізняються від інвесторів. Коли їм потрібно запланувати тривалий процес, вони зазвичай обирають якусь годину.

SQL трюки від DBA. Небанальні поради для розробників БД

Типове нічне навантаження на систему.

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

Також рекомендується застосовувати в розкладах випадкові затримки, щоб не запускатися кожен раз в один і той же час. Тоді навіть якщо на цей час запланована інша задача, це не буде великою проблемою. Якщо ви застосовуєте таймер systemd, можете скористатися опцією RandomizedDelaySec.

Висновок

У статті наведені поради з різним ступенем очевидності на основі мого досвіду. Деякі з них легко запровадити, деякі вимагають глибокого розуміння принципів роботи баз даних. Бази — це каркас більшості сучасних систем, так що час, витрачений на вивчення їх роботи, буде хорошим вкладом для будь-якого розробника!

Джерело ENG: hakibenita.com

Коментарі (3)

  1. Картинки в статті не відображаються. А стаття цікава.

    1. В якому браузері вони не показуються? І чи нема там помилок в консолі JS? Зображення повинні завантажуватися при прокрутці сторінки.

      1. Зараз все в порядку. Я користуюсь Chrome.

Щоб залишити коментар необхідно авторизуватися.

Війти / Зареєструватися