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

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

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

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

GORM и zap: логи базы данных

Мост gorm logger zap — это своя реализация интерфейса gorm logger.Interface поверх zap: ошибки SQL уходят в Error, медленные запросы — в Warn, обычный трейс — в Info. Разберём контракт из четырёх методов, порог SlowThreshold, обход ErrRecordNotFound, source location и риск, из-за которого SQL со значениями в логах держат под замком.

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

ORM в проекте — и у него свой голос. Дефолтный логгер GORM пишет текстом через стандартный log: цветные строки в stdout, мимо JSON-конвейера, без request_id и без полей. Молчит он по умолчанию почти всегда — уровень Warn: обычные запросы не показывает, пишет только ошибки и медленные запросы дольше двухсот миллисекунд. В итоге логирование gorm-слоя живёт отдельной жизнью: приложение пишет структурно, база — текстом, и связать «этот медленный SELECT был в этом запросе» можно только по времени вручную. Лекарство — мост: интерфейс logger.Interface у GORM маленький, четыре метода, и реализовать его на *zap.Logger — полчаса работы, которые соединяют оба потока в один. Здесь соберём мост целиком и разберём решения, которые в нём спрятаны.

Gorm logger zap: контракт logger.Interface#

Пакет gorm.io/gorm/logger описывает контракт: LogMode(level) Interface переключает минимальный уровень и возвращает логгера, Info, Warn и Error получают строку формата с аргументами для редких событий GORM — миграций и внутренних предупреждений, а Trace — сердце моста. Его вызывают после каждого SQL-вызова с тремя аргументами: контекст запроса, время начала и функция fc(), которая возвращает готовую SQL-строку и число затронутых строк, плюс err. Константы уровня свои: Silent=1, Error=2, Warn=3, Info=4 — выше, тем больше SQL попадает в лог. Уровни не совпадают с zap, и мост честно переводит: Error GORM — Error zap, Warn — Warn, трейс — Info или Debug по вашему выбору. Логирование gorm-слоя начинается с решения, куда направить обычные запросы: у нас они пойдут в Debug — иначе под Info утонет весь трафик приложения.

Каркас моста: структура и простые методы

GO
import (
        gormlogger "gorm.io/gorm/logger"
)

type ZapGormLogger struct {
        ZapLogger                 *zap.Logger
        LogLevel                  gormlogger.LogLevel
        SlowThreshold             time.Duration
        IgnoreRecordNotFoundError bool
}

func (l ZapGormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
        l.LogLevel = level
        return l
}

func (l ZapGormLogger) Info(ctx context.Context, format string, args ...any) {
        l.ZapLogger.Sugar().Infof(format, args...)
}

func (l ZapGormLogger) Warn(ctx context.Context, format string, args ...any) {
        l.ZapLogger.Sugar().Warnf(format, args...)
}

func (l ZapGormLogger) Error(ctx context.Context, format string, args ...any) {
        l.ZapLogger.Sugar().Errorf(format, args...)
}

Мост — обычная структура, реализующая интерфейс. LogMode возвращает копию с новым уровнем: так сделан и логгер GORM, значение-приёмник здесь безопасен. Info, Warn и Error у GORM приходят редкими пачками текста — их честнее всего отправлять через SugaredLogger, не выдумывая поля ради строк без структуры.

Trace: три исхода одного вызова#

Вся логика моста живёт в Trace, и исходов три. Первый — err не nil: запрос упал, уходим в Error с полем error, длительностью, числом строк и SQL — но с одной оговоркой, которой посвящён отдельный раздел ниже. Второй — ошибок нет, но время превысило SlowThreshold: медленный запрос уходит в Warn с полем elapsed рядом с порогом, и именно эти записи кормят дашборд производительности. Третий — обычный запрос: Info или Debug с длительностью и числом строк. Важная мелочь: fc() стоит времени — он собирает финальную строку SQL с подставленными значениями, поэтому его вызывают только когда уровень позволяет писать, а не безусловно. Экономия мелкая на запрос, но запросов тысячи, и в мостах, где fc() зовётся всегда, она видна в бенчмарках.

Реализация Trace

