Manticoresearch - это Open-Source проект, форк проекта sphinxsearch от Андрея Аксенова и его команды. Проект позиционирует себя как открытое высокопроизводительное решение для полнотекствого поиска. Судя по бенчмаркам (правда они от самих создателей Мантикоры), "средняя по больнице" скорость превышает скорость популярного Elasticsearch.

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

Индексы

В manticore (сокращенно от manticoresearch - далее буду писать manticore для простоты) есть четыре типа индексов: plain, real-time, distributed и percolate. Plain индексы - это т.н. простые индексы, которые хранятся на диске, создаются один раз, поддерживают обновление атрибутов, но не полнотекстовых полей. Real-time индексы похожи на таблицы в базах данных, поддерживают полную замену документов через REPLACE, вставку, обновление в т.ч. и полнотекстовых полей в режиме "онлайн".

Также существует тип percolate индекс, основанный на Real-time-индексе. Этот тип не хранит данные, но хранит запросы.

Четвертый тип - это distributed индекс. Он ничего не хранит ни в каких файлах, это просто составной индекс который под собой содержит несколько plain или/и rt-индексов.

Plain и real-time индексы могут состоять из трех видов полей и атрибутов:

1) id - это идентификатор документа в индексе. Механизма автоинкремента для id в мантикоре не имеется, обеспечение уникальности id ложится на плечи программиста. Тип id всегда unsigned int64.

2) Полнотекстовые поля - хранят проиндексированный текст. Упрощенно полнотекстовые поля -- это структура инвертированного индекса: в памяти хранится словарь, на диске цепочки указателей на местоположение слова (терма) в документе. В настоящее время manticore не поддерживает хранение оригинального текста в полнотекстовых полях. В одном индексе может быть сразу несколько полнотекстовых полей, и разумеется, можно искать (сопостовлять, матчить) документы сразу по нескольким полнотекстовым полям. Именно по полнотекстовым полям manticore вычисляет релевантность - weight() для дальнейшего ранжирования результатов поиска. Для подробностей см. раздел searching

3) Атрибуты - дополнительные поля, по которым можно дофильтровать, сгруппировать или сортировать сматченные документы прежде чем отдать их клиенту. Атрибуты могут быть использованы в формуле ранжирования. Существует несколько типов поддерживаемых manticore атрибутов:

  • беззнаковый int32 и int64 со знаком. В manticore их типы обозначаются соответсвенно - uint и bigint.

  • 32 битные числа с плавающей точкой одинарной точности (float)

  • unix-timestamps

  • булевые типы (bool)

  • строки (string)

  • JSON

  • multi-value - MVA, это типа массивов, которые могут содержать только лишь 32 битные целые без знака

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

Файл, расширение

Что хранится?

Как хранится по умолчанию

spa

Скалярные атрибуты

Отображение в память через mmap()

spd

Список документов

Читается с диска

spi

Словарь

Всегда в памяти

sph

Заголовок индекса(или блока)

Всегда в памяти

spk

kill list

Загружается в память при запуске и выгружается после применения

spl

lock файл

Всегда на диске

spm

row map

mmap

sphi

гистограммы

Всегда в памяти

spt

структуры для обращения к docid

mmap

spp

позиции ключевых слов

Читается с диска

spb

Атрибуты - mva, строки и json

mmap

Real-time индексы имеют дополнительные файлы:

Файл, расширение

Что хранится?

Как хранится по умолчанию

kill

RT kill - документы, которые были заменены через REPLACE, прошли очистку и сброшеные как блок (чанк)

Всегда на диске

meta

Заголовок rt-индекса

Всегда в памяти

lock

RT lock-файл

Всегда на диске

ram

Копия блока (чанка) из памяти - создается, когда блок из памяти сбрасывается на диск.

Всегда на диске

Для чтения индексов мантикора использует два метода - seek+read и mmap.

