Игорь Чакрыгин Игорь Чакрыгин

У любой задачи существует по крайней мере одно очевидное и невероятно простое для понимания неправильное решение

Sphinx - Распределённый поиск

Как известно, одним из ключевых достоинств Sphinx является очень высокая скорость поиска по очень большим объёмам данных. Как утверждают разработчики, самый большой кластер содержит 3 миллиарда документов и выдерживает 50 миллионов запросов в сутки. Понятно, что чем больше нагрузка на Sphinx, тем мощнее ему нужен сервер, мощность которого невозможно бесконечно увеличивать. К счастью, это и не обязательно, поскольку Sphinx имеет встроенные инструменты для горизонтального масштабирования, позволяющие параллельно выполнять запросы на нескольких серверах, а затем объединять результаты.

В этой статье я на простом примере постараюсь показать, как можно настроить несколько экземпляров Sphinx, а затем объединить их для выполнения распределённого поиска.

Подготовка базы данных

Прежде всего нам потребуются тестовые данные. Снова воспользуемся базой данных AdventureWorks, которую можно скачать с сайта codeplex.com.

В этой базе данных нас будут интересовать три таблицы: Production.Product (Товары), Production.ProductSubcategory (Подкатегории) и Production.ProductCategory (Категории). Мы создадим два распределённых индекса, содержащих товары из категорий «Bikes» и «Clothing», при этом один из индексов будет использовать распределённый поиск, а второй - зеркалирование.

Хранимая процедура для получения товаров

Чтобы сократить количество sql-запросов в файлах конфигурации, создадим в базе данных хранимую процедуру для получения товаров из определённой категории.

create procedure [dbo].[GetProducts]
    @Agent nvarchar(max),
    @Index nvarchar(max),
    @Part int = null,
    @Parts int = null
as
begin

    set nocount on;

    select
        [id] = p.ProductID,
        [_agent] = @Agent,
        [_index] = @Index,
        [name] = cast(p.Name as nvarchar(max)),
        [price] = p.ListPrice
    from Production.Product as p
        inner join Production.ProductSubcategory as ps
            on ps.ProductSubcategoryID = p.ProductSubcategoryID
        inner join Production.ProductCategory as pc
            on pc.ProductCategoryID = ps.ProductCategoryID
    where FinishedGoodsFlag = 1 
        and (@Index is null or pc.Name = @Index)
        and (@Part is null or @Parts is null or p.ProductID % @Parts = @Part - 1);

end

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

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

После создания этой процедуры, мы сможем, например, получить товары с чётными идентификаторами из категории «Bikes» для экземпляра «Sphinx01».

exec GetProducts @Agent = 'Sphinx01', @Index = 'Bikes', @Part = 1, @Parts = 2

Распределённый поиск

Теперь, когда мы можем получать товары из базы данных при помощи хранимой процедуры, нам необходимо запустить несколько экземпляров Sphinx и объединить их вместе. Для простоты мы запустим их все на одном компьютере, но ничто не мешает запустить каждый экземпляр на отдельной машине. Различаться при этом будут только ip-адреса в файлах конфигурации.

Нам потребуется три экземпляра Sphinx. Экземпляры, которые мы назовём Sphinx01 и Sphinx02, будут содержать физические индексы, для которых необходимо будет выполнять индексацию. Экземпляр Sphinx01 будет содержать индексы bikes01 и clothing01, а экземпляр Sphinx02 - индексы bikes02 и clothing02. Третий экземпляр, который мы назовём SphinxMain, будет содержать распределённые индексы bikes и clothing, которые будут объединять результаты, полученные от экземпляров Sphinx01 и Sphinx02.

Индекс bikes01 будет содержать в себе только товары с чётными идентификаторами, а индекс bikes02 - с нечётными. При поиске в индексе bikes поиск будет производиться в обоих индексах, bikes01 и bikes02, а затем результаты будут объединяться вместе. Такой подход хорошо использовать для очень больших индексов, поскольку позволяет параллельно выполнять поиск на нескольких серверах.

Индексы clothing01 и clothing02 будут содержать в себе все товары вне зависимости от чётности их идентификаторов. При поиске в индексе clothing поиск будет производиться в одном из индексов, clothing01 или clothing02, и результаты будут возвращаться только из него. Такой подход называется «зеркалированием» и обычно используется для повышения отказоустойчивости кластера, поскольку, если один из серверов выйдет из строя, то поиск может производиться на другом сервере.

Шаг 1: Подготовка файлов конфигурации для экземпляров Sphinx01 и Sphinx02

Создадим в папке c:\sphinx\data\ файл конфинурации config01.txt, который будут использоваться экземпляром Sphinx01.

searchd
{
    listen      = 9301
    listen      = 9401:mysql41
    pid_file    = c:/sphinx/data/pid/01.pid
    log         = c:/sphinx/data/log/01/log.txt
    query_log   = c:/sphinx/data/log/01/query_log.txt
    binlog_path = c:/sphinx/data/binlog/01/
}

source base
{
    type     = mssql
    sql_host = localhost
    sql_db   = AdventureWorks
    sql_user =
    sql_pass =
    mssql_winauth = 1
    mssql_unicode = 1
}

