Продолжаем цикл статей о том, как мы постигали ES в процессе создания Ambar. Первая статья цикла была о Хайлайтинге больших текстовых полей в ElasticSearch.


В этой статье мы расскажем о том как заставить ES работать быстро с документами более 100 Мб. Поиск в таких документах при подходе "в лоб" занимает десятки секунд. У нас получилось уменьшить это время до 6 мс.


Заинтересовавшихся просим под кат.


Проблема поиска по большим документам


Как известно, всё действо поиска в ES строится вокруг поля _source — исходного документа, пришедшего в ES и затем проиндексированного Lucene.


Вспомним пример документа, который мы храним в ES:


{
    sha256: "1a4ad2c5469090928a318a4d9e4f3b21cf1451c7fdc602480e48678282ced02c",
    meta: [
        {
            id: "21264f64460498d2d3a7ab4e1d8550e4b58c0469744005cd226d431d7a5828d0",
            short_name: "quarter.pdf",
            full_name: "//winserver/store/reports/quarter.pdf",
            source_id: "crReports",
            extension: ".pdf",
            created_datetime: "2017-01-14 14:49:36.788",
            updated_datetime: "2017-01-14 14:49:37.140",
            extra: [],
            indexed_datetime: "2017-01-16 18:32:03.712"
        }
    ],
    content: {
        size: 112387192,
        indexed_datetime: "2017-01-16 18:32:33.321",
        author: "John Smith",
        processed_datetime: "2017-01-16 18:32:33.321",
        length: "",
        language: "",
        state: "processed",
        title: "Quarter Report (Q4Y2016)",
        type: "application/pdf",
        text: ".... очень много текста здесь ...."
    }
}

_source для Lucene это атомарная единица, которая по умолчанию содержит в себе все поля документа. Индекс в Lucene представляет собой последовательность токенов из всех полей всех документов.


Итак, индекс содержит N документов. Документ содержит около двух десятков полей, при этом все поля довольно короткие, в основном типов keyword и date, за исключением длинного текстового поля content.text.


Теперь попытаемся в первом приближении понять, что будет происходить когда вы попытаетесь выполнить поиск по какому-либо из полей в приведенных выше документах. Например, мы хотим найти документы с датой создания больше 14 января 2017 года. Для этого выполним следующий запрос:


curl -X POST -H "Content-Type: application/json" -d '{ range: { 'meta.created_datetime': { gt: '2017-01-14 00:00:00.000' } } }' "http://ambar:9200/ambar_file_data/_search"

Результат этого запроса вы увидите очень нескоро, по нескольким причинам:


Во-первых, в поиске будут участвовать все поля всех документов, хотя казалось бы зачем они нам нужны если мы делаем фильтрацию только по дате создания. Это происходит, т.к. атомарная единица для Lucene это _source, а индекс по умолчанию состоит из последовательности слов из всех полей документов.


Во-вторых, ES в процессе формирования результатов поиска выгрузит в память из индекса все документы целиком с огромным и не нужным нам content.text.


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


Ок, третью причину легко решить включив source filtering в запрос. Как быть с остальными?


Ускоряем поиск


Очевидно, что поиск, выгрузка в память и сериализация результатов с участием большего поля content.text — это плохая идея. Чтобы избежать этого необходимо заставить Lucene раздельно хранить и обрабатывать большие поля отдельно от остальных полей документов. Опишем необходимые для этого шаги.


Во-первых, в маппинге для большого поля следует указать параметр store: true. Так вы скажете Lucene что хранить это поле необходимо отдельно от _source, т.е. от остального документа. При этом важно понимать, что на уровне логики, из _source данное поле не исключится! Просто Lucene при обращении к документу будет собирать его в два приёма: берём _source и добавляем к нему хранимое поле content.text.


Во-вторых, надо указать Lucene что "тяжелое" поле больше нет необходимости включать в _source. Таким образом при поиске мы больше не будем выгружать большие 100 Мб документы в память. Для этого в маппинг надо добавить следующие строчки:


_source: {
    excludes: [
        "content.text"
    ]
}

