Суть проблемы

Раньше я работала на проекте N, где главной бизнесовой сущностью было событие. Это событие имеет свое название и еще несколько полей. У нас был реализован поиск по всем событиям, который представлял из себя обычный iLIKE в PostgreSQL.
Когда-то нам пришел запрос от юзеров: событие у нас в системе называется, например, "событие от Ивана Ивановича", а они пытаются вбить в поиск "иван иванович рассказал про X" и не получают никаких результатов.
Данная проблема решается с помощью полнотекстового поиска. Вопрос в том, как его реализовать.

Выбор технологии

Мы хотели сделать все быстро, безболезненно, с минимумом изменений. Для этого рассматривали либо Lucene, либо ElasticSearch. Сравним их:

ElasticSearch:
+ хорошо зарекомендовавшее себя решение. 9/10 проектов, где есть full text search, используют именно его
+ огромное количество фичей и инструментов, которые к тому же отлично документированы
- необходимость поднимать отдельный сервис и обслуживать его
- в последствии необходимость менять код и на бэкэнде, и на фронтэнде

Lucene:
+ не надо поднимать новый сервис и заниматься его обслуживанием
+ не надо менять код на фронтэнде, достаточно просто держать логику полнотекстового поиска на бэкэнде в том сервисе, где у нас реализован поиск по событиям
- у нас было много смежных проектов внутри компании, которые тоже работали с событиями

Именно последний минус и привел нас к тому, что мы выбрали в итоге ElasticSearch. Если бы мы взяли Lucene, нам бы пришлось делать свой собственный сервис по типу ElasticSearch поверх Lucene, чтобы с ним могли общаться другие сервисы в нашей компании. В случае с монолитной архитектурой, думаю, мы бы выбрали Lucene.

После того, как технология была выбрана, мы начали ее итерационно внедрять.

Первая итерация

Собственно, это настройка ElasticSearch - то, что легло на плечи наших DevOps-инженеров, а потом мы должны были написать код, который заставит наш бэкэнд работать с эластиком.
Мы написали модуль, который коннектится к эластику, создает в нем необходимые нам индексы, если они еще не созданы, а потом заполняет их. Заполняли мы их событиями и связанными с ними сущностями - под каждую сущность делали по два индекса (stage и prod).

Сначала у нас была простыня кода под маппинги - у наших сущностей было очень много информации, которую мы хотели хранить в эластике, огромная куча полей. Это привело к тому, что под каждую сущность мы создавали отдельный модуль, в котором лежал маппинг. В итоге, когда нам нужно было добавить новое поле в какую-то таблицу, нам приходилось добавлять его в двух местах - собственно, в саму модель, а потом отдельно в маппинг.
Для того, чтобы решить эту проблему, я написала библиотеку elasticmapper, которая автоматически по модели ORM (в текущей реализации поддерживаются Peewee, DjangoORM, SQLAlchemy, но в следующих версиях планируется расширить этот список) генерирует маппинг для ElasticSearch. Благодаря ей новое поле нужно было добавить только в модель, дальше происходила автоматическая генерация маппинга и код дублировать больше было не нужно - разве что в каких-то редких кейсах, где нужна была более "тонкая" настройка с помощью, например, custom_values или keyword_fields.

Под конец первого двухнедельного релиза у нас было все готово, чтобы начать переписывать наш поиск.

Вторая итерация

Итак, у нас было множество эндпоинтов под сами события и связанные с ними сущности, которые мы также хотели хранить в ElasticSearch. На начальных этапах мы хотели, чтобы фронтэнд ничего не знал про существование ElasticSearch, следовательно ничего не менял у себя в коде.
Для этого мы сделали так, чтобы эти эндпоинты самостоятельно ходили в эластик, а потом просто возвращали на фронт данные из него. То есть мы убрали все походы в PostgreSQL и убрали iLIKE, заменив их на запросы к ElasticSearch.
Фронт по-прежнему ничего не знал об этой подмене, но у наших юзеров уже был видимый результат - теперь они могли вбить "иван иванович рассказывает про Х", "выступление ивановича" и любой другой запрос. В любом случае они получали релевантные результаты поиска, а не 404, как раньше.

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

Придумали два способа:
1) Добавить в существующие индексы кучу полей с переводами. Например, было просто field, а стало бы field_ru, field_en, field_fr и т.д.
2) Один язык = один индекс. Существующие индексы остались бы нетронутыми, зато мы наделали бы кучу похожих индексов, но каждый под свою локаль