GO
func (l ZapGormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
        if l.LogLevel <= gormlogger.Silent {
                return
        }

        elapsed := time.Since(begin)

        switch {
        case err != nil && l.LogLevel >= gormlogger.Error &&
                (!errors.Is(err, gorm.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
                sql, rows := fc()
                l.ZapLogger.Error("sql failed",
                        zap.Error(err), zap.Duration("elapsed", elapsed),
                        zap.Int64("rows", rows), zap.String("sql", sql),
                )
        case l.SlowThreshold != 0 && elapsed > l.SlowThreshold && l.LogLevel >= gormlogger.Warn:
                sql, rows := fc()
                l.ZapLogger.Warn("slow sql",
                        zap.Duration("elapsed", elapsed), zap.Duration("threshold", l.SlowThreshold),
                        zap.Int64("rows", rows), zap.String("sql", sql),
                )
        case l.LogLevel >= gormlogger.Info:
                sql, rows := fc()
                l.ZapLogger.Debug("sql",
                        zap.Duration("elapsed", elapsed),
                        zap.Int64("rows", rows), zap.String("sql", sql),
                )
        }
}

Порядок веток — приоритет записи: ошибка важнее медлительности, медлительность важнее трейса. fc() вызывается внутри каждой ветки — строка SQL собирается только когда запись состоится. gorm.ErrRecordNotFound проверяется через errors.Is: обёрнутые ошибки всё равно распознаются.

SlowThreshold 200 мс: порог медленных#

SlowThreshold — главный рабочий параметр моста, и дефолт у GORM осознанный: двести миллисекунд. Это примерно граница, за которой интерактивный ответ уже чувствуется пользователем: быстрее — норма, дольше — повод посмотреть план запроса и индексы. Каждое превышение становится Warn-записью с elapsed и SQL — прямым материалом для алерта «медленный SQL растёт». Менять порог стоит не глобально, а по месту: отчётные запросы по своей природе медленные, и рубить их порогом в двести миллисекунд — заспамить Warn самим собой. У GORM для этого db.Session(&gorm.Session{Logger: мостСПорогом}) — отдельная сессия со своим мостом и, например, секундным порогом. Такую настройку честнее делать не ради одной функции, а через репозиторий отчётов: у медленного кода свой порог, у интерактивного — свой.

Настройки моста и их эффект#

ПолеРабочее значениеЭффект
LogLevelgormlogger.Warnтихий режим: ошибки и медленные; Info — всё SQL
SlowThreshold200 * time.Millisecondпорог Warn для медленных запросов
IgnoreRecordNotFoundErrortrueNotFound не превращается в Error-алерт
ZapLoggerlogger.Named("gorm")все записи с полем logger: gorm
Session{Logger: ...}свой мост на сессиюотчётные запросы живут с секундным порогом

Проверка: уровень медленного запроса

LogLevel — Warn, SlowThreshold — 200 мс, ошибок нет. Запрос выполнялся 350 мс. Что запишет мост?

IgnoreRecordNotFoundError: NotFound — не авария#

db.First(&u, id) на несуществующем ключе возвращает gorm.ErrRecordNotFound — и это нормальный поток приложения: проверка наличия записи, а не сбой. Дефолтный конфиг моста поднимает флаг IgnoreRecordNotFoundError, и NotFound не превращается в Error-запись: без него каждый 404-подобный сценарий заводил бы алерт, и через неделю алерты отключили бы целиком. Запись при этом не исчезает совсем — на уровне Info она остаётся как обычный трейс. Игнорировать NotFound стоит не всегда: если для вашей логики отсутствие записи — исключение, которое ломает инварианты, а не ожидаемая ветка, оставьте его ошибкой и поднимите флаг только в интерактивных сервисах. Как всегда с уровнями: правило задаёт бизнес-смысл, а не привычка.

Проверка: NotFound

IgnoreRecordNotFoundError = true. Что запишет мост для First() по несуществующему ID при LogLevel = Info?

Контекст: SQL-записи с request_id#

Trace принимает контекст, и мост может достать из него request_id посредника — но только если контекст вообще доехал до GORM. Вызов db.WithContext(r.Context()) прокидывает контекст запроса в слой базы, и в Trace он приходит тем же самым: мост вытаскивает идентификатор и добавляет полем к каждой SQL-записи. Без WithContext контекст пустой — background, и связка рвётся на первом же шаге: access-лог говорит, что запрос был, а SQL-записей с этим request_id нет. Практика, которая работает: репозитории принимают ctx первым аргументом и не зовут GORM без WithContext — правило закрепляется код-ревью, а не призывами. Проверка на два grep: один запрос, поиск request_id в access-записи и в SQL-записи — оба должны найтись.

Source location: откуда вызван SQL#

У моста GORM есть опция SourceLocation: с ней в запись добавляется файл и строка вызова — не моста, а места, где код позвал .Find или .Save. Разница с zap.AddCaller принципиальная: caller zap показал бы внутренности GORM, где лог реально писался, и был бы бесполезен. GORM решает это через utils.FileWithLineNum — обход стека до первого кадра вне orm. Опция отвечает на вопрос, который без неё разбирается глазами: один и тот же SELECT выполняется из пяти мест, и какое из них медленное? Цена — обход стека на каждый SQL-вызов, на фоне самого запроса она теряется, но включайте осознанно: в горячих циклах с тысячами мелких запросов разница видна. Рабочая схема: SourceLocation включена всегда, а выключают её по замеру, а не по вере.

Подключение моста к gorm.Open

GO
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
        Logger: ZapGormLogger{
                ZapLogger: logger.Named("gorm"),
                LogLevel:  gormlogger.Warn,
                SlowThreshold: 200 * time.Millisecond,
                IgnoreRecordNotFoundError: true,
        },
})
if err != nil {
        panic(err)
}

