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)
 - Kant825.03.2018 03:32- Не совсем понятно, причем тут вообще селективность. Какой бы идеальной она не была, если фильтр запроса пропустил хоть одну колонку в последовательности индекса, не видать index seek'ов как своих ушей. 
 
 В данном случае первый запрос фильтровал по дате, ему только дата и нужна была в индексе, если бы это был SqlServer, то сообщение спокойно можно было бы вообще пихать в инклюды.
 Второй же запрос наоборот чхал на дату, и от текущего индекса ему пользы никакой, всё равно условный «бинарный поиск» не может отработать, тк данные отсортированы сначала по дате, и только на следующем уровне уже по сообщению.
 Так что поведение более чем ожидаемо. Для таких диаметрально противоположных запросов надо просто два отдельных индекса.
 
 Конечно, если в итоге оказывается, что фильтр по дате никогда и не используется, то и проблема не стоит, но и новый индекс тогда не особо полезен, тк в нем нет типа сообщения, и чтобы его проверить, придется лазить в саму таблицу. Хотя на этом конкретном примере скорее всего тексты сообщений не пересекаются между типами (обратное для логов было бы странно :)), так что фильтр по типу сообщения скорее всего вообще никогда ничего в реальности не фильтрует и его можно спокойно убрать.
 
 Ну а если еще и ищут в 99% случаев только тип 'error' то вообще можно сделать фильтрованный индекс, который был бы маленьким, и позволял бы искать ошибки быстро, а просто логи читать можно было бы как раньше, по дате - the_kane_is_alive Автор25.03.2018 09:34- Если будет два индекса на разные колонки, тогда модификация данных будет хуже выполняться - Tortortor25.03.2018 09:47- а от одного индекса модификация не стала «хуже»? 
 и вообще, логи модифицировать никто не должен.
 
 и вообще, вот эти «лучше/хуже» просчитываются на этапе проектирования приложения. но до этого надо ещё подрасти. - the_kane_is_alive Автор25.03.2018 10:02- и вообще, логи модифицировать никто не должен. 
 Под модификацией данных я имел ввиду не update/delete, а insert конечно же. Свой старый комментарий изменить не могу - RPG1825.03.2018 13:11- Вы не думали, из-за чего происходит это замедление вставки и нельзя ли это оптимизировать? 
 
 
  - lair25.03.2018 10:48- … и что? Абстрактное "хуже выполняться" никому не интересно, интересны реальные метрики: вот мы добавили индекс, наши типовые запросы на чтение ускорились на столько-то процентов (или нет), наши типовые запросы на запись замедлились на столько-то процентов (или нет), вот абсолютные значения на всякий случай тоже. 
 - Может вам вообще нужна append-only БД, где индексы строятся отдельным процессом, кто ж вас знает. 
 
 
 - silvercaptain25.03.2018 04:20- Выгрузить таблицу log куда-то в доступное место в csv можно?  - the_kane_is_alive Автор25.03.2018 09:36- В данной задаче можно, но здесь суть задачи заключается именно в хранении логов в таблице, а не в переносе их в другое место 
 
- VolCh25.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- Исправил. 
 Надо было конечно сразу исправить, но лучше поздно, чем никогда)
 
 - musicriffstudio25.03.2018 11:48+2- ох уже эти логи. Тысячу раз повторялось, но всё одно и тоже. 
 
 Логи нужне писать в текстовые файлы и регулярно (по задаче, можно раз в секунду, можно раз в месяц) грузить их в бд, откуда и будет производиться выборка для анализа.
 
 Так запись будет отделена от чтения.
 
 Таблица в бд это три поля:
 — datetime — миллисекунды в юниксовом стандартном формате
 — message — целое число (int) раз там только одно из заранее известных 10 тыс. значений
 — type — целое число т.к. там одно из трёх значений
 т.е. всего три поля с типом int который хорошо индексируется и маленький, и по индексу по каждому полю.
 
 Все так делают. И у всех всё летает. А у вас пипелины какие-то и запросы по 30 секунд.
 
 Более того. Если у вас в результате выборки получаются 172 тыс. записей, то это не выборка. Выборка это, например, «десять самых частых сообщений за такой-то период». А у вас
 это переписывание куска данных из одного места в другое.
 
 В таком случае сервер бд не нужен вообще. Продублируйте все сообщения в текстовые файлы по двум папкам — в попдапках по message и по type. Чтоб можно было зайти (как в вашем примере) в папку «message 183» и прочитать там все сообщения с этим типом. Доступ к переписыванию кусков будет мгновенный. Даже просто мышкой в проводнике, без всякого этого вашего программирования.
 - xtender25.03.2018 16:24- В Oracle с этим все легко и просто: 
 1. Можно использовать композитное секционирование, например: интервальное секционирование по датам datetime и подсекции по message. Допустим, каждая секция содержит только одни сутки, и каждая подсекция только свой message. В таком случае индексы вообще будут не нужны, поэтому такой вариант обычно и рекомендуется для логов, т.к. там нагрузка в основном write-only.
 2. если лидирующее поле/поля в индексе имеет небольшое количество разных значений и в запросе нет по нему предиката, но есть хороший селективный предикат по второму полю из индекса, то может использоваться Index Skip Scan
 - the_kane_is_alive Автор26.03.2018 14:36- Подводя итог, скажу, что целью статьи было вовсе не создание системы логов как таковой, а в первую очередь донесение до читателя той мысли, что не всегда ставить селективный атрибут влево – это хорошо. 
 Логи выступали лишь в качестве примера, и судя по комментариям, пример получился неудачный. Но на данный момент ничего лучше в голову не приходит - lair26.03.2018 15:02- Вам уже сказали: дело не в "селективности" атрибута, а в том, под какие задачи вы оптимизируете хранилище. В вашем случае оптимизация была под одни задачи, а в реальности ожидались другие. Кнут передает вам привет. 
  - minamoto26.03.2018 16:19+1- Когда речь идет о том, что первым должен стоять самый селективный атрибут, имеется в виду самый селективный атрибут из запроса, а не из таблицы. Странно индексировать таблицу по одному полю, а фильтровать в запросе по другому, но именно это вы в своей статье и продемонстрировали. 
 В вашем запросе используется два поля: `message` и `type`, если бы вдруг написали, что в индексе именно `type` должен стоять на первом месте, а `message` на втором и привели бы обоснование с примером, то такое содержимое соответствовало бы заголовку статьи, а пока — извините, вы не до конца понимаете, о чем пишете.
 
 
           
 
miksoft
the_kane_is_alive Автор
Поэтапно подвожу читателя к этому пункту