Files

8 lines
24 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": 59,
"slug": "editorial-2026-05-mechanism-systems-performance",
"title": "Почему короткий trace не доказывает ускорение системы",
"excerpt": "Как читать длительность root span, отделять ожидание от работы и проверять сопоставимость нагрузки до заявления об улучшении.",
"contentHtml": "<p>Короткий trace выглядит как хороший результат, но сам по себе ничего не говорит об ускорении. В одном запросе root span может включать ожидание в очереди, обработку сервера, вызов базы и ответ внешней системы. Если во втором запуске изменилась нагрузка или потерялся фрагмент дерева, число стало меньше, но сравнение перестало быть честным. Цена ошибки — оптимизация SQL, сети или CPU без изменения времени, которое видит пользователь.</p>\n<p>В этой статье я использую небольшой детерминированный пример. Его цель — научиться формулировать ограниченный вывод: «в этой записи такой-то сегмент был самым длинным». Это не измерение production и не способ автоматически найти bottleneck. Чтобы назвать причину, нужна следующая проверка с тем же входом, понятной границей и одним изменённым условием.</p>\n<h2>Что именно измеряет span</h2>\n<p>В OpenTelemetry span представляет одну операцию внутри trace. Root span обычно описывает весь путь, а дочерние span — отдельные подоперации. Для request-response операции начало и конец должны охватывать обработку запроса, включая middleware, бизнес-логику, сборку и отправку ответа. Поэтому длина root — это интервал всей операции, а не время одного наиболее заметного дочернего вызова.</p>\n<p>У каждого интервала есть границы: имя, start, end, parent и тип операции. Поле <code>SpanKind</code> помогает различать входящую серверную обработку, исходящий клиентский вызов и внутреннюю работу. Оно описывает роль span, но не отвечает на вопрос, почему операция была долгой. Например, <code>CLIENT</code> означает вызов удалённого сервиса, ожидающий ответ; это не доказательство, что удалённый сервис — причина задержки.</p>\n<table><caption>Минимальный контракт учебной trace</caption><thead><tr><th scope=\"col\">Поле</th><th scope=\"col\">Пример</th><th scope=\"col\">Что проверяет</th><th scope=\"col\">Чего не доказывает</th></tr></thead><tbody><tr><td>traceId</td><td><code>trace-a</code></td><td>К какой записи относится span</td><td>Что все компоненты действительно попали в запись</td></tr><tr><td>parentId</td><td><code>gateway</code></td><td>Связь с родительской операцией</td><td>Причину ожидания или порядок параллельных работ</td></tr><tr><td>start/end</td><td><code>40/560</code></td><td>Длительность конкретного интервала</td><td>Что интервал был полезной работой, а не ожиданием</td></tr><tr><td>kind</td><td><code>CLIENT</code></td><td>Роль операции в модели trace</td><td>Виновника latency и эффект изменения кода</td></tr></tbody></table>\n<p>W3C Trace Context стандартизует перенос идентификаторов между HTTP-компонентами через <code>traceparent</code> и <code>tracestate</code>. Это позволяет связать записи разных участников, но не создаёт отсутствующие span и не гарантирует, что каждый запрос был записан: выборка и полнота зависят от конкретной системы наблюдения.</p>\n<figure><img src=\"/assets/editorial/2026/systems-performance-2026-load-latency-curve.svg\" alt=\"Учебный график: две точки с разной нагрузкой нельзя сравнивать как доказательство ускорения по одной меньшей задержке\" loading=\"lazy\" /><figcaption>Рисунок. Более низкая задержка имеет смысл только вместе с одинаковой границей нагрузки и одинаковым составом входа.</figcaption></figure>\n<h2>Учебная trace: наблюдение без диагноза</h2>\n<p>Пусть root <code>gateway</code> длится от 0 до 1000 условных единиц. Внутри него последовательно расположены ожидание допуска в очередь от 40 до 560, вызов базы от 570 до 720 и вызов каталога от 730 до 930. Промежутки между дочерними span оставлены намеренно: они показывают, что сумма видимых частей не обязана совпадать с root.</p>\n<table><caption>Разбор одной фиксированной записи</caption><thead><tr><th scope=\"col\">Span</th><th scope=\"col\">Границы</th><th scope=\"col\">Длительность</th><th scope=\"col\">Корректный вывод</th></tr></thead><tbody><tr><td><code>gateway</code></td><td>0–1000</td><td>1000 units</td><td>Полный интервал выбранного пути</td></tr><tr><td><code>admission-queue</code></td><td>40–560</td><td>520 units</td><td>Самый длинный названный сегмент записи</td></tr><tr><td><code>db-call</code></td><td>570–720</td><td>150 units</td><td>Интервал вызова базы в этой trace</td></tr><tr><td><code>catalog-call</code></td><td>730–930</td><td>200 units</td><td>Интервал внешнего вызова в этой trace</td></tr></tbody></table>\n<p>Из таблицы можно сделать два вывода. Первый: в этой записи длиннее всего назван <code>admission-queue</code>. Второй: root равен 1000 units. Нельзя сделать третий вывод — «очередь является корневой причиной» — без эксперимента, который изменяет только условие допуска и сохраняет остальную границу. Нельзя также перевести units в миллисекунды: единица времени не определена примером.</p>\n<pre><code>const trace = {\n root: { name: 'gateway', start: 0, end: 1000, parent: null },\n spans: [\n { name: 'admission-queue', start: 40, end: 560, parent: 'gateway', kind: 'INTERNAL' },\n { name: 'db-call', start: 570, end: 720, parent: 'gateway', kind: 'CLIENT' },\n { name: 'catalog-call', start: 730, end: 930, parent: 'gateway', kind: 'CLIENT' },\n ],\n};\n\nconst duration = ({ start, end }) =&gt; end - start;\nconst longest = trace.spans\n .map((span) =&gt; ({ name: span.name, units: duration(span) }))\n .sort((a, b) =&gt; b.units - a.units)[0];\n\nconsole.log({ rootUnits: duration(trace.root), longest });\n// { rootUnits: 1000, longest: { name: 'admission-queue', units: 520 } }\n// Это наблюдение одной записи, а не диагноз и не замер production.</code></pre>\n<p>Скрипт можно скопировать в файл <code>trace-review.mjs</code> и выполнить командой <code>node trace-review.mjs</code>. Он не обращается к сети, базе или системе трассировки. Значения в нём фиксированы, чтобы любой читатель получил одинаковый результат и увидел границу между вычислением длительности и интерпретацией.</p>\n<h2>Почему сравнение «было/стало» ломается</h2>\n<p>Предположим, после изменения root стал равен 800 units. Это выглядит как улучшение на 20 процентов, но процент имеет смысл только при сопоставимом знаменателе. Во втором запуске могли измениться число логических запросов, concurrency, набор данных, cache state, cohort пользователей, доля ошибок или способ выборки trace. Тогда мы сравниваем два разных эксперимента.</p>\n<p>Перед просмотром результата зафиксируйте comparison key — набор полей, который обязан совпасть. В учебном случае это <code>cohort=fixed-load-a</code>, <code>logicalRequests=12</code>, <code>concurrency=3</code> и <code>inputShape=fixed-read-shape-a</code>. Это не универсальный стандарт нагрузки. Для другой системы в ключ придут размер ответа, регион, версия клиента, состояние кеша или доля холодных запросов.</p>\n<pre><code>const comparisonKey = {\n cohort: 'fixed-load-a',\n logicalRequests: 12,\n concurrency: 3,\n inputShape: 'fixed-read-shape-a',\n};\n\nfunction isComparable(baseline, candidate) {\n return Object.keys(comparisonKey).every(\n (key) =&gt; baseline[key] === candidate[key],\n );\n}\n\nconst baseline = { ...comparisonKey, rootUnits: 1000 };\nconst candidate = { ...comparisonKey, rootUnits: 800 };\nconsole.log(isComparable(baseline, candidate)); // true\nconsole.log(candidate.rootUnits &lt; baseline.rootUnits); // true\n// Даже true не объясняет причину и не заменяет серию измерений.</code></pre>\n<p>В рабочем проекте функция должна сравнивать не только четыре поля из примера. Составьте ключ до эксперимента, включите в него условия, влияющие на путь, и отдельно решите, что делать с пропущенными значениями. Молчаливое превращение «нет данных» в «совпадает» создаёт ложную сопоставимость.</p>\n<h2>Как отделить ожидание от исполнения</h2>\n<p>Время root складывается не обязательно как простая сумма дочерних span. Часть времени уходит на ожидание свободного worker, соединения или ответа; часть — на фактическое выполнение; несколько вызовов могут идти параллельно. Поэтому сначала нужно проверить дерево и интервалы, а уже затем обсуждать вклад отдельных сегментов.</p>\n<table><caption>Симптом, проверка и безопасное действие</caption><thead><tr><th scope=\"col\">Наблюдение</th><th scope=\"col\">Гипотеза</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие до доказательства</th></tr></thead><tbody><tr><td>Длинный отдельный span перед обработкой</td><td>Запрос ждёт допуска</td><td>Проверить имя, parent, интервалы и instrumented boundary</td><td>Описать ожидание как наблюдение; не переписывать SQL</td></tr><tr><td>Длинный <code>INTERNAL</code> без ясной операции</td><td>Скрыта неизвестная задержка</td><td>Уточнить границу и добавить измерение подоперации</td><td>Остановить причинный вывод</td></tr><tr><td>Root короче в candidate</td><td>Изменилась нагрузка</td><td>Сверить comparison key и sampling</td><td>Не считать разницу эффектом изменения</td></tr><tr><td>Child ссылается на отсутствующий parent</td><td>Trace неполна</td><td>Проверить экспорт и propagation context</td><td>Вернуть запись на доработку, не соединять span по времени</td></tr><tr><td>Заявлено «быстрее»</td><td>Нет контрольной выборки</td><td>Найти baseline, размер серии и критерий успеха</td><td>Сузить формулировку до наблюдаемого факта</td></tr></tbody></table>\n<p>Особенно осторожно читайте трассы с sampling. W3C описывает sampled flag как рекомендацию о том, что вызывающая сторона могла записать данные; он не означает, что вся система сохранила каждый span. Если сравнивать traces, попавшие в backend по разным правилам, пропавший span легко принять за исчезнувшую работу.</p>\n<h2>Воспроизводимая проверка границы</h2>\n<p>Полезная проверка должна возвращать не только «да» или «нет», но и причину отказа. Ниже — минимальный вариант для фиксированных объектов. Он проверяет сравнимость входа и явный флаг полноты, но не претендует на валидатор OpenTelemetry или на профилировщик.</p>\n<pre><code>function review(baseline, candidate) {\n const required = ['cohort', 'logicalRequests', 'concurrency', 'inputShape'];\n const changed = required.filter((key) =&gt; baseline[key] !== candidate[key]);\n\n if (changed.length &gt; 0) {\n return { status: 'stop-incomparable-input', changed };\n }\n if (!candidate.traceComplete) {\n return { status: 'stop-incomplete-trace' };\n }\n if (candidate.rootUnits &gt;= baseline.rootUnits) {\n return { status: 'observe-no-lower-root' };\n }\n return {\n status: 'comparable-lower-root',\n differenceUnits: baseline.rootUnits - candidate.rootUnits,\n };\n}\n\nconsole.log(review(\n { cohort: 'a', logicalRequests: 12, concurrency: 3, inputShape: 'read', rootUnits: 1000 },\n { cohort: 'b', logicalRequests: 12, concurrency: 3, inputShape: 'read', rootUnits: 800, traceComplete: true },\n));\n// { status: 'stop-incomparable-input', changed: [ 'cohort' ] }</code></pre>\n<p>Чтобы положительный путь был воспроизводимым, добавьте в candidate те же четыре поля, <code>traceComplete: true</code> и <code>rootUnits: 800</code>. Затем функция вернёт <code>comparable-lower-root</code> и разницу 200 units. Это всё ещё не доказывает, что изменение ускорило систему: нужно повторить серию на заранее выбранном числе запросов и проверить распределение задержки, ошибки и побочные эффекты.</p>\n<h2>От наблюдения к причинной гипотезе</h2>\n<p>Хороший разбор меняет вопрос по шагам. Сначала есть факт: <code>admission-queue</code> занял 520 units в одной записи. Затем гипотеза: политика допуска или нехватка worker создаёт ожидание. Следующая проверка должна оставить comparison key неизменным и изменить только условие, которое относится к этой гипотезе. Если root и очередь меняются вместе, гипотеза получает поддержку; если меняется только root, а очередь нет, нужно искать другой участок.</p>\n<p>Здесь важно различать корреляцию и эксперимент. То, что очередь длиннее базы, помогает выбрать следующий замер. Это не разрешение увеличить число worker, изменить лимит или переписать запрос. Операционный шаг должен иметь владельца, безопасный диапазон, критерий отката и сигнал результата; в статье мы ограничиваемся проектированием проверки и не выдаём учебные units за этот сигнал.</p>\n<p>Такая осторожность относится и к базе. Span <code>db-call=150</code> измеряет интервал клиентской операции, но не сообщает, сколько времени заняли планирование запроса, чтение страниц, блокировка или сериализация результата. Для SQL-вывода нужны план выполнения, собственные метрики базы и сопоставимые входы. Trace помогает выбрать запрос для исследования, но не заменяет профилирование базы.</p>\n<h2>Порядок действий</h2>\n<ol><li>Выбрать один root span и определить, какую пользовательскую или сервисную границу он покрывает.</li><li>Проверить связность дерева: parent существует, start не позже end, дочерние интервалы относятся к тому же trace.</li><li>Разметить тип операции: ожидание, внутренняя работа, база, HTTP-клиент или обработка ответа. Не угадывать роль по одному имени.</li><li>Зафиксировать comparison key до сравнения baseline и candidate. Пропуск обязательного поля считать отказом от сравнения.</li><li>Отделить наблюдение от гипотезы: «длиннее всех» не равно «является причиной».</li><li>Изменять в следующей проверке одно условие, сохранить серию и заранее выбрать метрику успеха: например, p95 root при той же ошибочности и throughput.</li><li>Проверить отрицательный путь: неполное дерево, изменившуюся нагрузку, другой sampling и отсутствие baseline должны возвращать явную причину остановки.</li><li>Только после этого обсуждать изменение конфигурации или кода, его откат и дальнейшее наблюдение.</li></ol>\n<h2>Ограничения применимости</h2>\n<p>Один trace не описывает распределение задержки. Значение root не заменяет p50/p95/p99, throughput, error rate и размер выборки. Условные units нельзя переводить в миллисекунды или использовать как SLA. Пересекающиеся span нельзя складывать без учёта параллельного выполнения. Неполный export может скрыть работу, а sampling — изменить состав наблюдаемых записей.</p>\n<p>W3C Trace Context отвечает за перенос контекста, OpenTelemetry — за модель trace и роль span, а HTTP RFC 9110 — за семантику request/response. Ни один из этих документов не утверждает, что конкретная очередь, база или внешний сервис является bottleneck. Реальный вывод потребует данных вашей версии SDK, схемы экспорта, окружения, нагрузки и контрольного эксперимента.</p>\n<h2>Критерий готовности вывода</h2>\n<p>Разбор можно передать коллеге, если он получает исходную запись, видит границу root, понимает единицы, проверяет parent/child и может повторить вычисление самого длинного сегмента. Для заявления об ускорении нужны дополнительно одинаковые входы, серия измерений, критерий успеха и описание побочных эффектов. Если есть только короткий trace и фраза «стало быстрее», честный результат — наблюдение или остановка проверки, а не рекомендация по оптимизации.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.w3.org/TR/trace-context/\" target=\"_blank\" rel=\"noopener noreferrer\">W3C Trace Context</a> — Recommendation от 23 ноября 2021 года: формат <code>traceparent</code>/<code>tracestate</code>, trace-id, parent-id и ограничения контекста. Документ не доказывает полноту записи или причинность.</li><li><a href=\"https://opentelemetry.io/docs/specs/otel/trace/api/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry Tracing API</a> — описание span, времени операции, parent/child и <code>SpanKind</code>. Спецификация задаёт модель данных, а не результат конкретного сервиса.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — нормативное описание HTTP как stateless request/response protocol. RFC не задаёт SLA, latency или capacity конкретной системы.</li></ul>"
}