Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 19 из 25 ~18 мин +50 XP

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

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

Содержание урока · 13 разделов

Сервис на пике обрабатывает восемь тысяч запросов в секунду, и профиль 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?

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 лучше, чем наносекунды.

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

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?

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

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

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

Прочитайте код и выберите, что он выведет:

logger := zap.NewNop()

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

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

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

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

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

Field

нажмите, чтобы перевернуть
Определение

Структура zapcore: ключ, код типа, строка, интерфейс. Конструкторы zap.String и zap.Int64 пишут значение без рефлексии.

1 / 6

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

Механика ядра — интерфейс Core из пяти методов, через который проходят и Check, и Write — разобрана в уроке про zapcore; про обрезание потока в цифрах — урок про сэмплирование. Если типизированный API кажется многословным для краёв кода, посмотрите мост logr и zapr — тот же zap под другим интерфейсом. Карта курса — на странице курса zap.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Скорость 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 · Производительность: почему zap быстрый)

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

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

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

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

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

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

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Colly25 уроков~9 чпрактика25 уроков уже доступны

Colly: парсинг и веб-скрапинг на Go

Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.

  • Писать скраперы на Colly от первого Visit до обхода целого сайта
  • Вынимать данные через CSS-селекторы и складывать в структуры Go
  • Настраивать темп: задержки, параллелизм, лимиты на домен
  • Работать с капризными сайтами: заголовки, cookies, прокси