В seek+read режиме (значение опций acess_* = file, об опциях доступа ниже) чтение выполняется через pread(2), то есть мантикора с настройками по умолчанию использует этот системный вызов для чтения файлов spd и spp с диска. Для оптимизации чтения на старте алоцируются буферы, размер которых можно подстроить через опции read_buffer_docs и read_buffer_hits для чтения spd (документы) и чтения spp (позици ключевых слов) соответственно. Важно знать также, что по умолчанию на старте мантикоры индексы еще не открыты, то есть вызов open() происходит при каждом обращении к файлам индекса. Это поведение регулируется опцией preopen. У меня на практике (довольно высокая нагрузка) эта опция всегда была выставлена в 1, это позволяет избежать вызовов open() на каждый запрос, однако в таком режиме мантикора создает 2 файловых дескриптора на каждый индекс. Плохо это или хорошо, зависит от ситуации, если много индексов у вас, и не такая большая нагрузка, то имеет смысл оставить по умолчанию.

В режиме mmap файлы отображаются в память системным вызовом mmap(2). Опции read_buffer_docs и read_buffer_hits не влияют на производительность никаким образом. Этот режим доступа может быть применен к файлам, хранящим скалярные атрибуты (spa), документам (spd), позициям ключевых слов (spp) и атрибутам переменной длины - json, строкам и mva (spb).

Есть еще один режим в котором можно запретить ОС свопить на диск кешированные данные индексов из памяти. Это осуществляется через системный вызов mlock(2), но чтобы воспользоваться им, мантикора должна быть запущена в привелигированном режиме (например, из под рута).

Теперь про настройки и выбор их значений в различных ситуациях.

Настройка

За что отвечает

По умолчанию

access_plain_attrs

Определяет доступ к скалярным атрибутам (типы bigint, bool, float, timestamp, uint).

mmap_preread

access_blob_attrs

Определяет доступ к blob-атрибутам - json, string, mva

mmap_preread

access_doclists

Определяет доступ к документам

file

access_hitlists

Определяет доступ к позициям

file

Значения настроек доступа:

Значение

Краткое описание

file

Буферизированное чтение с диска с вызовом pread(2). Размеры буферов регулируются read_buffer_docs и read_buffer_hits.

mmap

Файлы индекса будут отображаться в память через системный вызов mmap(2), ОС будет кешировать все обращения к файлам в памяти.

mmap_preread

Тоже самое что mmap, но файлы индекса будут прочитаны на старте мантикоры. Своего рода "прогрев" кеша.

mlock

Файлы индекса будут отображены в память и через системный вызов mlock(2) данные будут закешированы ОС и заблокированы от сброса на диск.

Я проводил небольшой эксперимент, подкрутил настройки access_doclists и access_hitlists выставив в значение mmap. Ниже дан график, наглядно демонстрирующий, что особо сильно это не дает прироста производительности. Настройки применены в 1:05PM.

Машина имела такую конфигурацию:

  • Intel(R) Xeon(R) CPU E5-2690 v2 @ 3.00GHz 40 ядер

  • 96Гб памяти

  • Рабочие виртуалки редиса и редиса под кеш портала

  • 43 640 037 проиндексированных документов

  • 16 Gb (spd) +9 Gb (spa) + 8Gb (spp) + 25 Gb (spb) ~ 60Gb монолитный plain-индекс + rt-индекс

Но! На нашей машине с мантикорой установлен SSD диск, что и не дает вау-эффекта. Если у вас HDD и памяти хватает, чтобы закешировать все индексы, то смело выставляйте access_doclists и access_hitlists в mmap, а access_plain_attrs и access_blob_attrs в mlock. Тогда это даст, на сколько я понял, максимальную производительность мантикоры.

В случае, если памяти не хватает, то не используйте mlock - "горячие" данные все равно будут в памяти, а редко используемые будут погружаться с диска ОСью.

