---
title: "Производительность: ноль аллокаций"
description: "Производительность zerolog: ноль аллокаций, буфер из sync.Pool, Disabled для горячего пути, Enabled() для дорогих полей, b.Run-бенчмарки и цифры README."
course: "Zerolog в Go: быстрое JSON-логирование"
url: "https://golangmaster.ru/courses/zerolog/lessons/13-log-proizvoditelnost/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Производительность: ноль аллокаций

*Курс «Zerolog в Go: быстрое JSON-логирование», урок 13 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/zerolog/lessons/13-log-proizvoditelnost/*

Производительность zerolog держится на нуле аллокаций: событие собирается в переиспользуемый байтовый буфер и уходит writer-ом одним вызовом. Разберём, откуда берётся скорость, что стоит Disabled на горячем пути, как guard-ить дорогие поля и как мерить всё это b.Run-бенчмарками на своём железе.

Логгер на горячем пути — это сотни тысяч событий в секунду, и каждая аллокация в них платит дважды: памятью под объект и работой сборщика мусора, который потом всё это собирает. Отсюда правило быстрого логирования: цена записи должна быть не «маленькой», а предсказуемой и не зависящей от нагрузки. Быстрое логирование в zerolog устроено ровно так: событие — это байтовый буфер, который переиспользуется, поля дописываются в него без промежуточных структур, а writer получает готовую JSON-строку одним вызовом. GC-давление от логов исчезает не потому, что записи стали дешевле по наносекундам, а потому, что их вклад в общую кучу равен нулю. Второй эффект тише, но заметнее: хвост задержек. Аллокации размазаны по времени выполнения, и сборщик мусора отвечает за пики, на которых p95 подпрыгивает без видимой причины — лог без аллокаций из этого уравнения уходит. В этом уроке разберём механику, посмотрим на цифры из бенчмарков и — главное — на приёмы горячего пути: Disabled, nil-событие, guard через Enabled() и Bool-хелперы. И научимся мерить самостоятельно, потому что чужие цифры — это ориентир, а не гарантия: на вашем железе они будут другими.

### Почему zerolog быстрый: буфер вместо структур

Под капотом каждого события — sync.Pool. Внутренний eventPool раздаёт структуры Event с байтовым буфером: поля дописываются в буфер сразу как JSON-байты — Str пишет строку с кавычками и экранированием, Int — число, — и никакой промежуточной карты или дерева полей не существует. Контекст логгера, собранный через With, хранится как готовые байты и приписывается к событию копированием, поэтому копия логгера с десятью предзаданными полями почти ничего не добавляет к цене записи. После Msg буфер уходит writer-у одним вызовом Write, а Event возвращается в пул для следующего события. Возврат не безусловный: буфер, разросшийся свыше шестидесяти четырёх килобайт, в пул не возвращается — гигантский дамп одним событием портит переиспользование, и zerolog просто выбрасывает его GC-у. Контраст со старым поколением логгеров разительный: logrus на каждое событие строит карту, считает рефлексией и форматирует текст — README-бенчмарки zerolog показывают на десять полей разницу в разы и на десятки аллокаций. Отсюда же и ограничения: типизированные методы Str и Int не случайны — Any с произвольным интерфейсом идёт через json.Marshal и стоит дороже, поэтому на горячем пути поля должны быть типизированными, а карта полей map[string]interface{} — исключением. Time и Dur тоже пишут числа сами, без общего маршалера: типизация — не стиль, а прямой путь мимо json.Marshal.

### Бенчмарк с b.Run: включено и отключено

```go
package perf

import (
        "io"
        "testing"

        "github.com/rs/zerolog"
)

func BenchmarkHotPath(b *testing.B) {
        logger := zerolog.New(io.Discard)
        quiet := logger.Level(zerolog.Disabled)

        b.Run("info", func(b *testing.B) {
                for i := 0; i < b.N; i++ {
                        logger.Info().Str("op", "scan").Msg("tick")
                }
        })

        b.Run("disabled", func(b *testing.B) {
                for i := 0; i < b.N; i++ {
                        quiet.Info().Str("op", "scan").Msg("tick")
                }
        })
}

// go test -bench . -benchmem
```

b.Run разбивает бенчмарк на подслучаи, и вывод покажет оба сценария рядом. Приёмник io.Discard выбрасывает байты: меряется именно цена сборки события, а не диск. Флаг -benchmem добавит B/op и allocs/op — на стандартном пути zerolog даст ноль аллокаций, а ветка disabled покажет, сколько стоит вызов на отключённом логгере.

Цифры из README zerolog — ориентир для порядка величин, а не обещание. BenchmarkDisabled: 4.07 наносекунды на событие и ноль аллокаций — отключённый логгер почти ничего не стоит. Пустое событие без полей — 19.1 ns/op, Info с сообщением — 42.5, Info с четырьмя полями — 184: поля добавляют десятки наносекунд каждая, но аллокаций всё ещё ноль. Ещё две строки той же таблицы: запись с логгером, у которого десять полей уже в контексте, — 52 ns/op и ноль аллокаций; статичная строка без полей — 50. Предзаданные поля хранятся готовыми байтами и почти ничего не добавляют. В сравнении zap-бенчмарка «сообщение и 10 полей» zerolog пишет 767 ns/op при 552 B/op и 6 аллокациях — сюда входит форматирование времени, — zap показывает 848 ns/op и 2 аллокации, sugared-zap 1363 ns/op и 20, а logrus 5661 ns/op и 78. Эти числа сняты на чужой машине чужой версии Go, поэтому трактовать их надо так: разница между логгерами — множители, разница между включённым и Disabled — два порядка, и именно второй факт влияет на дизайн горячего пути. [zap](/courses/zap/) — отличный логгер, и в большинстве сервисов разница не видна; zerolog выигрывает там, где события пишутся на каждом тике.

### Disabled и nil-событие: цена горячего пути

Порог Disabled — не «очень высокий уровень», а самый верхний из восьми: метод should сначала проверяет nil-writer и сравнивает уровень события с порогом — локальным и глобальным, — и на Disabled не проходит ни один вызов. Инфо-цепочка на отключённом логгере возвращает nil-событие, и вся остальная цепочка становится no-op: Str на nil возвращает nil, Msg на nil просто выходит. Отсюда 4 наносекунды из бенчмарка — цена нескольких сравнений. Включается режим копией: quiet := logger.Level(zerolog.Disabled), — и value-семантика делает это безопасным: базовый логгер продолжает писать как раньше. Это отличие от SetGlobalLevel(zerolog.Disabled), глушащего все логгеры разом: копия отключает один путь, глобальный порог — весь сервис. Полезное следствие — код горячего пути не нуждается в if вокруг каждой записи: debug-трассы можно оставлять в коде, а глушить уровнем, переключая копию при старте или по сигналу. Для тестов есть zerolog.Nop() — отключённый логгер одним вызовом. И следствие обратное: nil-событие экономит запись и сериализацию, но не ваш код вокруг цепочки — об этом «живое место» ниже.

> **Живое место: дорогие аргументы считаются всегда.** Вызов logger.Debug().Str("dump", buildDump()).Msg("x") на отключённом логгере всё равно вызывает buildDump() — Go вычисляет аргументы до вызова Str, даже если Str на nil-событии ничего не сделает. nil-событие экономит запись в writer и сериализацию, но не вашу дорогую работу: дамп конфига, сериализацию прото-сообщения, обход дерева. На проде с Info-порогом такие debug-строки умножают CPU на ноль полезного результата. Лечится guard-ом if e := logger.Debug(); e.Enabled() { ... } — он защищает и запись, и вычисление аргументов. Диагностика проста: поставьте счётчик внутрь buildDump и посмотрите на график под нагрузкой — если счётчик растёт на Info-пороге, guard где-то забыт. Тот же счётчик показывает и обратное: на Debug-пороге он обязан расти — если нет, guard стоит слишком высоко, и полезная ветка отключила сама себя.

### Bool-хелперы и Enabled(): дорогие поля под контролем

Guard из «живого места» — это Event.Enabled(): метод возвращает false у nil-события, и форма if e := logger.Debug(); e.Enabled() стала идиомой zerolog. Дорогая работа прячется внутрь условия целиком — и поля, и вычисления. Для флагов есть.Bool(key, b): один типизированный вызов вместо условных конструкций «если флаг — писать поле, иначе не писать», и событие всегда имеет одинаковую схему, что ценят дашборды; приёмы условных полей разобраны в [уроке про условные поля](/courses/zerolog/lessons/07-uslovnye-polya/). Время тоже стоит денег: RFC3339-строка форматируется каждый вызов, а zerolog.TimeFieldFormat = zerolog.TimeFormatUnix пишет целое число секунд — дешевле, и системе сбора так же удобно. По той же логике целые миллисекунды для Dur включаются флагом DurationFieldInteger, а точность дробных полей подрезает zerolog.FloatingPointPrecision — оба меняют формат, не трогая код записи. И контраст с ConsoleWriter: там на каждую запись идёт json.Unmarshal для колонок — на горячем пути он съедает весь выигрыш, поэтому его место строго в dev, как разобрано в [уроке про красивый вывод](/courses/zerolog/lessons/05-log-console/).

### Приёмы горячего пути одним списком

- один логгер, общие поля в контексте With — собираются один раз, а не на каждом событии
- горячий путь — копия .Level(zerolog.Disabled): 4 наносекунды и ноль аллокаций на событие
- дорогие поля — только под guard if e := logger.Debug(); e.Enabled()
- флаги — Bool(key, b): одна схема события без условных цепочек
- время — zerolog.TimeFieldFormat = zerolog.TimeFormatUnix: целое дешевле RFC3339-строки
- ConsoleWriter — только dev: Unmarshal каждой записи съедает выигрыш
- шумные ветки — семплер из следующего урока, а не ручные счётчики
- замер — go test -bench . -benchmem на своём железе, чужие цифры — только ориентир

### Guard вокруг дорогих полей

```go
func planScan(root string, logger zerolog.Logger) {
        if e := logger.Debug(); e.Enabled() {
                e.Str("root", root).
                        Int("files", countFiles(root)).
                        Msg("scan planned")
        }

        logger.Info().Bool("cached", root == lastRoot).Msg("scan scheduled")
}
```

Guard вокруг дорогих полей: countFiles вызывается только когда debug включён — nil-событие не спасает аргументы, а Enabled() спасает. Флаг cached пишется Bool-хелпером одним вызовом, и схема события одинакова для каждой записи. Форма if с инициализацией ограничивает e телом условия: записать что-то мимо guard-а случайно невозможно.

### Как мерить самому: b.Run и -benchmem

Бенчмарк из урока — шаблон, который стоит скопировать. Три правила честного замера. Первое: sink — io.Discard, иначе вы меряете диск и терминал, а не логгер; если нужен writer с реальным форматированием времени — оставьте With().Timestamp(), но сравнивайте варианты на одинаковом логгере. Второе: -benchmem обязателен — наносекунды без аллокаций рассказывают половину истории, а GC-давление живёт именно в B/op и allocs/op. Третье: прогоняйте несколько раз и берите стабильные цифры — планировщик, кэши и соседние процессы дают разброс; -benchtime помогает, если ветки слишком короткие. Альтернатива флагу — b.ReportAllocs() внутри самой ветки b.Run: аллокации печатаются всегда, даже если коллега запустил бенчмарк без -benchmem. Сравнивайте только свои со своими: цифры README сняты на другой машине, и «в три раза быстрее» между логгерами на вашем железе может оказаться «на двадцать процентов». Отдельный приём — добавить в b.Run вариант с ConsoleWriter против чистого JSON: спор о читаемости против скорости решается за минуту, когда оба варианта стоят рядом в одном выводе. Тот же приём работает для уровней: Info и Debug рядом в одном бенчмарке дают точную цену debug-ветки на Info-пороге — наносекунды, о которых спорят в ревью.

### Стоимость события zerolog (бенчмарки README)

| Сценарий | Время | Аллокации |
| --- | --- | --- |
| Отключённый логгер (Disabled) | 4.07 ns/op | 0 B / 0 allocs |
| Пустое событие Log().Msg("") | 19.1 ns/op | 0 B / 0 allocs |
| Info с сообщением | 42.5 ns/op | 0 B / 0 allocs |
| Info с четырьмя полями | 184 ns/op | 0 B / 0 allocs |
| Сообщение и 10 полей (zap-бенчмарк) | 767 ns/op | 552 B / 6 allocs |
| То же на logrus — для контраста | 5661 ns/op | 6092 B / 78 allocs |

> **Про песочницу.** Бенчмарки в песочнице не живут: песочница — тематическая программа на чистом Go, она собирает события в буфер и печатает JSON, чтобы механика полей была видна построчно. Замеры go test -bench . -benchmem идут локальным Go: цифры на вашем железе будут отличаться от README — и это нормально, сравнивайте только свои со своими.

### Проверка: аллокации

**Вопрос:** Сколько аллокаций делает Info().Str(...).Msg(...) на стандартном пути zerolog?

- ✅ 0 — событие собирается в буфере из sync.Pool
- 1 — буфер события
- По одной на каждое поле
- Зависит от числа полей

Буфер события переиспользуется через внутренний eventPool, поля дописываются байтами без рефлексии — README-бенчмарки показывают 0 allocs/op вплоть до сценария с четырьмя полями.

### Проверка: аргументы nil-события

**Вопрос:** Что происходит с buildDump() в вызове logger.Debug().Str("dump", buildDump()).Msg("x") на отключённом логгере?

- ✅ Вызывается — аргументы вычисляются до вызова Str
- Не вызывается — nil-событие отменяет всё
- Вызывается, но результат не пишется в JSON
- Компилятор вырежет вызов

Go вычисляет аргументы перед вызовом: nil-событие экономит запись и сериализацию, но не ваш buildDump. Дорогую работу прячет guard if e := logger.Debug(); e.Enabled().

### Проверка: дорогой writer

**Вопрос:** Какой writer дороже всего на горячем пути?

- ✅ ConsoleWriter — json.Unmarshal каждой записи
- os.Stdout через zerolog.New
- Файл через lumberjack
- io.Discard

ConsoleWriter парсит и пересобирает каждую строку для колонок — на порядки дороже прямой записи байтов. Его место — dev-терминал; в проде остаётся чистый JSON.

### Что выйдет на отключённом логгере?

```go
quiet := logger.Level(zerolog.Disabled)

quiet.Info().Str("op", "scan").Msg("tick")
```

**Что выведет программа?**

- ✅ Ничего: порог Disabled режет событие до создания буфера
- Запись без поля level
- Паника: Info на отключённом логгере запрещён
- Запись уйдёт, но без полей

should сравнивает уровень события с порогом и проверяет nil-writer до создания события — на Disabled не проходит ни один уровень. Цепочка возвращает nil-событие, Msg становится no-op — отсюда 4 ns/op в бенчмарке README.

### Поставьте guard

Восстановите идиому: событие, проверка и типизированное поле.

```go
func scan(logger zerolog.Logger) {
        if e := logger.___(); e.___() {
                e.___("dump", buildDump()).Msg("scan planned")
        }
}
```

Подсказка: Событие начинается Debug, guard проверяет Enabled, поле пишется типизированным Str.

Правильные ответы в пропусках: `Debug`, `Enabled`, `Str`

### Программа с Disabled

Расставьте строки в правильном порядке: импорты, отключённый логгер и guard-нутая запись.

Правильный порядок строк:

1. `package main`
2. `import (`
3. `  "io"`
4. `  "github.com/rs/zerolog"`
5. `)`
6. `func main() {`
7. `  quiet := zerolog.New(io.Discard).Level(zerolog.Disabled)`
8. `  if e := quiet.Debug(); e.Enabled() {`
9. `    e.Str("op", "scan").Msg("tick")`
10. `  }`
11. `}`

### Карточки: производительность

- **sync.Pool события** — Буферы событий переиспользуются через внутренний eventPool: ноль аллокаций на стандартном пути, буфер возвращается в пул после записи.
- **Disabled** — Уровень-заглушка: Level(zerolog.Disabled) делает копию, которая режет все события за 4 ns/op. Для горячего пути и тестов есть zerolog.Nop().
- **nil-событие** — Событие ниже порога — nil: вся цепочка Str, Int, Msg становится no-op. Экономит запись, но не вычисление аргументов.
- **Event.Enabled()** — Проверка до дорогой работы: if e := logger.Debug(); e.Enabled() { e.Str("dump", buildDump()) } — защищает и запись, и аргументы.
- **TimeFormatUnix** — zerolog.TimeFieldFormat = zerolog.TimeFormatUnix: время целым числом секунд — дешевле RFC3339-строки при каждом событии.
- **-benchmem** — Флаг go test: показывает B/op и allocs/op. Только с ним видно, что ноль аллокаций — не миф.

### Что дальше по курсу

Скорость — половина дела; вторая половина — объём. [Sampling](/courses/zerolog/lessons/14-sampling/) покажет, как прореживать поток, когда отключить нельзя: BurstSampler пропускает всплеск и режет хвост. Дальше — горутины: потокобезопасность логгера и то, почему ноль аллокаций не мешает писать из сотни воркеров одновременно. Приём из этого урока, который стоит забрать в каждый сервис: debug-записи живут в коде, а решает их судьбу порог — поэтому включение подробных логов на деградирующем инстансе не требует релиза, только смены уровня. Справочное закрепление: возьмите свой самый горячий обработчик, добавьте debug-запись с дорогим полем без guard-а и замерьте через b.Run-бенчмарк на Info-пороге — разница между guard-нутой и голой версией станет аргументом для ревью, который не нужно объяснять словами.

## Коротко: что запомнить

- Событие собирается в буфере из sync.Pool: ноль аллокаций на стандартном пути.
- Disabled — верхний порог: should отсекает событие до создания; копия .Level(zerolog.Disabled) стоит 4 ns/op.
- nil-событие делает цепочку no-op, но не отменяет вычисление аргументов.
- Guard if e := logger.Debug(); e.Enabled() защищает и запись, и дорогие поля.
- Bool(key, b) — флаги одной схемой события; условные цепочки ломают схему.
- TimeFormatUnix пишет время целым — дешевле RFC3339 при каждом вызове.
- ConsoleWriter на горячем пути съедает выигрыш: Unmarshal каждой записи.
- Чужие цифры — ориентир: замеряйте b.Run с -benchmem на своём железе.

## Домашнее задание

Скопируйте бенчмарк урока, добавьте третий вариант b.Run с полями Str и Int — и сравните info против disabled против fields на своём железе. Возьмите свой горячий обработчик и поставьте в него debug-запись с дорогим полем без guard-а: замерьте, затем оберните в if e.Enabled() и замерьте снова — разницу запишите в PR. Переключите TimeFieldFormat на TimeFormatUnix и посмотрите на JSON: время стало целым. Наконец, добавьте в бенчмарк вариант с ConsoleWriter и убедитесь, что Unmarshal каждой записи виден в наносекундах даже на io.Discard. Сравните десять полей в контексте With против тех же полей на событии — разница ns/op покажет, что контекст собирается один раз.

## Вопросы и ответы по уроку

### Почему zerolog не делает аллокаций?

Событие — это байтовый буфер из внутреннего sync.Pool: поля дописываются сразу как JSON-байты без промежуточных структур и рефлексии, буфер уходит writer-у одним Write и возвращается в пул. Контекст логгера хранится готовыми байтами и копируется в событие, поэтому предзаданные поля почти бесплатны. Исключение — дорогой Any с произвольным интерфейсом: он идёт через json.Marshal, и на горячем пути его заменяют типизированными методами.

### Как полностью отключить логгер на горячем пути?

Копией с порогом Disabled: quiet := logger.Level(zerolog.Disabled). Сравнение уровня с порогом в should отказывает до создания события — 4 ns/op и ноль аллокаций. Для тестов есть zerolog.Nop(). Базовый логгер при этом продолжает писать: копия ничего не меняет у родителя.

### Зачем Enabled(), если nil-событие и так no-op?

nil-событие отменяет запись и сериализацию, но Go вычисляет аргументы до вызова: buildDump() в Str("dump", buildDump()) выполнится всегда. Guard if e := logger.Debug(); e.Enabled() защищает и вычисление аргументов — это единственный способ не платить за дорогие debug-поля на Info-пороге.
