Go slog Attr 里的切片为什么会延迟改变日志内容
如果 slog.Attr 里放的是切片,日志内容在稍后才发生变化,通常不是 slog 把字符串“缓存错了”,而是 slog.Value 保存了切片这个引用值。标准文本或 JSON Handler 会在 Handle 里同步处理;但自定义 Handler、队列或 LogValuer 把处理时间推迟后,看到的就是那一刻切片里的内容。
需要稳定日志内容,就在把切片放进 Attr 前复制一份;需要自定义 Handler 时,还要明确 Record、Attr 和底层切片分别由谁拥有。
先看清 Attr 保存了什么
slog.Attr 本身是键和值,slog.Any("items", items) 不会把任意类型的切片逐元素复制成新的切片。它把切片作为一个 Go 值放进 slog.Value,而切片仍然指向原来的底层数组。
因此要分开两个时间点:
- 调用
logger.Info时,标准 Handler 通常马上格式化并写出,随后修改切片不会倒改已经写出的那一行。 - 如果 Handler 先保存
slog.Record,或者 Value 内部通过LogValuer延迟计算,切片在真正解析前被改动,就可能出现“日志内容延迟改变”。
官方文档还特别说明,GroupValue 返回的值会使用传入的 Attr 列表,调用者之后不能再修改那一段切片;这不是实现细节,而是 API 契约。

用一个延迟 Handler 复现问题
下面的代码只做一个小实验:Handler 收到 Record 后先存起来,等到 Flush 才解析 Attr。这里的“延迟”是示例 Handler 设计出来的,不能代表内置 Handler 的默认输出时机。
package main
import (
"context"
"fmt"
"log/slog"
)
// delayedHandler 模拟异步日志队列,只保存 Record,不立即解析 Attr。
type delayedHandler struct {
records []slog.Record
}
func (h *delayedHandler) Enabled(context.Context, slog.Level) bool { return true }
// Handle 先入队,真正读取切片内容的动作放到 Flush。
func (h *delayedHandler) Handle(_ context.Context, r slog.Record) error {
h.records = append(h.records, r)
return nil
}
// WithAttrs 和 WithGroup 只是为了满足 Handler 接口;本实验没有使用它们。
func (h *delayedHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *delayedHandler) WithGroup(string) slog.Handler { return h }
func (h *delayedHandler) Flush() {
for _, record := range h.records {
record.Attrs(func(attr slog.Attr) bool {
// Resolve 让 LogValuer 在真正输出前完成解析。
fmt.Printf("%s=%s\n", attr.Key, attr.Value.Resolve().String())
return true
})
}
}
func main() {
handler := &delayedHandler{}
logger := slog.New(handler)
items := []string{"before"}
// Any 保存的是切片值;这里没有创建独立副本。
logger.Info("queued", slog.Any("items", items))
// Flush 之前修改底层数组,队列稍后读取时会看到 after。
items[0] = "after"
handler.Flush()
}
这个实验的关键不是输出格式,而是所有权:队列保存了 Record,却没有把 Attr 中的引用值转换成不可变数据。若改成内置 slog.NewTextHandler 直接写入,Info 返回前就会完成这次处理,后面的赋值不会改变已写出的文本。
建立不可变快照的三种做法
最直接的是在构造 Attr 前复制切片。复制的是元素数组,不是只复制切片头;如果元素本身还是指针或包含引用字段,还要继续判断元素的可变性。
func snapshot(items []string) []string {
// append 到 nil 切片会分配独立底层数组,避免后续复用 items 的存储。
return append([]string(nil), items...)
}
func attrForLog(items []string) slog.Attr {
// 先建立快照,再交给 slog;异步 Handler 读取到的就是这一版。
return slog.Any("items", snapshot(items))
}
第二种是把快照封装到一个值类型里,让 LogValue 每次返回新的 Attr。注意不要在 LogValue 里直接返回仍会被业务代码复用的切片;如果日志可能异步解析,应在进入日志对象时就完成复制,而不是把复制拖到不确定的未来。
第三种是修复自定义 Handler 的所有权边界。官方 Handler 契约允许 WithAttrs 保留、修改或丢弃传入的 Attr 切片,因此 Handler 不能假定调用方会永远不改它;如果 Handler 需要长期保存,就复制 Attr 列表,并对其中的引用型 Value 设计明确的快照策略。
异步日志里最容易漏掉的边界
第一,复制 []slog.Attr 只保护 Attr 数组本身,不会深拷贝 Value.Any() 里的 []byte、[]string、map 或指针对象。第二,Record 的副本也可能共享内部状态;官方文档建议修改或跨边界保存 Record 前使用 Record.Clone,但 Clone 解决的是 Record 的共享状态,不会替你深拷贝任意业务值。
第三,多个 goroutine 一边复用缓冲区、一边把它交给异步日志,会同时引入内容漂移和数据竞争。正确做法是让生产者把所有权转移给队列,或者在入队前复制;不要一边继续填充同一数组,一边期待日志系统替你冻结它。

