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

В июле 2021 года разработчик выкатывает новую карточку: форма отвечает успешно, а поиск не возвращает её по заголовку. Прямая ссылка открывает запись, поэтому первая гипотеза звучит просто: «поиск сломан». Наблюдаемый симптом ещё не объясняет, на какой границе возникла проблема. Это учебная сцена, а не отчёт о конкретном production-инциденте. Её цена понятна: повторная запись, полный reindex или безусловный refresh меняют систему до того, как сохранено исходное evidence.

\n

Сохранение и видимость в поиске — разные события. Source of truth хранит доменные данные, ingest принимает работу, индексатор строит поисковую проекцию, refresh открывает подготовленные сегменты для search, а query применяет поле, фильтр, область и права. Пока неизвестно, на какой границе остановилась нужная версия, исправлять запрос или запускать массовую индексацию рано.

\n

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

\n

Успешная запись доказывает только результат write-path. Для фоновой обработки нужен принятый event с идентификатором и версией. Для поиска нужен документ в индексе и видимый снимок этого индекса. В небольшом приложении несколько состояний могут жить в одном процессе, но границы всё равно должны быть различимы в коде и диагностике.

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

Такая модель не требует четырёх сервисов. Она требует четырёх проверяемых фактов. Если их свести к флагу saved, команда принимает задержку за потерю данных или лечит ошибку фильтра повторной индексацией. Одна запись с одним id обычно обновляет одну проекцию; дубликат появляется только при отдельной ошибке идентичности, например при разных ключах для одного доменного объекта.

\n

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

\n

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

\n
import assert from 'node:assert/strict';\n\nconst sourceOfTruth = new Map([\n  ['article-42', { id: 'article-42', version: 7, title: 'Контракт свежести выдачи', body: 'refresh' }],\n]);\nconst pendingIndex = new Map();\nconst visibleIndex = new Map();\nconst acceptedKeys = new Set();\n\nfunction enqueue(event) {\n  const key = event.id + ':' + event.version;\n  if (acceptedKeys.has(key)) return { state: 'duplicate-suppressed', key };\n  acceptedKeys.add(key);\n  return { state: 'accepted', key };\n}\n\nfunction consume(event) {\n  const current = sourceOfTruth.get(event.id);\n  if (!current || current.version !== event.version) {\n    return { state: 'stale-skipped', event };\n  }\n  pendingIndex.set(event.id, { ...current, indexedVersion: event.version });\n  return { state: 'indexed-pending-refresh', version: event.version };\n}\n\nfunction refreshSearch() {\n  for (const [id, document] of pendingIndex) visibleIndex.set(id, document);\n  pendingIndex.clear();\n}\n\nfunction search(query) {\n  const needle = query.toLowerCase();\n  return [...visibleIndex.values()].filter((document) =>\n    (document.title + ' ' + document.body).toLowerCase().includes(needle),\n  );\n}\n\nconst stale = { id: 'article-42', version: 6 };\nconst current = { id: 'article-42', version: 7 };\nassert.equal(enqueue(stale).state, 'accepted');\nassert.equal(consume(stale).state, 'stale-skipped');\nassert.equal(enqueue(current).state, 'accepted');\nassert.equal(consume(current).state, 'indexed-pending-refresh');\nassert.equal(search('свежести').length, 0); // pending ещё не виден\nrefreshSearch();\nassert.equal(search('свежести')[0].indexedVersion, 7);\nassert.equal(enqueue(current).state, 'duplicate-suppressed');
\n

Этот фрагмент запускается в Node.js и проверяет только учебную модель: устаревшее событие отбрасывается, актуальное попадает в pending, до refresh поиск пуст, после refresh видна версия 7, повтор того же ключа подавляется. acceptedKeys — in-memory граница примера, а не гарантия брокера. В рабочей системе нужны долговечное хранение receipt, политика повторов и атомарное решение о порядке применения.

