Reindex без даунтайма: как обновить маппинг Elasticsearch, не останавливая магазин
Когда мы решили добавить русскую морфологию в индекс с 80 000 товаров, я понял, что у нас нет плана.
Elasticsearch не позволяет менять маппинг на живом индексе. Поле, которое ты создал как keyword, навсегда останется keyword. Хочешь добавить анализатор — создавай новый индекс. Это задокументировано и не обсуждается.
Можно было дать даунтайм. Ночью, когда трафик минимальный. Но у нас именно ночью работал импорт из 1C — 30 000 обновлений остатков с 23:00 до 02:00. Остановить поиск в окно импорта значит получить битые данные или потерять изменения.
Пришлось разобраться в alias swap. Вот как это работает на практике, включая часть, которую не описывают в документации.
Почему Elasticsearch не даёт просто поменять маппинг
Маппинг — это схема индекса. Как только ты проиндексировал первый документ, структура полей зафиксирована. Можно добавить новое поле, но нельзя поменять тип существующего или перенастроить анализатор.
Причина техническая: Lucene (движок под Elasticsearch) на этапе индексации создаёт инвертированный индекс и term vectors под конкретный анализатор. Поменяй анализатор — и 80k уже проиндексированных документов содержат «старые» токены. Поиск начнёт давать разные результаты для новых и старых документов.
У нас был такой случай. Мы добавили анализатор russian для полей name и description. Новые товары с утренним импортом находились нормально. Остатки каталога — нет. Пользователь искал «телевизор» и не находил товары, проиндексированные неделю назад.
Единственный выход — переиндексировать всё. Вопрос только: как сделать это без остановки магазина.
Aliases: как Elasticsearch позволяет переключать индексы без изменений в коде
Alias — это имя-указатель на один или несколько индексов. Вместо того чтобы обращаться к индексу напрямую (catalog_v1), приложение работает с alias (catalog). Сам alias можно переключить атомарно: одной API-командой он начинает указывать на catalog_v2.
Это и есть основа zero-downtime миграции: код приложения не знает, на какой физический индекс он смотрит. Он знает только alias.
Если alias изначально не настроен — придётся внести одно изменение в код. В нашем случае Bitrix REST API-адаптер использовал имя индекса напрямую. Мы поменяли его на alias перед началом миграции. Это единственное изменение в коде, которое потребовалось.
Четыре шага: создать → залить → переключить → убедиться
Шаг 1. Создать новый индекс с нужным маппингом.
Имя — с суффиксом версии: catalog_v2. Полный маппинг: все поля, нужные анализаторы, settings для шардов и реплик. Проверили на тестовом документе, что токенизация работает как ожидается.
Шаг 2. Запустить _reindex API.
POST /_reindex
{
"source": { "index": "catalog_v1" },
"dest": { "index": "catalog_v2" }
}
По умолчанию — синхронный. Для 80k документов это заняло 4 минуты 12 секунд на нашем single-node (4 vCPU, 16 GB RAM). Ни магазин, ни поиск в это время не останавливались — читали из catalog_v1 через alias.
Для больших индексов используй ?wait_for_completion=false и отслеживай task_id.
Шаг 3. Атомарный alias swap.
POST /_aliases
{
"actions": [
{ "remove": { "index": "catalog_v1", "alias": "catalog" } },
{ "add": { "index": "catalog_v2", "alias": "catalog" } }
]
}
Elasticsearch выполняет оба действия атомарно. Между ними нет момента, когда alias ни на что не указывает. Для наших пользователей это был один запрос с разрывом < 1 мс.
Шаг 4. Проверка.
Несколько тестовых поисков по известным словам. Проверка, что _cat/indices?v показывает оба индекса, alias указывает на catalog_v2. Мониторинг ошибок в логах.
Двойная запись: что происходит с обновлениями каталога во время reindex
Это часть, про которую обычно молчат.
Пока reindex идёт, каталог продолжает обновляться. В нашем случае — 1C каждые 15 минут присылает обновления остатков. Если писать только в catalog_v1, то в catalog_v2 попадут данные на момент начала reindex — все изменения за 4 минуты будут потеряны.
Решение: во время миграции писать в оба индекса.
В Bitrix-адаптере мы добавили режим dual_write:
if ($this->isDualWriteMode()) {
$this->indexDocument($doc, 'catalog_v1');
$this->indexDocument($doc, 'catalog_v2');
}
Флаг dual_write включали вручную перед запуском reindex и выключали через ~10 минут после alias swap — после того как убедились, что catalog_v1 больше не читается.
Overhead от двойной записи составил около 18% по времени ответа на запросы индексации. Приемлемо для окна в 15 минут.
После отключения dual_write старый индекс catalog_v1 удалили через неделю — дали выстояться на случай, если понадобится rollback.
Мониторинг прогресса и оценка времени
Синхронный reindex не показывает прогресс. Проверить, сколько документов уже залито, можно через _count:
curl -s localhost:9200/catalog_v2/_count | jq '.count'
Грубая оценка времени: (кол-во документов * коэффициент нагрузки анализатора) / скорость reindex.
На нашем железе: простой маппинг давал около 2 000 doc/s, с тяжёлым анализатором (russian + нормализатор + синонимы) падал до 320 doc/s. При 80k документов получается 250 секунд. На практике так и вышло.
Для асинхронного reindex (wait_for_completion=false) отслеживай прогресс через Tasks API:
GET /_tasks/<task_id>
Rollback за 30 секунд
Если что-то пошло не так после alias swap, откат — это одна команда:
POST /_aliases
{
"actions": [
{ "remove": { "index": "catalog_v2", "alias": "catalog" } },
{ "add": { "index": "catalog_v1", "alias": "catalog" } }
]
}
Мы сделали это через 20 минут после первой миграции — не из-за ошибки в поиске, а потому что обнаружили, что в catalog_v2 оказались недоиндексированные документы из-за тайм-аута reindex на одном из шардов. Alias вернули обратно, исправили проблему, через 3 часа мигрировали повторно.
Поэтому не торопитесь удалять старый индекс сразу.
Что мы не предусмотрели
После alias swap поиск стал находить больше релевантных товаров — морфология заработала. Но пользователи, которые в этот момент были в середине сессии, получили несогласованные результаты: в кэше браузера одни данные, в поиске — другие.
Мы не очистили Redis-кэш результатов поиска при переключении alias. На практике это выразилось в том, что один пользователь написал в чат поддержки: «только что искал и не нашёл, теперь нашёл, что за магия?». Одно обращение за 20 минут. Не катастрофа, но теперь очистка кэша поиска — часть процедуры alias swap.
Alias swap — это единственный рабочий способ обновить маппинг Elasticsearch без остановки магазина. Сложность не в API: он простой. Сложность в том, что надо держать в голове все потоки записи и точно знать, когда что переключается.
Если у вас идёт непрерывный поток обновлений из 1C или любого другого источника — включите dual write заранее. Это единственное место, где у вас может быть потеря данных, и оно не в Elasticsearch, а в логике вашего приложения.
Предыдущая часть серии — про то, как вообще проектировать маппинг, чтобы реже упираться в эту процедуру: Маппинг Elasticsearch — это архитектура, а не конфигурация.
А откуда взялась потребность в морфологии — описано здесь: «Носки» и «гольфы» — один товар. Elasticsearch об этом не знал.