Также порекомендую всегда запускать мантикору с опцией --force-preread, это задержит мантикору на начальном этапе, так как она будет считывать сначала индексы и только потом начнет принимать запросы от клиентов. Каждая часть индекса будет прочитана для того, чтобы ОС закешировала обращения к диску, это как прогрев кеша. Зато после старта ваши запросы будут обрабатываться гораздо быстрее. Но это опять же, совет для тех, у кого HDD.

Concurrency

Теперь поговорим о том, как мантикора реализует конкуретное исполнение ваших запросов. В ранних версиях, когда мантикора была сфинксом, на каждое сетевое соединение порождался тред, в котором и происходила обработка запроса/запросов. Данный режим до сих пор остался в мантикоре по соображениям обратной совместимости. С версии 2.3.1-beta сфинкса был добавлен режим конкурентности thread pool. Этот режим является самым предпочтительным. Основные настройки конкурентности в мантикоре отвечают следующие основные опции (секция searchd):

Опция

Краткое описание

workers

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

queue_max_length

Размер очереди воркеров в режиме thread_pool. По умолчанию в мантикоре бесконечная очередь.

max_children

Кол-во потоков запускаемых в параллель. В режиме thread_pool определяет размер пула потоков на старте. В режиме threads ограничивает максимальное кол-во параллельных воркеров. По умолчанию равняется нулю, что означает в thread_pool размер пула равен кол-ву ядер*1.5.

dist_threads

Кол-во потоков для обработки внутри одного запроса. По умолчанию 0, что означает, параллельность внутри обработки одного запроса отключена.

Далее будет подразумеваться, что в мантикоре выставлен рекомендуемый режим - пул потоков - workers = thread_pool.

Для начала давайте еще глубже спустимся и посмотрим по-диогонали, как вообще устроен пул потоков в мантикоре. Это поможет понять какие метрики надо отслеживать и какие настройки тюнить.

Давайте вспомним или поймем, что такое пул потоков или thread pool. Классический пул потоков представляет собой некую структуру, которая при запуске инициализирует определенное кол-во потоков. Каждый новый таск поступает на обработку в определенный уже запущенные поток исполнения. Таски или джобы прежде чем попасть в поток исполнения попадают в очередь. Очередь позволяет балансировать нагрузку на пул.

рис.1. Схема пула потоков
рис.1. Схема пула потоков

Взглянем на код

Рис.2. CSphThdPool
Рис.2. CSphThdPool

Пул потоков в мантикоре представлен классом CSphThdPool. Внутри как мы видим, все по классике: очередь представленна в виде связанного списка (указатели на голову и хвост m_pHead и m_pTail соответственно), вектор пула потоков - m_dWorkers. Чуть ниже - поля показывающие статистику пула потоков - m_tStatActiveWorkers - сколько воркеров сейчас исполняется (то есть сколько активных запросов или служебных задач сейчас в исполнении), и m_tStatQueuedJobs - кол-во джобов, ожидающих в очереди на исполнение.

Как же происходит обработка запроса? Взглянем на метод AddJob, который принимает указатель на джоб:

Рис.3 AddJob
Рис.3 AddJob

Пока что тут все ясно: джоб добавляется в связанный список, представляющий очередь пула потоков, и увеличивается счетчик m_iStatQueuedJobs. Именно этот счетчик ототображается в результате запроса SHOW STATUS; в метрике work_queue_length:

Рис.4 Пример вывода SHOW STATUS;
Рис.4 Пример вывода SHOW STATUS;

Джоб еще не начал свое выполнение, он просто поставлен в очередь и ждет, когда наступит событие, по которому его "возьмут" на исполнение. В идеале, метрика work_queue_length должна быть всегда равна нулю. Если она начнет расти, тогда у меня плохие новости. Это говорит о том, что пул потоков иссяк, все потоки заняты и вновь приходящие запросы от клиентов будут "висеть".

За ограничение размера очереди отвечает настройка queue_max_length, по умолчанию она равна 0, что обозначает бесконечную очередь. Если задать эту настройку, то при превышении лимита, мантикора начнет отдавать вновь "прибывшим" запросам ответ maxed out в случае работы через старый бинарный протокол, либо too many requests в случае, если ваш драйвер работает по протоколу mysql. Эта ошибка возвращается со статусом "retry"