source products_base : base
{
    sql_field_string = _agent
    sql_field_string = _index
    sql_field_string = name
    sql_attr_float   = price
}

source bikes : products_base
{
    sql_query = exec GetProducts @Agent = 'Sphinx01', @Index = 'Bikes', @Part = 1, @Parts = 2
}
source clothing : products_base
{
    sql_query = exec GetProducts @Agent = 'Sphinx01', @Index = 'Clothing'
}

index bikes01
{
    source = bikes
    path   = c:/sphinx/data/index/01/bikes
    charset_type = utf-8
}
index clothing01
{
    source = clothing
    path   = c:/sphinx/data/index/01/clothing
    charset_type = utf-8
}

Обратите внимание, что в блоке search указано две опции listen. Порт 9301 используется, чтобы экземпляр Sphinx01 был доступен по протоколу SphinxAPI, поскольку именно его будет использовать экземпляр SphinxMain при обращении к экземплярам Sphinx01 и Sphinx02. Порт 9401 используется, чтобы мы могли подключиться к каждому экземпляру при помощи клиента для базы данных MySQL.

После этого создадим файл config02.txt, который будут использоваться экземпляром Sphinx02. Его содержимое будет практически полностью совпадать с содержимым файла config01.txt, но тем не менее будет содержать несколько отличий:

  • вместо портов 9301 и 9401 нужно указать 9302 и 9402;
  • во всех путях ко всем папкам и файлам вместо «01» нужно использовать «02»;
  • при вызове хранимой процедуры GetProducts в блоках source bikes и source clothing в параметре @Agent вместо значения Sphinx01 нужно использовать значение Sphinx02, а в параметре @Part вместо значения 1 - значение 2;
  • в блоках index названия индексов bikes01 и clothing01 нужно заменить на названия bikes02, bikes02.

Шаг 2: Индексация и запуск экземпляров Sphinx01 и Sphinx02

Перед индексацией не забудьте также создать все папки, которые используются в файлах конфигурации (например, c:\sphinx\data\pid\ или c:\sphinx\data\log\01\), иначе Sphinx не запустится.

Выполним индексацию всех индексов в обоих экземплярах Sphinx.

c:\sphinx\bin\indexer --all --rotate --config c:\sphinx\data\config01.txt
c:\sphinx\bin\indexer --all --rotate --config c:\sphinx\data\config02.txt

После того, как индексация успешно завершится, установим и запустим экземпляры Sphinx01 и Sphinx02.

c:\sphinx\bin\searchd --install --servicename Sphinx01 --config c:\sphinx\data\config01.txt
net start Sphinx01
c:\sphinx\bin\searchd --install --servicename Sphinx02 --config c:\sphinx\data\config02.txt
net start Sphinx02

Шаг 3: Проверка экземпляров Sphinx01 и Sphinx02

Теперь, когда у нас есть два запущеных экземпляра Sphinx, мы можем попробовать подключиться к ним, чтобы проверить, что в индексах находятся правильные данные.

Сначала подключимся экземпляру Sphinx01.

c:\mysql\bin\mysql -h 127.0.0.1 -P 9401 --default-character-set=utf8

Попробуем получить все товары из индексов bikes01 и clothing01.

select * from bikes01;

select * from clothing01;

После этого подключимся экземпляру Sphinx02.

c:\mysql\bin\mysql -h 127.0.0.1 -P 9402 --default-character-set=utf8

Попробуем получить все товары из индексов bikes02 и clothing02.

select * from bikes02;

select * from clothing02;

Как мы и хотели, индекс bikes01 содержит только товары с чётными идентификаторами, индекс bikes02 - с нечётными, а индексы clothing01 и clothing02 содержат все товары и, по сути, одинаковы за исключением атрибута _agent. Теперь можно объединить экземпляры Sphinx01 и Sphinx02 при помощи ещё одного экземпляра SphinxMain.

Шаг 4: Подготовка файла конфигурации для экземпляра SphinxMain

Создадим в папке c:\sphinx\data\ файл конфинурации config.txt со следующим содержимым:

searchd
{
    listen      = 9300
    listen      = 9400:mysql41
    pid_file    = c:/sphinx/data/pid/Main.pid
    log         = c:/sphinx/data/log/Main/log.txt
    query_log   = c:/sphinx/data/log/Main/query_log.txt
    binlog_path = c:/sphinx/data/binlog/Main/
}

index bikes
{
 type  = distributed
 agent = 127.0.0.1:9301:bikes01
 agent = 127.0.0.1:9302:bikes02
}
index clothing
{
 type  = distributed
 agent = 127.0.0.1:9301:clothing01|127.0.0.1:9302:clothing02
}

В этом файле конфигурации мы создаём два индекса с типом distributed.

Индекс bikes содержит две опции agent ссылающихся на экземпляры Sphinx01 и Sphinx02. Такая запись означает, что при поиске в распределённом индексе нужно получить данные от каждого из агентов, а затем объединить их вместе.