В первом случае у нас получилось бы огромное количество полей. Я упоминала, что у нас и так их было изначально немало, а в такой реализации их количество увеличилось бы в 10+ раз. Это повлекло бы следующие проблемы:
- более медленный поиск (поиск по маленькому датасету работает быстрее)
- если нам понадобится сделать что-то специфичное для одного или нескольких языков, например, добавить какое-то поле, то весь индекс придется реиндексировать. В случае с разными индексами под разные языки этого можно было бы избежать
- плохое масштабирование. Гораздо проще масштабироваться, если вы имеете возможность закинуть индекс с французской локализацией на один сервер, а с английской на другой

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

Третья итерация

Мы передали фронтэндерам все запросы для всех эндпоинтов, которые использовали на бэкэнде для общения с ElasticSearch. Удалили лишние эндпоинты, а фронтэндеры начали напрямую общаться с эластиком. Это был завершающий этап внедрения полнотекстового поиска.

upd: как указали в комментариях, ходить из фронта напрямую в эластик может быть небезопасно. В нашем случае на всех индексах стоит read-only флаг, а в самих индексах нет никакой чувствительной информации, которая могла бы быть интересна для злоумышленников. Если у вас мутабельные и/или чувствительные данные хранятся в эластике, то, конечно же, вам нужна прослойка в виде бэкэнда.

Заключение

Здесь был описан кейс для конкретного проекта. Вероятно, в вашем случае такое решение может не подойти вовсе, либо подойти частично - it depends. Буду рада, если кому-то поможет мой опыт внедрения ElasticSearch в проект. Спасибо за внимание!

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


  1. mirwide
    27.12.2022 05:08
    +5

    Плохо ходить в эластик с фронта. Это всё-таки БД, хоть и с http интерфейсом: авторизация, изменение схемы, обновление версий, это всё будет приносить боль в такой схеме.

    Впрочем он он всегда будет приносить боль, я бы отметил некоторое количество недостатков.
    1) Невозможность изменить схему после создания. Мапинги можно добавлять только на новые поля, если изначально неправильно был выбран тип поля, а он 100% будет выбран неправильно при сколько-нибудь сложной схеме данных, что бы изменить мапинги придется полность пересоздать индекс и перелить в него данные.
    2) Невозможность перешардирования на лету, как это например сделано в кассандре. Шард это физическая сущность, которая всегда привязана к ноде, если количество шардов не делится на количество нод, а так будет всегда при горизонтальном масштабировании, что бы изменить количество шардов нужно пересоздать индекс и перелить в него данные.
    3) Неуправлемое кеширование полей, пресловутая fielddata. Размер fielddata зависит от параметров запроса, если он большой, инстанс упадет в OOM, если его ограничить инстанс перейдет в режим CB и производительность сильно упадет.
    4) Это jvm, со всеми вытекающими плюсами и минусами. Из минусов, ограничение на вертикальное масштабирование из размера хипа(а его займет та самая fielddata), накладные на GC, не самая выдающаяся производительность.
    5) Отсутсвие нормальной авторизации, её либо не будет совсем, либо это будет сквозное шифрование.
    6) Пертурбации с лицензирванием.


    Эти недостатки, мне кажется, общеизвестны и частично описаны в документации. Когда для схожей задачи был выбран эластик пару лет назад, очень надеялся что это все проблемы, но была обнаружена еще одна. Пункт 3 и 4 из списка выше, выливаются в ботлнек на поиск в виде нескольких тысяч rps на 1 ноду. При нагрузке выше начинается гонка при получении блокировки к fielddata cache и дальнейшее масштабирование возможно только увеличением колчества нод и шардов.


    1. pfffffffffffff
      27.12.2022 09:21

      Какие есть альтернативы эластику?


      1. mirwide
        27.12.2022 10:09

        Можно использовать индексы в postgres, поддерживающие полнотекстовый поиск. Но у меня нет такого опыта. В монге, кажется, тоже есть поддержка.


        1. whoisking
          27.12.2022 12:50

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


          1. mirwide
            27.12.2022 14:53

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

            Для небольших индексов, либо больших, но с низким рейтом поиска - эластик не плох.


      1. nomilkinmyhome Автор
        27.12.2022 10:46

        В статье упоминается lucene - эластик работает поверх него. Есть еще tantivy


      1. beatleboy
        28.12.2022 11:54

        Советую рассмотреть bleve, 4 месяца назад ушел на него с эластика. Сказать доволен - ничего не сказать. Bleve вообще не потребляет ресурсов по сравнению с эластиком, работает шустро, и дает полный контроль. Из минусов, нужно будет немного покодить на Go (хотя для меня это плюс).


      1. Lodary
        29.12.2022 09:34

        Apache Solr. С шардированием все сильно проще. Тоже ява, но тут как и с эластиком, вертикально масштабировать можно, но аккуратно.


  1. hssergey
    27.12.2022 09:55
    +1

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


    1. nomilkinmyhome Автор
      27.12.2022 10:45

      У всех индексов стоит read-only флаг. Данные в этих индексах публичные, чувствительной информации в эластике не было вообще