---
title: "Производительность: почему zap быстрый"
description: "zap производительность: типизированные поля без рефлексии, пул CheckedEntry, ноль аллокаций на Check, цена SugaredLogger и что мерить в бенчмарках. Разбор."
course: "Zap: структурное логирование на Go"
url: "https://golangmaster.ru/courses/zap/lessons/19-proizvoditelnost/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Производительность: почему zap быстрый

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

zap производительность — не побочный эффект, а сумма инженерных решений: типизированные поля вместо рефлексии, пул записей и буферов, проверка уровня до того, как собраны поля. Разберём, из чего складывается скорость, чем SugaredLogger дороже типизированного API и как logger.Check убирает аллокации на горячем пути.

Сервис на пике обрабатывает восемь тысяч запросов в секунду, и профиль CPU показывает неожиданное: пять процентов съедает логирование. Приложение написано аккуратно, а логи идут «как привыкли» — через сахар, с форматированием строк и на Info без разбора. Вопрос «почему zap быстрый» на практике важнее в обратную сторону: какие привычки превращают быстрый логгер go в тормоз. Разбор ниже — не рекламный лист: у каждого механизма есть цена и условия, при которых он ничего не даёт. Если ваш объём — сотни записей в минуту, вся эта инженерия вас не касается, и это честный вывод, а не упрёк.

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

zap производительность складывается из трёх источников. Первый — типизированные поля: конструкторы zap.String и zap.Int64 возвращают zapcore.Field — структуру с кодом типа, и энкодер делает switch по коду, записывая значение напрямую, без reflect. Второй — переиспользование: записи CheckedEntry лежат в sync.Pool, буферы под JSON тоже, и сборщик мусора видит константный объём работы. Третий — проверка уровня до сборки: выключенный уровень — это сравнение int8 и возврат, самая дешёвая ветка в системе. Всё вместе README zap называет zero-allocation JSON encoding: сама кодировка в буфер аллокаций не делает. Заметьте, чего в списке нет: записи на диск и fsync — скорость кодировки не отменяет цену вывода, о нём отдельный разговор ниже.

### Две записи, один JSON

```go
// Типизированный API: Field собирается без рефлексии.
logger.Info("payment accepted",
        zap.String("order_id", order.ID),
        zap.Int64("amount_kop", amount),
)

// SugaredLogger: значения идут как interface{}.
sugar.Infow("payment accepted",
        "order_id", order.ID,
        "amount_kop", amount,
)
```

Обе записи дают одинаковый JSON, разница — в пути. Field создаётся конструктором с известным типом, энкодер пишет значение напрямую. Сахар упакует значения в interface{} и на записи разложит их через zap.Any: сначала type switch по знакомым типам, для остальных — reflect. На горячем пути это постоянная надбавка к каждой записи, плюс риск аллокаций на упаковке крупных значений — sugar в циклах обработки запросов не бесплатен ни по CPU, ни по GC.

### Разбор Field: почему нет рефлексии

Field — маленькая структура из четырёх полей: ключ, код типа, строка и интерфейс. Конструктор zap.String кладёт значение в строковый слот, zap.Int64 — в целочисленный, и энкодер JSON делает switch по коду: строку экранирует, число пишет напрямую. Рефлексии в этом пути нет вообще — и это главный ответ на вопрос «чем zap быстрый». Интерфейсное поле достаётся сложным случаям: zap.Object, zap.Any и ошибки. Отсюда практическое правило: в горячем коде поле — конструктор с конкретным типом, а zap.Any — инструмент для краёв, где тип неизвестен заранее. Компилятор частично страхует: zap.String("n", 42) не скомпилируется. А вот zap.Any("n", 42) пройдёт тихо — и скроет от вас, что поле пойдёт через общую ветку.

### Цена SugaredLogger

