tl;dr В этой статье мы рассмотрим случай, когда лучше переместить самый селективный атрибут из префикса составного индекса в суффикс.
А также рассмотрим, что такое pipeline
и как с его помощью select
-ить данные уже отсортированными.
Описание предметной области
Есть логгер событий в системе X. Нужно сделать приложение для просмотра данных логов из этой системы.
Предполагается, что в системе есть свои коды ошибок и соотвествующие им сообщения. Из них уникальных около 10к.
Есть три типа сообщений:
- notice — нотификация,
- warning – предупреждение,
- error — кранты.
Таблица логов:
create table `log` (
`message` text not null,
`datetime` datetime not null,
`type` enum('notice', 'warning', 'error') not null default 'notice'
);
create index `datetime_message` on `log`(`datetime`, `message`(150));
В таблице 10 миллионов записей. datetime уникален всегда, а вот поле message имеет всего 10к уникальных полей.
Для генерации записей сделал процедуру, генерирующую рандомный лог. datetime изменяется так, будто логи записываются раз в секунду.
delimiter //
create procedure `generate_logs`(`amount` int, `amountOfUniqueMessages` int)
not deterministic
modifies sql data
sql security invoker
begin
declare i int default 1;
set @datetime = cast(current_date as datetime) - interval 9 year;
-- проверка входных параметров
input_params: begin
if (amount <= 0 or amountOfUniqueMessages <= 0) then
leave input_params; -- покидаем процедуру
end if;
end;
start transaction;
-- вставляем [amountOfUniqueMessages] уникальных сообщений с уникальным datetime
-- уникальность у сообщений достигается добавлением остатка от деления,
-- а у datetime делается interval-ом
while i < amount DO
set @message = concat('message ', i % amountOfUniqueMessages);
insert into `log`(`message`, `datetime`) values
(@message, @datetime + interval i second);
end while;
commit;
end;
//
delimiter ;
Реализация
Предполагаем, что самой частой операцией пользователя приложения будет вывод всего лога за конкретную дату.
Лучше всего это сделать через запрос типа
select `message`, `datetime` from `log`
where `datetime` >= '2017-04-01 00:00:00'
and `datetime` < '2017-04-02 00:00:00'
order by `datetime`;
upd: спасибо VolCh за совет с датами, исправил<= '2017-04-01 23:59:59'
на< '2017-04-02 00:00:00'
. Подробнее в комментариях к посту.
Т.е. выбрать все записи за определённую дату с сортировкой по ней же. Причём если в составном индексе дата идёт первой, то её даже сортировать не нужно, она возвращается уже в отсортированном виде.
Explain этого запроса показывает хорошие результаты:
id: 1
select_type: SIMPLE
table: log
partitions: NULL
type: range
possible_keys: datetime_message
key: datetime_message
key_len: 5
ref: NULL
rows: 172242
filtered: 100.00
Extra: Using index condition
Затронуто 172к полей. Это вполне ожидаемый результат при том условии, что данные генерировались так, будто логгер каждую секунду что-то пишет в БД.
Order by asc/desc
Отмечу, что даже если сортировка будет по убыванию (descending
), то всё равно данные fetch-ятся уже отсортированными, и их не надо сортировать filesort
–ом:
- Запрос с
order by ... descending
:
select `message`, `datetime` from `log` where `datetime` >= '2009-03-24 00:00:00' and `datetime` < '2009-03-25 00:00:00' order by `datetime` desc;
- Его explain:
id: 1 select_type: SIMPLE table: log partitions: NULL type: range possible_keys: datetime_message key: datetime_message key_len: 5 ref: NULL rows: 172242 filtered: 100.00 Extra: Using index condition
Без filesort
–а и без temporary
. Всё точно также, как и в первом случае.
Сиё явление называют pipeline, за то, что данные хранятся как бы связанными по цепочке, один за другим. И можно вытянуть все значения, начиная как с начального звена (order by asc
), так и с конечного (desc
).
Чтобы понять, как отсортирован message в составном индексе, можно представить себе школьные классы. Школьники в каждом классе отсортированы от а до я:
1 "а" | 1 "б" |
---|---|
Иванов | Кузнецов |
Петров | Попов |
Сидоров | Новиков |
Если select
-ить всех школьников из 1 "а", то они возвратятся уже отсортированными без использования filesort
или temporary
; неважно что использовалось, ascending
или descending
:
select `surname` from `schoolkids`
where `class` = '1' and `liter` = 'а';
вернёт
surname |
---|
Иванов |
Петров |
Сидоров |
Однако стоит только нам взять всех школьников из обоих классов и отсортировать их, как explain
тут же выдаст зловещий Using filesort
или Using temporary
:
select `surname` from `schoolkids`
where `class` = '1' and `liter` in ('а', 'б')
order by `surname`
surname |
---|
Иванов |
Кузнецов |
Новиков |
Петров |
Попов |
Сидоров |
Произошло это очевидно потому, что значения уже не могут быть взяты по pipeline-у, поэтому СУБД нужно сортировать их самостоятельно.
Посмотрим на ещё один пример: нужно отсортировать предыдущий запрос по message. При этом атрибут уже отсортирован, но уже относительно префикса индекса, т.е. относительно datetime.
select `message`, `datetime` from `log`
where `datetime` >= '2009-03-24 00:00:00'
and `datetime` < '2009-03-25 00:00:00'
order by `message` desc;
Explain:
id: 1
select_type: SIMPLE
table: log
partitions: NULL
type: range
possible_keys: datetime_message
key: datetime_message
key_len: 5
ref: NULL
rows: 172242
filtered: 100.00
Extra: Using index condition; Using filesort
Почему произошёл filesort
? Вспомним пример со школьниками: если тридцать школьников (суффикс индекса) учатся в одном классе (префикс индекса), то они отсортированы по pipeline–у; однако при выборе нескольких классов нужно сортировать вручную (брать в руки журнал и на новом листе бумаги создавать новый отсортированный список со всеми учениками 1го класса). Здесь такой же принцип, но с поправкой на то, что datetime – атрибут полностью уникальный (равносильно тому, что в каждом классе учится только один школьник). Значит, СУБД нужно проделать самостоятельную сортировку. Поэтому в данном запросе filesort
– это норма, от которой никуда не денешься.
Всё работает, но внезапно...
Однако после анализа самых частых sql–запросов, совершаемых над таблицей log
, выяснилось, что самая частая операция, совершаемая в приложении — это поиск логов с конкретным message–м и типом, без конкретных временных промежутков.
Например, поиск всех ошибок с сообщением "message 183".
Такой запрос будет уже неоптимальным и выполняться будет около 30 секунд:
select `datetime`, `message` from `log`
where `message` = 'message 183'
and `type` = 'error';
Explain этого запроса выдал такую картину:
id: 1
select_type: SIMPLE
table: log
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 10010745
filtered: 3.33
Extra: Using where
Теперь видно, что индекс вовсе не используется. Оно и понятно: слишком затратно искать информацию по суффиксу индекса.
Делаем вывод, что нужно менять структуру индекса так, чтобы message был на первом месте:
drop index `datetime_message` on `log`;
create index `message_datetime` on `log`(`message`(150), `datetime`);
Теперь запрос, который при прошлом индексе ронял бы БД, выглядит вполне оптимально:
id: 1
select_type: SIMPLE
table: log
partitions: NULL
type: ref
possible_keys: message_datetime
key: message_datetime
key_len: 452
ref: const
rows: 1000
filtered: 100.00
Extra: Using where
Однако старый запрос на получение мессаджей по конкретной дате теперь неоптимален.
Но если он выполняется редко, то можно оставить его неоптимальным, поскольку основная задача оптимизации выполнена: все частые запросы к базе данных оптимизированы.
Подведём итоги
Не всегда самый селективный столбец должен стоять в префиксе составного индекса.
Бывают ситуации, когда атрибут, у которого куча повторов в таблице, является наиболее часто выбираемым. И толку его ставить вправо нет, потому что операции поиска по нему приведут к полному перебору индексного дерева.
Есть люди, которые считают мифом ставить самый селективный столбец влево.
Сложно назвать это мифом, поскольку на практике самый селективный столбец даёт больше преимущества в поиске над остальными.
Просто помимо селективности нужно обращать внимание и на саму предметную область, и отталкиваться в первую очередь от её требований, а не только от сухих данных.
Полезные ссылки
- Почему ставить самый селективный столбец влево – это миф
- Use the index, Luke! Это, пожалуй, must have любого разраба баз данных. Очень хорошо объяснено оптимальное использование индексов, большое количество примеров с запросами и фишки по различным СУБД.
- Ещё раз на тему того, почему самый селективный столбец слева – это миф
Комментарии (18)
Kant8
25.03.2018 03:32Не совсем понятно, причем тут вообще селективность. Какой бы идеальной она не была, если фильтр запроса пропустил хоть одну колонку в последовательности индекса, не видать index seek'ов как своих ушей.
В данном случае первый запрос фильтровал по дате, ему только дата и нужна была в индексе, если бы это был SqlServer, то сообщение спокойно можно было бы вообще пихать в инклюды.
Второй же запрос наоборот чхал на дату, и от текущего индекса ему пользы никакой, всё равно условный «бинарный поиск» не может отработать, тк данные отсортированы сначала по дате, и только на следующем уровне уже по сообщению.
Так что поведение более чем ожидаемо. Для таких диаметрально противоположных запросов надо просто два отдельных индекса.
Конечно, если в итоге оказывается, что фильтр по дате никогда и не используется, то и проблема не стоит, но и новый индекс тогда не особо полезен, тк в нем нет типа сообщения, и чтобы его проверить, придется лазить в саму таблицу. Хотя на этом конкретном примере скорее всего тексты сообщений не пересекаются между типами (обратное для логов было бы странно :)), так что фильтр по типу сообщения скорее всего вообще никогда ничего в реальности не фильтрует и его можно спокойно убрать.
Ну а если еще и ищут в 99% случаев только тип 'error' то вообще можно сделать фильтрованный индекс, который был бы маленьким, и позволял бы искать ошибки быстро, а просто логи читать можно было бы как раньше, по датеthe_kane_is_alive Автор
25.03.2018 09:34Если будет два индекса на разные колонки, тогда модификация данных будет хуже выполняться
Tortortor
25.03.2018 09:47а от одного индекса модификация не стала «хуже»?
и вообще, логи модифицировать никто не должен.
и вообще, вот эти «лучше/хуже» просчитываются на этапе проектирования приложения. но до этого надо ещё подрасти.the_kane_is_alive Автор
25.03.2018 10:02и вообще, логи модифицировать никто не должен.
Под модификацией данных я имел ввиду не update/delete, а insert конечно же. Свой старый комментарий изменить не могуRPG18
25.03.2018 13:11Вы не думали, из-за чего происходит это замедление вставки и нельзя ли это оптимизировать?
lair
25.03.2018 10:48… и что? Абстрактное "хуже выполняться" никому не интересно, интересны реальные метрики: вот мы добавили индекс, наши типовые запросы на чтение ускорились на столько-то процентов (или нет), наши типовые запросы на запись замедлились на столько-то процентов (или нет), вот абсолютные значения на всякий случай тоже.
Может вам вообще нужна append-only БД, где индексы строятся отдельным процессом, кто ж вас знает.
silvercaptain
25.03.2018 04:20Выгрузить таблицу log куда-то в доступное место в csv можно?
the_kane_is_alive Автор
25.03.2018 09:36В данной задаче можно, но здесь суть задачи заключается именно в хранении логов в таблице, а не в переносе их в другое место
VolCh
25.03.2018 09:26+1Офтоп: выборку по диапазону datetime лучше в общем соучае делать `date >= '2018-03-24 00:00:00' AND 'date' < '2018-03-25 00:00:00'` Это застрахует от изменения в точности представления datetime.
the_kane_is_alive Автор
26.03.2018 14:44Исправил.
Надо было конечно сразу исправить, но лучше поздно, чем никогда)
musicriffstudio
25.03.2018 11:48+2ох уже эти логи. Тысячу раз повторялось, но всё одно и тоже.
Логи нужне писать в текстовые файлы и регулярно (по задаче, можно раз в секунду, можно раз в месяц) грузить их в бд, откуда и будет производиться выборка для анализа.
Так запись будет отделена от чтения.
Таблица в бд это три поля:
— datetime — миллисекунды в юниксовом стандартном формате
— message — целое число (int) раз там только одно из заранее известных 10 тыс. значений
— type — целое число т.к. там одно из трёх значений
т.е. всего три поля с типом int который хорошо индексируется и маленький, и по индексу по каждому полю.
Все так делают. И у всех всё летает. А у вас пипелины какие-то и запросы по 30 секунд.
Более того. Если у вас в результате выборки получаются 172 тыс. записей, то это не выборка. Выборка это, например, «десять самых частых сообщений за такой-то период». А у вас
это переписывание куска данных из одного места в другое.
В таком случае сервер бд не нужен вообще. Продублируйте все сообщения в текстовые файлы по двум папкам — в попдапках по message и по type. Чтоб можно было зайти (как в вашем примере) в папку «message 183» и прочитать там все сообщения с этим типом. Доступ к переписыванию кусков будет мгновенный. Даже просто мышкой в проводнике, без всякого этого вашего программирования.
xtender
25.03.2018 16:24В Oracle с этим все легко и просто:
1. Можно использовать композитное секционирование, например: интервальное секционирование по датам datetime и подсекции по message. Допустим, каждая секция содержит только одни сутки, и каждая подсекция только свой message. В таком случае индексы вообще будут не нужны, поэтому такой вариант обычно и рекомендуется для логов, т.к. там нагрузка в основном write-only.
2. если лидирующее поле/поля в индексе имеет небольшое количество разных значений и в запросе нет по нему предиката, но есть хороший селективный предикат по второму полю из индекса, то может использоваться Index Skip Scan
the_kane_is_alive Автор
26.03.2018 14:36Подводя итог, скажу, что целью статьи было вовсе не создание системы логов как таковой, а в первую очередь донесение до читателя той мысли, что не всегда ставить селективный атрибут влево – это хорошо.
Логи выступали лишь в качестве примера, и судя по комментариям, пример получился неудачный. Но на данный момент ничего лучше в голову не приходитlair
26.03.2018 15:02Вам уже сказали: дело не в "селективности" атрибута, а в том, под какие задачи вы оптимизируете хранилище. В вашем случае оптимизация была под одни задачи, а в реальности ожидались другие. Кнут передает вам привет.
minamoto
26.03.2018 16:19+1Когда речь идет о том, что первым должен стоять самый селективный атрибут, имеется в виду самый селективный атрибут из запроса, а не из таблицы. Странно индексировать таблицу по одному полю, а фильтровать в запросе по другому, но именно это вы в своей статье и продемонстрировали.
В вашем запросе используется два поля: `message` и `type`, если бы вдруг написали, что в индексе именно `type` должен стоять на первом месте, а `message` на втором и привели бы обоснование с примером, то такое содержимое соответствовало бы заголовку статьи, а пока — извините, вы не до конца понимаете, о чем пишете.
miksoft
the_kane_is_alive Автор
Поэтапно подвожу читателя к этому пункту