Настаняват се хиляди мениджъри от търговски офиси в цялата страна
Ето защо не е изненадващо, че за пореден път анализирайки „тежките“ заявки към една от най-натоварените бази данни – нашата собствена
Освен това по-нататъшното разследване разкри интересен пример първо оптимизация, след това влошаване на производителността искане с последователното му изпълнение от няколко екипа, всеки от които действаше единствено от най-добри намерения.
0: какво иска потребителят
[KDPV
Какво обикновено има предвид потребителят, когато говори за „бързо“ търсене по име? Почти никога не се оказва "справедливо" търсене на подниз като ... LIKE '%роза%'
- в края на краищата, тогава резултатът е не само 'Розалия'
и 'Магазин Роза'
но 'Гроза'
и дори 'Дом Деда Мороза'
.
Потребителят означава на ниво домакинство, че ще му предоставите търсене в началото на дума в заглавието и покажете по-подходящо какво започва в въведени. И го направи почти моментално - с въвеждане на долен индекс.
1: ограничете задачата
И още повече, човек няма да представи специално 'роз магаз'
така че трябва да търсите префикс за всяка дума. Не, за потребителя е много по-лесно да отговори на бърза подсказка за последната дума, отколкото умишлено да „въведе под“ предишните - вижте как всяка търсачка прави това.
Като цяло, правилно формулирането на изискванията към проблема е повече от половината решение. Понякога внимателен анализ на случаите на употреба
Какво прави абстрактен разработчик?
1.0: външна търсачка
О, търсенето е трудно, изобщо не искате да правите нещо - нека го дадем на devops! Нека внедрят търсачка, външна за базата данни за нас: Sphinx, ElasticSearch, ...
Работеща, макар и времеемка опция от гледна точка на синхронизация и ефективност на промените. Но не и в нашия случай, тъй като търсенето се извършва за всеки клиент само в рамките на данните за неговия акаунт. И данните имат доста голяма променливост - и ако сега мениджърът е въвел карта 'Магазин Роза'
, след 5-10 секунди вече може да си спомни, че е забравил да посочи имейла там и иска да го намери и поправи.
Затова – нека търсене "директно в базата данни". За щастие, PostgreSQL ни позволява да правим това, и то повече от една опция – ще ги разгледаме.
1.1: подниз "честен".
Вкопчваме се в думата "подниз". Но точно за търсене в индекс по подниз (и дори по регулярни изрази!) Има отличен
Нека се опитаме да вземем в името на простотата на модела такава табела:
CREATE TABLE firms(
id
serial
PRIMARY KEY
, name
text
);
Ние качваме 7.8 милиона записа на реални организации там и ги индексираме:
CREATE EXTENSION pg_trgm;
CREATE INDEX ON firms USING gin(lower(name) gin_trgm_ops);
Нека потърсим първите 10 записа за търсене на подниз:
SELECT
*
FROM
firms
WHERE
lower(name) ~ ('(^|s)' || 'роза')
ORDER BY
lower(name) ~ ('^' || 'роза') DESC -- сначала "начинающиеся на"
, lower(name) -- остальное по алфавиту
LIMIT 10;
Ами такива... 26ms, 31MB прочетени данни и повече от 1.7K филтрирани записи - за 10 търсени. Режийните разходи са твърде високи, възможно ли е да се направи нещо по-ефективно?
1.2: търсене на текст? това е FTS!
Наистина, PostgreSQL предоставя много мощен
CREATE INDEX ON firms USING gin(to_tsvector('simple'::regconfig, lower(name)));
SELECT
*
FROM
firms
WHERE
to_tsvector('simple'::regconfig, lower(name)) @@ to_tsquery('simple', 'роза:*')
ORDER BY
lower(name) ~ ('^' || 'роза') DESC
, lower(name)
LIMIT 10;
Паралелизирането на изпълнението на заявката ни помогна малко тук, съкращавайки времето наполовина 11ms. Да, и трябваше да четем 1.5 пъти по-малко - общо 20MB. И тук колкото по-малко - толкова по-добре, защото колкото по-голям обем изваждаме, толкова по-големи са шансовете да получим пропуск в кеша, а всяка допълнителна страница с данни, прочетена от диска, е потенциална "спирачка" за заявката.
1.3: все още ХАРЕСВАТЕ?
Предишната заявка е добра за всички, но само ако я дърпате сто хиляди пъти на ден, тогава ще работи 2TB четете данни. В най-добрия случай - от паметта, но ако нямате късмет, тогава от диска. Така че нека се опитаме да го направим по-малък.
Запомнете какво иска да види потребителят първо "което започва с...". Така че е в най-чистата си форма. text_pattern_ops
! И само ако „нямаме достатъчно“ до 10 необходими записа, тогава ще трябва да ги прочетем с помощта на FTS търсенето:
CREATE INDEX ON firms(lower(name) text_pattern_ops);
SELECT
*
FROM
firms
WHERE
lower(name) LIKE ('роза' || '%')
LIMIT 10;
Отлично представяне - общо 0.05ms и малко над 100KB Прочети! Просто забравихме сортиране по иметака че потребителят да не се изгуби в резултатите:
SELECT
*
FROM
firms
WHERE
lower(name) LIKE ('роза' || '%')
ORDER BY
lower(name)
LIMIT 10;
О, нещо вече не е толкова красиво - изглежда, че има индекс, но сортирането минава покрай него ... Разбира се, вече е многократно по-ефективно от предишната версия, но ...
1.4: "завършете с файл"
Но има индекс, който ви позволява да търсите по диапазон и е нормално да използвате сортиране - редовно btree!
CREATE INDEX ON firms(lower(name));
Само заявката за него ще трябва да бъде „сглобена ръчно“:
SELECT
*
FROM
firms
WHERE
lower(name) >= 'роза' AND
lower(name) <= ('роза' || chr(65535)) -- для UTF8, для однобайтовых - chr(255)
ORDER BY
lower(name)
LIMIT 10;
Отлично - и сортирането работи, а потреблението на ресурси остава "микроскопично", хиляди пъти по-ефективен от "чистия" FTS! Остава да съберете в една заявка:
(
SELECT
*
FROM
firms
WHERE
lower(name) >= 'роза' AND
lower(name) <= ('роза' || chr(65535)) -- для UTF8, для однобайтовых кодировок - chr(255)
ORDER BY
lower(name)
LIMIT 10
)
UNION ALL
(
SELECT
*
FROM
firms
WHERE
to_tsvector('simple'::regconfig, lower(name)) @@ to_tsquery('simple', 'роза:*') AND
lower(name) NOT LIKE ('роза' || '%') -- "начинающиеся на" мы уже нашли выше
ORDER BY
lower(name) ~ ('^' || 'роза') DESC -- используем ту же сортировку, чтобы НЕ пойти по btree-индексу
, lower(name)
LIMIT 10
)
LIMIT 10;
Обърнете внимание, че втората подзаявка се изпълнява само ако първият е върнал по-малко от очакваното Последният LIMIT
брой редове. За този начин за оптимизиране на заявки, I
Така че да, сега имаме btree и gin на масата едновременно, но статистически се оказа, че по-малко от 10% от заявките достигат до изпълнението на втория блок. Тоест, с такива типични ограничения за задачата, известни предварително, успяхме да намалим общото потребление на сървърни ресурси почти хиляда пъти!
1.5*: направете без файл
по-висок LIKE
бяхме възпрепятствани да използваме грешно сортиране. Но може да бъде "настроен на правилния път", като посочите оператора USING:
По подразбиране е
ASC
. Освен това можете да посочите името на конкретен оператор за сортиране в клаузатаUSING
. Операторът за сортиране трябва да е член на "по-малко" или "по-голямо" от някое семейство от оператори на B-дърво.ASC
обикновено еквивалентниUSING <
иDESC
обикновено еквивалентниUSING >
.
В нашия случай "по-малко" е ~<~
:
SELECT
*
FROM
firms
WHERE
lower(name) LIKE ('роза' || '%')
ORDER BY
lower(name) USING ~<~
LIMIT 10;
2: как исканията се влошават
Сега оставяме нашата заявка да се „вари“ за шест месеца или година и с изненада отново я намираме „в горната част“ с показатели за общото ежедневно „изпомпване“ на паметта (буферира споделено попадение) в 5.5TB - тоест дори повече, отколкото беше първоначално.
Не, разбира се, и нашият бизнес се разрасна, и натоварването се увеличи, но не със същата сума! И така, нещо не е чисто тук - нека да го разберем.
2.1: раждането на пейджинг
В един момент друг екип за разработка искаше да направи възможно „скок“ в регистъра от бързо търсене с индекс със същите, но разширени резултати. И какъв регистър без пагинация? Хайде да го прецакаме!
( ... LIMIT <N> + 10)
UNION ALL
( ... LIMIT <N> + 10)
LIMIT 10 OFFSET <N>;
Сега беше възможно разработчикът да покаже регистъра на резултатите от търсенето със зареждане „тип страница“ без напрежение.
Разбира се, всъщност, все повече и повече се чете за всяка следваща страница с данни (всички от предишния път, които ще изхвърлим, плюс желаната „опашка“) - тоест това е недвусмислен антимодел. И би било по-правилно търсенето да започне на следващата итерация от ключа, съхранен в интерфейса, но повече за това друг път.
2.2: искам екзотика
В един момент разработчикът искаше разнообразете получената извадка с данни от друга таблица, за която цялата предишна заявка беше изпратена до CTE:
WITH q AS (
...
LIMIT <N> + 10
)
SELECT
*
, (SELECT ...) sub_query -- какой-то запрос к связанной таблице
FROM
q
LIMIT 10 OFFSET <N>;
И дори така, не е лошо, тъй като подзаявката се оценява само за 10 върнати записа, ако не ...
2.3: ЯСНО безсмислено и безмилостно
Някъде в процеса на такава еволюция от 2-ра подзаявка изгубен NOT LIKE
състояние. Ясно е, че след това UNION ALL
започна да се връща някои записи два пъти - първо се намира в началото на реда, а след това отново - в началото на първата дума от този ред. В ограничението всички записи на второто подзаявка могат да съответстват на записите на първото.
Какво прави един разработчик вместо да търси причина?.. Не е въпрос!
- удвоете размера първоначални проби
- налагат РАЗЛИЧНИза да получите само единични екземпляри от всеки ред
WITH q AS (
( ... LIMIT <2 * N> + 10)
UNION ALL
( ... LIMIT <2 * N> + 10)
LIMIT <2 * N> + 10
)
SELECT DISTINCT
*
, (SELECT ...) sub_query
FROM
q
LIMIT 10 OFFSET <N>;
Тоест, ясно е, че резултатът в крайна сметка е абсолютно същият, но шансът да „прелетите“ във 2-ра CTE подзаявка стана много по-висок и дори без него, ясно прочетете повече.
Но това не е най-тъжното. Тъй като разработчикът поиска да избере DISTINCT
не за конкретни, а за всички полета наведнъж записи, тогава автоматично се включва полето sub_query - резултатът от подзаявката. Сега, за изпълнение DISTINCT
, базата данни вече трябваше да се изпълни не 10 подзаявки, а всички <2 * N> + 10!
2.4: сътрудничеството преди всичко!
И така, разработчиците живяха - те не скърбяха, защото в регистъра се „завинтват“ до значителни стойности на N с хронично забавяне на получаването на всяка следваща „страница“, потребителят очевидно не е имал търпение.
Докато разработчиците от друг отдел не дойдоха при тях и не искаха да използват такъв удобен метод за итеративно търсене - тоест вземаме парче от някаква проба, филтрираме по допълнителни условия, рисуваме резултата, след това следващото парче (което в нашия случай се постига с увеличаване на N) и така докато запълним екрана.
Общо взето в уловен екземпляр N достигна почти 17K, и само за ден поне 4K такива заявки бяха изпълнени „по веригата“. Последният от тях смело сканиран вече от 1GB памет на итерация...
Общо
Източник: www.habr.com