DarkRiDDeR16 мин

Индексация и поиск: как договориться о свежести выдачи

ПоискДанныеПрактика

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

В июле 2021 года я бы начал не с параметра конкретного поискового движка, а с договора для одной выдачи. Нужно назвать момент, от которого считаем свежесть, момент, когда запись должна участвовать в запросе, и данные, по которым можно отличить обычное ожидание от потери события. Ниже используется один локальный сценарий на JavaScript. Он держит source-of-truth, ingest, pending index и видимую проекцию в памяти. Это не Elasticsearch, не база, не broker и не результат замера в production.

Свежесть выдачи — отдельный результат, а не побочный эффект записи

Сохранение источника и видимость в поиске отвечают на разные вопросы. Источник хранит корректную версию карточки. Поисковая проекция хранит форму, удобную для запроса. Между ними появляется работа: сформировать ingest-задачу, принять нужную версию, обновить индексную проекцию и открыть её для query. Если назвать все эти шаги словом «сохранили», невозможно понять, где искать причину: в записи, в постановке, в обработчике, в refresh или в самом запросе.

Договор свежести полезно писать рядом с пользовательским сценарием. Например: «после сохранения статьи обычная текстовая выдача должна получить либо видимую текущую версию, либо понятный статус ожидания; редактор не создаёт вторую статью вместо первой». Это не SLA и не числовое обещание по умолчанию. Здесь важнее граница: какую выдачу обсуждаем, какая версия источника является актуальной, какой сигнал подтверждает видимость и кто принимает решение при отставании.

Минимальный договор свежести для одной поисковой выдачи
Часть договораЧто фиксируемЧем проверяемЧего не обещаем
Источникid и доменная version сохранённой карточкичтение source-of-truth по idчто query уже видит эту версию
Ingestключ id:version и состояние постановкиодна запись задачи для одинакового ключачто любая повторная постановка создаст новую работу
Индексированиеversion, принятая в pending-проекциюсравнение event.version с source.versionчто старая задача может переписать новую версию
Видимостьмомент refresh и version в visible-проекцииquery плюс visibleAtMsмгновенную видимость после сохранения
Реакциядопустимое действие при задержкезафиксированный маршрут диагностикичто delete и reindex всегда безопасны

У этой таблицы есть практическая польза: она запрещает спорить о «медленном поиске» без объекта наблюдения. Если проблема относится к фильтру категории, это уже договор query. Если событие не поставлено, это ingest. Если pending-версия есть, а visible ещё нет, это переход видимости. Каждая ветка требует своей проверки и своего владельца. Нельзя лечить их одинаковым повтором сохранения страницы.

Сначала отмечаем границу источника и проекции

Для одной учебной статьи источник содержит устойчивые id, version, title и body. Его version принадлежит доменной записи, а не поисковой строке. Проекция может менять форму текста, поля и стратегию запроса, но не должна изобретать новую версию содержимого. Поэтому ingest получает id и version. Обработчик сверяет их с текущим источником до того, как положит данные в pending index. Эта проверка не делает систему распределённо согласованной; она только не даёт старому учебному событию молча выдать себя за новую карточку.

const sourceDocument = {
  id: "article-2021-07-42",
  version: 7,
  title: "Контракт свежести выдачи",
};

// source-of-truth отвечает за содержимое и версию.
// search-индекс отвечает только за отдельную проекцию для запроса.

Стабильный ключ постановки строится из id и version. Fixture сохраняет принятый ключ отдельно от самой очереди: повтор до consume и повтор после consume получают duplicate-ingest-suppressed, а не создают вторую учебную работу. Это узкий инвариант. Он не доказывает идемпотентность реального producer, очереди или API, потому что в модели нет сети, базы и конкурирующих процессов. Но именно такой маленький тест помогает заранее назвать ключ и границу его хранения, которые в проекте придётся сохранять и наблюдать.

function enqueueIngest(queue, seenKeys, document) {
  const key = document.id + ":" + document.version;
  if (seenKeys.has(key)) return { state: "duplicate-ingest-suppressed", key };
  queue.set(key, { id: document.id, version: document.version });
  seenKeys.add(key);
  return { state: "ingest-enqueued", key };
}

// seenKeys задаёт учебную границу идемпотентности после consume.
Вертикальная схема учебного пути документа: source-of-truth сохраняет version 7, ingest подавляет повторный ключ, pending index ещё не участвует в query, refresh переносит документ в visible index, после чего query находит одну версию
Учебный путь записи: сохранение источника и видимость в выдаче разделены явным переходом refresh.

Бюджет задержки строится из наблюдаемых точек, а не из чужого значения

Вместо формулы «поиск должен обновляться быстро» полезно записать четыре точки: savedAtMs, enqueuedAtMs, indexedAtMs и visibleAtMs. Их разность показывает, в каком отрезке находится отставание. В fixture источник сохранён на 100, ingest поставлен на 110, проекция подготовлена на 125, а refresh сделан на 160. Разница между сохранением и видимостью равна 60 условным миллисекундам. Это арифметика теста, не реальная задержка и не целевой бюджет для какого-либо кластера.

