- И тогда самец забирается на самое высокое дерево и оттуда планирует.
- Простите, что планирует?
@ к/ф «День радио»
Когда говорят "мы ускорили выполнение нашего запроса в N раз" это значит, что сначала сделали плохо а потом начали думать как улучшить.
Так я думал раньше.
Мне досталась вполне распространенная прикладная задача - найти в базе по ИНН или названию нужные компании и из нескольких таблиц собрать по ним статистику - количество
email
телефонов
размещенных вакансий
назначенных исполнительных производств
Структура данных в базе
Без индексов конечно никуда, поэтому для всех полей таблиц, содержащих ИНН создал соответствующие индексы:
для полей типа varchar - BTREE-индексы,
для полей jsonb[] -GIN-индексы
В качестве точки отсчета написал такой запрос
explain (FORMAT JSON, ANALYZE)
select
inn, ogrn, name,
count(distinct email) as email_count,
count(distinct phone) as phone_count,
count(debtor_inn) as proverka_count,
count(company_inn) as vacancy_count
from company
left join email on jsonb_build_object('inn', inn) <@ any (email.company)
left join phone on jsonb_build_object('inn', inn) <@ any (phone.company)
left join fssp on debtor_inn = inn
left join vacancy on company_inn = inn
where
inn = '7708503727'
group by (inn, ogrn, name)
Результаты плана
время выполнения 5555ms
GIN-индексы для jsonb[] полей при планировании запроса не используются
85% времени выполнения запроса занимает соединение данных вложенным циклом (Nested Loop Left Join). количество обрабатываемых строк в цикле - 239 тыс.
Прежде всего нужно подключить в поиске GIN-индекс.
Одна из конструкций с которой работает GIN - (jsonb @@ jsonpath)
. Поэтому применил такой лайфхак - создал новое поле jsonb и перенес в него значения jsonb-массива, обернув его в jsonb.
{
'companies': jsonb[]
}
и создал GIN-индексы на новое поле
CREATE INDEX IF NOT EXISTS company2_idx
ON tablename USING gin
(company2 jsonb_path_ops)
WHERE company2 IS NOT NULL;
Далее переписал условия поиска ИНН используя конструкцию (jsonb @@ jsonpath)
...
left join view.phone on phone.company2 @@ ('exists($.companies[*].inn ? (@=="' || '7708503727' || '"))')::jsonpath
left join view.email on email.company2 @@ ('exists($.companies[*].inn ? (@=="' || '7708503727' || '"))')::jsonpath
...
После этого планировщик подключил созданный индекс
В результате
время запроса сократилось в 5 раз - 960ms
планировщик начал использовать GIN-индексы
количество строк во вложенном цикле сократилось в 2 раза (- до 123 тыс) но по прежнему более 80% времени выполнения запроса приходилось на соединение строк во вложенном цикле
Пришло время оптимизации запроса. Гибкость языка SQL позволяет написать его как минимум тремя разными способами (именно столько получилось у меня). Самым удачным вариантом с точки зрения времени выполнения стал такой
explain (FORMAT JSON, ANALYZE)
select
b.*,
count(company_inn) as vacancy_count
from
(select
a.*,
count(debtor_inn) as fssp_count
from
(select
inn, ogrn, name,
count(distinct email) as email_count,
count(distinct phone) as phone_count
from company
left join phone on phone.company2 @@ ('exists($.companies[*].inn ? (@=="' || '7708503727' || '"))')::jsonpath
left join email on email.company2 @@ ('exists($.companies[*].inn ? (@=="' || '7708503727' || '"))')::jsonpath
where inn = '7708503727'
group by (inn, ogrn, name)) as a
left join fssp on debtor_inn = inn
group by (inn, full_name, email_count, phone_count)) as b
left join vacancy on company_inn = inn
group by (inn, full_name, email_count, phone_count, fssp_count)
Результаты планировщика:
время запроса - 176ms
95% времени выполнения приходится на операцию сортировки данных перед последней группировкой.
Вроде и не плохой результат - получилось ускорить запрос еще более чем 5 раз. Смущала длительная сортировка. Большое количество строк не позволяло выполнить быструю сортировку в памяти, данные сохранялись на диск, отсюда и такой результат. Победить эту проблему путем перестройки структуры запроса у меня не получилось. Увеличение параметра work_mem
и отключение enable_sort
дало 3-х кратный прирост производительности, но я посчитал это "нечистым" подходом. Поэтому полез в документацию и "придумал" таки 4-й вариант запроса.
explain (FORMAT JSON, ANALYZE)
select
b.*, vacancy_count, fssp_count
from
(select
inn, ogrn, name,
count(distinct email) as email_count,
count(distinct phone) as phone_count
from company
left join phone on phone.company2 @@ ('exists($.companies[*].inn ? (@=="' || '616106158873' || '"))')::jsonpath
left join email on email.company2 @@ ('exists($.companies[*].inn ? (@=="' || '616106158873' || '"))')::jsonpath
where inn = '616106158873'
group by (inn, ogrn, name)
) as b
left join (
select debtor_inn, count(debtor_inn) as fssp_count
from fssp where debtor_inn = '616106158873' group by debtor_inn
) as c on debtor_inn = inn
left join (
select company_inn, count(company_inn) as vacancy_count
from vacancy where company_inn = '616106158873' group by company_inn
) as a on company_inn = inn
Результат планировщика:
время выполнения запроса - 0.13ms Ускорение более 100 раз по отношению к последнему варианту!
Теперь обернем запрос в sql-функцию и проверим скорость выполнения.
select * from company_stat('7708503727')
Время выполнения запроса - 1.7ms
Вторая функция для поиска по названию компании проектировалась аналогично. Нужно было только добавить дополнительный запрос на поиск ИНН по названию компании, конечно учитывая ее не уникальность.
Для этой цели конечно использовал FTS (наверное самый мой любимый инструмент в postgresql):
в таблице company создал дополнительное поле типа ts_vector
создал для поля GIN-индекс Окончательный вариант функции, в которую обернул запрос выглядит так
create or replace function company_stat_by_name(X text)
returns table(inn_ character varying(20), orgn character varying(25), company_name text, email_num bigint, phone_num bigint, fssp_num bigint, vacancy_num bigint)
as $$
DECLARE
r record;
BEGIN
FOR r IN
(select inn from company
where fts_company_name @@ websearch_to_tsquery('config', X))
LOOP
RETURN QUERY select
b.*, vacancy_count, fssp_count
from
(select
inn, ogrn, full_name,
count(distinct email) as email_count,
count(distinct phone) as phone_count
from company
left join phone on phone.company2 @@ ('exists($.companies[*].inn ? (@=="' || r.inn || '"))')::jsonpath
left join email on email.company2 @@ ('exists($.companies[*].inn ? (@=="' || r.inn || '"))')::jsonpath
where inn = r.inn
group by (inn, ogrn, full_name)
) as b
left join (
select debtor_inn, count(debtor_inn) as fssp_count
from fssp where debtor_inn = r.inn group by debtor_inn
) as c on debtor_inn = r.inn
left join (
select company_inn, count(company_inn) as vacancy_count
from vacancy where company_inn = r.inn group by company_inn
) as a on company_inn = r.inn;
END LOOP;
END;
$$ language plpgsql
Поскольку может быть найдено несколько компаний совпадающих по названию, оценил время выполнения для крайних случаев:
3-4 строки в результате выполнения функции - время выполнения 2.4ms
> 200 строк в результате выполнения функции - время выполнения 34ms
Результатом остался доволен.
Мораль - сразу хорошо получается только у гениев. Для всех остальных есть планировщик запросов и документация.
Комментарии (27)
RedWolf
25.03.2024 09:35+4Так же делаете два подзапроса с группировкой по компании - один вернёт почту, другой телефоны. С группировками можно даже inner join-ами обойтись, поскольку записи всегда будут.
Первый запрос работает некорректно. Если у вас два телефона и три почты у компании, то это даст шесть записей. Даже если будет по одному делопроизводству и вакансии, то в итоге их будет по шесть в count-ах
Грубо говоря,
Телефоны
(c1, t1), (c1, t2)
Почта
(c1, m1), (c1, m2), (c1, m3)
Делопроизводства
(c1, d1)
Вакансии
(c1, v1), (c2, v2)
Где c1 - некая компания
Тогда (это не настоящий sql), company join phone join mail join fssp join vacancy where company.inn = 'c1' дам нам аж двенадцать записей. Дистинкт по телефонам и почте нам поможет, а для fssp и вакансий его нет. Он бы помог посчитать уникальные вакансии и fssp компании, но вы сами должны понимать, что вы
наплодили кучу ненужных данных в виде декартова произведения четырех коллекций
Заставили СУБД их обрабатывать
vk6677
25.03.2024 09:35Извините за глупый вопрос (не приходилось работать с БД), не могло ли кэширование ОС повлиять на результаты? Повторное обращение или обращение к другим данным сильно влияет ?
osp2003 Автор
25.03.2024 09:35Насчет кеширования на уровне ОС сказать не могу, не знаю как postgres взаимодействует с ОС. Функция pgsql в который оборачивал запрос судя по документации при первом вызове формирует и кешируеет план запроса. Что касается изменения входных параметров (инн или названия компании) скрость выполнения зависела только от количества и вывода найденных строк. это по моим экспериментам
domix32
25.03.2024 09:35А точно все запросы выдают ровно тоже самое? А то как-то подозрительно выглядит захардкоженные ИНН и прочие.
osp2003 Автор
25.03.2024 09:35+1В тексте код запроса с конкретными параметрами для примера. При тестировании скорости запросы оборачивались в функции.
select * from company_stat_by_inn('0054803450334')
select * from company_stat_by_name('ржд')
Akina
25.03.2024 09:35+1Пожалуйста, дополните фотографию со схемой внешних связей таблиц собственно структурами таблиц (их CREATE TABLE). Вернее, наоборот, структуры (с внешними ключами) - это основа, а картинка - так, дополнение для наглядности. Ну и, конечно, лишние для содержания статьи поля можно опустить, или хотя бы закомментировать.
И когда пишете запрос, источником данных которого является более чем одна таблица - обязательно для абсолютно каждого поля исходной таблицы указывайте алиас этой таблицы. Иначе совершенно непонятно, что откуда берётся. Нет, у себя в коде можете творить всё, что угодно - но в статье подобное совершенно недопустимо.
ALexKud
25.03.2024 09:35+3Для таких запросов удобно использовать каскадные CTE. Я бы пробовал именно так. Возможно будет чуть медленнее, но зато понятнее, чем мешанина из запросов и подзапросов. Потом понадобится скорректировать и долго будете вникать что же здесь написано.
Akina
25.03.2024 09:35+1У товарища Постгресс, да ещё и не факт что самый свежий. Так что CTE запросто могут кэшироваться на диске, что скажется на производительности отнюдь не лучшим образом.
А вот агрегирующие подзапросы и последующее связывание их с основной таблицей - это, скорее всего, решение. Конечно, условие отбора по INN должно быть как в подзапросах, так и в основном запросе. А если отбор идёт всегда строго по одному ИНН (т.е. выходных записей немного) - так и вообще самым разумным может быть использование агрегирующих коррелированных подзапросов в списке вывода.
Что же до понятности и отсутствия мешанины - так это всего лишь вопрос вменяемого форматирования и комментирования, по крайней мере в случае одиночного запроса.
Batalmv
25.03.2024 09:35+3Честно говоря, проблемы изначально в структуре. Если вы ходите связывать по полю - зачем его держать внутри JSON? Место под данные стоит намного дешевле, чем все остальное. Часто ключ к успеху - не героическое "преодоление", а оптимизированная структура, даже иногда ценой избыточности данных.
Как писать запросы - да такое. Можете вообще попробовать писать что-то вроде select inn, (select count(*) from ... where inn = inn), (select count(*) from ... where inn = inn), (select count(*) from ... where inn = inn) from ... where
Условно, вы можете не пытаться собрать в одном "декартовом произведении" все что вам нужно, чтобы потом вырезать и группиировать из него, а пользоваться подзапросами. Оптимизатор обычно все равно это "раздупляет" и делает как надо.
Вам надо количество email - так выберите его явно :)
Да, и еще - можно использовать аналитические запросы вроде count over partition by. Тоже явно выбираете что вам надо
osp2003 Автор
25.03.2024 09:35Насчет структуры - согласен полностью. В полной мере прочувствовал что ее улучшение - отдельная задача. Что касается оконных функций - пробовал такой вариант - скорость выполнения не очень была. Спасибо за отзыв
Kilor
25.03.2024 09:35+1Если в этой системе inn является уникальным ключом, то зачем в phone/email хранятся какие-то jsonb-объекты? Простого массива inn'ов разве недостаточно? Тогда они и искались бы эффективнее исходно.
osp2003 Автор
25.03.2024 09:35Не совсем так. Уникальна пара инн+Огрн
А одна почта может быть привязана к нескольким компаниям отсюда и jsonb
Kilor
25.03.2024 09:35На схеме нарисовано, что ИНН - это PK в company. Где тут про ОГРН?
Ну, и в первом варианте jsonb-объект с единственным ключом inn ищется в phone/email в массиве... очевидно, состоящем из объектов такой же структуры?
osp2003 Автор
25.03.2024 09:35Да схему нужно было поразвернутей приводить. Писали уже выше. Учту спасибо
kozlov_de
25.03.2024 09:35+2У всякой проблемы всегда есть решение — простое, удобное и, конечно, ошибочное. Генри Луис Менкен (100+)
Kilor
25.03.2024 09:35+4При сравнении производительности запросов неплохо бы приводить планы, иначе может оказаться, что все "тормоза" первичного варианта вызваны исключительно стартовой незакэшированностью данных (
shared read
).
RedWolf
Первый запрос неправильный. Так с one to many не работают - у вас телефоны умножатся на мыло, потом на fssp, а потом на вакансии, и вы потом будете думать, откуда там столько fssp и вакансий?
Дальнейшие запросы - это не оптимизация первого запроса, они совершенно от него отличаются - вы в них работу с one to many прячете в подзапросы, тем самым не давая данным разъезжаться, при этом второй запрос пердец какой безграмотный. Так же вы так и не избавились от дубликатов при работе с мылом и телефоном, просто вы их стыдливо спрятали под ковер дистинктом.
osp2003 Автор
Да спасибо. С точки зрения вычислений запросы работают корректно
Что касается конструкции финального запроса - наверное вы правы. Если подскажете как еще можно избавиться от дубликатов кроме дистинкта будет здорово
Akina
Есть шикарный метод избавления от дубликатов - просто не плодить их.
AgentFire
Сразу с козырей ходите :)
Akina
Не, ну а чё? Вот реально - товарищ сперва создаёт себе проблемы, а потом их мужественно преодолевает. Иначе я никак не могу интерпретировать его горячее желание сначала всё связать, получив очевидный JOIN multiplying, а потом посчитать количество уникальных значений.
Rijen
Сразу говорю, это вредный совет.
Предисловие: Любе и ли вы Firebird, такую СУБД, где легитимный индексированный join может просто безосновательно (почти) выполнятся дольше вложенного select?
Как вариант, избежать лишних сущностей можно агрегацией телефонов и мыл в лист
Или наоборот, сначала сплющить её полностью:
Ivan22
а то что в принципе один емейл тут может принадлежать нескольким компаниям, вас не смущает??? Это в реальности вообще как??? И с телефонами так же. Тут похоже гуманитарий всю модель данных рисовал. SQL запросы это тут уже следствие
Zhuck
Некий бух ведет десяток ИПшек, как думаете этот бух будет заводить 10 телефонов и 10 емейлов для связи с поставщиками/клиентами ИПшек или будет один общий? Да и сам чел может иметь ооошку и ипшку(продукты ведем по одной кассе, алкоголь по другой, например). В жизни и не такое случается
Serg7777
У одного собственника может быть несколько компаний и при этом нет ничего, что мешало бы сделать им общий e-mail, например, личный e-mail собственника.
Ivan22
даже в таком примере, модель отстойная. Нормальный вариант - M:M - 10 строк в таблице Company_Email c 10 id-шками компаний и 10 одинаковыми мейлами.