Содержание урока · 13 разделов
- zap производительность: три источника скорости
- Разбор Field: почему нет рефлексии
- Цена SugaredLogger
- 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?
logger.Check: ноль аллокаций до записи#
logger.Info внутри zap устроен так: проверка Enabled, сборка CheckedEntry из sync.Pool, добавление полей, запись, возврат в пул. Для выключенного уровня всё заканчивается проверкой — но поля вызова уже собраны на месте вызова: varargs-слайс и конструкторы полей стоят, даже когда запись не состоится. Для самых горячих мест zap предлагает Check: ce := logger.Check(zapcore.InfoLevel, "event") — и только если ce не nil, вызывать ce.Write с полями. Здесь поля собираются после проверки уровня: выключенный уровень не тратит ни байта. Приём оправдан, когда вероятность записи мала, а построение полей дорого — сериализация больших объектов, форматирование длинных строк.
Ручной Check и Write
GOfunc 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?
Микрооптимизации, которые не важны#
В ревью я видел споры о длине ключей и порядке полей — на фоне вывода разница неразличима, и я не трачу на это время. Что действительно двигает цифры: 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.
Соберите логгер через Check
Расставьте строки от энкодера до записи поля.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: скорость zap
Что дальше по курсу#
Механика ядра — интерфейс 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 быстрее zerolog?
Бенчмарки расходятся по сценариям: на одних полях выигрывает zap, на других zerolog. Оба относятся к классу «без рефлексии», разница между ними ниже шума для большинства проектов. Смотрите на свой профиль нагрузки, а не на чужие таблицы.
Стоит ли переписать весь sugar на typed API?
Только там, где запись попала в профиль или идёт в горячем цикле. Разовая диагностика, инициализация и CLI читаются на сахаре быстрее и ломаются так же. Правило «всё на typed» редко окупается реальными цифрами.
Как померить именно мой конвейер?
go test -bench на функции с вашими полями и вашим WriteSyncer — реальным файлом, а не Discard. Отдельно смотрите allocs/op: они предсказывают поведение под GC. Замер на локальном ноутбуке занижает contention мьютекса — для него есть прод-метрики.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Colly: парсинг и веб-скрапинг на Go
Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.
- Писать скраперы на Colly от первого Visit до обхода целого сайта
- Вынимать данные через CSS-селекторы и складывать в структуры Go
- Настраивать темп: задержки, параллелизм, лимиты на домен
- Работать с капризными сайтами: заголовки, cookies, прокси