Go 用 slog 打结构化日志:分级、上下文字段与自定义 Handler 实战线上出了问题,你grep日志想按user_id把一次请求的所有日志串起来,结果发现日志长这样:2026/08/07 09:12:33 order created for user 8812 amount 199.00 2026/08/07 09:12:33 payment failed: gateway timeout第二行根本不知道是哪个用户、哪个订单。用fmt.Sprintf拼出来的日志,机器没法解析,人也串不起来。Go 1.21 把log/slog收进标准库,专治这个病。这篇手把手把它用明白。先看朴素写法的问题老代码通常是这样:log.Printf(order created for user %d amount %.2f,userID,amount)问题有三个:字段名和值混在一句话里,日志系统(Loki、ELK)无法结构化索引;级别只有一个,线上想只看 error 得靠 grep;想给一次请求的所有日志都带上request_id,得每行手动拼。slog 三分钟上手slog的核心是 key-value 结构化输出。默认给你一个文本 Handler:packagemainimport(log/slogos)funcmain(){// JSONHandler 直接吐 JSON,喂给日志采集器最省事logger:slog.New(slog.NewJSONHandler(os.Stdout,nil))logger.Info(order created,slog.Int(user_id,8812),slog.Float64(amount,199.00),)}输出:{time:2026-08-07T09:12:33Z,level:INFO,msg:order created,user_id:8812,amount:199}现在user_id是独立字段,采集器能直接按它建索引、做聚合。slog.Int/slog.Float64这类强类型辅助函数比裸传user_id, 8812更快(避免any装箱),高频日志路径上值得用。也可以设成全局默认 logger,然后直接用包级函数:slog.SetDefault(logger)slog.Warn(gateway slow,latency_ms,820)// 无需再传 logger分级:让线上只看你想看的slog内置 Debug/Info/Warn/Error 四级。生产环境通常只想要 Info 以上,本地调试才开 Debug。通过HandlerOptions.Level控制:opts:slog.HandlerOptions{Level:slog.LevelInfo,// 低于 Info 的直接丢弃,零开销}logger:slog.New(slog.NewJSONHandler(os.Stdout,opts))logger.Debug(cache hit,key,u:8812)// 不会输出logger.Error(payment failed,err,gateway timeout)想运行时动态调级别(比如线上临时开 Debug 排查),用slog.LevelVar:varlvlnew(slog.LevelVar)// 默认 Infolvl.Set(slog.LevelInfo)logger:slog.New(slog.NewJSONHandler(os.Stdout,slog.HandlerOptions{Level:lvl}))// 收到信号后热切到 Debug,不用重启进程lvl.Set(slog.LevelDebug)LevelVar内部用原子操作,并发调Set/Level是安全的。上下文字段:把一次请求的日志串起来这才是结构化日志真正省事的地方。用With派生一个带固定字段的子 logger,之后每条日志自动带上:funchandleOrder(reqIDstring,userIDint){// 派生一次,后面所有日志都带 request_id 和 user_idlog:slog.With(slog.String(request_id,reqID),slog.Int(user_id,userID),)log.Info(order created,amount,199.00)log.Error(payment failed,err,gateway timeout)}两行日志都会带上request_id,线上直接按它一 grep 就把整条链路捞出来了。With返回的是新 logger,不改原来的,并发安全。配合context.Context跨函数传递更自然,用InfoContext系列方法:typectxKeystruct{}funcwithLogger(ctx context.Context,l*slog.Logger)context.Context{returncontext.WithValue(ctx,ctxKey{},l)}funcloggerFrom(ctx context.Context)*slog.Logger{ifl,ok:ctx.Value(ctxKey{}).(*slog.Logger);ok{returnl}returnslog.Default()}funcchargeUser(ctx context.Context){// 从 ctx 拿到带 request_id 的 logger,深层函数也能续上同一条链路loggerFrom(ctx).InfoContext(ctx,charging)}分组:给字段加命名空间字段多了容易撞名(两个子系统都叫id)。用WithGroup或slog.Group加前缀:logger.Info(request done,slog.Group(http,slog.String(method,POST),slog.Int(status,200),),)// JSON: http:{method:POST,status:200}自定义 Handler:脱敏与字段改写真实项目里常有两个需求:密码/token 不能进日志,时间字段要换格式。用HandlerOptions.ReplaceAttr在写出前拦截每个字段:opts:slog.HandlerOptions{ReplaceAttr:func(groups[]string,a slog.Attr)slog.Attr{// 把 password 字段的值统一抹掉ifa.Keypassword{returnslog.String(password,***)}// 把内置 time 字段换成 Unix 秒ifa.Keyslog.TimeKeylen(groups)0{returnslog.Int64(ts,a.Value.Time().Unix())}returna},}logger:slog.New(slog.NewJSONHandler(os.Stdout,opts))logger.Info(login,user,amy,password,hunter2)// {ts:1754557953,level:INFO,msg:login,user:amy,password:***}ReplaceAttr每个字段都会走一遍,高频路径上别写太重的逻辑。返回一个Key为空的Attr可以彻底丢弃该字段。一个容易踩的坑:奇数个参数slog支持logger.Info(msg, key, val)这种松散写法,但如果 key-value 个数对不上(漏了一个值),它不会 panic,而是把落单的那个当成一个特殊的!BADKEY字段:logger.Info(oops,user_id)// 少了值// ...,!BADKEY:user_id} —— 静默出错,不易发现高频或关键日志建议一律用slog.Int/slog.String这类强类型形式,既快又不会漏配对。可以用go vet(启用sloglint或第三方 linter)在 CI 里把松散写法的配对问题挡住。小结slog是 Go 1.21 标准库,输出结构化 key-value,机器可索引、人可串链路,取代log.Printf拼字符串。用JSONHandler喂日志采集器;HandlerOptions.Level配LevelVar可运行时热调级别,不用重启。With/slog.With派生带固定字段的子 logger,把request_id一次带上,是串联一次请求所有日志的关键。ReplaceAttr做脱敏和字段改写;强类型slog.Int等既快又避免!BADKEY静默配对错误。记忆点:别再 Printf 拼日志了——把值当字段传,让机器帮你串链路。
网站建设
高端定制
企业官网