Совет дня №36
Перечитав прошлый совет, я понял, что он нуждается в технической демке. Быстренько покажу, на что влияет индекс внешнего ключа.
Уточнение: будет много планов. Поскольку они длинные, их трудно записать в Телеграме в понятном виде. Здесь только фрагменты, а в конце – ссылка на гист.
Итак, делаем две таблицы, пользователей и профили:
create table users (
id serial primary key
);
create table profiles (
id serial primary key,
user_id integer not null references users(id)
);
Вставим миллион пользователей:
insert into users
select x
from generate_series(1, 999999) as seq(x);
Профилей тоже миллион, но есть нюанс. Я не хочу, чтобы n-ая строка профиля ссылалась на n-ую строку пользователя. Это слишком хорошо и не отражает действительности. Поэтому первый профиль ссылается на последнего пользователя, второй – на предпоследнего и так далее. Другими словами, максимально усложним базе жизнь в плане соединений:
insert into profiles
select x, 1000000 - x
from generate_series(1, 999999) as seq(x);
Проверим, что в таблицах:
table users limit 100;
┌─────┐
│ id │
├─────┤
│ 1 │
│ 2 │
│ 3 │
│ 4 │
│ 5 │
│ 6 │
│ 7 │
│ 8 │
│ 9 │
│ 10 │
table profiles limit 100;
┌─────┬─────────┐
│ id │ user_id │
├─────┼─────────┤
│ 1 │ 999999 │
│ 2 │ 999998 │
│ 3 │ 999997 │
│ 4 │ 999996 │
│ 5 │ 999995 │
│ 6 │ 999994 │
│ 7 │ 999993 │
│ 8 │ 999992 │
│ 9 │ 999991 │
Индекса на user_id пока что нет. Что будет, если выбрать профили ста
определенных пользователей? Посмотрим план:
explain
select * from profiles
where user_id between 450000 and 450100;
Gather (cost=1000.00..11684.39 rows=94 width=8)
Workers Planned: 2
Parallel Seq Scan on profiles
(cost=0.00..10674.99 rows=39 width=8)
Filter: ((user_id >= 450000) AND (user_id <= 450100))
Видим, что было полное сканирование профилей. При этом Postgres сделал это параллельно в два процесса (Parallel Seq Scan, Workers =2), а потом объединил результат (шаг Gather). Итоговая стоимость довольно высокая (11684).
Предположим, я делаю отчет по всем пользователям и профилям: тупо соединяю две таблицы джоином:
explain
select *
from users u, profiles p
where u.id = p.user_id;
Hash Join (cost=30831.98..59602.98 rows=999999 width=12)
Hash Cond: (p.user_id = u.id)
Seq Scan on profiles p
(cost=0.00..14424.99 rows=999999 width=8)
Hash (cost=14424.99..14424.99 rows=999999 width=4)
Seq Scan on users u
(cost=0.00..14424.99 rows=999999 width=4)
То, что был seq scan по таблице users, это нормально – мы выбираем все записи. Однако по таблице profiles тоже был full scan, при этом стоимость хэширования огромна (cost=14424.99..14424.99). Финальный обход хэшей тоже дорогой: 59602.98
Другой пример: предположим, мы выбрали малую часть пользователей и нужно присоединить им профили. Для этого рассмотрим запрос:
explain
with some_users as (
select * from users
where id between 450000 and 450100
)
select *
from some_users sm
join profiles p on sm.id = p.user_id;
Считаем, что выборка some_users даст всего 100 пользователей. В реальности мы
бы фильтровали по городу или другому признаку, но поскольку у нас только id,
остается по нему.
Gather (cost=1007.61..10702.82 rows=98 width=12)
Workers Planned: 2
Hash Join (cost=7.61..9693.02 rows=41 width=12)
Hash Cond: (p.user_id = users.id)
Parallel Seq Scan on profiles p
(cost=0.00..8591.66 rows=416666 width=8)
Hash (cost=6.39..6.39 rows=98 width=4)
Index Only Scan using users_pkey on users
(cost=0.42..6.39 rows=98 width=4)
Index Cond: ((id >= 450000) AND (id <= 450100))
Этот план говорит о следующем: выборка some_users сработала моментально, но
дальше все плохо. Чтобы найти профили, Postgres опять зарядил два параллельных
потока с объединением. Итоговая стоимость (10702.82) высокая.
Добавим индекс на поле user_id и посмотрим, как все изменится:
create unique index idx_profiles_user_id_u
on profiles using btree (user_id);
analyze profiles;
Простой пример: найти профили по диапазону пользователей:
explain
select * from profiles
where user_id between 450000 and 450100;
Index Scan using idx_profiles_user_id_u on profiles
(cost=0.42..10.37 rows=97 width=8)
Index Cond: ((user_id >= 450000) AND (user_id <= 450100))
Очевидно, стоимость копеечная.
Далее: имея узкую выборку пользователей (например, по городу), присоединить им профили:
explain
with some_users as (
select * from users
where id between 450000 and 450100
)
select *
from some_users sm
join profiles p on sm.id = p.user_id;
Nested Loop
(cost=0.85..825.75 rows=98 width=12)
Index Only Scan using users_pkey on users
(cost=0.42..6.39 rows=98 width=4)
Index Cond: ((id >= 450000) AND (id <= 450100))
Index Scan using idx_profiles_user_id_u on profiles p (cost=0.42..8.36 rows=1 width=8)
Index Cond: (user_id = users.id)
Было два прохода по индексам, итоговые данные соединены вложенным циклом (Nested Loop). Сложность такого соединения квадратична, но на малых объемах выигрывает. Стоимость 825 – низкая.
И наконец, узрите истинную мощь Merge Join – соединения слиянием. Повторим запрос, в котором мы соединяем две таблицы без каких-либо фильтров:
explain
select *
from users u, profiles p
where u.id = p.user_id;
Вы, наверное, думаете, что все полетит? Нет, план останется таким же, то есть очень медленным:
Hash Join
(cost=30831.98..59602.98 rows=999999 width=12)
...
Будет два seq scan-а по обеим таблицам и ручное хеширование. Напомню, я специально создал худший сценарий, когда порядок профилей обратный: первый профиль ссылается на последнего пользователя и так далее. Это делает хэширование дорогим. Стоимость первой строки равна 30831 – это значит, что перед получением данных база будет тупить – считать хэши.
Сделаем вот что: в запросе выше заменим profiles на подзапрос, отсортированный
по user_id:
explain
select *
from users u, (select * from profiles order by user_id) p
where u.id = p.user_id
order by u.id;
План:
Merge Join (cost=5.14..81384.50 rows=999999 width=12)
Merge Cond: (profiles.user_id = u.id)
Index Scan using idx_profiles_user_id_u on profiles (cost=0.42..30408.41 rows=999999 width=8)
Index Only Scan using users_pkey on users u (cost=0.42..25980.41 rows=999999 width=4)
Оба набора упорядочены по тем полям, по которым их соединяют, поэтому сработает соединение слиянием. Стоимость его гораздо ниже, чем у хеширования. Первая строка доступна уже через 5.14 единиц стоимости – а было 30831.98. Четыре порядка! Когда я вижу в плане Merge Join, все мои железы активируются и выделяют жидкости.
Планы – полезная вещь. Читайте их, экспериментируйте, пытайтесь понять, почему план именно такой, а не другой.
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter