Files

8 lines
20 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 234,
"slug": "editorial-2021-07-practice-search-indexing",
"title": "Индексация и поиск: почему сохранённая запись ещё не видна",
"excerpt": "Карточка открывается по прямой ссылке, но поиск возвращает пустой результат или старую версию. Разбираем границы source, ingest, индексирования и refresh, а затем показываем проверяемый маршрут диагностики.",
"contentHtml": "<p>Карточка уже открывается по прямой ссылке, но поиск по её заголовку возвращает пустой результат. Иногда пользователь видит старое название. Иногда один документ появляется дважды. Цена ошибки быстро растёт: редактор повторяет сохранение, оператор запускает повторную индексацию, а система получает дубликаты и лишнюю нагрузку. При этом исходная запись могла сохраниться правильно.</p>\n<p>Тезис простой: запись в source и видимость в поисковой выдаче — разные события. Между ними стоят ingest, обработчик, индексная проекция и переход видимости. У каждого шага должен быть свой признак успеха. Если свести всё к флагу <code>saved</code>, причина исчезнувшего результата останется неизвестной.</p>\n<h2>Механизм: источник, проекция и запрос</h2>\n<p>Источник данных владеет содержимым и версией документа. Поисковая проекция хранит форму, удобную для запроса: нормализованный текст, поля фильтра и идентификатор. Проекция не заменяет источник. Она строится после изменения источника и может стать видимой позже.</p>\n<p>Для расследования разделите путь на четыре состояния. <code>source</code> содержит текущую карточку. <code>ingest</code> содержит намерение построить проекцию для пары <code>id:version</code>. <code>pending index</code> содержит подготовленный документ, который ещё не возвращает обычный query. <code>visible index</code> содержит снимок, доступный поиску. В настоящем проекте эти состояния могут жить в разных сервисах. В статье они показаны в памяти, чтобы сделать переходы наблюдаемыми.</p>\n<table><caption>Состояния одной карточки</caption><thead><tr><th>Состояние</th><th>Что уже доказано</th><th>Чего ещё нет</th></tr></thead><tbody><tr><td><code>source</code></td><td>Содержимое и версия сохранены</td><td>Поисковый запрос видит документ</td></tr><tr><td><code>ingest</code></td><td>Поставлена работа для конкретной версии</td><td>Проекция обработана</td></tr><tr><td><code>pending index</code></td><td>Документ подготовлен индексатором</td><td>Он доступен обычному query</td></tr><tr><td><code>visible index</code></td><td>Запрос может вернуть эту версию</td><td>Другие фильтры и области поиска корректны</td></tr></tbody></table>\n<p>Версия нужна для порядка обновлений. Событие с <code>version: 6</code> не должно молча переписать документ, который source уже поднял до версии 7. Ключ <code>article-17:7</code> задаёт и границу повтора. Одинаковая работа должна распознаваться как повтор, а не как новая независимая задача.</p>\n<h2>Учебный пример на JavaScript</h2>\n<p>Ниже приведён ограниченный, но сквозной пример. Он не запускает Elasticsearch, не моделирует брокер и не даёт production-метрик. Все четыре фрагмента нужно выполнить в одном Node.js-сеансе сверху вниз: сначала создаются хранилища и функции, затем документ сохраняется, индексируется и проверяется до и после refresh. Заголовок входит в текст проекции специально: это делает воспроизводимым заявленный в начале поиск по заголовку.</p>\n<pre><code>const source = new Map(); const acceptedIngest = new Set(); const pending = new Map(); const visible = new Map(); function saveArticle(article, savedAtMs) { source.set(article.id, { ...article, savedAtMs }); return { id: article.id, version: article.version }; } function enqueueArticle(id, version, enqueuedAtMs) { const key = `${id}:${version}`; if (acceptedIngest.has(key)) return { status: 'duplicate-ingest-suppressed', key }; acceptedIngest.add(key); pending.set(key, { id, version, enqueuedAtMs }); return { status: 'queued', key }; }</code></pre>\n<p>Обработчик сначала читает текущий source. Если событие старее записи, он завершает работу без записи устаревшего текста. Это отрицательный путь: очередь может доставить старое событие после более нового. Проверка версии не делает очередь надёжной и не заменяет транзакцию. Она только не даёт устаревшему сообщению притвориться текущим.</p>\n<pre><code>function consumeArticle(id, version, indexedAtMs) { const article = source.get(id); if (!article || article.version !== version) return { status: 'stale-or-missing', id, version }; const key = `${id}:${version}`; pending.set(key, { id, version, title: article.title, text: `${article.title} ${article.body}`.toLowerCase(), indexedAtMs }); return { status: 'prepared', key }; }</code></pre>\n<p>На этом месте документ ещё не обязан находиться в выдаче. Учебный <code>refreshSearch</code> переносит подготовленную проекцию в видимую. В реальном движке название и стоимость операции зависят от версии, настроек и нагрузки. Поэтому нельзя превращать явный refresh в универсальную кнопку после каждого изменения.</p>\n<pre><code>function refreshSearch(visibleAtMs) { for (const [key, document] of pending) visible.set(key, { ...document, visibleAtMs }); pending.clear(); } function searchArticles(term) { const needle = term.toLowerCase(); return [...visible.values()].filter((document) =&gt; document.text.includes(needle)); }</code></pre>\n<p>Теперь запустите один сценарий с фиксированными входными данными. До refresh запрос по слову «индексация» должен вернуть ноль документов, хотя <code>source</code> и <code>pending</code> уже заполнены. После refresh тот же запрос должен вернуть одну карточку с версией 7. Повторная постановка того же <code>id:version</code> не создаёт вторую работу.</p>\n<pre><code>const article = { id: 'article-17', version: 7, title: 'Индексация и поиск', body: 'Проекция становится видимой после refresh.' }; const saved = saveArticle(article, 100); const queued = enqueueArticle(saved.id, saved.version, 110); const duplicate = enqueueArticle(saved.id, saved.version, 111); const prepared = consumeArticle(saved.id, saved.version, 125); const beforeRefresh = searchArticles('индексация'); const pendingBeforeRefresh = pending.size; refreshSearch(160); const afterRefresh = searchArticles('индексация'); console.log({ saved, queued, duplicate, prepared, beforeRefresh: beforeRefresh.length, pendingBeforeRefresh, afterRefresh: afterRefresh.length, visibleVersion: afterRefresh[0]?.version }); // beforeRefresh: 0, pendingBeforeRefresh: 1, afterRefresh: 1, visibleVersion: 7</code></pre>\n<p>У примера есть намеренная граница: <code>acceptedIngest</code> — только in-memory receipt-set. Он показывает, как назвать ключ и подавить повтор внутри одной модели, но не доказывает идемпотентность реального producer, broker или API. В рабочем проекте нужно отдельно решить, где живёт такой ключ, сколько хранится receipt и как повторяется задача после временной ошибки.</p>\n<figure><img src='/assets/editorial/2021/search-indexing-pipeline-2021.svg' alt='Путь документа от source-of-truth через ingest и pending index к visible index и поисковому запросу' loading='lazy'><figcaption>Сохранение источника и видимость в выдаче разделены явным переходом. Схема иллюстрирует учебную модель, а не устройство конкретного кластера.</figcaption></figure>\n<h2>Как читать симптом</h2>\n<p>Если прямая ссылка не открывает карточку, начинать с refresh нельзя. Сначала проверьте source. Если source есть, но ingest отсутствует, проблема находится между записью и постановкой работы. Если ingest есть, а pending пуст, смотрите обработчик и отказ по версии. Если pending есть, а query пуст, проверяйте переход видимости. Если visible содержит документ, но выдача всё равно пуста, причина уже в тексте, фильтре, alias, области поиска или контракте запроса.</p>\n<div class='table-scroll'><table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Карточка не открывается по id</td><td>Source не сохранён или читается не тот владелец</td><td>Прочитать source по id и сравнить version</td><td>Исправить запись или маршрут чтения; не запускать reindex</td></tr><tr><td>Source есть, ingest нет</td><td>Не создано событие или потерян переход writer → ingest</td><td>Сопоставить запись и ключ <code>id:version</code></td><td>Восстановить постановку по согласованной политике и добавить наблюдение</td></tr><tr><td>Ingest есть, pending пуст</td><td>Обработчик упал, пропустил задачу или отклонил старую version</td><td>Проверить статус consume и текущую version source</td><td>Разобрать ошибку; не считать повтор записи лечением</td></tr><tr><td>Pending есть, query пуст</td><td>Проекция ещё не стала видимой</td><td>Сравнить <code>indexedAtMs</code> и <code>visibleAtMs</code></td><td>Дождаться штатного refresh или применить документированный режим ожидания</td></tr><tr><td>Visible есть, результата нет</td><td>Фильтр, поле, анализатор или область запроса не совпадает</td><td>Выполнить минимальный query без лишнего фильтра</td><td>Исправить контракт запроса, mapping или данные</td></tr><tr><td>Две карточки с одним смыслом</td><td>Повтор обработан как новый документ или ключ не учитывает version</td><td>Сравнить document id, ingest key и версии</td><td>Закрепить idempotency key и удалить дубликаты отдельной процедурой</td></tr></tbody></table></div>\n<h2>Задержка должна иметь точки измерения</h2>\n<p>Слово «лаг» ничего не объясняет без начала и конца измерения. Для одной выдачи зафиксируйте <code>savedAtMs</code>, <code>enqueuedAtMs</code>, <code>indexedAtMs</code> и <code>visibleAtMs</code>. Разности покажут, где копится время: при постановке, обработке или открытии сегмента для поиска.</p>\n<pre><code>const timeline = { savedAtMs: 100, enqueuedAtMs: 110, indexedAtMs: 125, visibleAtMs: 160 }; const totalDelay = timeline.visibleAtMs - timeline.savedAtMs; const visibilityDelay = timeline.visibleAtMs - timeline.indexedAtMs; // totalDelay === 60; числа выбраны для учебной арифметики</code></pre>\n<p>Эти числа не являются замером и не задают SLA. В рабочем коде время должно приходить из событий и логов, а не из fixture. Для пользователя может быть важнее доля запросов, которые видят актуальную version, чем средняя задержка. Выберите метрику по сценарию. Не смешивайте задержку pipeline с ошибкой фильтра.</p>\n<h2>Порядок действий</h2>\n<ol><li>Выберите один конкретный сценарий: например, поиск опубликованной статьи по заголовку.</li><li>Запишите source id, текущую version и момент сохранения. Проверьте прямое чтение.</li><li>Найдите ingest key <code>id:version</code> и состояние постановки. Убедитесь, что повтор не создаёт вторую работу.</li><li>Проверьте обработчик: он должен принять текущую version и отклонить устаревшее событие с понятным статусом.</li><li>Снимите время подготовки pending-проекции. Не называйте её видимой, пока query этого не подтверждает.</li><li>Проверьте политику refresh выбранного движка. Для синхронного сценария используйте только документированный режим и оцените его стоимость.</li><li>Повторите тот же query после перехода видимости и сравните id, version, фильтры и число результатов.</li><li>Если visible уже содержит документ, прекратите повторную индексацию и расследуйте контракт запроса.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Пустой поиск до refresh может быть нормальным состоянием. Он становится ошибкой только тогда, когда нарушен согласованный договор свежести. Для новостной ленты допустимо near-real-time обновление. Для экрана подтверждения заказа может потребоваться чтение источника или явное ожидание видимости. Один и тот же параметр нельзя назначить всем сценариям.</p>\n<p>В документации Elasticsearch 7.13 параметр <code>refresh=false</code> означает отсутствие действия refresh, <code>wait_for</code> — ожидание ближайшего refresh, а <code>true</code> — немедленный refresh затронутых shard. Поэтому успешный запрос индексации с настройкой по умолчанию не равен мгновенной доступности в search, но и принудительный refresh не должен автоматически появляться в каждом write-path.</p>\n<p>Учебный пример не знает о репликах, alias, mapping, анализаторах, shard allocation, сетевых сбоях и повторной доставке между процессами. Он не доказывает идемпотентность вашего producer. Он также не обещает production-результат. Эти границы нужно проверять на выбранной версии движка и на обезличенном документе.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Сценарий готов, если команда может назвать владельца source и проекции, показать текущую version, найти ingest key и объяснить каждую точку времени. Для нового документа видны: сохранённый source, одна постановка, результат consume и момент видимости. Для старого события есть проверенный отказ. Для пустого query после visible есть отдельный тест фильтра и текста. Повторное сохранение не используется как универсальное исправление.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://www.elastic.co/guide/en/elasticsearch/reference/7.13/near-real-time.html' target='_blank' rel='noopener noreferrer'>Elastic Docs 7.13: Near real-time search</a> — официально объясняет, почему изменения документа становятся видимыми поиску после refresh, а не в момент записи.</li><li><a href='https://www.elastic.co/guide/en/elasticsearch/reference/7.13/docs-refresh.html' target='_blank' rel='noopener noreferrer'>Elastic Docs 7.13: The refresh parameter</a> — описывает значения <code>true</code>, <code>false</code> и <code>wait_for</code> и предупреждает о стоимости немедленного refresh.</li></ul>"
}