8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 232,
|
||
"slug": "editorial-2021-07-field-search-indexing",
|
||
"title": "Документ сохранён, но не найден: как отделить задержку индекса от ошибки запроса",
|
||
"excerpt": "Карточка уже открывается, но поиск не возвращает новый документ. Разбираем путь от source до видимой выдачи, проверяем version и refresh и выбираем безопасное действие без удаления исходной записи.",
|
||
"contentHtml": "<p>Симптом на стенде знаком: оператор сохраняет новый товар: карточка открывается по id, но поиск по названию возвращает пустой список. Через несколько минут товар появляется сам. Сценарий выглядит временным, но его цена вполне материальна: пользователь не находит доступный товар, оператор запускает дорогую повторную индексацию, а удаление и повторное создание записи стирают след исходного состояния. После такого вмешательства уже трудно понять, не была ли причина в запросе, фильтре или правах.</p>\n<p>Рабочее правило такое: source и search отвечают на разные вопросы. Успешное чтение записи по id доказывает, что источник существует. Оно не доказывает, что текущая версия уже попала в видимую поисковую проекцию. Пустой search до refresh может быть нормальной задержкой. Пустой search после подтверждения видимости указывает на другой участок: поле, анализатор, filter, scope или права.</p>\n<h2>Механизм: четыре границы вместо одного «индекса»</h2>\n<p>Для диагностики удобно разложить путь документа на четыре границы: <code>source</code> хранит исходную запись и её version; <code>ingest</code> принимает событие на построение проекции; <code>pending</code> содержит подготовленную версию, которую ещё не видит обычный search; <code>visible</code> — снимок, по которому выполняется запрос. Между границами есть переходы. Каждый переход нужно связывать с id, version, временем и результатом.</p>\n<p>Это абстрактная модель потока, а не четыре обязательных статуса Elasticsearch. В конкретной системе <code>ingest</code> может быть очередью, воркером или Ingest Node pipeline, а <code>pending</code> — сообщением в очереди, staging-индексом или буфером до refresh. Эти детали нельзя угадывать по одному пустому ответу: их нужно сопоставить с реальной схемой доставки.</p>\n<p>Предположим, source содержит документ <code>product-42</code> с версией <code>7</code>. Событие получает ключ <code>product-42:7</code>. Повторная постановка с тем же ключом не должна создавать вторую работу, если это правило закреплено в контракте обработчика. После обработки версия 7 может находиться в pending, но search всё ещё вернёт ноль. Только после перехода видимости запрос получает право увидеть эту версию. Если та же версия уже видима в том же индексе и с тем же routing, refresh больше не объясняет пустой результат.</p>\n<pre><code>source(version=7)\n -> ingest(key="product-42:7")\n -> pending(version=7)\n -> refresh\n -> visible(version=7)\n -> search(query, scope, filter)\n\nПравило диагностики:\nsource read != search hit</code></pre>\n<p>Version связывает источник, работу и проекцию, но сама по себе не делает очередь идемпотентной. Ключ <code>id:version</code> — это договорённость приложения: её должен поддерживать consumer или хранилище событий. В Elasticsearch не следует автоматически принимать поле source version за служебное <code>_version</code>: внутренний номер документа и версия из исходной системы решают разные задачи. Для защиты от устаревших изменений подходят внешний versioning или optimistic concurrency control с <code>if_seq_no</code> и <code>if_primary_term</code> — выбор зависит от того, кто владеет порядком изменений.</p>\n<h2>Учебный пример: duplicate, stale и refresh</h2>\n<p>Ниже — минимальная модель без Elasticsearch, базы и сетевых задержек. Она намеренно показывает три проверяемых свойства: одинаковый ключ не создаёт вторую работу, старая версия не заменяет новую, а поиск видит принятую версию только после <code>refresh</code>. Код можно сохранить как файл <code>demo.mjs</code> и запустить командой <code>node demo.mjs</code>; внешние пакеты не нужны.</p>\n<pre><code>const source = new Map([\n ['product-42', { id: 'product-42', version: 7, title: 'Свежие яблоки' }],\n]);\nconst pending = new Map();\nconst visible = new Map();\nconst acceptedKeys = new Set();\n\nfunction ingest(doc) {\n const key = doc.id + ':' + doc.version;\n if (acceptedKeys.has(key)) return { status: 'duplicate', key };\n\n const current = pending.get(doc.id) ?? visible.get(doc.id);\n if (current && current.version >= doc.version) {\n return { status: 'stale', key };\n }\n\n acceptedKeys.add(key);\n pending.set(doc.id, doc);\n return { status: 'queued', key };\n}\n\nfunction refresh() {\n for (const [id, doc] of pending) {\n const current = visible.get(id);\n if (!current || current.version <= doc.version) visible.set(id, doc);\n pending.delete(id);\n }\n}\n\nfunction search(term) {\n return [...visible.values()].filter((doc) =>\n doc.title.toLowerCase().includes(term.toLowerCase()),\n );\n}\n\nconsole.log(search('яблоки')); // []\nconsole.log(ingest(source.get('product-42'))); // queued, product-42:7\nconsole.log(search('яблоки')); // []\nrefresh();\nconsole.log(search('яблоки')); // [{ id: 'product-42', version: 7, ... }]\nconsole.log(ingest(source.get('product-42'))); // duplicate\nconsole.log(ingest({ id: 'product-42', version: 6, title: 'Старые яблоки' })); // stale\nconsole.log(visible.get('product-42').version); // 7</code></pre>\n<p>Здесь <code>visible</code> — не внутренний объект Elasticsearch, а тестовый снимок. В реальном Elasticsearch операция записи с <code>refresh=false</code> не обязана сразу менять поисковую выдачу; <code>refresh=wait_for</code> ждёт очередного обновления, а <code>refresh=true</code> принудительно обновляет затронутые shards и требует оценки нагрузки. Поэтому вызов <code>visible.set</code> нельзя переносить в production как готовую команду. Он нужен, чтобы не смешивать две проверки: «проекция построена» и «проекция доступна этому запросу».</p>\n<figure><img src=\"/assets/editorial/2021/search-indexing-diagnosis-2021.svg\" alt=\"Дерево диагностики: source, ingest, pending, refresh и контракт query\"><figcaption>Проверяйте путь от source к query по границам. Если текущая version уже видима, переходите к контракту запроса.</figcaption></figure>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>По id source отсутствует</td><td>Ошибка записи или неверный id</td><td>Сверить id, version и результат сохранения</td><td>Остановиться. Не создавать копию до проверки write path</td></tr><tr><td>Source есть, ingest не подтверждён</td><td>Работа не поставлена или потеряна</td><td>Найти ключ <code>id:version</code> и результат постановки</td><td>Сохранить evidence и проверить очередь по её policy</td></tr><tr><td>Pending содержит текущую version, search пуст</td><td>Проекция ещё не стала видимой</td><td>Сравнить время обработки и refresh или признак видимости</td><td>Применить согласованную policy. Не удалять source</td></tr><tr><td>Visible содержит старую version</td><td>Устаревшая работа или нарушение порядка</td><td>Сравнить source.version, event.version и visible.version</td><td>Поставить текущую version идемпотентно и сохранить старый след</td></tr><tr><td>Visible содержит текущую version, search пуст</td><td>Ошибка query contract</td><td>Проверить поле, analyzer, filter, scope, routing и права</td><td>Исправить только найденный участок и повторить исходный query</td></tr></tbody></table>\n<p>Таблица задаёт порядок, а не заменяет доказательство. Один пустой ответ не говорит, где произошёл сбой. Если начать с фильтра, можно не заметить, что ingest вообще не принял version. Если сразу вызвать широкий reindex, можно получить хороший результат без понимания причины. Такой результат не защищает следующий релиз.</p>\n<h2>Как отличить задержку видимости от ошибки запроса</h2>\n<p>Сначала зафиксируйте точный query: индекс или alias, поле, текст, фильтры, сортировку, routing и права. Затем получите source по id и запишите версию. В Elasticsearch Get API по умолчанию работает в realtime-режиме и не зависит от refresh rate индекса, тогда как обычный search видит изменения после refresh. Поэтому результат Get нельзя использовать как доказательство готовности поисковой выдачи.</p>\n<p>После Get найдите ту же версию в состоянии проекции и проверьте, что это тот же индекс, alias, shard и routing. Если версия находится только в pending, причина ещё находится до refresh. Если текущая версия действительно видима, проверьте mapping, анализатор, нормализацию, filter, scope и права. При использовании служебной версии запроса сохраните также <code>_seq_no</code> и <code>_primary_term</code>, чтобы отличить конфликт обновления от задержки поиска.</p>\n<p>Проверяйте отрицательный путь отдельно. Для документа version 7 ожидайте пустой search до подтверждённого refresh. После refresh ожидайте ровно один hit с version 7. Для повторного ingest с ключом <code>product-42:7</code> ожидайте подавление дубликата. Для старой version 6 ожидайте отказ от отката видимого документа. Эти ожидания проверяют механизм, но не обещают SLA, hit rate или поведение кластера.</p>\n<h2>Порядок действий</h2>\n<ol><li>Запишите id, source version, точный query, индекс или alias, параметры фильтра и время наблюдения. Уберите из evidence секреты и лишние персональные данные.</li><li>Проверьте source по id. Если записи нет, остановите поисковое расследование и выясните write path. Не создавайте дубликат для проверки.</li><li>Проверьте ключ ingest в формате <code>id:version</code>. Сопоставьте постановку, обработку и повторные попытки. Не называйте успешной постановкой сам факт отправки запроса.</li><li>Найдите version в pending или видимой проекции. Если текущая version ждёт refresh, зафиксируйте время и примените только заранее выбранную policy.</li><li>Если видима старая version, сравните порядок версий и найдите источник старой работы. Повторную постановку делайте идемпотентной и не стирайте исходное evidence.</li><li>Если видима текущая version, проверьте query contract: поле, mapping, нормализацию текста, analyzer, filter, scope, routing и права.</li><li>Повторите исходный query без дополнительных изменений. Сравните id и version в результате. Затем добавьте проверку на отрицательный путь в тест или runbook проекта.</li></ol>\n<h2>Ограничения</h2>\n<p>Эта схема не моделирует shards, replicas, alias, persistence, конкуренцию, сбои сети, очередь с гарантией доставки, права доступа и анализатор конкретного движка. Она не даёт точного срока, за который документ обязан появиться в поиске. Термин «near real time» не означает мгновенную видимость. Реальную задержку нужно измерять в собственной конфигурации по timestamps и результатам фиксированного query.</p>\n<p>Не каждый пустой поиск требует refresh. Явное обновление может увеличить нагрузку и замедлить indexing. Не каждый устаревший hit исправляется повторной постановкой: причиной может быть задержанная старая работа, неверный alias или фильтр. Не каждый найденный source можно показывать всем: query обязан соблюдать scope и права. Если эти границы не записаны, оператор легко превращает временный обход в постоянную нагрузку.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Диагностика готова, когда для одного тестового документа можно показать непрерывную историю <code>id → version → ingest key → pending или visible → точный query → результат</code>. В истории есть отрицательный случай до refresh, положительный случай после него, подавление дубликата и отказ от отката старой version. Исправление считается доказанным только тогда, когда исходный query возвращает ожидаемый id и текущую version, а запись не удаляли ради получения этого результата.</p>\n<p>Для production этого критерия недостаточно: добавьте метрики задержки, ошибки ingest и долю документов, которые не достигают видимости в допустимое время. Но порядок расследования останется тем же. Сначала защитите source и восстановите цепочку фактов. Потом меняйте тот слой, который не выполнил свой контракт.</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\">Elastic: Near real-time search</a> — объясняет сегменты Lucene, refresh и то, почему изменения не всегда видны search сразу.</li><li><a href=\"https://www.elastic.co/docs/reference/elasticsearch/rest-apis/refresh-parameter\" target=\"_blank\" rel=\"noopener\">Elastic: The refresh parameter</a> — описывает <code>refresh=false</code>, <code>refresh=wait_for</code>, <code>refresh=true</code> и стоимость принудительного обновления.</li><li><a href=\"https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-get\" target=\"_blank\" rel=\"noopener\">Elastic: Get a document by its ID</a> — фиксирует realtime-поведение Get API и поля <code>_version</code>, <code>_seq_no</code> и <code>_primary_term</code>.</li><li><a href=\"https://www.elastic.co/docs/reference/elasticsearch/rest-apis/optimistic-concurrency-control\" target=\"_blank\" rel=\"noopener\">Elastic: Optimistic concurrency control</a> — объясняет защиту от устаревших изменений через sequence number и primary term.</li></ul>"
|
||
}
|