{ "index": 233, "slug": "editorial-2021-07-mechanism-search-indexing", "title": "Индексация и поиск: почему сохранённая запись ещё не видна", "excerpt": "Запись уже открывается по прямой ссылке, но пропала из поиска. Разбираем путь source → ingest → index → refresh → query, проверяем устаревшую версию и выбираем безопасное действие.", "contentHtml": "

Новая карточка открывается по прямой ссылке, но поиск её не возвращает. Или поиск показывает старый текст, хотя форма сохранения ответила успешно. Это наблюдаемый симптом, а не одна причина. Если сразу повторить запись, запустить полный reindex или включить принудительный refresh, система получит лишнюю нагрузку, а расследование потеряет исходную версию. В худшем случае появятся дубликаты: источник содержит одну запись, а индекс — старую и новую проекции.

\n

Главный тезис простой: сохранение и видимость в поиске — разные события. Source of truth отвечает за доменные данные. Индексатор строит поисковую проекцию. Refresh открывает подготовленные данные для search. Запрос проверяет ещё и поле, фильтр, область поиска и права. Пока мы не знаем, на каком переходе остановилась нужная версия, исправлять запрос рано.

\n

Механизм: одна запись проходит несколько состояний

\n

Для карточки достаточно успешной записи в источнике. Для фоновой обработки нужен принятый event с идентификатором и версией. Для полнотекстового поиска нужен документ в индексе и момент, когда его сегмент стал видимым для search. Эти состояния нельзя заменить одним флагом saved. У них разные владельцы и разные проверки.

\n
Состояние документа и доказательство готовности
СостояниеЧто уже доказаноЧего ещё нет
source-of-truthПо id читается актуальный текст и versionSearch ещё не обязан видеть запись
ingest queueЕсть задача построить проекцию для пары id:versionИндексатор ещё не применил задачу
pending indexАктуальный документ подготовлен индексаторомВидимый снимок поиска может оставаться старым
visible indexДокумент доступен конкретному queryЭто не доказывает работу другого alias, фильтра или окружения
\n

Такая схема не требует четырёх отдельных сервисов. В маленьком приложении несколько состояний могут жить в одном процессе. Граница всё равно существует: код должен различать «данные сохранены», «работа принята», «проекция подготовлена» и «поиск может вернуть документ». Если граница скрыта, команда принимает задержку за потерю данных или лечит фильтр повторной индексацией.

\n

Версия защищает от запоздалой работы

\n

Один идентификатор не описывает порядок изменений. Представим запись article-42. Сначала источник получает версию 6, затем версию 7. Event для версии 6 задержался в очереди. Если индексатор обработает его после версии 7 и не проверит номер, поиск снова покажет старый текст. Поэтому event должен нести как минимум id и version, а обработчик должен сравнивать event с текущим источником.

\n
const source = {\n  id: 'article-42',\n  version: 7,\n  title: 'Контракт свежести выдачи',\n};\n\nconst event = {\n  key: `${source.id}:${source.version}`,\n  id: source.id,\n  version: source.version,\n};\n\nconst current = sourceOfTruth.get(event.id);\nif (!current || current.version !== event.version) {\n  return { state: 'stale-ingest-skipped', event };\n}\n\nreturn {\n  state: 'indexed-pending-refresh',\n  document: { ...current, indexedVersion: event.version },\n};
\n

Код выше — учебный пример. В нём нет брокера, транзакции, конкурентных consumer-ов и долговечной очереди. Он показывает только порядок проверки: сначала найти источник, затем сравнить версию, затем подготовить проекцию. В реальном движке нужна его собственная политика конфликтов и повторов. Простая проверка в приложении не заменяет optimistic concurrency control, если несколько писателей меняют один документ одновременно.

\n

Ключ article-42:7 также делает повтор заметным. Повторная доставка того же события не должна создавать новую смысловую запись. Это идемпотентность на границе ingest. Она не гарантирует порядок всех событий, поэтому version check остаётся обязательным. Нельзя считать повторный вызов доказательством исправления: сначала нужно выяснить, было ли исходное событие принято, обработано или отклонено как устаревшее.

\n

Refresh меняет видимость, а не источник

\n

Подготовленный документ может находиться в состоянии pending. Запрос к visible index в этот момент вернёт ноль результатов или старую версию. Refresh переносит подготовленные изменения в структуру, которую использует search. Он не исправляет неправильный id, не добавляет отсутствующее поле и не отменяет фильтр доступа.

\n
// Учебная модель: здесь Map заменяет поисковый индекс.\nconst pendingIndex = new Map();\nconst visibleIndex = new Map();\n\nfunction refreshSearch() {\n  for (const [id, document] of pendingIndex) {\n    visibleIndex.set(id, { ...document, visibleAt: 'refresh-1' });\n  }\n  pendingIndex.clear();\n}\n\nfunction search(query) {\n  return [...visibleIndex.values()].filter((document) =>\n    `${document.title} ${document.body}`.toLowerCase().includes(query.toLowerCase()),\n  );\n}
\n

В учебной модели refresh синхронен и бесплатен. В рабочем Elasticsearch это отдельный механизм. Текущая документация Elastic описывает near-real-time поиск: изменения становятся видимыми после refresh, а не в тот же момент, когда API записи вернул ответ. Поэтому параметр refresh=true нельзя превращать в безусловную кнопку на каждом write-path. Частые принудительные refresh увеличивают работу индекса и могут ухудшить пропускную способность.

\n

Если пользовательский сценарий требует дождаться появления только что записанного документа, обычно проверяют контракт конкретного API и окружения. В Elasticsearch параметр refresh=wait_for ждёт обычного refresh и не обязан запускать немедленный refresh для каждой записи. Это не универсальная рекомендация для любого движка. Сначала нужно подтвердить версию, нагрузку и допустимую задержку в своём проекте.

\n
\"Временная
Одна учебная запись проходит четыре границы. Точки времени показывают порядок проверки, а не обещают задержку в production.
\n

Прямое чтение и search проверяют разные маршруты

\n

Когда карточка открывается по id, это подтверждает путь чтения источника или realtime get. Когда текстовый поиск возвращает карточку, он подтверждает query path. Между ними могут отличаться индекс, alias, фильтры, анализ текста, права и момент обновления. Поэтому отчёт «запись существует» не закрывает инцидент с пустой выдачей.

\n

Для диагностики запишите точный запрос. Слово поиска, поле, фильтр, alias и окружение важнее общего сообщения «не находится». В учебной функции выше поиск — простая проверка подстроки. Он не моделирует токенизацию, stemming, synonyms, routing, permissions или кэш. Успешный результат примера доказывает только причинную цепочку fixture, а не поведение настоящего анализатора.

\n

Симптом → причина → проверка → действие

\n
Безопасный маршрут от наблюдения к следующей проверке
СимптомВероятная причинаПроверкаДействие
По id нет записиОшибка записи или неверный идентификаторСверить результат write, id и version в источникеОстановиться. Не создавать копию для проверки
Источник есть, event ждётЗадержка или отказ ingestНайти ключ id:version и время постановкиСохранить evidence и проверить consumer
Pending есть, search пустRefresh ещё не открыл проекциюСравнить время подготовки и видимостиПрименить согласованную policy или локальный тест
Search возвращает старую versionЗапоздалый event или конфликт порядкаСравнить source.version, event.version и visible.versionПоставить текущую версию идемпотентно, сохранив след
Visible version актуальна, hit пустОшибка query contractПроверить scope, alias, filter, поле, анализатор и праваИсправлять запрос или mapping точечно
\n

Таблица задаёт порядок, а не угадывает причину по одному симптому. Сначала докажите наличие источника. Затем найдите судьбу конкретной пары id:version. После этого проверяйте видимость. Только при актуальной видимой версии переходите к запросу. Если начать с reindex, можно потратить ресурсы и не заметить, что источник отсутствует или consumer получает старое событие.

\n

Отрицательный путь: документ есть, но не виден

\n

Рассмотрим учебный сценарий. Источник сохранил article-42 с version 7. Event принят один раз. Индексатор подготовил version 7. До refresh query возвращает 0 hits. Это не доказывает потерю документа. Доказательства указывают на состояние awaiting-refresh. После refresh тот же query возвращает одну version 7.

\n
const report = diagnoseVisibility('article-42');\n\nif (report.stage === 'awaiting-refresh') {\n  // Не удаляем source и не создаём второй документ.\n  console.log({\n    sourceVersion: report.sourceVersion,\n    pendingVersion: report.pendingVersion,\n    action: 'measure-refresh-gap',\n  });\n}\n\nif (report.stage === 'query-contract') {\n  // Видимость доказана. Проверяем область и условия запроса.\n  console.log('inspect scope, filter and analyzer');\n}
\n

У этого примера нет production-результата. Значения времени, количество попаданий и имя состояния нужны, чтобы проверить порядок переходов в тесте. В настоящей системе вместо Map понадобятся журналы записи, метрики ingest, сведения о target индекса и безопасный запрос по тестовому документу. Не подставляйте условные миллисекунды в SLA.

\n

Порядок действий

\n
  1. Сохраните id, source version, точный текстовый query, alias или индекс, фильтры и время наблюдения. Не меняйте данные до появления этого минимального следа.
  2. Проверьте источник по id. Если записи нет, расследуйте write-path и идентификатор. Не создавайте дубликат «для проверки».
  3. Найдите event с ключом id:version. Уточните, принят ли он, обработан ли, повторён ли или отброшен как устаревший.
  4. Если проекция pending, измерьте промежуток до видимости. Применяйте только политику своего движка; локальный принудительный refresh не объявляет production исправленным.
  5. Если видимая версия старая, сравните порядок событий и текущую version. Повторите только актуальную работу с идемпотентным ключом и сохраните старый evidence.
  6. Если видимая версия актуальна, проверьте query contract: поле, analyzer, scope, alias, фильтр, права и окружение.
  7. Повторите исходный запрос после точечной правки. Запишите результат и добавьте тот же отрицательный путь в интеграционный тест или наблюдение.
\n

Ограничения и критерий готовности

\n

Эта модель не описывает все свойства поисковой системы. Она не проверяет шарды, реплики, alias, durable queue, конкурентные записи, mapping, analyzer, права, кэш, сетевые сбои и восстановление после отказа. Она также не говорит, какая задержка допустима для конкретного продукта. Эти решения зависят от движка, версии, нагрузки и пользовательского контракта.

\n

Решение готово, когда для одного безопасного тестового документа можно показать цепочку evidence: источник содержит ожидаемую version; event имеет ключ id:version; индексатор не принимает запоздалую версию; момент видимости измерен; исходный query после refresh возвращает нужный документ; при актуальной видимой версии проверен query contract. Отдельно должен существовать отрицательный тест: до видимости поиск не выдаёт документ, а диагностика не предлагает удалить источник или создать копию.

\n

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

\n

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

\n" }