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

Однако старый запрос на получение мессаджей по конкретной дате теперь неоптимален.
Но если он выполняется редко, то можно оставить его неоптимальным, поскольку основная задача оптимизации выполнена: все частые запросы к базе данных оптимизированы.


Подведём итоги


Не всегда самый селективный столбец должен стоять в префиксе составного индекса.
Бывают ситуации, когда атрибут, у которого куча повторов в таблице, является наиболее часто выбираемым. И толку его ставить вправо нет, потому что операции поиска по нему приведут к полному перебору индексного дерева.


Есть люди, которые считают мифом ставить самый селективный столбец влево.
Сложно назвать это мифом, поскольку на практике самый селективный столбец даёт больше преимущества в поиске над остальными.


Просто помимо селективности нужно обращать внимание и на саму предметную область, и отталкиваться в первую очередь от её требований, а не только от сухих данных.


Полезные ссылки


Комментарии (18)


  1. miksoft
    25.03.2018 00:21
    +1

    Однако после анализа самых частых sql–запросов
    Имхо, с этого надо было начинать.


    1. the_kane_is_alive Автор
      25.03.2018 09:30

      Поэтапно подвожу читателя к этому пункту


  1. Kant8
    25.03.2018 03:32

    Не совсем понятно, причем тут вообще селективность. Какой бы идеальной она не была, если фильтр запроса пропустил хоть одну колонку в последовательности индекса, не видать index seek'ов как своих ушей.

    В данном случае первый запрос фильтровал по дате, ему только дата и нужна была в индексе, если бы это был SqlServer, то сообщение спокойно можно было бы вообще пихать в инклюды.
    Второй же запрос наоборот чхал на дату, и от текущего индекса ему пользы никакой, всё равно условный «бинарный поиск» не может отработать, тк данные отсортированы сначала по дате, и только на следующем уровне уже по сообщению.
    Так что поведение более чем ожидаемо. Для таких диаметрально противоположных запросов надо просто два отдельных индекса.

    Конечно, если в итоге оказывается, что фильтр по дате никогда и не используется, то и проблема не стоит, но и новый индекс тогда не особо полезен, тк в нем нет типа сообщения, и чтобы его проверить, придется лазить в саму таблицу. Хотя на этом конкретном примере скорее всего тексты сообщений не пересекаются между типами (обратное для логов было бы странно :)), так что фильтр по типу сообщения скорее всего вообще никогда ничего в реальности не фильтрует и его можно спокойно убрать.

    Ну а если еще и ищут в 99% случаев только тип 'error' то вообще можно сделать фильтрованный индекс, который был бы маленьким, и позволял бы искать ошибки быстро, а просто логи читать можно было бы как раньше, по дате


    1. the_kane_is_alive Автор
      25.03.2018 09:34

      Если будет два индекса на разные колонки, тогда модификация данных будет хуже выполняться


      1. Tortortor
        25.03.2018 09:47

        а от одного индекса модификация не стала «хуже»?
        и вообще, логи модифицировать никто не должен.

        и вообще, вот эти «лучше/хуже» просчитываются на этапе проектирования приложения. но до этого надо ещё подрасти.


        1. the_kane_is_alive Автор
          25.03.2018 10:02

          и вообще, логи модифицировать никто не должен.

          Под модификацией данных я имел ввиду не update/delete, а insert конечно же. Свой старый комментарий изменить не могу


          1. RPG18
            25.03.2018 13:11

            Вы не думали, из-за чего происходит это замедление вставки и нельзя ли это оптимизировать?


      1. lair
        25.03.2018 10:48

        … и что? Абстрактное "хуже выполняться" никому не интересно, интересны реальные метрики: вот мы добавили индекс, наши типовые запросы на чтение ускорились на столько-то процентов (или нет), наши типовые запросы на запись замедлились на столько-то процентов (или нет), вот абсолютные значения на всякий случай тоже.


        Может вам вообще нужна append-only БД, где индексы строятся отдельным процессом, кто ж вас знает.


  1. silvercaptain
    25.03.2018 04:20

    Выгрузить таблицу log куда-то в доступное место в csv можно?


    1. the_kane_is_alive Автор
      25.03.2018 09:36

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


  1. VolCh
    25.03.2018 09:26
    +1

    Офтоп: выборку по диапазону datetime лучше в общем соучае делать `date >= '2018-03-24 00:00:00' AND 'date' < '2018-03-25 00:00:00'` Это застрахует от изменения в точности представления datetime.


    1. the_kane_is_alive Автор
      25.03.2018 09:37

      Спасибо за дельный совет)


    1. the_kane_is_alive Автор
      26.03.2018 14:44

      Исправил.
      Надо было конечно сразу исправить, но лучше поздно, чем никогда)


  1. musicriffstudio
    25.03.2018 11:48
    +2

    ох уже эти логи. Тысячу раз повторялось, но всё одно и тоже.

    Логи нужне писать в текстовые файлы и регулярно (по задаче, можно раз в секунду, можно раз в месяц) грузить их в бд, откуда и будет производиться выборка для анализа.

    Так запись будет отделена от чтения.

    Таблица в бд это три поля:
    — datetime — миллисекунды в юниксовом стандартном формате
    — message — целое число (int) раз там только одно из заранее известных 10 тыс. значений
    — type — целое число т.к. там одно из трёх значений
    т.е. всего три поля с типом int который хорошо индексируется и маленький, и по индексу по каждому полю.

    Все так делают. И у всех всё летает. А у вас пипелины какие-то и запросы по 30 секунд.

    Более того. Если у вас в результате выборки получаются 172 тыс. записей, то это не выборка. Выборка это, например, «десять самых частых сообщений за такой-то период». А у вас
    это переписывание куска данных из одного места в другое.

    В таком случае сервер бд не нужен вообще. Продублируйте все сообщения в текстовые файлы по двум папкам — в попдапках по message и по type. Чтоб можно было зайти (как в вашем примере) в папку «message 183» и прочитать там все сообщения с этим типом. Доступ к переписыванию кусков будет мгновенный. Даже просто мышкой в проводнике, без всякого этого вашего программирования.


  1. xtender
    25.03.2018 16:24

    В Oracle с этим все легко и просто:
    1. Можно использовать композитное секционирование, например: интервальное секционирование по датам datetime и подсекции по message. Допустим, каждая секция содержит только одни сутки, и каждая подсекция только свой message. В таком случае индексы вообще будут не нужны, поэтому такой вариант обычно и рекомендуется для логов, т.к. там нагрузка в основном write-only.
    2. если лидирующее поле/поля в индексе имеет небольшое количество разных значений и в запросе нет по нему предиката, но есть хороший селективный предикат по второму полю из индекса, то может использоваться Index Skip Scan


  1. the_kane_is_alive Автор
    26.03.2018 14:36

    Подводя итог, скажу, что целью статьи было вовсе не создание системы логов как таковой, а в первую очередь донесение до читателя той мысли, что не всегда ставить селективный атрибут влево – это хорошо.
    Логи выступали лишь в качестве примера, и судя по комментариям, пример получился неудачный. Но на данный момент ничего лучше в голову не приходит


    1. lair
      26.03.2018 15:02

      Вам уже сказали: дело не в "селективности" атрибута, а в том, под какие задачи вы оптимизируете хранилище. В вашем случае оптимизация была под одни задачи, а в реальности ожидались другие. Кнут передает вам привет.


    1. minamoto
      26.03.2018 16:19
      +1

      Когда речь идет о том, что первым должен стоять самый селективный атрибут, имеется в виду самый селективный атрибут из запроса, а не из таблицы. Странно индексировать таблицу по одному полю, а фильтровать в запросе по другому, но именно это вы в своей статье и продемонстрировали.
      В вашем запросе используется два поля: `message` и `type`, если бы вдруг написали, что в индексе именно `type` должен стоять на первом месте, а `message` на втором и привели бы обоснование с примером, то такое содержимое соответствовало бы заголовку статьи, а пока — извините, вы не до конца понимаете, о чем пишете.