SugaredLogger оборачивает Logger: Infow принимает пары ключ-значение через interface{}, и на каждой записи значения проходят sweetenFields — type switch, а для незнакомых типов reflect. Это не «в десять раз медленнее» на фоне вывода в файл: надбавка — наносекунды на поле, плюс возможные аллокации на упаковке. Дороже другое, человеческое: пары ключ-значение не типобезопасны, перепутать местами ключ и значение легко, и компилятор молчит. Моё рабочее разделение, за которое меня ругали: сахар — на краях приложения, инициализация, CLI-команды, разовая диагностика; типизированный API — в обработчиках запросов и любых циклах. Если у вас сотни записей в минуту, выбирайте то, что читается командой: производительность здесь не аргумент.

> **Типичная ошибка: нечётное число пар.** sugar.Infow("msg", "a", 1, "b") — висячий ключ b без значения. Zap не паникует: пару отбрасывают, а в лог уходит отдельная Error-запись Ignored key without a value — на проде она выглядит как авария, хотя это опечатка вызова. Нечётное число пар у Infow, Warnw и Errorw ловит смок-тест: вызов с заведомо нечётным числом аргументов и проверка, что в логе нет записи про ignored.

### Проверка: путь сахарного поля

**Вопрос:** Что происходит со значением неизвестного типа в sugar.Infow?

- Оно молча отбрасывается
- ✅ zap.Any доходит до reflect: тип вычисляется на лету
- Логгер паникует
- Оно всегда пишется через fmt.Sprint

Type switch покрывает примитивы и знакомые типы, остальное уходит в reflect. Запись всё равно состоится — вопрос в цене: рефлексия на каждую запись горячего цикла заметна в профиле.

### logger.Check: ноль аллокаций до записи

logger.Info внутри zap устроен так: проверка Enabled, сборка CheckedEntry из sync.Pool, добавление полей, запись, возврат в пул. Для выключенного уровня всё заканчивается проверкой — но поля вызова уже собраны на месте вызова: varargs-слайс и конструкторы полей стоят, даже когда запись не состоится. Для самых горячих мест zap предлагает Check: ce := logger.Check(zapcore.InfoLevel, "event") — и только если ce не nil, вызывать ce.Write с полями. Здесь поля собираются после проверки уровня: выключенный уровень не тратит ни байта. Приём оправдан, когда вероятность записи мала, а построение полей дорого — сериализация больших объектов, форматирование длинных строк.

### Ручной Check и Write

```go
func logEvent(logger *zap.Logger, event string, size int) {
        ce := logger.Check(zapcore.InfoLevel, "event")
        if ce == nil {
                return
        }
        ce.Write(
                zap.String("event", event),
                zap.Int("size", size),
        )
}
```

Check вернёт nil, если уровень выключен: возврат без сборки полей и без записи. При включённом — CheckedEntry достаётся из sync.Pool, поля добавляются к готовой записи, Write выводит её и возвращает в пул. Именно этот сценарий выполняется под капотом каждого logger.Info. Разница видна на бенчмарке с выключенным уровнем и дорогими полями: обычный вызов строит varargs-слайс, Check — нет. Не переусердствуйте: для обычного кода выигрыш — шум. Экономия в деталях: на выключенном уровне не выполняется ни zap.String, ни append в varargs-слайс — конструкторы полей стоят после проверки, а не до.

### Бенчмарки: что мерить и как читать

Данные из репозитория zap задают порядок величин: статическая строка — десятки наносекунд на запись, запись с десятком полей — сотни, и это без учёта вывода. SugaredLogger дороже типизированного API, стандартная log — выше zap на порядок на тех же полях. Переносить цифры на своё железо нельзя: результат зависит от числа и типов полей, энкодера, размера сообщений и особенно WriteSyncer — в бенчмарках zap вывод подменён на ioutil.Discard, и реальная запись в файл будет другой. Рабочая методика одна: go test -bench . -benchmem на своём коде и своём выводе на локальном Go, и смотреть ns/op и allocs/op отдельно — аллокации предсказывают поведение под GC лучше, чем наносекунды.

> **Самое узкое место — вывод.** Энкодер может писать за сотни наносекунд, но синхронный fsync на каждую запись стоит миллисекунды — разница на три порядка. Если логи «медленные», сначала смотрите WriteSyncer: диск, сеть, syslog. Буферизация из урока про выводы ускоряет запись ценой потери хвоста при крахе, sampling убирает записи целиком. Кодировка — последнее место, куда стоит смотреть.