Индекс clothing содержит одну опцию agent которая ссылается на два экземпляра Sphinx01 и Sphinx02. Такая запись означает, что оба агента являются «зеркалами» и нет разницы, в каком из них выполнять поиск. При этом агент для каждого запроса будет выбираться случайным образом.

Шаг 5: Запуск экземпляра SphinxMain

Запустим экземпляр SphinxMain, не забыв при этом создать все папки, которые упоминаются в файле конфигурации.

c:\sphinx\bin\searchd --install --servicename SphinxMain --config c:\sphinx\data\config.txt
net start SphinxMain

Шаг 6: Проверка экземпляра SphinxMain

Подключимся к экземпляру SphinxMain и попробуем сделать несколько запросов.

c:\mysql\bin\mysql -h 127.0.0.1 -P 9400 --default-character-set=utf8

Попробуем поискать что-нибудь в индексе bikes.

select * from bikes
where match('Black') and price < 1000
order by id asc;

Как мы видим, при поиске в индексе bikes результаты возвращаются от агентов Sphinx01 и Sphinx02 и объединяются, как мы и хотели.

Теперь попробуем поискать что-нибудь в индексе clothing.

select * from bikes
where match('Sports')
order by id asc;

Теперь результаты возвращаются только от агента Sphinx01, но если мы попробуем несколько раз повторить запрос, то можем получить результаты и от агента Sphinx02.

Заключение

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

В большинстве случаев, для приложения не будет никакой разницы, обращается оно к локальному индексу или распределённому, однако пока ещё (по крайней мере в версии 2.1.1-beta) встречаются исключения (например, функция group_concat с распределёнными индексами пока не работает), хотя я уверен, что всё это будет исправлено в следующих версиях Sphinx.

Скачать материалы к данной статье (Хранимая процедура GetProducts, файлы конфигурации, скрипты для Sphinx).

11 комментариев

  1. Подскажите, пожалуйста, есть ли какой то прирост производительности у варианта с поднятием нескольких инстансов searchd по сравнению с использованием соответствующего количества dist_threads?

    ОтветитьУдалить
  2. Прирост может быть, если их поднимать на нескольких машинах, иначе я никакой разницы не вижу.
    И опять же зависит от размера индекса, нагрузки, мощности сервера. Это надо на практике проверять.

    ОтветитьУдалить
  3. Статьи просто супер! Продолжайте в том же духе!!

    ОтветитьУдалить
  4. Спасибо!

    Про Sphinx осталась ещё одна статья. Нельзя же вечно о нём писать. Хочется уже про что-то новое: Erlang, Riak, Neo4j =)

    ОтветитьУдалить
  5. Если на одной машине - нет. Скорее будет небольшое падение (покуда совершенно зря задействуем сетевой стек).
    Но! Иногда это имеет смысл. Например, если нужно завести гигантский индекс на 32-битной машине с PAE и кучей памяти.

    ОтветитьУдалить
  6. Распределённый индекс не умеет не только group_concat, но и некоторые другие штуки. Некоторые связаны с распределённостью, некоторые нет.
    Например, генерация сниппетов - не может быть на чисто распределённом индексе (и это чистая недоделка, покуда технически ничего не мешает).

    ОтветитьУдалить
  7. group_concat они уже сделали (в 2.2.1-dev)
    http://sphinxsearch.com/bugs/view.php?id=1628



    Сниппетами не пользовался, но может стоит им про это завести инцидент? Вдруг починят.

    ОтветитьУдалить
  8. Да. Это в очереди - сразу после index type=template. Просто генерация сниппетов - штука довольно атомарная; её сложно распараллелить. Если кидать пачку сниппетов по разным агентам - тут дольше будут пакеты по сети летать. Если источники (тексты) лежат в файлах, а не шлются по сети - да, это вариант, и такой вариант реализован. Причём файлы могут быть разрежены между разными агентами - это уже как раз напоминает обычные запросы к зеркалам. А в обычном случае всё лопатит атомарно локальный индекс. И тут из-за атомарности возникает элементарное неудобство: настроил индекс, отладил приложение - и как только заменил один монолитный индекс в конфиге на дистр main+delta - упс, сниппеты встали!

    ОтветитьУдалить
  9. Есть ещё очевидный случай - count(uniq). Сколько _разных_ элементов в индексе?
    Очевидно, что в распределённом случае малой кровью такое вычислить невозможно.
    (на одном складе картошка и морковка, на другом - тоже, на третьем - свёкла и капуста - но про "обобщённый" склад можно сказать лишь то, что уникальных продуктов там от 2 до 6. И дальше уже уточнить невозможно)

    ОтветитьУдалить
  10. Подскажите, пожалуйста, еще один момент - как работает опция max_matches (допустим = 1000) при распределенном поиске? Из каждого агента приходит по 1000 отсортированных результатов и в основном индексе они объединяются, по новой сортируются и снова берется 1000, либо от каждого агента приходят все найденные результаты (total_found) и основной индекс работает со всеми ими и только он применяет max_matches?

    ОтветитьУдалить
  11. Не знаю, но я не думаю, что будут приходить все данные, т.к. это будет плохо для производительности.

    ОтветитьУдалить

© Игорь Чакрыгин. Все права защищены при помощи чёрной магии. Технологии Blogger.