Итак, что получаем в итоге: при добавлении документа в индекс, _source индексируется без "тяжелого" поля content.text. Оно индексируется отдельно. В поиске по любому "лёгкому" полю, content.text никакого участия не принимает, соответственно Lucene при этом запросе работает с обрезанными документами, размером не 100Мб, а пару сотен байт и поиск происходит очень быстро. Поиск по "тяжелому" полю возможен и эффективен, теперь он производится по массиву полей одного типа. Поиск одновременно по "тяжёлому" и "лёгкому" полям одного документа также возможен и эффективен. Он делается в три этапа:


  • лёгкий поиск по обрезанным документам (_source)
  • поиск в массиве "тяжелых полей" (content.text)
  • быстрый merge результатов без возвращения всего поля content.text

Для оценки скорости работы будем искать фразу "Иванов Иван" в поле content.text с фильтрацией по полю content.size в индексе из документов размером более 100 Мб. Пример запроса приведен ниже:


curl -X POST -H "Content-Type: application/json" -d '{
    "from": 0,
    "size": 10,
    "query": {
        "bool": {
            "must": [
                { "range": { "content.size": { "gte": 100000000 } } },
                { "match_phrase": { "content.text": "иванов иван"} }
            ]
        }
    }
}' "http://ambar:9200/ambar_file_data/_search"

Наш тестовый индекс содержит около 3.5 млн документов. Все это работает на одной машине небольшой мощности (16Гб RAM, обычное хранилище на RAID 10 из SATA дисков). Результаты следующие:


  • Базовый маппинг "в лоб" — 6.8 секунд
  • Наш вариант — 6 мс

Итого, выигрыш в производительности примерно в 1 100 раз. Согласитесь, ради такого результат стоило потратить несколько вечеров на исследование работы Lucene и ElasticSearch, и еще несколько дней на написание этой статьи. Но есть у нашего подхода и один подводный камень.


Побочные эффекты


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


Проблема следующая: вы не можете частично обновить поле документа из _source с помощью update scipt не потеряв отдельно хранимое поле! Если вы, к примеру, скриптом добавите в массив meta новый объект, то ES будет вынужден переиндексировать весь документ (что естественно), однако при этом отдельно хранимое поле content.text будет потеряно. На выходе вы получите обновлённый документ, но в stored_fields у него не будет ничего, кроме _source. Таким образом если вам необходимо обновлять какое-то из полей _source — вам придётся вместе с ним переписывать и хранимое поле.


Итог


Для нас это второе использование ES в крупном проекте, и снова мы смогли решить все наши задачи сохранив скорость и эффективность поиска. ES действительно очень хорош, нужно лишь быть терпеливым и уметь его правильно настроить.