### Атомарный вывод и синхронизация

WriteSyncer по умолчанию обёрнут в zapcore.Lock: мьютекс на каждую запись. Запись атомарна — две горутины не перемешают JSON в одну строку, но при большом числе пишущих горутин мьютекс становится точкой спора: все ждут одного писателя. На практике это видно на многоядерных машинах с большим потоком логов: contention появляется в профиле как ожидание на мьютексе. Лекарства три — sampling из урока про сэмплирование, уменьшение объёма и буферизованный writer, который сглаживает частоту системных вызовов, но сам мьютекс не отменяет. Атомарность держится на буфере: энкодер собирает запись целиком и отдаёт WriteSyncer одним вызовом Write — поэтому в логе нет половинных строк, и строка неделима.

### Что будет, если уровень сменить на живом сервисе

Атомарный уровень из урока про динамические уровни читается на каждом Check — смена порога видна со следующего же вызова, перезапуск не нужен. Две границы момента. Вызовы, уже прошедшие Check и собирающие поля, запись допишут: половины записи не бывает, буфер получает её целиком или не получает ничего. И смена порога не размагничивает историю: записи прежнего уровня уже в хранилище, дашборды не пересчитываются. На практике переключение Info на Debug на горячем сервисе мгновенно умножает объём: цена не в Check, который стоит сравнение, а в потоке записей под новым порогом. Перед сменой на проде — убедиться, что хранилище и система сбора переживут кратный рост.

### Что влияет на цену записи

| Фактор | Влияние | Что делать |
| --- | --- | --- |
| Число полей | линейно: каждое поле кодируется | логировать то, что анализируется |
| Тип поля | Object дороже скаляра | разбирать объекты только на Debug |
| Энкодер | JSON дороже console: экранирование | JSON для сбора, console локально |
| WriteSyncer | диск и сеть — миллисекунды | буферизация, отдельный канал ошибок |
| AddCaller | обход стека на запись | включать осознанно |
| AddStacktrace | стек дороже caller | только уровни выше Warn |

### Проверка: чтение бенчмарка

**Вопрос:** Что смотреть в бенчмарке логгера, чтобы предсказать поведение под нагрузкой с GC?

- Только ns/op
- ✅ allocs/op отдельно от ns/op
- Число строк в файле лога
- Размер пакета go.uber.org/zap

Аллокации — это работа сборщика мусора: каждая аллокация на запись умножается на число записей в секунду. ns/op показывает время, allocs/op — давление на GC, и под нагрузкой второе страшнее.

### Микрооптимизации, которые не важны

В ревью я видел споры о длине ключей и порядке полей — на фоне вывода разница неразличима, и я не трачу на это время. Что действительно двигает цифры: sampling, который превращает тысячу повторов в пару записей, динамические уровни, отключающие Debug на проде без перезапуска, и разумный минимум полей. Отдельно про чистку полей: убрать поля, которыми никто не пользуется, — не микрооптимизация, а гигиена, каждое поле — байты в хранилище и время индексации. Но начинать стоит с измерения, а не с чистки: если логи не видны в профиле, ускорять нечего, и время лучше потратить на вывод и уровни. Быстрый логгер go — это в первую очередь дисциплина объёма, а не тонкие настройки.

### Что напечатает Check на Nop-логгере?

```go
logger := zap.NewNop()

ce := logger.Check(zapcore.InfoLevel, "hot path")
if ce == nil {
        fmt.Println("nil")
} else {
        ce.Write()
        fmt.Println("written")
}
```

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

- ✅ nil
- written
- Паника: Write без полей запрещён
- Ничего не напечатает

zap.NewNop даёт ядро, где Enabled всегда false. Check возвращает nil — печатается nil. Именно поэтому Nop-логгер безопасен в тестах: каждая запись стоит один-два сравнения и ноль аллокаций.

### Соберите горячий путь

Восстановите паттерн Check и Write.

```go
func logEvent(logger *zap.Logger, name string) {
        ce := logger.___(zapcore.InfoLevel, "event")
        if ___ == nil {
                return
        }
        ce.___(zap.String("event", name))
}
```

