Содержание урока · 9 разделов
Лог стоит сервису дважды: на записанных строках — сборка Record и сериализация обработчиком, и на отброшенных — вычисление аргументов, которые никто не увидит. Вторая часть обычно удивляет сильнее: вызов Debug с дорогим аргументом на проде с уровнем Info платит за сборку аргумента на каждой итерации, хотя запись летит в корзину. Вопрос производительности лога поэтому не звучит как «быстрый ли slog» — он звучит как «сколько стоит мой вызов с моими полями и моим обработчиком». Ответ складывается из трёх частей: проверка Enabled до сборки дорогих полей, экономные атрибуты вместо структур в Any и честный бенчмарк своего пути. Ни одна из них не требует трюков: это дисциплина вызовов, а не борьба с библиотекой. Разберём каждую часть, посмотрим на ловушки вроде fmt.Sprintf в аргументах и соберём методику измерения, которую можно повторить на своём сервисе. Побочный эффект этой дисциплины — лог читается лучше: меньше склеек, меньше мусорных полей, каждая запись отвечает на свой вопрос.
Slog производительность: Enabled до сборки полей#
Разбор slog производительность начинается с отброшенной записи. Аргументы вызова вычисляются до того, как Logger спросит Handler, нужен ли ему этот уровень, — поэтому logger.Debug("состояние", "dump", buildDump(state)) соберёт дамп на каждой итерации даже там, где Debug отключён. Лечится это плоской проверкой: logger.Enabled(ctx, slog.LevelDebug) возвращает решение обработчика за одно сравнение, и дорогую сборку прячут под неё. Ставить проверку стоит там, где аргументы дорогие: дампы состояния, сериализация структур, запрос счётчиков. Дешёвые пары вида «user_id», id обёртывать не нужно — цена их вычисления сравнима с ценой самой проверки, и guard размажет по коду больше шума, чем сэкономит наносекунд. Отдельный приём — LogValuer из девятого урока: отложенное значение собирается только при записи, и отброшенный вызов его не вызывает; там, где поле дорогое и повторяется во многих местах, обёртка с LogValue чище, чем if на каждом вызове.
Guard на горячем пути
GOlogger := slog.New(slog.NewJSONHandler(os.Stdout,
&slog.HandlerOptions{Level: slog.LevelInfo}))
// дорогое поле без guard: дамп собирается и летит в корзину
logger.Debug("состояние", "dump", buildDump(state))
// с guard: buildDump вызывается только при включённом Debug
if logger.Enabled(context.Background(), slog.LevelDebug) {
logger.Debug("состояние", "dump", buildDump(state))
}Первый вызов платит за buildDump при любом пороге уровня: аргументы вычисляются до проверки. Второй спрашивает обработчик и собирает дамп только тогда, когда запись действительно уйдёт. Порог Info здесь для примера — в стенде он часто стоит на Debug, и guard становится прозрачным.
Цена Attr: типизированные конструкторы против Any#
Значение в slog хранится в Value вместе с типовым kind: slog.Int кладёт число как int64, slog.String — строку, и встроенные обработчики печатают их напрямую, без отражения. Пара с произвольным значением идёт через Any: для структур и срезов это интерфейс и marshalling через отражение в JSONHandler — аллокации и время на каждую записанную строку. Самый частый расход — fmt.Sprintf в аргументах: строка склеивается до проверки уровня, даже если запись отбросят, и это тот случай, когда «бесплатный Debug» стоит как настоящий. Строку, без которой не обойтись, собирают внутри guard'а или выносят в поле отдельным вызовом. Константные атрибуты вроде component=api выносят в переменные пакета: Attr — иммутабельное значение, безопасное для горутин из тринадцатого урока, и переиспользование ничего не стоит. Группы и вложенные поля собирают один раз при старте логгера — на горячем пути они не добавляют скорости, только структуру.
Бенчмарк своего вызова
GOfunc BenchmarkInfo(b *testing.B) {
h := slog.NewJSONHandler(io.Discard,
&slog.HandlerOptions{Level: slog.LevelInfo})
logger := slog.New(h)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
logger.Info("горячий путь", "user_id", slog.Int(42), "n", i)
}
}io.Discard поглощает записи без работы с диском — измеряется именно цена вызова и сериализации, а не writer. ReportAllocs показывает аллокации на вызов: их не ноль даже у чистого пути, и резкий рост после правки — сигнал. Так же меряют отброшенный вызов: замените Info на Debug и увидите цену аргументов без записи.
Бенчмарки: измеряйте свой вызов#
Числа из чужих таблиц сравнения логгеров к вашему случаю отношения не имеют: цена вызова зависит от полей, их типов, обработчика и writer. Методика простая и повторяемая: свой обработчик с вашим порогом уровня, свои поля в том сочетании, как они пишутся на горячем пути, io.Discard вместо внешнего мира. Отдельно меряют записанный вызов и отброшенный — второе число отвечает на вопрос, сколько стоит забытый Debug с дорогим аргументом. Бенчмарк отвечает «сколько», но не «где болит»: место лога в общей нагрузке покажет профиль сервиса — иногда наносекунды на вызов теряются в цене HTTP-запроса, а иногда цикл по миллиону элементов дышит логом. Для честного сравнения с zap измеряют ту же комбинацию полей на их логгере — и помнят, что у zap есть sampling, а у встроенных обработчиков slog его нет; при шторме записей помогает уровень и дисциплина полей, а не надежда на фильтр. Полезное соотношение — между двумя своими измерениями: во сколько раз отброшенный вызов дешевле записанного, настолько и тяжёлой была бы забытая отладка на проде.
Источники расхода и что с ними делать#
| Источник | Когда платим | Что делать |
|---|---|---|
| аргументы до проверки уровня | на каждом вызове, даже отброшенном | Enabled-guard или LogValuer для дорогих полей |
| Any со структурой | на каждой записанной строке | раскладывать на типизированные поля заранее |
| fmt.Sprintf в аргументах | на каждом вызове, даже отброшенном | собирать строку под guard'ом или передавать поля |
| сериализация Handler | на каждой записанной строке | меньше полей, типизированные конструкторы |
| writer и сеть | на каждой записанной строке | быстрый writer, буферизация с Flush, сбор на стороне |
Быстрый логгер go: дисциплина вместо трюков#
Собрать быстрый логгер go можно без единой экзотики, на четырёх правилах дисциплины. Первое: уровни по окружению — Info в проде, Debug в стенде; раскладку по переменным окружения даёт восемнадцатый урок, и это самый крупный рычаг: половина записей исчезает без единой строчки кода. Второе: дорогие значения — под Enabled или в LogValuer, чтобы отброшенная запись не платила за сборку. Третье: лог в цикле по элементам — антипаттерн из двадцать третьего урока; агрегируйте итог или логируйте выборочно, иначе даже дешёвый вызов в миллион итераций станет статьёй расходов. Четвёртое: ключи и константные атрибуты — константами пакета, а не строками по месту. Мьютекс обработчика из тринадцатого урока в этой картине не виноват: его цена на фоне сборки записи несущественна, и разъезд логгеров по горутинам её не уберёт, зато потеряет поля.
Кейс: после прочтения о цене аргументов команда обложила if logger.Enabled(...) каждый второй вызов в сервисе. Код стал нечитаемым, ревью растянулись, а профиль сервиса не сдвинулся: дорогих аргументов в логе было два, остальное — пары с идентификаторами, цена которых сопоставима с самой проверкой. Лечение: guards сняли везде, кроме двух горячих циклов, которые нашёл профиль; дампы переехали в LogValuer; уровень в проде подняли до Info. Проверка результата — тот же бенчмарк до и после: выигрыш на записанном пути измеряется наносекундами, а читаемость не восстановишь.
Как найти места, где guard нужен на самом деле. Первый шаг — профиль: pprof по нагруженному сценарию покажет, дышит ли лог в кадре вообще. Второй — просмотр циклов: вызов записи внутри for по элементам — кандидат на агрегацию или выборочную запись, особенно если внутри вызова склейка строк или сбор структуры. Третий — поиск по fmt.Sprintf рядом с вызовами лога: каждая такая строка платит за склейку до проверки уровня. После правок бенчмарк из урока фиксирует результат: наносекунды и аллокации на вызов до и после. И не забывайте направление: оптимизируют измеренное и профилированное, а не то, что выглядит медленным в коде — лог в HTTP-хендлере тонет в цене запроса, тот же лог в цикле по миллиону позиций определяет профиль целиком. Последняя проверка перед коммитом — глазами: guard вокруг дешёвой пары выглядит как забота, а читает его следующий разработчик, который не видел ваш профиль.
Порог уровня можно менять без пересборки логгера: var level slog.LevelVar; level.Set(slog.LevelInfo); обработчику передают Level: &level. Тесты поднимают порог до Debug на время кейса, сервис по сигналу включает отладку на минуту — восемнадцатый урок строит на LevelVar уровни по окружению. Проверка Enabled с LevelVar тоже честная: она читает текущее значение.
Пакета log/slog в песочнице нет — работают fmt, strings, strconv, math, sort, errors и time. Методику бенчмарка честно разминают на time: замерьте time.Now и time.Since вокруг цикла, который склеивает строку с полями через fmt, и сравните вариант с fmt.Sprintf и вариант с прямыми значениями — разница видна и без testing.B. Полные бенчмарки со своим обработчиком запускаются локально через go test -bench; интерактивы ниже проверят момент вычисления аргументов, цену полей и сборку бенчмарка.
Правила горячего пути#
- Дорогие аргументы прячут за Enabled или LogValuer: отброшенная запись не платит за сборку.
- Дешёвые пары guard'ом не обкладывают: цена проверки сопоставима с ценой вычисления.
- Структуры и срезы в Any раскладывают на типизированные поля до места записи.
- fmt.Sprintf в аргументах убирают: склейка платит до проверки уровня.
- Лог внутри цикла по элементам агрегируют или делают выборочным.
- Решение принимают по бенчмарку и профилю своего сервиса, а не по чужим таблицам.
Связи с другими уроками: девятый урок про LogValuer переносит сбор значения на момент записи, третий урок про уровни — база семантики, восемнадцатый — уровни по окружению. Контракт Enabled у своего обработчика описывает двенадцатый урок, цена мьютекса при конкурентной записи — тринадцатый, а лог в цикле как антипаттерн разбирает двадцать третий. Методика бенчмарков — в уроке про benchmarki курса по продвинутому Go, sampling как фильтр штормов — в уроке про sampling курса по zap. Карта курса — на странице курса.
Проверка: цена отброшенной записи
Уровень обработчика — Info. Какой вызов дороже всех на горячем пути?
Проверка: как измерять
Как понять, дорого ли стоит лог на горячем пути вашего сервиса?
Сколько раз вызовется buildDump?
Прочитайте код и выберите, что он выведет:
logger := slog.New(slog.NewJSONHandler(io.Discard,
&slog.HandlerOptions{Level: slog.LevelInfo}))
for i := 0; i < 100; i++ {
if logger.Enabled(context.Background(), slog.LevelDebug) {
logger.Debug("состояние", "dump", buildDump(state))
}
}Соберите динамический уровень и guard
Допишите: изменяемый уровень, проверку на горячем пути, типизированный атрибут и отчёт аллокаций.
Бенчмарк по строкам
Расставьте строки бенчмарка по порядку: функция, обработчик, логгер, аллокации, цикл, закрытие.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: производительность
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Аргументы вычисляются до проверки уровня: отброшенный Debug платит за свою сборку.
- Enabled-guard ставят только под дорогими полями: дампы, сериализация, счётчики.
- LogValuer переносит сбор значения на момент записи и чистит горячий путь без if.
- Типизированные конструкторы печатаются без отражения; структуры в Any — дорогие.
- Бенчмарк со своим обработчиком и io.Discard даёт время и аллокации именно вашего вызова.
- Быстрый логгер — дисциплина: уровни по окружению, поля без склеек, цикл без спама.
Домашнее задание
Соберите локально бенчмарк из урока со своим набором полей и прогоните его go test -bench: зафиксируйте наносекунды и аллокации. Затем добавьте дорогой аргумент через buildDump-подобную функцию и сравните три варианта: без guard, с Enabled и с LogValuer — разницу в отброшенных вызовах видно по бенчмарку.
Личные заметкиПусто
Вопросы и ответы по уроку
Насколько slog медленнее zap?
Ответ зависит от ваших полей и обработчика, поэтому честный путь — бенчмарк своего вызова на обоих логгерах. На типовых записях разница редко определяет профиль сервиса; при штормах записей zap спасает sampling, а slog — уровни по окружению и дисциплина полей.
Нужно ли оборачивать проверкой Enabled каждый вызов?
Нет: цена вычисления дешёвой пары сопоставима с ценой самой проверки, а код с guards на каждом шаге нечитаем. Проверку ставят под дорогими аргументами и в горячих циклах, которые показал профиль; остальное закрывают LogValuer и уровни по окружению.
Почему аргументы вычисляются до проверки уровня?
Go вычисляет аргументы функции в месте вызова, а slog.Debug — обычная функция: к моменту вызова dump уже собран. Механизм отложенных значений вынесен в LogValuer — обёртка отдаёт slog.Value, который обрабатчик раскроет только при записи.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Сквозные поля: request ID через слои
- Куда пишут логи: stdout, файл и ротация
- Логи для сбора: поля для Loki и ELK
- Уровни по окружению: debug в dev, info в прод
- Конкурентность: один логгер на сервис
- Свой Handler: интерфейс, Record и WithAttrs
- Ошибки в логах: err как атрибут, wrap и детали
- Миграция с log на slog: мосты и совместимость
- Все курсы по Go
Следующая ступень учебного пути
PostgreSQL в Go: драйвер pgx с нуля до продакшена
Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.
- Подключаться к PostgreSQL через pgx и pgxpool
- Сканировать строки в структуры и справляться с NULL
- Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
- Проводить транзакции и массовые вставки через Batch и COPY