Go slog JSON 日志为什么混入不可序列化字段
来源:17golang原创
时间:2026-10-23 03:34:29 470浏览 收藏
我第一次看到这类日志时,以为是采集器把 JSON 改坏了:某个字段明明传入了对象,最终却变成了 !ERROR:json: unsupported type: chan int。真正的原因通常在应用进程内:slog.JSONHandler 会把 Any 值交给 encoding/json,而 channel、函数、复数等值不能直接编码。编码失败时,Handler 不返回错误,而是把错误文本写回当前字段。
看到!ERROR:json,先不要查日志平台。先检查这个字段及其嵌套值是否包含 JSON 不支持的运行时对象;修复方式是记录稳定摘要,而不是把整个对象原样塞进slog.Any。
本文只讨论 Go 标准库 log/slog 的 JSON 输出边界,不讨论采集器、索引模板或日志平台的二次加工。
为什么日志字段会变成 !ERROR:json
JSONHandler 的普通属性会按 encoding/json.Encoder 编码。顶层 Attr 如果直接放入一个 error,Handler 会调用它的 Error 方法;但嵌套在 map、slice 或结构体里的值,仍由 JSON 编码器整体处理。

例如,下面把一个 channel 藏进 map。问题不在 map 本身,而在 map 的值树里出现了 chan int:
package main
import (
"bytes"
"log/slog"
"os"
)
func main() {
// 用 JSONHandler 观察 Any 值的编码边界;channel 不能被 encoding/json 编码。
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
logger.Info("request finished", "payload", map[string]any{
"request_id": "req-17",
"stream": make(chan int),
})
// 这里把说明结果写到标准输出;日志内容仍是一条 JSON 记录。
_, _ = os.Stdout.Write(buf.Bytes())
}
{"level":"INFO","msg":"request finished","payload":"!ERROR:json: unsupported type: chan int"}
这也解释了两个容易误判的现象:第一,整条日志通常还在,只有当前字段被替换;第二,调用方感知不到一个普通的编码返回错误,因为 JSONHandler.Handle 会把编码失败格式化为字符串后继续写出。
先区分三种值,定位会快很多
排查时我会把字段拆成三类,而不是笼统地说“JSON 有问题”。
| 值的来源 | 典型写法 | 处理结果 |
|---|---|---|
| 可直接编码的值 | slog.String、数字、布尔、普通结构体 | 按 JSON 值输出 |
| 顶层 error Attr | slog.Any("err", err) | 调用 Error(),输出字符串 |
| 嵌套不可编码值 | map、slice、结构体里含 channel 或 func | 当前字段变为 !ERROR:json: ... |
因此,看到这类字符串时,先回到产生该字段的 slog.Any 或 Logger.With 调用,沿着 map、slice 和结构体继续找,不要只盯着字段名。
把运行时对象改成稳定摘要
最小修正不是把值强行转成字符串,而是定义日志需要的字段。连接、channel、函数、锁或请求对象往往带有运行时状态,直接序列化既可能失败,也可能泄露不该进入日志的内容。

如果一个类型经常被记录,可以让它实现 slog.LogValuer。下面只暴露状态和容量,不把内部 channel 写进日志:
package main
import "log/slog"
type workerPool struct {
name string
jobs chan int
}
// LogValue 把运行时对象收敛为稳定字段,避免把 channel 传给 JSONHandler。
func (p workerPool) LogValue() slog.Value {
// 只输出读者排障真正需要的摘要,不泄露内部任务内容。
return slog.GroupValue(
slog.String("name", p.name),
slog.Int("capacity", cap(p.jobs)),
)
}
func logPool(logger *slog.Logger, pool workerPool) {
// LogValuer 会在 Handler 处理该属性时解析为可编码的 group。
logger.Info("worker pool ready", slog.Any("pool", pool))
}
如果对象只使用一次,也可以在调用点显式构造 slog.Group。关键是把“日志模型”和“运行时对象”分开:
// 只记录稳定摘要;不要把 pool.jobs 原样放入 slog.Any。
logger.Info("worker pool ready",
slog.Group("pool",
slog.String("name", pool.name),
slog.Int("capacity", cap(pool.jobs)),
),
)
修复后怎么判断已经处理对了
复查时我只看日志字段的契约,不追求把对象“完整打印出来”。可以按下面三点检查:
- 同一种对象在不同调用点输出相同的字段名和类型,不能一处是对象、一处是长字符串。
- 字段树里不再包含 channel、func、不可控的自定义 Marshaler 等运行时值;必要信息已经转换为字符串、数字或 group。
- 敏感字段在
LogValue或显式 group 中被主动省略,不能因为“方便排查”把整个请求对象带进日志。
如果仍出现 !ERROR:json,继续沿着被替换的字段向下拆分。尤其要注意:一个顶层结构体即使看起来普通,只要某个导出字段或自定义 MarshalJSON 返回错误,整个该字段仍可能触发 JSONHandler 的兜底文本。
常见问题
为什么直接记录 error 通常没有这个问题?
因为 JSONHandler 对作为 Attr 值的 error 有专门处理,会调用 Error() 输出字符串。但 error 如果嵌在 map、slice 或结构体内部,就回到 encoding/json 的普通规则,不能把这个特例扩大到整个对象树。
能不能用 fmt.Sprint 统一解决?
可以临时止血,但会丢掉结构化字段,也可能把内部状态和敏感内容一股脑写进日志。长期方案优先使用显式 slog.Group 或 LogValuer,让字段集合成为稳定契约。
为什么不直接让 Handler 返回错误?
这是内置 JSONHandler 的设计:单条记录的某个值编码失败时,Handler 用 !ERROR: 字符串保留这条日志。应用如果需要严格失败策略,应在进入 slog 前自行构造可编码的日志 DTO,而不是依赖 Handler 作为数据校验器。
总结一下:!ERROR:json 不是日志平台凭空混入的字段,而是 slog.JSONHandler 对编码失败的可见兜底。找到产生 slog.Any 的对象,拆掉不可序列化运行时值,再用稳定摘要或 LogValuer 输出,问题就会从“日志偶尔变形”变成可维护的字段契约。
-
502 收藏
-
502 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
343 收藏
-
333 收藏
-
423 收藏
-
179 收藏
-
246 收藏
-
451 收藏
-
375 收藏
-
427 收藏
-
129 收藏
-
196 收藏
-
325 收藏
-
264 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习