直接用Zap.Info写SQL日志会丢字段,因其仅支持键值对,无法结构化记录SQL、参数、耗时、行数、错误等多维信息;正确做法是用zap.String("sql",q)、zap.Any("args",a)、zap.Duration("duration",d)、zap.Int64("rows",r)、zap.Error(err)等独立字段确保可筛选可聚合。

为什么直接用 Zap 的 Info 写 SQL 查询日志会丢字段?
因为 Zap 默认的 Info、Debug 等方法只接受键值对(zap.String、zap.Int 等),而查询日志的核心信息——SQL 语句、参数、耗时、影响行数、错误——需要同时携带语义和结构。如果硬塞进一个 zap.String("query", sql),后续想按「耗时 > 500ms」筛选或聚合「慢查询 TOP 10」就只能靠正则硬解析,完全失去结构化意义。
正确做法是定义专用日志字段,确保每个关键维度独立可查:
-
zap.String("sql", query)—— 原始 SQL(带占位符) -
zap.Any("args", args)—— 参数列表(注意:args必须是可序列化类型,如[]interface{}或[]any) -
zap.Duration("duration", elapsed)—— 耗时(Zap原生支持,Kibana/Elasticsearch 可直接做直方图) -
zap.Int64("rows", rowsAffected)—— 影响行数(避免用int,防止 32 位平台溢出) -
zap.Error(err)—— 错误时自动展开堆栈,不手动转字符串
如何让 GORM / sqlx / database/sql 的钩子自动注入 Zap 字段?
不能在每个 db.Query 后手写一堆 zap.Xxx。要利用驱动层钩子统一埋点。以 database/sql 为例,需包装 sql.Conn 或使用 driver.Driver 包装器;但更简单的是用 sqlx 的 Queryx + 中间件风格封装:
func (l *Logger) QueryWithLog(ctx context.Context, db *sqlx.DB, query string, args ...interface{}) (sqlx.Rows, error) {
start := time.Now()
rows, err := db.QueryxContext(ctx, query, args...)
elapsed := time.Since(start)
fields := []zap.Field{
zap.String("sql", query),
zap.Any("args", args),
zap.Duration("duration", elapsed),
zap.Error(err),
}
if err == nil {
// 注意:Rows 不提供影响行数,此处仅示意;实际可记录 "type": "query"
fields = append(fields, zap.String("type", "select"))
} else {
fields = append(fields, zap.String("type", "query_error"))
}
l.Logger.Info("db_query", fields...)
return rows, err
}
关键点:
- 别在钩子里调
defer记录耗时——defer执行时机不可控,可能跨 goroutine,time.Since失准 -
zap.Any对[]interface{}序列化安全,但对含func、chan、unsafe.Pointer的值 panic,生产环境建议预过滤 - 若用 GORM,优先实现
GORM Logger接口,重写Trace方法,而非劫持底层database/sql
JSON 日志里 SQL 参数被转成 base64 怎么办?
这是 zapcore.EncoderConfig.EncodeLevel 或编码器配置导致的。默认 jsonEncoder 对非基本类型(如 []interface{})会 fallback 到 fmt.Sprintf("%+v"),而某些 Zap 版本(尤其 v1.24+)为防 JSON 注入,对疑似二进制内容自动 base64 编码。
解决方法只有两个:
- 显式用
zap.Array替代zap.Any,并提供自定义zapcore.ArrayMarshaler,确保参数被扁平序列化为 JSON 数组 - 降级使用
zap.String("args", fmt.Sprintf("%v", args))(仅调试用,丢失类型信息)
推荐前者,例如:
type argsArray []interface{}
func (a argsArray) MarshalLogArray(enc zapcore.ArrayEncoder) error {
for _, arg := range a {
switch v := arg.(type) {
case string:
enc.AppendString(v)
case int, int64, float64, bool:
enc.AppendReflected(v)
default:
enc.AppendString(fmt.Sprintf("%v", v))
}
}
return nil
}
// 使用时:
zap.Array("args", argsArray(args))
上线后发现日志体积暴涨,怎么平衡结构化和磁盘开销?
结构化日志的代价是字段重复(每个日志行都带 "sql"、"duration" 等 key)。Zap 本身已极致优化,但高频查询场景下仍可能成为瓶颈。
可操作的压缩策略:
- 对只读查询(
SELECT)降低采样率:if rand.Float64() ,写操作全量保留 - 禁用非必要字段:生产环境去掉
zap.Any("args"),改用zap.String("args_summary", summarizeArgs(args))(例如只打前 3 个参数 + 类型) - 用
zap.Stringer延迟格式化:把耗时转换逻辑包进fmt.Stringer,避免日志未启用时也执行time.Since - 确认日志输出目标——写文件时用
lumberjack轮转,但发到 Loki/ES 时,字段名长度影响索引大小,建议用短 key:"q"代替"sql","t"代替"duration"(需团队约定)
最易被忽略的一点:Zap 的 Logger.With 返回新实例,若在循环内反复 With(zap.String("trace_id", ...)) 而不复用,会导致内存分配激增。查询日志应尽量用一次 Logger 实例 + 显式字段传入。


















