8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"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-метрик. Он показывает только контракт переходов: источник сохраняется, ingest дедуплицируется, индексатор проверяет версию, а query начинает видеть документ после отдельного 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', 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.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) => document.text.includes(needle)); }</code></pre>\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>Явный refresh способен увеличить нагрузку. Официальная документация Elasticsearch описывает <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/docs/manage-data/data-store/near-real-time-search' target='_blank' rel='noopener noreferrer'>Elastic Docs: Near real-time search</a> — официально объясняет, почему изменения документа становятся видимыми поиску после refresh, а не в момент записи.</li><li><a href='https://www.elastic.co/docs/reference/elasticsearch/rest-apis/refresh-parameter' target='_blank' rel='noopener noreferrer'>Elastic Docs: The refresh parameter</a> — описывает значения <code>true</code>, <code>false</code> и <code>wait_for</code> и предупреждает о стоимости немедленного refresh.</li></ul>"
|
||
}
|