Универсальный индекс по документам на эластике
Всем привет. Меня зовут Женя Редько, я работаю в ядре Диадока — это сервис электронного документооборота от Контура. В моей подкоманде Документов мы занимаемся основными бизнес-сценариями Диадока.
В статье я расскажу про универсальный индекс, с помощью которого у нас реализовано большое количество функциональностей, почему индекс один, и какими хитрыми манипуляциями мы его ускоряли. Но для начала немного контекста.
Немного про Диадок
В базе Диадока находится порядка 9 миллиардов документов, которые расположены по 10 миллионам ящиков. Ящиком у нас называются организации. При этом пользователь всегда работает только в рамках одного ящика.
В «холодный» день происходит ~100k событий по документам в минуту, в «горячий» день доходит до ~150k. И эта нагрузка постоянно растет, что приводит к определенной архитектуре системы.
Мы в Диадоке сильно оптимизированы на запись. Но в Cassandra — нашем основном хранилище — чтение идет только по идентификатору документов. Этого не всегда достаточно, поэтому индексы нам критично необходимы. Например, гораздо удобнее воспользоваться индексом, чтобы построить список Входящих документов.
Типы чтения
Индексы могут использоваться для разных сценариев. Глобально можно разделить чтение на два типа: фильтрация и полнотекстовый поиск.
Фильтрация
Фильтрацией мы называем чтение по точному совпадению в неком конкретно ограниченном наборе полей. Например, во Входящих документах передаются идентификатор ящика и признак, что документ находится на входящей стороне.
Фильтрации используются не только для того, чтобы создавать списки; существуют и внутренние использования. Например, подобным запросом фильтрации внутри системы ищется корректируемый документ.
Такие запросы должны выполняться крайне быстро, ведь когда пользователь открывает сервис, он не должен долго ждать загрузку списка. Так что требования здесь серьёзные.
Полнотекстовый поиск
Полнотекстовый поиск в нашем интерфейсе представлен как поисковая строка. Можно написать что угодно, и результат будет искаться по всем метаданным и по неточному совпадению. В данном примере я написал «п1» и нашлись документы, у которых название файла начинается на «п1».
В эту поисковую строку можно написать любой запрос, и скорость будет зависеть от его сложности. Поэтому, в отличие от фильтрации, требования здесь не такие серьёзные. Пользователь понимает, что он занимается непростым поиском, и может чуть-чуть подождать.
Итак, эти два глобально больших сценария мы и положили в один индекс. Возникает резонный вопрос, зачем мы это сделали и почему это может понадобиться вам.
Зачем один большой индекс?
Основные причины:
Сложная модель доступа к данным
Устроена модель следующим образом. Допустим, есть документ. Он постоянно приходит в какое-то подразделение. При этом подразделения имеют древовидную иерархию: есть головное подразделение (создается в каждом ящике по умолчанию), и от него можно создавать дочерние подразделения, у дочерних — свои дочерние подразделения.
При этом в документе мы храним только идентификатор текущего подразделения. А чтобы узнать все подразделения-родители, нужно обходить дерево.

Сценарии, что мы предоставляем:
— Поиск документов в конкретном подразделении (например, Dep1)
Простой сценарий. Индексируем Id подразделения и ищем по нему.
— Поиск по нескольким подразделениям (например, Dep1 и Dep2.1)
Тоже простой сценарий. В запрос поиска передаем нужные нам точечные идентификаторы и и одного ИД в индексе здесь достаточно.
— Поиск по подразделению и по всем его дочерним (например, Dep1, Dep1.1, Dep1.2, Dep1.2.1)
Здесь начинаются сложности. Самым наивным вариантом будет просто собирать всё поддерево, в плоский список и передавать в запрос. Но в реальных структурах бывают деревья с 2000+ подразделениями. В таком случае запрос будет огромным и исполнить его нереально.
Поэтому мы индексируем не только само подразделение документа, но и список всех его родителей: Dep1.2 → Head/Dep1/Dep1.2. И для этого помимо индекса документов ведётся отдельный индекс путей до подразделений.
Поскольку такой индекс путей необходимо собирать, обновлять, поддерживать, то данную логику хотелось написать один раз и пользоваться один раз. Если бы у нас было много разных индексов, то пришлось бы дублировать. Поэтому решили делать один общий индекс.
Возможность объединить поиск и фильтрацию
Мы знали про такой потенциал, но изначально его не использовали. То есть на тот момент времени это была больше гипотетическая причина одного индекса. Объединенный поиск, безусловно, дал бы пользователям довольно богатые инструменты, чтобы находить нужные документы.
Вторичная причина:
Унификация
Когда меньше звеньев в системе, то проще понять архитектуру, нужно изучать меньше инструментов.
Новый поиск
Возможность нового объединенного поиска не отпускала. И в итоге мы запустили крутой поиск, в котором есть как и разные возможности настройки фильтров, например, по контрагентам, по типам, по статусам, так и возможность написать что-то полнотекстово.

