Пролог
Сделать что-нибудь — это полдела, сопровождать что-либо весь жизненный цикл — действительный путь настоящего уважающего себя самурая. Касается чего угодно — от проведения полноформатных ивентов с тысячами посетителей, до проектирования, запуска, серийного производства, обслуживания и выведения из эксплуатации с утилизацией пассажирских и грузовых авиалайнеров.
С программными продуктами это также справедливо в полной мере, сделать популярную игру — это ещё далеко не всё. Одной из важнейших функций для комфортного окружения пользователя является поддержка: пользователь должен быть обласкан, пользователь должен получать за свои деньги ту услугу, которую он оплатил, пользователь никогда не должен быть покинут. Он должен быть окружён незаметной заботой и моментально получать свою долю внимания, когда у него случается что-нибудь, чего он не ожидал и что ему портит настроение — пользователь должен быть счастлив.
Привет, Интернет! Меня зовут Алексей, я из платформенной разработки Wargaming. Наша команда занимается разработкой и поддержкой инструментов для служб поддержки игроков. Мы делаем счастливыми тех, кто делает счастливыми игроков. И сегодня я поделюсь с вами опытом эксплуатации системы полнотекстовых индексации и поиска, решением проблем совместимости, увлекательным набиванием шишек и не менее захватывающим решением поступающих как из рога изобилия проблем.
Who is Mr. Sphinx?
Sphinx Search (здесь и далее по тексту „г-н Сфинкс”) — это одна из первых массовых систем для полнотекстового поиска (здесь и далее full text search, FTS, „полнотекстового поиска”, проф.). Она появилась из-под пера нашего соотечественника Андрея Аксёнова на рубеже веков и моментально приобрела огромную популярность у нас и далеко за пределами СНГ.
Взрывную экспансивную популярность навскидку (sic! здесь и далее оценочное суждение автора) обеспечили следующие факторы:
поисковая система написана на православных плюсах (а не традиционно для систем полнотекстовой индексации и поиска — на Java с её фантастической JVM, стремящейся, аки идеальный газ,
зохавать вселеннуюаллоцировать всю доступную память и обратно никогда любимую не отдавать);распространяется удобными и маленькими статическими бинарниками;
не имела и поныне не обладает какими-либо специфическими требованиями к платформе и окружению;
характеризуется очень маленьким размером;
обладает очень высокой скоростью индексации;
низкими (сравнительно и относительно) требованиями к оперативной памяти, дисковой подсистеме и процессорному времени;
индексирует источники данных (в виде традиционных реляционных баз данных), самостоятельно выполняя запросы с обычным себе текстовым SQL синтаксисом к подлежащим индексации таблицам.
Всё это позволяет использовать индексатор и поисковый движок демоном хоть из под PHP-FPM воркеров на слабых шаред-хостингах в условиях очень ограниченных ресурсов с прекрасным КПД, а про феномен популярности PHP на постсоветском пространстве и далеко за его пределами можно и должно докторскую написать.
Почему именно г-н Сфинкс?
Для кикстарта очень низкие косты: установка, настройка, отладка, индексация, оптимизация, донастройка, обслуживание, эксплуатация, поддержка, само, собственно, железо, и всё связанное делается на коленке специалистом с почти любой квалификацией. И действительно удобно: пишется пара SQL запросов в разные базы (или не пишется вовсе, а всякий раз приезжает XML со схемой которая даже не требует перезапуска сервиса) и эти данные сразу попадают в поисковый индекс минуя сотни адаптеров, шлюзов, API, ABI и так далее.
Сразу же предвосхищу первый же тредиционный комментарий под каждым постом на Хабре про Sphinx Search: когда создавалось это решение не то, что ELK, сам ElasticSearch ещё не увидел свет, Solr был ослепительно молод, а Сфинкс есмь идеально подходящее по совокупности перечисленных выше параметров решение и поныне.
А как мы используем Сфинкс?
Как индексатор на стероидах, как предвычисленный сайд-индекс, то есть утилизируем лишь самую малую часть фичей FTS и большего FTS related нам не надо. У нас есть сотни миллионов игроков в десятках игр, много различных баз данных между которыми данные логически связаны, но с другой стороны высокой степени связности не имеют; в одной базе данных может быть аккаунт игрока, а в другой с подробностями состояния аккаунта — данных может не быть; есть различные состояния аккаунта игрока и его имущества в разных играх, различные настройки и дополнительные поля. Нам нужно иметь инструмент который будет позволять оптимально, с минимальной ценой индексации, обновления и изменения/удаления — искать и находить множества аккаунтов игроков по различным критериями.
Это всё понятно, но для чего конкретно?
Например, типовым кейсом является поиск по подстроке никнейма или части номера телефона плюс условие активности аккаунта в, например, World of Warships и с каким-нибудь установленным тегом. Типовым кейсом использования является служебная выгрузка содержащая информацию по игроку/игрокам, их имуществе, параметрам, состоянию — отфильтрованную и отсортированную по какому-либо критерию игра / логин и наличию игрового имущества на этом аккаунте.
То есть мы банально создаём индекс всех наших игроков, текстовые отображения параметров аккаунтов и с десяток вычисляемых на стороне СУБД значений плюс поигровые индексы с более специфичными полями.
Окей, и как оно вообще?
Да всё плюс-минус отлично на самом деле, FTS запросы сотрудников поддержки выполняются за тысячные доли секунды, многоуровневое кеширование снижает нагрузку на все звенья цепи и обеспечивает плавность и приятность пользовательского UI; уже десяток лет in production для всех наших самых знаменитых игр.
Проблемы (никогда такого не было и вот опять)
Что же тогда могло пойти не так?
Проблема отлично работающих сервисов в том, что про них забывают: мониторинг работает, алертов нет, всё хорошо, экспертиза рассеивается, всё ещё всё хорошо, люди меняются, и вот тут внезапно однажды и всенепременно происходит нежданчик и бубух, причём чем дальше — тем последствия тяжелее, потому что сервисы необходимо поддерживать, обновлять их, обновлять и сопровождать документацию, следить за ABI/API, читать в багтрекер, про минорные и мажорные версии интересоваться, etc.
И не так пошло то, что самая свежая версия которая у нас работала — это версия 2.0.9 (начала десятых годов, в сентябре 2021 актуальная версия 3.3.1). Которая несмотря на то, что мы используем очень скромную часть функциональности Sphinx Search, а именно, чуть ли не wildcard search для текстовых полей, имеет своё (прекрасное и авторитетное, спору нет) мнение и понимание о том, как ей следует хранить свои внутренние служебные и персональные личные данные.
if ( wrDict.GetPos()>UINT_MAX ) // FIXME!!! change to int64
sphDie ( "INTERNAL ERROR: dictionary size "INT64_FMT" overflow at infix save", wrDict.GetPos() );
Разнообразие пользовательских самоназваний, фантазия сотен миллионов наших игроков и само их количество — привели в конечном счёте к тому, что инфиксный словарь стал перманентно переполнен (FATAL: INTERNAL ERROR: dictionary size (любое integer > 2 ** 32) overflow at infix save) несмотря на более чем скромные и простые настройки поисковой системы. Несмотря на разделение на регионы и на различные базы данных физически. Данных натурально — вал.
И нужно срочно что-то делать, потому что passthru поиск через поисковые FTS запросы в очень (и даже САМЫЕ) нагруженные базы данных компании — это очень дорого, долго, совсем не то, что ожидается и не совсем то, что ожидается от нас; и даже чёрт бы с ним, но отключение полнотекстового поиска от сервисов означало отключение так же FTS related функциональности очень таки зависимой от легковесного поиска со многими атрибутами, которая, в свою очередь, необходима для игроков и их поддержки.
Так заскейли горизонтально, в чём проблема?
Проблема в том, что распределенные индексы (как и real time, которые с нашими объемами совсем никак: сотни миллионов игроков, изменения состояний очень частые, все изменённые записи, а это, получается, вообще каждый активный игрок — должны немедленно попадать в апдейты) завезли не в 2.0.9, а в 2.1.2.
Все обновления адаптеров (через которые запросы из прекрасных приложений попадают в Sphinx и получают обратно результаты поиска) в тех древних версиях были очень сложны и тяжелы, потому что использовался бинарный протокол с непрозрачным и неочевидным версионированием. А вот в более старших версиях был внесён в Sphinx Search так называемый SphinxQL использующий нативный сишный MySQL коннектор и избавиться от бинарного протокола в пользу SQL — стало очень заманчивой идеей. И по совокупности вышеперечисленного возникает вопрос:
Тогда почему бы и не обновиться кардинально?
Отличная идея, к тому же гугление всех наших проблем постоянно показывало на официальном форуме топики о том как кто-то без каких-либо неприятных неожиданностей и не менее прекрасных сюрпризов переехал со второй версии на третью и, буквально, с минимальной правкой пары изменённых ключевых слов в конфиге — всё всегда с полпинка заводилось, летало и мурчало. По этой же причине не стали экспериментировать с Мантикорой (форк второго Сфинкса) и приступили к работам по приготовлению третьей версии.
Всё выглядело на первый взгляд чрезмерно оптимистично (но мы-то знаем, что это как раз повод ещё больше усомниться и проверять всё и вся с особым пристрастием), а тщательный анализ показал, что в различных компонентах продуктов используется кастомизация запросов лишь с изменением максимального количества результатов поиска и фильтрации, сами запросы имеют вид типичных wildcard запросов (да! никакого прорастания в наших приложениях низкоуровневых компонентов наверх и наоборот (ну хорошо, почти никакого прорастания :3)), фильтрация и какие-либо джойны данных полученных приложениями данных высокоуровнево абстрагированы, все зависимости локализованы в одном модуле из которого наружу торчит типичный интерфейс; это всё было очень хорошо, поэтому все работы с кодом сосредоточились только в самом модуле-адаптере Сфинкса, который непосредственно выполняет запросы и притворяется моделькой со всеми прокси-классами, а также непосредственно кастует какую-то магию запрещённую вне стен Хогвартса.
То есть, в сухом остатке, поднимаем версию до последней, деплоим, кормим тестовыми данными в масштабах продакшена, перфим, допиливаем напильником адаптеры и пару интерфейсов, профит. Малой кровью, с минимальным количеством ломающих изменений. С нуля всегда можно вкатить ELK, с нуля всегда много свободы и мало обратно-совместимой ответственности. Нам нужно было починить и если не сделать лучше, то уж точно не сделать хуже.
Подводные камни во время пути
Сервис не сдавался сразу без боли или „no pain — no gain”
Многолетняя привычка сразу же нырять в исходный код сыграла злую шутку: третий Сфинкс стал с закрытыми исходниками, почитать с наскоку в код стало никак, на этом месте пришла Морра из сказок Туве Янссон и села холодной попой на энтузиазм.
Что ж, холодная голова делу больше помощник, выписали ломающие изменения, подкрутили конфиги, проштудировали насквозь всю историю версий снизу вверх с 2.0.9 to the Moon and back до 3.3.1 и обратно сверху вниз, выписали, переформатировали — и завелось. Собрали всю необходимую информацию и сразу же в бой.
Параметризируй то
Поисковые индексы разных регионов находятся на разных континентах, везде различное количество данных, различные настройки связанные со странами и регионами — и чтобы два раза не вставать мы сразу же кастомизировали настройки индексации, баз данных, сконфигурировали клиентскую и серверную валидацию условий поиска. Это был очень верный шаг, потому что после фиксации настроек зависящих от нагрузок и объёмов данных что-либо переделывать в индексации было бы очень больно.
На тестовом стейдже после initial создания большого индекса внезапно оказалось, что данных в индекс совсем чуть-чуть приехало. Хотя запросы на тот момент были один в один точно такие, как в проде без изменений уже семь лет, сразу же запустили индексацию вручную, индексация прекратилась почти моментально якобы заполнив данные. Здесь нужно было сразу же сверить количество добавленных в индекс документов и количество фактических записей, мы взяли последний выполненный запрос и обнаружили вполне резонно пустой резалтсет.
Параметрируй это
Сфинкс на пустой резалтсет реагирует как на EOF: „всё, хозяинама, документы закончились, можно завершать процесс индексации, я сделяль”. Причина почему у нас так с документами в базе очень простая — различные диапазоны айдишников различных данных зарезервированы под различные задачи.
Почему айдишники разряжены на диапазоны „с дырками”? Всё просто, это архаические следы реликтовых ископаемых исторического легаси мержей различных компонентов в одну структуру, один компонент резервирует айдишники, например, с первого миллиарда, другой со второго, и так далее, это всё мержится в одну большую базу.
Хорошо, чтобы это обойти временно убрали условие выборки по диапазону айдишников добавив в условие, чтобы айдишник начальный текущего резалтсета был не меньше найденного в предыдущем батче, а это условие сунули прямо в сам SQL запрос индексатора; временно проблема была решена. Временно, потому что создавать счётчики на продакшене это не очень идея, так как индексатор оперирует со специально обученными read only слейвами для которых наша нагрузка не проблема, а временно сделали упрощённо как-то вот так:
sql_query_pre = CREATE TEMPORARY SEQUENCE IF NOT EXISTS last_id NO MINVALUE
sql_query_post = DROP SEQUENCE last_id
sql_query = SELECT users.id AS doc_id, \
SETVAL('last_id', users.id +1) \
FROM WHERE \
users.id >= CURRVAL('last_id') AND users.id <= (SELECT MAX(id) FROM users) \
ORDER BY id ASC LIMIT 1000000 /* $start -> $end */
Впоследствии была ещё одна забавная модификация, которая выбирала всё, что можно, а если нельзя и нечего, то возвращала минус единицу как признак „ничего”. В результате всех пертурбаций — мы лишь чуть припатчили оригинальный запрос подобрав оптимальный размер резалтсета с альтернативным возвращением SELECT -1 с killbatch, впрочем, это необходимо только один раз после деплоя и лишь на время создания первичного большого индекса.
Чуть боли
Два слова о терминологии: есть индекс, а есть дельта-индекс; из названия можно сделать правильный вывод, что дельта-индекс — это накопительный быстрый индекс аккумулирующий в себе изменения по какому-либо критерию, в нашем случае — по минимальной дате изменений.
Первая боевая конфигурация состояла из большого первичного индекса, ежемесячной дельты и дневной дельты плюс кастомизированные сторонние индексы из баз данных с дополнительной информацией. И сразу же при тестировании на достаточном объёме данных мы получили точно такую же ошибку, что не хватает места в инфиксном дереве, ошибка была точь-в-точь такой же как в Сфинксе 2.0.9, а после пройденного пути, когда я увидел это на тестовом стенде то натурально, как пишется в художественной литературе — я стал охвачен неиллюзорной пичялькой. >_<
Што-ш, мы к этому были готовы, как и всегда и ко всему, потому что изначально собирались шардить большой индекс на несколько подиндексов и переписали конфиги на локальные шарды буквально за пару минут, чего нельзя сказать о тестировочном запуске индексатора на таблицах с доброй сотней миллионов не менее добрых аккаунтов. Мытьём и катаньем мы подобрали оптимальное значение количества шардов с запасом: что-то в районе десяти миллионов записей из большой таблицы на один шард и индексация первичная стала проходить без сучка и задоринки.
Изредка рандомно стали валиться мержи шардовых дельт в основной индекс, протестировали на диапазонах данных, искали бисектом на каких конкретно данных валится — нет, валится случайным образом, сейчас и ныне автоматом пересоздаётся один маленький шард незаметно и всё снова работает как надо, но проблему нужно локализовать для порядка.
Избавление от боли
Приняли это как данность, увеличили немного количество шардов уменьшив количество записей на один шард, переписали конфиг на большой локальный шардированный индекс действительный до даты деплоймента, нешардированный дельта-индекс собирающий данные за период от последнего обновления основного индекса и мержащийся в один шард round-robin’ом, а оперативные данные за сутки просто выгребаются из короткой дельты с начала суток.
И так же мы заранее были готовы разнести шарды из локального конфига в различные инстансы, но это будет дороже и сложнее в оперировании и настройке, не менее сложнее в отладке. Текущее решение оказалось жизнестойким, всегда, если упадёт один из шардов на операции слияния с помесячной дельтой — то потребуется переиндексировать лишь этот один шард, а так как это будет уже чуть позже операции мержа, то никак не зааффектит следующий дельта-индекс на новый месяц. Иными словами, можно совершенно безболезненно в фоне ребилдить любой конкретный шард. Очень удобно!
Закончили с демонами, возвращаемся к коду
Принцип знает стар и млад: KISS, DRY, ACID, брат
Особенно первый второй с конца, шардированный поиск всё-таки стал медленнее реализации без шардов, что логично и никаких вопросов здесь не возникло. Это всё было с лихвой гиперкомпенсировано работой с самим адаптером для наших приложений, в котором чуть подшаманив значения предельного количества результатов поиска; докинув механизм префетча в адаптер (вполне логично, что когда пользователь приходит за первой страницей результатов поиска — ему может потребоваться также вторая) выбирающий разумное количество результатов поиска (на самом деле все, в самых тяжёлых случаях запикленый резалтсет занимал до сжатия 2 мегабайта, а с zstd уже почти модемных 50 килобайт, который моментально выскакивает из редиса и эвалится во встроенные питоновые примитивы) — мы решили и эту проблему сократив по пути почти нахаляву (а по сравнению с экономией на утилизации памяти / диска после переезда на третий Сфинкс это вообще бесплатно, то есть абсолютно даром) до незаметного нуля выборку данных из адаптера, а конкретно после выхлопа профайлера — до малозначимых и неуловимых глазом пользователя 0.1% времени. В одном из пользовательских приложений остальное время ORM Джанги ходит в свои базы заполняя затребованные поля, оптимизация и рефакторинг этого древнего кода первых людей Вестероса сильно выходит за рамки поставленной задачи, к тому же уже получилось очень хорошо срезать на всех углах трассы не расплескав по пути суть.
Кастомизация или снова чуть магии
Естественно, что древняя магия первых людей, ройнаров и андалов незримо присутствовала с нами весь путь; одним из таких артефактов старины стал факт, что одна и та же настройка разрешённых для индексации символов (например, для индексации логинов игроков с этой части континента можно \w, но у разных игр и регионов свои требования) перестала работать с пробельными символами и даже не со всеми, а с одним только банальным 0x20. Как-то до начала всей кулстории работала, а вот в 3.3.1 перестала.
Мы не стали заморачиваться и тратить время на обратный инвестигейт куда оно пропало, потому что судя по конфигам его и не должно было быть, к тому же кастомизацию мы заложили задолго до начала войн об поискового демона и г-на индексатора, а просто донесли везде разрешённый символ поигравшись с blend chars. Получилось быстро, легко и непринуждённо.
Единственное, что у нас вызывало страх и мандраж: это индексация и нормализация данных для поиска по странам Юго-Восточной Азии, но и там ничего страшного не произошло, лишь поправили хардкодное перечисление диапазонов юникодных символов на более красивые и полные диапазоны, чуть поперфили с уменьшенным размеров шарда (потому что из-за требований к самому поиску и валидации пришлось поиграться с инфиксами-суффиксами) и — дело в шляпе!
Эпилог
Мораль басни, выводы и смешная третья опция
Основной задачей было не допустить дальнейшей эскалации проблем с существующей системой поиска, естественно, как водится в таких случаях, желательно, чтобы это было готово позавчера, с минимальными затратами а ещё лучше, по возможности, с каким-то профитом.
И это получилось: апдейт самого сервиса свёлся к созданию новой rpm’ки, правке пары ямлов, дополнительный код был сильно упрощён (буквально выброшено почти всё методой rm -rf) относительно того что был и который работал с бинарным протоколом, добавлено сразу искоробочное подробное логгирование всех ситуаций, с префетчем и кешированием на разных уровнях полностью даже пропало ощущение реальности — работа с базами данных с сотнями миллионов пользователей по скорости отзывчивее, чем сишный «Hello World!» на локалхосте.
Аппаратно поиск крутится на bare metal, с третьим Сфинксом утилизация памяти сократилась на треть, средняя утилизация cpu на две трети, iops’ы на запись упали более, чем на 90%, а чтение кроме моментов реиндексации полностью прекратилось (второй постоянно что-то да почитывал, я понимаю, тоже очень читать люблю, жаль не man’ы).
Кардинально, результатом переосмысления структуры данных, самих необходимых данных, устранением избыточности, включением сжатия и экспериментами с различными настройками хранения данных на диске, сократился занимаемый дисковый спейс; например на одном из регионов, с половины терабайта до смешных плюс-минус сорока гигабайт.
Респонс тайм для конечного пользователя зааффекчен существенно только на тяжёлой пагинации из-за префетчей и новых кешей, но и для остальных вьюх с участием результатов поиска тоже отличный результат — время ожидания результатов сократилось даже мимо кешей на один порядок.
Повреждение индекса также больше не страшно из-за маленьких шардов, упадёт на мерже — ничего страшного, полный (автоматический) реиндекс одного шарда это ситуация про вполне себе корректные 500-700 секунд и пока это выполняется — поиск будет продолжать работать как ни в чём не бывало. С выбранной структурой индексов полная индексация вообще выполняется ровно один раз при initial деплойменте. Хотя с такими нагрузками можно переиндексировать хоть по одному шарду раз в час по кругу и никому не помешать, что тоже очень важно.
Таким образом и для узкоспециализированных задач Sphinx Search остаётся простым и очень быстрым инструментом. Для простых кейсов, где есть несколько таблиц в базах данных и требуется простой поиск по паре критериев — лучше вряд ли можно что-либо придумать. Архитектурные вопросы начинают быстро возникать при необходимости кастомизировать поиск чаще, чем пару раз за весь лайфтайм поддерживаемого продукта, но это уже совсем другая история и об этом я расскажу в следующих статьях, stay tuned!