Мост подаётся как обычное поле конфига GORM. Named("gorm") даёт записям поле logger: gorm — их видно в системе сбора отдельным потоком. Для разового включения подробностей по цепочке есть db.Debug(): он поднимает уровень до Info только у своего вызова, не трогая глобальные настройки.

Почему SQL-параметры в логах — плохая идея#

Вот место, где мост становится оружием. fc() возвращает SQL уже с интерполированными значениями — так устроен дефолтный вывод GORM: вместо плейсхолдеров $1 в записи стоит готовый запрос. Значит, в лог с каждым трейсом уходят значения аргументов: почта пользователя, номер карты, токен из WHERE, поле с персональными данными из INSERT. Логи читают шире, чем базу: дежурные, системы сбора, бэкапы логов — и регуляторные требования к ним жёстче. Мой рабочий компромисс, за который меня ругали: в интерактивных сервисах SQL пишем на Debug и маскируем значения, в системных интеграциях — пишем как есть, но канал с ограниченным доступом и коротким сроком хранения. Универсального ответа нет, но осознанное решение должно быть: «просто логировать SQL» — это решение писать персональные данные всем, у кого есть доступ к логам.

Что запишет молчащий мост?

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

bridge := ZapGormLogger{
        ZapLogger: logger.Named("gorm"),
        LogLevel:  gormlogger.Silent,
        SlowThreshold: 200 * time.Millisecond,
}

db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: bridge})

db.Raw("SELECT pg_sleep(1)").Scan(&result)

Соберите Trace

Восстановите каркас Trace: длительность, SQL, запись ошибки.

elapsed := time.(begin) sql, rows := () if err != nil { l.ZapLogger.("sql failed", zap.Error(err), zap.String("sql", sql)) }

Соберите ветвление Trace

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

Строки

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

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

Карточки: GORM и zap

Термин

logger.Interface

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

Контракт GORM: LogMode, Info, Warn, Error и Trace(ctx, begin, fc, err) — всё SQL-логирование живёт в Trace.

1 / 6

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

Мост встраивается в конвейер из уроков про HTTP-middleware и request id: SQL-записи с request_id соединяются с access-логом в одну хронологию. Цену логирования каждого запроса в цифрах считают в материале про производительность zap, а полю error из ветки ошибки место в уроке про zap.Error и стек-трейсы. Подключение самой GORM и DSN разбирались в уроке про подключение к базе курса GORM; карта остальных тем — на странице курса.

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

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

Вывод

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

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

  • Мост — реализация logger.Interface: LogMode, Info, Warn, Error и сердцевина Trace.
  • Trace разбирает три исхода: ошибка — Error, превышение порога — Warn, обычный запрос — Info или Debug.
  • SlowThreshold 200 мс — дефолт GORM; для отчётных запросов порог поднимают отдельной сессией.
  • IgnoreRecordNotFoundError оставляет NotFound обычным трейсом: это поток приложения, а не авария.
  • fc() возвращает SQL с подставленными значениями: персональные данные уходят в лог вместе с запросом.
  • SourceLocation показывает место вызова GORM: caller zap без неё бесполезен.
  • db.Debug() — разовая лупа: Info на одну цепочку вызовов.

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

Реализуйте ZapGormLogger целиком и подключите к локальной базе: Named("gorm"), LogLevel Warn, порог 200 мс, IgnoreRecordNotFoundError true. Проверьте три сценария по JSON-выводу: упавший запрос на несуществующей таблице — Error, запрос с pg_sleep(0.3) — Warn с elapsed, First() по отсутствующему ID — тишина. Затем поднимите LogLevel до Info и найдите в записях поле logger со значением gorm. После этого вызовите тот же репозиторий без WithContext и найдите отсутствие request_id в SQL-записях — связка рвётся ровно на этом шаге.

Личные заметкиПусто
Хранятся только в вашем браузере (Zap · GORM и zap: логи базы данных)

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

Почему бы не использовать logger.Default с перенаправлением вывода?

logger.Default пишет через стандартный log в заданный io.Writer — текстом, без полей. Его можно направить в файл или буфер, но в JSON-конвейер он поля не принесёт. Мост нужен именно ради структуры: elapsed, rows, sql как отдельные значения.

Куда направлять обычные SQL-запросы — в Info или Debug?

В Debug: полный трейс каждого запроса полезен при разборе и вреден в постоянном потоке. Info оставьте для окружений, где SQL-лог ведут намеренно — локальная разработка, воспроизведение бага под флагом.

Как связать SQL-записи с конкретным HTTP-запросом?

Через контекст: если Trace принимает ctx с request_id от middleware, мост может достать идентификатор и добавить полем. GORM передаёт контекст с WithContext в вызовах — прокинуть его в мост можно вытащив логгер из request context.

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

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

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

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

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

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

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

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

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