Поделиться с друзьями
-->

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


  1. pavlick
    07.02.2017 18:43
    -2

    похоже на экономию на спичках


    1. jrip
      07.02.2017 18:57
      +4

      только спички там, походу, размером с вагон.


      1. pavlick
        07.02.2017 19:10

        Эластик — отличная штука. Его гибкость — это и главный плюс, и главный минус (по началу, потом этот минус пропадает)
        в эластике есть два механизма: query и filter. Они работают по разному и предназначены для разных вещей.
        Разные фильтры работают по разному, есть filter и query, которые на первый взгляд выполняют одно и то же действие, но реализуют они это действие совершенно разными подходами. Если в двух словах, то query — медленный, filter — быстрый.
        rule of thumb: нужно сначала максимально срезать выборку фильтрами и потом по результатам прокатывать query.
        Для того же range есть фильтры.
        Вот этот запрос

        { "match_phrase": { "content.text": "иванов иван"} }
        

        фактически обозначает поиск документов, которые содержат слово «иванов» и слово «иван» в поле context.text.
        Что это означает? Это означает, что есть индекс всех токенов (условно слов) из поля context.text для всех документов маппинга, и нужно найти пересечение двух запросов типа token=«иванов» и token=«иван». Это вроде как уже давно не мега задача для индексов.


        1. lostpassword
          07.02.2017 20:24

          А индекс по полю context.text эластик сам сформирует или надо отдельно в конфиге задавать?


          1. pavlick
            07.02.2017 20:37
            +1

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


            1. lostpassword
              07.02.2017 21:23

              Мда.
              А ведь одно время я почти убедил себя, что понимаю, чем query отличается от search…
              Похоже, снова забыл. ;-(

              Про автомаппинг я, пожалуй, смогу сделать вид, что понял. А вот про индексы — не очень. Грубо говоря, эластик автоматически формирует индексы по всем определённым в маппинге полям и наполняет эти индексы токенами, полученными после разбора строк в этих полях?


              1. pavlick
                08.02.2017 04:12
                +1

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


                1. lostpassword
                  08.02.2017 07:31

                  Интересно, спасибо.


        1. sochix
          07.02.2017 21:28

          Вы упустили главную деталь статьи — документы в ES большого размера. Если ваши документы небольшие то искать по ним с помощью ES очень легко, если же поле content.text размером 150 Мб вот тут и начинаются оптимизации


          1. pavlick
            08.02.2017 04:06
            +2

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


      1. pavlick
        07.02.2017 19:32

        иными словами статья получается такая:
        ребята, смотрите, какой классный набор «молоток и микроскоп». Перед нами возникла задача забивать гвозди. Сначала мы попробовали забивать гвозди окуляром, но вышло плохо, стекла все время бьются. После этого мы немного изучили микроскоп, и пришли к выводу, что лучше всего забивать гвозди основанием микроскопа. В результате все гвозди были успешно забиты.
        Ну серьезно


    1. sochix
      07.02.2017 21:25
      +1

      Я считаю что вы не правы. Увеличение скорости поиска в 1100 раз это не "экономия на спичках" — это описание решения конкретной задачи


      1. pavlick
        08.02.2017 04:00

        Ну это я сразу понял, что вы так считаете) Я имел в виду то, что если исходить из того, что полный набор возможностей оптимизаций вашей выборки составляет 100%, то вы тут описываете ну не больше 10%.


    1. fpd4444
      08.02.2017 11:04
      +1

      Уважаемый pavlick. Предлагаю вам вместо того, чтобы критиковать наш подход, просто предложить ваше решение проблемы. Потому что пока от вас были только комментарии, которые показывают только то, что вы или невнимательно прочитали статью, или просто не разбираетесь в теме.
      С уважением.


      1. pavlick
        08.02.2017 18:51

        Уважаемый fpd4444. Во-первых, спасибо за вашу квалифицированную оценку моих навыков, ваша оценка для меня очень важна. Во-вторых, обычно правильная постановка вопроса дает как минимум половину ответа на вопрос. Совсем глобально ваш вопрос понятен — как ускорить работу поисковых запросов. Какие-то аспекты я уже упомянул. Какие-то аспекты вашей задачи вообще не упомянуты, хотя они важны в вопросе скорости выполнения запросов. Могу предложить вам двигаться в таком направлении:
        — как устроен индекс ES (шарды, сегменты, собственно сам Lucene)
        — как выполняются запросы в индексе ES
        Когда с частью, за которую отвечает именно ES разобрались, можно приступать к оптимизации уже на уровне базовых индексов lucene.
        — в чем разница между query и filter
        — для чего стоит использвать каждый из них
        — кто из них и как связан со скорингом
        — выяснить, как вы в своей задаче используете скоринг
        — подходит ли вам дефолтный скоринг и нужен ли он вам вообще

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

        Потом можете посмотреть, а что же сейчас из себя представляет ваш индекс, может быть, он в настолько плачевном состоянии, что надо изменить «какие-то» настройки самого индекса. (Тут вдруг может оказаться, что вы могли существенно сократить время выполнения ваших запросов, даже не трогая мапинг и сам запрос).

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

        Желаю вам удачи в освоении ES :)


        1. fpd4444
          09.02.2017 17:15

          Спасибо. Но я его уже давно освоил.


          1. pavlick
            09.02.2017 19:30
            +1

            Ну… тогда могу только предположить, что этот цикл вы собрались растянуть на пару сотен постов )


  1. doggygray
    08.02.2017 15:34

    Стоить заметить, что для поля content.text указывать store: true нужно только если мы хотим чтобы работал highlighting, или если мы хотим получать это поле в ответе от elastic. Если это не требуется, то мы можем указать store: false, что заметно снизит размер индекса.
    А вслучае если нам не надо получать от elastic ничего кроме id документа (например сам документ можно достать из другой базы) то можно указать в маппинге _source: {enabled: false }, что дополнительно сохратит размер индекса и повысит производительность, то же самое можно сделать и со специальным полем _all, так как оно обычно не нужно _all: {enabled: false}


    1. sochix
      08.02.2017 15:35

      Полностью согласен с вами. Нам нужны highlights поэтому поставили store: true