Назад в блог

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 об этом не знал.