Когда эти точки известны, команда может договориться о реакции без ложной точности. Для обычной выдачи допустимо показать состояние ожидания и измерить следующую проверку. Для сценария, который обязан читать свою запись, потребуется другой путь: например, чтение источника или явно выбранный режим ожидания. Выбирать его надо по цене ожидания, нагрузке и поведению конкретной версии движка. Историческая документация Elasticsearch 7.13 описывает refresh как переход, делающий операции доступными search, и отдельно предупреждает, что search работает near-real-time. Это описание механизма, а не переносимый SLA.

const freshnessContract = {
  object: "карточка статьи",
  searchAudience: "обычная выдача",
  startsAt: "source-saved",
  endsAt: "visible-to-query",
  evidence: ["sourceVersion", "eventKey", "savedAtMs", "indexedAtMs", "visibleAtMs"],
  policy: "проект выбирает ожидание, уведомление или controlled refresh",
};

// Это структура разговора о свежести, а не значение SLA.

В реальном проекте сама единица бюджета тоже зависит от вопроса. Иногда нужно измерить время от публикации до первой видимости. Иногда важнее число текущих pending-версий или доля запросов, где карточка не прошла фильтр. Нельзя смешивать их в один «лаг поиска». Один показатель отвечает на вопрос о pipeline, другой — о запросе, третий — о содержимом. Сначала выбираем один пользовательский случай, затем оставляем у него минимальный набор времени, version и ключа события.

Fixture показывает stale выдачу без настоящего движка

Сквозная fixture сначала сохраняет документ в source-of-truth и ставит ingest. Затем второй вызов постановки с тем же id и version подавляется. После обработки document лежит в pending index, но searchVisible ещё возвращает ноль попаданий. Это и есть контролируемая stale выдача: источник существует, проекция подготовлена, но переход видимости не выполнен. Только после явного refreshSearch тот же запрос получает одну карточку с version 7.

Такой пример важен именно своей скромностью. Он не строит индекс, не анализирует русский текст так же, как поисковый движок, не вызывает HTTP и не имитирует shard. Его результат проверяем: assertions фиксируют сохранение источника, подавление дубликата, пустой поиск до refresh, видимый текущий version после refresh, арифметику 60 и неразрушающий диагноз. Если один из этих результатов перестать выполняться после правки модели, Node завершится ошибкой.

const fixture = runSearchIndexingFixture();
if (!Object.values(fixture.assertions).every(Boolean)) {
  throw new Error("search fixture failed");
}

fixture.beforeRefreshSearch.hitCount; // 0: source уже сохранён, search ещё stale
fixture.afterRefreshSearch.hitCount;  // 1: тот же version стал видимым
fixture.observedLagMs;                // 60: учебная арифметика, не SLA

Маршрут для обычной выдачи

Маршрут остаётся линейным: симптом — source уже сохранён, а query пуст; причина ищется в одном из переходов id:version; проверка фиксирует version и точки времени; действие выбирается только для найденной границы. Такой порядок не подменяет отставание видимости повторным сохранением карточки.

  1. Выбрать одну выдачу и один объект: например, поиск опубликованной статьи по заголовку. Не начинать с общего слова «индекс».
  2. Записать source id и version, затем определить, какой ключ представляет постановку для этой версии.
  3. Сохранить точки savedAtMs, enqueuedAtMs, indexedAtMs и visibleAtMs там, где они реально доступны. Не подставлять значения из fixture в рабочие логи.
  4. Проверить один отрицательный путь: source уже существует, а query до refresh не находит документ. Это отличает ожидание видимости от потери source.
  5. Для одинакового id:version повторить постановку и убедиться, что наблюдаемое действие не создаёт второй независимый ingest.
  6. Выбрать реакцию для превышения проектного бюджета: ждать следующего планового перехода, показывать статус или запускать заранее согласованную проверку. Не начинать с удаления источника.
  7. После исправления снова проверить query, version и время видимости. Если запрос всё ещё пустой, перейти к фильтру, области поиска и анализу текста, а не повторять refresh бесконечно.

Что остаётся за границей этой заметки

Elasticsearch 7.13 документирует, что refresh управляет видимостью для search, а Index API имеет собственные параметры refresh и versioning. Эти сведения нужны, чтобы не считать успешный index request универсальным признаком видимости. Но учебная Map не является проекцией внутреннего устройства Elastic и не может подтвердить поведение любого индекса, alias, реплики или настройки interval. Значения 100, 110, 125 и 160 выбраны только для детерминированного теста.

В пакете не запускаются Elasticsearch, база, брокер, HTTP, браузер, CI и deployment. Здесь нет настоящего SLA, лога нагрузки, данных пользователей или обещания мгновенного поиска. Следующий проверяемый шаг в своём проекте — взять один безопасный документ, записать его id и version, сравнить момент сохранения с моментом поиска и отдельно проверить политику refresh выбранной исторической версии движка. Тогда симптом станет маршрутом, а не поводом создавать дубликаты.

Проверяемые источники

  • Elasticsearch 7.13: Near real-time search — историческая документация линии 7.13: refresh делает операции с индексом доступными для поиска; описание near-real-time не является SLA учебной модели
  • Elasticsearch 7.13: Index API — источник для параметра refresh и раздела versioning; fixture не вызывает API и не задаёт настройки движка
  • Elasticsearch 7.13: The refresh parameter — историческое описание значений false, true и wait_for и их влияния на видимость операции для search; решение о режиме зависит от нагрузки и контракта проекта