Рис.5. Статусы ответа мантикоры
Рис.5. Статусы ответа мантикоры

Ошибку клиент должен обработать и попытаться повторить запрос.

Далее рассмотрим "сердце" пула потоков, цикл обработки джоба в потоке:

Рис. 6. Обработка джоба в потоке
Рис. 6. Обработка джоба в потоке

Здесь все просто, на строках 1942-1950 мы выбираем джоб из очереди, далее уменьшаем счетчик m_iStatQueuedJobs (work_queue_length), увеличиваем счетчик активных джобов, запускаем джоб на исполнение. Как только джоб выполнился (повторюсь, это может быть запрос клиента или внутренний джоб мантикоры, например flush атрибутов на диск), уменьшаем счетчик m_tStatActiveWorkers - счетчик активных джобов. Этот счетчик отражается в метрике SHOW STATUS; под названием workers_active (см. рис. 4).

Итого, имеем три метрики, за которыми стоит следить:

workers_active - кол-во исполняемых в данный момент джобов

work_queue_length - кол-во джобов, "висящих" в очереди на исполнение

workers_total - текущий размер пула потоков, который задается на этапе запуска мантикоры и остается постоянным на всем протяжении работы. Этот размер задается настройкой max_children.

Еще одна интересная настройка dist_threads, по умолчанию она равна 0, что означает "каждый запрос будет обработан в один поток". Если у вас достаточно большой plain индекс и есть real-time индекс, то есть смысл plain-индекс разбить на несколько частей и создать ditributed-индекс, выставив dist_threads равное или чуть большее кол-ву частей distributed-индекса. На практике у меня на работе есть два rt-индекса и один plain. dist_threads выставлен в 3, то есть каждый запрос обрабатывается в три потока и каждый поток "смотрит" в plain либо в один из rt индексов. Прироста производительности не было замечено при выставлении dist_threads в "3", однако в нынешней ситуации роста кол-ва документов в plain индексе скорее всего придется разбивать plain индекс на две части и менять dist_threads соответственно. Время покажет.

Проблемы

Пока что есть довольно существенная проблема в мантикоре, это то, что real-time индекс фрагментируется, по мере роста кол-ва постоянных обновлений. У real-time индексов есть такая настройка rt_mem_limit которая задает лимит данных индекса, находящихся в памяти, по мере приближения к этому лимиту, мантикора начинает сбрасывать редко используемые данные на диск в виде блоков (chunk). Если измений с последнего сброса real-time индекса накопилось довольно много, то таких блоков становится много, более того, потребляемая процессом searchd память растет и может вырасти на десятки гигабайт.

Есть такая операция в мантикоре OPTIMIZE, она решает проблему фрагментированного rt-индекса, и позволяет "схлопнуть" все блоки в один, тем самым удерживая общую производительность в пределах нормы.

Существенная проблема мантикоры - это исчерпание пула потоков, если джобы "уперлись" в диск например. Тогда клиенту ничего не остается делать, как прервать запрос по таймауту и повторить позднее либо отправить запрос на другую ноду в реплике. По документации рекомендуется выставлять max_children в 1.5*кол-во ядер на машине, однако учтите также и создаваемую нагрузку на диск. Практически я пришел к выводу, что лучше все таки выставить явно max_children равное кол-ву ядер на хосте.

Заключение

Данная заметка далеко не полная и даже не является инструкцией к действию. Это просто сборник из моих записей и наблюдений в процессе эксплуатации sphinx и далее - manticoresearch. По моему скромному мнению, проект очень даже не плохой, если нужен полнотекстовый поиск на сайте, то это вполне рабочее решение. Возможно, если понравится читателям, в следующий раз я соберусь и опишу другие интересеные части manticoresearch.

Для подробностей обращайтесь к официальной документации.