\n

Проверка версии в приложении также не равна optimistic concurrency control. Elasticsearch поддерживает собственные варианты versioning и sequence numbers, но проект должен выбрать один источник порядка и один контракт записи. Нельзя одновременно молча полагаться на номер события, внутреннюю версию индекса и время доставки.

\n

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

\n

Документ может уже находиться в pending index, пока запрос читает предыдущий видимый снимок. Refresh делает недавние операции доступными для search; он не исправляет неправильный id, не добавляет отсутствующее поле и не отменяет фильтр доступа. Поэтому «сделали refresh — стало видно» доказывает только границу видимости, но не исправность всей цепочки.

\n
const pending = new Map([\n  ['article-42', { title: 'Контракт свежести выдачи', version: 7 }],\n]);\nconst visible = new Map();\n\nfunction refresh() {\n  for (const [id, document] of pending) visible.set(id, { ...document });\n  pending.clear();\n}\n\nconsole.log(visible.has('article-42')); // false\nrefresh();\nconsole.log(visible.get('article-42').version); // 7
\n

В учебной модели refresh синхронен и бесплатен. В Elasticsearch это отдельный механизм near-real-time поиска: изменения становятся видимыми после refresh, а не обязательно в момент ответа API записи. Параметр refresh=true запрашивает немедленную видимость и может увеличить нагрузку. Параметр refresh=wait_for ждёт обычного refresh; он не является универсальной кнопкой «починить поиск».

\n

Если продукту нужно дождаться видимости только что записанного документа, сначала зафиксируйте контракт конкретного API и окружения. Для одного пользовательского запроса ожидание может быть оправдано, а на массовом write-path — слишком дорогим. Частоту refresh, размер пакета и допустимую задержку нельзя переносить из документации или учебной модели без измерения.

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

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

\n

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

\n

Для диагностики запишите точный запрос: слово, поле, alias или индекс, фильтр, область, окружение и время. Учебная функция search проверяет подстроку и не моделирует токенизацию, stemming, synonyms, routing, permissions или кэш. Её успешный результат доказывает причинную цепочку fixture, а не поведение настоящего анализатора.

\n

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

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

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

\n

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

\n

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

\n
function diagnoseVisibility(source, pending, visible, query) {\n  if (!source) return { stage: 'missing-source', destructiveAction: false };\n  if (!pending) return { stage: 'ingest-or-index', destructiveAction: false };\n  if (!visible) return { stage: 'awaiting-refresh', destructiveAction: false };\n  return visible.title.includes(query)\n    ? { stage: 'query-hit', version: visible.version, destructiveAction: false }\n    : { stage: 'query-contract', version: visible.version, destructiveAction: false };\n}\n\nconst diagnosis = diagnoseVisibility(\n  { id: 'article-42', version: 7 },\n  { id: 'article-42', version: 7 },\n  null,\n  'свежести',\n);\nconsole.log(diagnosis.stage); // awaiting-refresh\n// Не удаляем source и не создаём второй документ.
\n

Теперь отрицательная ветка воспроизводима: функция получает явные состояния, возвращает безопасный диагноз и не маскирует отсутствие входных данных. В настоящей системе вместо объектов понадобятся журналы записи, метрики ingest, сведения о target индексе и запрос к тестовому документу. Условные этапы не являются названиями API и не заменяют проверку конкретного движка.

\n

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

\n
  1. Сохраните id, source version, точный текстовый query, alias или индекс, фильтры, область и время наблюдения. Не меняйте данные до появления этого минимального следа.
  2. Проверьте источник по id. Если записи нет, расследуйте write-path и идентификатор. Не создавайте дубликат «для проверки».
  3. Найдите event с ключом id:version. Уточните, принят ли он, обработан ли, повторён ли или отброшен как устаревший.
  4. Если проекция pending, измерьте промежуток до видимости. Применяйте policy своего движка; локальный 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" }