Подсказка: Уровень проверяет Check, пустой результат — это ce, запись делает Write.

Правильные ответы в пропусках: `Check`, `ce`, `Write`

### Соберите логгер через Check

Расставьте строки от энкодера до записи поля.

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

1. `enc := zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig())`
2. `core := zapcore.NewCore(enc, zapcore.AddSync(os.Stdout), zapcore.InfoLevel)`
3. `logger := zap.New(core)`
4. `ce := logger.Check(zapcore.InfoLevel, "event")`
5. `if ce != nil {`
6. `ce.Write(zap.String("event", name))`

### Карточки: скорость zap

- **Field** — Структура zapcore: ключ, код типа, строка, интерфейс. Конструкторы zap.String и zap.Int64 пишут значение без рефлексии.
- **CheckedEntry** — Запись, прошедшая проверку уровня: logger.Check возвращает её из sync.Pool или nil, если уровень выключен.
- **sync.Pool в zap** — Переиспользование CheckedEntry и буферов: константный объём мусора, спокойный GC.
- **zap.Any** — Поле для неизвестного типа: type switch, потом reflect. На горячем пути — лишняя цена.
- **zapcore.Lock** — Обёртка WriteSyncer мьютексом: атомарная запись и точка contention при множестве горутин.
- **allocs/op** — Метрика бенчмарка: аллокации на операцию — предсказатель поведения под GC важнее ns/op.

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

Механика ядра — интерфейс Core из пяти методов, через который проходят и Check, и Write — разобрана в [уроке про zapcore](/courses/zap/lessons/10-zapcore/); про обрезание потока в цифрах — [урок про сэмплирование](/courses/zap/lessons/11-sampling/). Если типизированный API кажется многословным для краёв кода, посмотрите мост [logr и zapr](/courses/zap/lessons/22-logr-zapr/) — тот же zap под другим интерфейсом. Карта курса — на [странице курса zap](/courses/zap/).

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

- Скорость zap: типизированные поля без рефлексии, пул записей и буферов, проверка уровня до сборки.
- SugaredLogger проходит через interface{} и reflect для незнакомых типов — на горячем пути дороже.
- logger.Check возвращает nil на выключенном уровне: поля не собираются, аллокаций ноль.
- Бенчмарки репозитория задают порядок величин; переносить цифры на своё железо нельзя.
- Узкое место почти всегда WriteSyncer: диск, сеть, fsync — а не энкодер.
- WriteSyncer под Lock атомарен, но мьютекс при множестве горутин — точка спора.
- Сначала измерьте: если логов не видно в профиле, ускорять нечего.

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

Соберите логгер с ядром из урока про zapcore и сравните на локальном Go два пути: logger.Info с десятью полями и Check с Write тех же полей. Напишите бенчмарк с -benchmem при выключенном уровне и зафиксируйте ns/op и allocs/op для обоих вариантов. Затем замените WriteSyncer с io.Discard на файл и повторите замер — запишите, во сколько раз изменилась цена записи. После этого включите на логгере сэмплер 100/100 и повторите замер при тысяче однотипных записей подряд — зафиксируйте, во сколько раз упал объём и что случилось с allocs/op.

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

### Правда ли zap быстрее zerolog?

Бенчмарки расходятся по сценариям: на одних полях выигрывает zap, на других zerolog. Оба относятся к классу «без рефлексии», разница между ними ниже шума для большинства проектов. Смотрите на свой профиль нагрузки, а не на чужие таблицы.

### Стоит ли переписать весь sugar на typed API?

Только там, где запись попала в профиль или идёт в горячем цикле. Разовая диагностика, инициализация и CLI читаются на сахаре быстрее и ломаются так же. Правило «всё на typed» редко окупается реальными цифрами.

### Как померить именно мой конвейер?

go test -bench на функции с вашими полями и вашим WriteSyncer — реальным файлом, а не Discard. Отдельно смотрите allocs/op: они предсказывают поведение под GC. Замер на локальном ноутбуке занижает contention мьютекса — для него есть прод-метрики.
