{ "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Для карточки достаточно успешной записи в источнике. Для фоновой обработки нужен принятый event с идентификатором и версией. Для полнотекстового поиска нужен документ в индексе и момент, когда его сегмент стал видимым для search. Эти состояния нельзя заменить одним флагом saved. У них разные владельцы и разные проверки.
| Состояние | Что уже доказано | Чего ещё нет |
|---|---|---|
| source-of-truth | По id читается актуальный текст и version | Search ещё не обязан видеть запись |
| ingest queue | Есть задача построить проекцию для пары id:version | Индексатор ещё не применил задачу |
| pending index | Актуальный документ подготовлен индексатором | Видимый снимок поиска может оставаться старым |
| visible index | Документ доступен конкретному query | Это не доказывает работу другого alias, фильтра или окружения |
Такая схема не требует четырёх отдельных сервисов. В маленьком приложении несколько состояний могут жить в одном процессе. Граница всё равно существует: код должен различать «данные сохранены», «работа принята», «проекция подготовлена» и «поиск может вернуть документ». Если граница скрыта, команда принимает задержку за потерю данных или лечит фильтр повторной индексацией.
\nОдин идентификатор не описывает порядок изменений. Представим запись article-42. Сначала источник получает версию 6, затем версию 7. Event для версии 6 задержался в очереди. Если индексатор обработает его после версии 7 и не проверит номер, поиск снова покажет старый текст. Поэтому event должен нести как минимум id и version, а обработчик должен сравнивать event с текущим источником.
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 остаётся обязательным. Нельзя считать повторный вызов доказательством исправления: сначала нужно выяснить, было ли исходное событие принято, обработано или отклонено как устаревшее.
Подготовленный документ может находиться в состоянии pending. Запрос к visible index в этот момент вернёт ноль результатов или старую версию. Refresh переносит подготовленные изменения в структуру, которую использует search. Он не исправляет неправильный id, не добавляет отсутствующее поле и не отменяет фильтр доступа.
// Учебная модель: здесь 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 увеличивают работу индекса и могут ухудшить пропускную способность.
Если пользовательский сценарий требует дождаться появления только что записанного документа, обычно проверяют контракт конкретного API и окружения. В Elasticsearch параметр refresh=wait_for ждёт обычного refresh и не обязан запускать немедленный refresh для каждой записи. Это не универсальная рекомендация для любого движка. Сначала нужно подтвердить версию, нагрузку и допустимую задержку в своём проекте.
Когда карточка открывается по id, это подтверждает путь чтения источника или realtime get. Когда текстовый поиск возвращает карточку, он подтверждает query path. Между ними могут отличаться индекс, alias, фильтры, анализ текста, права и момент обновления. Поэтому отчёт «запись существует» не закрывает инцидент с пустой выдачей.
Для диагностики запишите точный запрос. Слово поиска, поле, фильтр, alias и окружение важнее общего сообщения «не находится». В учебной функции выше поиск — простая проверка подстроки. Он не моделирует токенизацию, stemming, synonyms, routing, permissions или кэш. Успешный результат примера доказывает только причинную цепочку fixture, а не поведение настоящего анализатора.
\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 точечно |
Таблица задаёт порядок, а не угадывает причину по одному симптому. Сначала докажите наличие источника. Затем найдите судьбу конкретной пары id:version. После этого проверяйте видимость. Только при актуальной видимой версии переходите к запросу. Если начать с reindex, можно потратить ресурсы и не заметить, что источник отсутствует или consumer получает старое событие.
Рассмотрим учебный сценарий. Источник сохранил article-42 с version 7. Event принят один раз. Индексатор подготовил version 7. До refresh query возвращает 0 hits. Это не доказывает потерю документа. Доказательства указывают на состояние awaiting-refresh. После refresh тот же query возвращает одну version 7.
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.
id, source version, точный текстовый query, alias или индекс, фильтры и время наблюдения. Не меняйте данные до появления этого минимального следа.id. Если записи нет, расследуйте write-path и идентификатор. Не создавайте дубликат «для проверки».id:version. Уточните, принят ли он, обработан ли, повторён ли или отброшен как устаревший.Эта модель не описывает все свойства поисковой системы. Она не проверяет шарды, реплики, alias, durable queue, конкурентные записи, mapping, analyzer, права, кэш, сетевые сбои и восстановление после отказа. Она также не говорит, какая задержка допустима для конкретного продукта. Эти решения зависят от движка, версии, нагрузки и пользовательского контракта.
\nРешение готово, когда для одного безопасного тестового документа можно показать цепочку evidence: источник содержит ожидаемую version; event имеет ключ id:version; индексатор не принимает запоздалую версию; момент видимости измерен; исходный query после refresh возвращает нужный документ; при актуальной видимой версии проверен query contract. Отдельно должен существовать отрицательный тест: до видимости поиск не выдаёт документ, а диагностика не предлагает удалить источник или создать копию.
Такой критерий не обещает мгновенный поиск. Он показывает, где проходит граница ответственности и какое действие можно выполнить без разрушения исходных данных. Сохранение записи остаётся фактом источника. Видимость в поиске становится отдельным проверяемым фактом.
\ntrue, false и wait_for и их влияния на стоимость и видимость.