可以按下面的速查表判断:
| 场景 | 风险 | 建议 |
|---|---|---|
| 内置 Handler 立即写出 | 后续修改通常不影响已输出行 | 仍避免把复用缓冲区暴露给并发代码 |
| 队列保存 Record | 处理时读到修改后的引用值 | 入队前复制业务切片,必要时 Clone Record |
| GroupValue 使用 Attr 切片 | 调用者后续修改违反契约 | 构造完成后不再修改该切片 |
| 切片元素含指针或 map | 浅复制后内部对象仍可变 | 按业务需要做深快照或转成不可变字段 |
相关问题
slog.Any 会不会自动复制切片?
不会把任意切片自动深拷贝。它把值交给 slog.Value,是否在后续读取取决于 Handler 的处理时机。
为什么普通 logger.Info 看不出这个问题?
内置 Handler 的 Handle 在这次调用链里同步格式化,修改发生在返回之后时,通常已经错过了影响当前日志的时间点。
只调用 Record.Clone 就够了吗?
不够。Clone 保护 Record 的共享状态;Attr 中的切片、map 和指针仍需按业务要求单独建立快照。
归根结底,slog 并不承诺把所有业务值变成不可变副本。把数据交给异步 Handler 前先确定所有权,并把“何时解析”和“谁能修改”写成接口约束,才是解决这类日志漂移的稳定办法。
Go httptest.NewRequest 与 http.NewRequest 测试服务端请求时有什么区别
- 上一篇
- Go httptest.NewRequest 与 http.NewRequest 测试服务端请求时有什么区别
- 下一篇
- Go slog Handler WithGroup 后字段名为什么变了
-
- Golang · Go问答 | 17分钟前 |
- Go httptest.NewRequest 与 http.NewRequest 测试服务端请求时有什么区别
- 241浏览 收藏
-
- Golang · Go问答 | 20分钟前 |
- Go embed.FS 路径以斜杠开头为什么读取失败
- 387浏览 收藏
-
- Golang · Go问答 | 21分钟前 | CGO · Go问答 · Go 垃圾回收 Go指针 CGO runtime/cgo.Handle C指针 runtime.Pinner
- Go cgo 指针规则为什么不允许保存 Go 指针
- 471浏览 收藏
-
- Golang · Go问答 | 28分钟前 |
- Go simd 代码在 WebAssembly 上为什么需要不同构建路径
- 493浏览 收藏
-
- Golang · Go问答 | 32分钟前 | 并发 · 标准库 · 单元测试 · 随机数 · Go问答 · Go 并发测试 math/rand/v2 测试复现 rand.Source rand.Rand rand.NewPCG
- Go rand/v2 使用全局源为什么难以复现测试
- 331浏览 收藏
-
- Golang · Go问答 | 35分钟前 | 结构体 · Go问答 · Go struct embedded field promoted field composite literal
- Go 结构体嵌入字段字面量与提升字段访问为什么不同
- 455浏览 收藏
-
- Golang · Go问答 | 35分钟前 |
- Go 泛型方法的接收者类型为什么影响类型推导
- 270浏览 收藏
-
- Golang · Go问答 | 38分钟前 | 模块 · go · Go replace go.mod go doc pkg@version
- Go doc pkg@version 为什么找不到本地 replace 模块
- 497浏览 收藏
-
- Golang · Go问答 | 42分钟前 | API · 开发工具 · Go问答 · Go 版本选择 pkg.go.dev API semantic version 伪版本 模块查询
- Go pkg.go.dev API 返回多个版本时怎样选目标版本
- 407浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 1017次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 1020次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 1148次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 1120次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 962次使用
-
- 用Nginx反向代理部署go写的网站。
- 2023-01-17 502浏览
-
- GoLand调式动态执行代码
- 2023-01-13 502浏览
-
- Go types.Selection.Type 与 Obj.Type 为什么不总是相同
- 2026-11-10 501浏览
-
- Go flag.FlagSet ContinueOnError 怎么捕获帮助输出和解析错误
- 2026-11-05 501浏览
-
- Go netip.ParseAddr 为什么拒绝带端口字符串
- 2026-11-03 501浏览