Так что если бы универсального индекса у нас не было, то нам пришлось бы его изобрести! Давайте заглянем под капот и узнаем, как всё работает.
Заглянем под капот
Elasticsearch
По заголовку статьи вы могли понять, что индекс использует Elasticsearch.
Elastic — база данных, которая занимается в основном регистрацией и масштабированием данных. Но самой работой по индексации занимается Apache Lucene — известная библиотека, которая развивается уже несколько десятилетий. Elastic, в свою очередь, позволяет эту структуру масштабировать.
Индекс Apache Lucene внутри себя содержит много разных структур, которые позволяют вам искать данные: docValues, stored fields и тд. Но мы рассмотрим главную — инвертированный индекс.
Term | Document |
quick | 0,1 |
float | 1,2 |
idle | 0,2 |
В инвертированном индексе хранятся значения Term, которым соответствует отсортированный список Id документов. Если поиск по нескольким значениям, то нужно проиттерироваться по этим нескольким спискам и пересечь их или объединить в зависимости от нашего запроса.
Индекс Elastic представляет собой несколько объединённых индексов Lucene, называемых шардами. Elastic умеет координировать запросы между шардами и объединять ответы от разных шард. Он прозрачно для пользователя масштабирует все эти данные. Более того, можно отправить общий запрос на несколько индексов, и Elastic также смержит все ответы в один понятный результат для пользователя. То есть можно масштабировать наши данные не только путём шардирования, но и путём разделения их на несколько индексов.

Давайте попробуем воспользоваться данными инструменты масштабирования и построить индекс.
Пробуем построить индекс стандартными средствами
Прямо на проде Диадока мы посчитали, что индексы надо создавать раз в полгода, а данные за полгода шардировать на 64 шарда, чтобы они имели оптимальный размер. Схематично выглядит следующим образом:

По умолчанию Elastic распределяет все документы по шардам в зависимости от идентификатора документа, и по умолчанию в каждый из шард попадают документы из всех ящиков. Соответственно, делаем запрос в базу данных, и Elastic собирает данные со всех шард.
Упрощенная схема индексированного документа на C# :
class Document
{
public Guid Id { get; set; }
public Guid BoxId { get; set; } // используется в каждом запросе
public string DepartmentPath { get; set; }
public FilterScope Scope { get; set; } // Incoming, Outgoung, ...
public Status Status { get; set; }
public bool IsDeleted { get; set; }
public long Timestamp { get; set; }
public DocumentUniversalKeyValue[] KeyValues { get; set; } // для полнотекстового поиска
}
Здесь есть идентификатор документа, идентификатор ящика, путь до подразделений. Scope
— документ входящий, исходящий и тд.Status
— документ отправлен, необходимо подписать и тд. Также нам важно знать, удалён документ или нет. Есть метка времени, например, дата отправки, по которой мы всегда сортируем. И набор ключей, строковых значений, которые используются для полнотекстового поиска. Мы записываем все эти метаданные.
Какие запросы можно выполнить над этой моделью:
Получить все документы (BoxId+IsDeleted)
Получить все входящие документы (BoxId+IsDeleted+Scope)
Получить все документы с определённым статусом (BoxId+IsDeleted+Status)
Полнотекстовый поиск (BoxId+KeyValues)
Результат сортируем по меткам времени Timestamp. И на случай, если они совпадут, добавляем ещё и Id документа, чтобы поле для сортировки было всегда уникальным.
Итак, делаем такую модель. И получаем, что даже такой простейший запрос, как BoxId+IsDeleted, выполняется довольно долго. У этого печального результата есть две причины:
Нужно обойти все шарды
Как мы помним, по умолчанию документы раскидываются по всем шардам. Получается, для поиска необходимо сходить 64 раза в 64 шарда, получить 64 ответа, как-то их отсортировать, объединить. А так как мы берём данные за год, мы в индекс за второе полугодие точно так же делаем 64 запроса, сортируем, объединяем. А затем два этих объединённых результата нужно опять отсортировать и объединить… Очень много внутренней работы.
Внутри шарды в индексе поля
IsDeleted
будут документы всех ящиковА ящиков — 10 миллионов, и пройти нужно их все. Получается, что такие поля низкоселективные.
Вывод: так оставлять нельзя, нужно оптимизировать. Давайте посмотрим, что нам предлагает Elastic.
Пробуем стандартные инструменты оптимизации
Отдельный индекс для каждого ящика
Первое, что приходит в голову, — создать для каждого ящика отдельные индексы. Тогда всё будет изолировано. Но так как ящиков в наличии уже 10 миллионов и число растет, то и индексов потребуется столько же. Слишком много накладных расходов.
Вывод: Нереалистично, 10 миллионов индексов нам не создать. Не делаем.
Routing
Routing — это дополнительный аргумент в запросе, при помощи которого выбирается шарда. То есть когда мы пишем документ либо отсылаем запрос, то можем передать строку routing, с помощью которой координатор Elastic«a будет выбирать конкретную шарду, куда мы будем либо писать документ, либо искать.
В качестве идентификатора для роутинга в нашем случае идеально подошёл идентификатор ящика. Ведь передавая BoxId как аргумент для роутинга, все документы конкретного ящика попадут в одну шарду и количество внутренних запросов уменьшится.
Но дело в том, что если посмотреть на статистику ящиков, то видно, что примерно в 20 ящиках документов в разы больше, чем во всех остальных. И если распределять по Id ящиков, то шарды получаются неравномерные. Это приведёт к тому, что запросы в одном индексе будут выполняться с разным временем, то есть перестанут быть гомогенными.
Поэтому мы отделили этот топ-20 ящиков в отдельный индекс, в который роутинг не завезли. Но там индекс получился достаточно маленьким, чтобы это работало. А все остальные 10 миллионов ящиков пороутили по своим шардам, что дало значительное ускорение.
Вывод: Решение частично подходит, делаем.
Сортировка при индексации
При создании индекса можно указать набор полей, по которому документы будут отсортированы. То есть документы будут сразу сортироваться по этому набору полей в конкретную сторону, которую вы укажете при создании индекса. Это значит, что при запрашивании документов не надо будет делать дополнительную сортировку.
Но задача стоит в создании универсального индекса и сортировка идёт по разным меткам времени. Более того, если мы создаем сортированный индекс, но передаем ему в качестве параметра сортировки в запросы любые другие параметры, то такие запросы сильно замедляются. У нас Диадоке есть другие индексы, в которых мы это применяем и получаем крутой профит. Но в данном индексе такое не подходит.
Вывод: Может существенно замедлить остальные запросы. Не делаем.
Искать не во всех индексах
Идея в том, чтобы сделать более простым запрос по умолчанию, менее нагружающим наш Elastic. Выяснив по бизнес-сценариям, что в большинстве случаев пользователям нужны документы за последние 3 года, наш список документов без фильтров дальше этого периода не ищет.
Но при этом пользователь всегда может открыть фильтр и расширить период вручную. Для нас главное, что запросы по умолчанию —, а это довольно частые запросы — будут легче.
Вывод: Подходит, делаем.
И на этом стандартные инструменты оптимизации Elastic закончились. Да, некоторые из них значительно позволяют ускорить запросы. Но можно больше. И для этого уже требуется применять инженерные решения, нужно что-то изобретать.
Инженерные решения
Напомню, что изначально у нас две большие проблемы: обход всех шард и низкоселективные поля.
Проблему с необходимостью обхода всех шард решилась благодаря routing«у. Получилось, что документы одного ящика хранятся в одной шарде. Нам не надо ничего склеивать. Но вторая проблема осталась — обход списка всех документов со всех ящиков всё ещё резко тормозит ситуацию.
Как можно повысить селективность?
Делаем стандартные значения уникальными
В разных системах бывает, что есть значения, которые всегда ставятся по умолчанию и имеют какое-то определённое значение. Например, в Диадоке головное подразделение, которое есть у всех ящиков, имеет идентификатор Guid.Empty
.
Вместо Guid.Empty
мы записали BoxId и получилось, что все документы из головных подразделений в нашем индексе автоматически растеклись в свои поля. Селективность не просто повысилась, а стала максимальной, потому что нет вероятности того, что одно и то же поле хранит документы разных ящиков.
Сортируем по числам, а не строкам
Как я уже говорил, мы сортируем по метке времени, которая является числом, и по идентификатору документа, которое всё реализуется как строка. Guid
— это 16-байтное число, но в Elastic оно пишется как строка. А сортировка по строкам работает медленнее, чем по числам.
Поэтому мы взяли половину от Guid, первые 8 байт, и записали в числовое поле, которое используется только для сортировки:
public Guid Id { get; set; } // Строка
public long SortableId { get; set; } // Половина от Id
Напомню, что сортировка по Guid нужна только для уникальности, поэтому потеря точности в данном случае не страшна, ведь это всего лишь второй параметр после Timestamp. Однако запросы сортировки ускоряются значительно, потому что числа можно разбить на диапазоны, и все они имеют гарантированно одинаковую длину.
Вспомним битовую арифметику!
Пришло время вспомнить битовую арифметику, ведь она нам поможет для понимания следующих инженерных решений.
Итак, все числа представлены в битовом виде и их можно в этом виде завести в код. Например, переменная А в битовом виде является 1. В десятиричном виде А будет тоже равна 1. Кроме арифметических операций, таких как сложение, вычитание, есть ещё и битовые операции:
byte a = 0b00000001; // 1
var b = a << 1; // 0b00000010 == 2
var c = b | a; // 0b00000011 == 3
Например, во второй строке применён сдвиг влево на 1, и в битовом представлении эта единица, которая была справа, стала второй справа. Получилось число 2.
Кроме того, есть побитовые операции. Например, побитовые ИЛИ, как в третьей строчке. Берем первые два числа, применяем побитовое ИЛИ, и каждому биту применилась логическая операция ИЛИ. То есть были 01 и 10, стало 11.
Так что если вы проходили это в универе или просто сталкивались с наличием таких операций и не понимаете, для чего может пригодиться битовая арифметика… Сейчас я вам покажу.
Объединяем несколько полей в одно числовое
Итак, мы делаем запросы по нескольким полям, между которыми нужно итерироваться. В нашем случае есть Scope и Status.
public FilterScope Scope { get; set; } // Incoming, Outgoung, ...
public Status Status { get; set; }
И можно записать их в одно числовое поле. Давайте представим это в байтовом представлении и заведем 8-байтовую переменную типа long.
public long FilterCategory { get; set; } // Scope + Status
И если визуализировать, то 8-байтовая переменная выглядит так:
00 | 00 | 00 | 00 | 00 | 00 | 00 | 00 |
Так как значений Scope и Status не так много, они влезают в 1 байт. Со сдвигами и логическими операциями можно записать в long значения двух полей.
00 | 00 | 00 | 00 | 00 | 00 | 01 (Scope) | 03 (Status) |
До этого маневра в поисковом запросе необходимо было обходить два списка, а теперь только один.
Получается, что из 8 байт забили только 2 байта, остальные остались пустыми. Можно взять ещё несколько полей, можно long поменять на int — будет 4 байта и всего 2 пустых. А можно…
Добавляем к значению префикс
… Сделать финт! Если в оставшиеся нолики записать префикс от BoxId организации, то это значительно повысит селективность. Теперь в каждом значении статуса есть кусочек от значения ящика.
ba (BoxId) | 5e (BoxId) | de (BoxId) | ad (BoxId) | be (BoxId) | a7 (BoxId) | 01 (Scope) | 03 (Status) |
Конечно, тут присутствуют коллизии, но они маловероятны. В этом случае их гораздо меньше, чем если бы мы не делали никаких префиксов. А селективность повышается очень сильно.
Для bool
В случае с булами мы вообще можем использовать только 1 бит под значение була, а всё остальное использовать для префикса. Получится 63 бит для long или 31 для int.
Для Timestamp
С таймтемпами всё сложнее. У нас есть метки времени, которые являются дотнетовскими таймтемпами. И по сути они уже long, то есть уже используют всё число. Но давайте попробуем его ужать.
Если мы берём значение DateTime.Now
, то видим, что Текущее время затрагивает 60 бит. Если эти 60 бит заполнить единичками, мы получим следующую дату — 18.06.3654 21:21:00. И вот тут уже надо понять, хватает ли ограничения дат под бизнес-сценарий вашего продукта.
Например, в Диадоке не отправляются документы в прошлом или в будущем, только в текущий момент. Поэтому можно ограничивать размер дат, которые индексируются, потому что просто невозможно попасть за этот промежуток. То есть если мы используем 60 бит, то попадаем в диапазон дат 01.01.0001 00:00:00 — 18.06.3654 21:21:00. На префикс остается 4 бита, то есть получается всего 16 префиксов. На 10 миллионов ящиков маловато. Конечно, лучше, чем ничего, но хотелось бы большего. Можно ли?
Диадок запущен в 2010 году. Опять же документы в прошлом и в будущем не отправляются. Поэтому есть логичная идея попробовать ограничить дату не только сверху, но и снизу. Если сдвинуть минимальную дату к 01.01.2000 и вычесть из нее текущую дату, то текущее время займёт только 53 бита. Если 53 бита забить единичками, то максимальная дата — 16.07.2028 23:58:45. Это довольно скоро, останется меньше 3 лет до того момента, как придется бежать и всё переделывать.
Но если берём 54 бит, то получаем 30.01.2057 23:57:30. Вот этого вполне достаточно. У нас есть 30 лет, чтобы технологии прокачались и развились. Решено — 54 бита используем под дату, остальные 10 под префикс. Получаем 1024 префикса, что тоже, конечно, не фонтан, но уже гораздо больше. И получается, что при запросах в каком-то рейндже с даты по дату таймстемпы уже будут разделены.
Результаты инженерных решений
После этих оптимизаций схема изменилась. Вместо понятных енумов и булов встали лонги. Появилась FilterCategory
, которая хранит Scope
и Status
; IsDeleted
тоже стало лонгом. А также появился SortableId
, который хранит половину Id и используется только для сортировки.
class Document
{
public Guid Id { get; set; }
public Guid BoxId { get; set; }
public string DepartmentPath { get; set; } // Head = BoxId
public long FilterCategory { get; set; } // Scope + Status
public long IsDeleted { get; set; }
public long Timestamp { get; set; }
public long SortableId { get; set; } // Половина от Id
public DocumentUniversalKeyValue KeyValues { get; set; } // for fulltext search
}
Плюсы оптимизации:
Ускорились запросы и упала нагрузка на Elasticsearch
Это графики после одного из релизов, где мы применили префиксную магию. Загрузка процессора и загрузка диска упали:


И еще один из других релизов:

Как я упоминал в начале, все доработки идут итеративно. Здесь мы перешаардировали индекс, сделали его равномерным; обновили список VIP-ов. Внезапно для нас самих ускорилась запись!
Минусы оптимизации:
Код индексации и отправки запросов стал намного сложнее
Запросы стали абсолютно не человекочитаемыми
Еще запросы стало невозможно составить руками
Но профит оказался важнее.
Планы на будущее
Новый VIP-индекс
В разделе про роутинг мы обсудили, что индекс поделился на два: стандартный и на VIP-индекс. В этом VIP-индексе сейчас 20 ящиков, и все документы распределяются на 8 шард без всякого роутинга. Иначе шарда у 1 ящика была бы сильно больше остальных.

Так что сейчас перед нами стоит задача ускорить VIP-индекс. Например, можно побить на 8 индексов по 1 шарде. То есть физически данных останется столько же, зато логически запрос облегчится, а роутинг будет и не нужен, потому что всё везде только 1 шарда.
Добавить автоматику
Хочется настроить метрики и алерты на размер шард, которые сейчас отслеживаются руками. Никакого переноса ящиков в VIP и обратно автоматического тоже нет. Такие списки тоже собираются руками перед раскатыванием нового индекса.
Нагрузочное тестирование
Кроме того, хочется иметь нагрузочное тестирование, чтобы какие-то последующие эксперименты над индексом были более предсказуемыми.
Например, мы делали индекс уже 11 версии и решили, что VIP-ящиков маловато –– надо расширить. Увеличили количество ящиков, увеличили количество шарт с 8 до 15. И на боевой нагрузке поняли, что столько эластик не выдерживает. ЦПУ ушёл в потолок, утилизация, диск ушла в потолок, запросы тупили. Пришлось быстро откатывать.


Благо, откатывать и накатывать мы можем без проблем. Переиндексировать не надо — просто рядом заводим два индекса. Но для предсказуемости таких изменений все-таки хочется иметь нагрузочное тестирование.
Исследовать повышение читаемости
Я могу гарантировать, что все наши оптимизации — это не потолок. Например, можно поля для фильтрации попытаться сделать читаемыми и хранить их в виде строк с префиксом. Но без нагрузочного тестирования мы не знаем последствий, поэтому не делаем этого.
Заключение
По сути, мы построили один большой жирный индекс, который принёс бизнес-профит, — крутой поиск для пользователей. Но при этом данных стало много и при масштабировании нужно их правильно распределить под профиль запросов, чтобы для какого-то из конкретных запросов затрагивалось как можно меньше узлов.
Да, часто стандартных инструментов, которые предоставляет какая-то технология, недостаточно, и приходится смотреть на её реализацию и бесконечно думать, как можно лучше преподать данные так, чтобы всё работало быстрее. Важно помнить, что у любых оптимизаций есть и обратные стороны. В нашем случае потеря читаемости: мы не можем дебажить запросы без кода, не можем составлять запросы без кода. Зато запросы работают быстро за нужную нам скорость.