slog 自定义 Handler 怎样批量提交结构化日志
slog.Handler 要批量提交结构化日志,推荐把它设计成“同步快照、并发入队、单协程聚合”:Handle 在调用协程里把 slog.Record 转成不可变事件,然后写入有界队列;后台工作协程按条数或时间窗口组成批次,再调用 Sink.WriteBatch。这样既能减少网络往返,也能把并发、背压和关闭刷新边界讲清楚。
Handler的方法可能被并发调用,共享状态必须自行同步。WithAttrs、WithGroup必须返回新 Handler,不能修改原接收者。- 异步处理前先建立事件快照,避免稍后读取已经变化的 Record 或业务对象。
- 队列满时必须明确选择阻塞、丢弃或同步回退;本文示例选择背压。
Close要停止入口、排空队列并返回异步提交错误。
官方文档:https://pkg.go.dev/log/slog
接口目标:Handle 只做快照和入队
slog.Logger 在日志级别通过 Enabled 检查后构造 Record,再调用 Handler 的 Handle。如果每次 Handle 都直接请求远端接口,请求线程会承担完整网络延迟;如果为每条日志启动 goroutine,又会失去并发上限和关闭边界。
更容易维护的结构是:多个 Handler 视图共享一个提交核心。Handler 视图只保存 WithAttrs 和 WithGroup 形成的不可变派生状态;提交核心拥有有界队列、唯一批量工作协程和 Sink。所有网络提交都发生在这个工作协程里,Sink 因此不需要处理多个批次同时写入。

调用方需求:吞吐、尾延迟和可靠性要分开决定
| 需求 | 对应设计 | 需要接受的代价 |
|---|---|---|
| 减少网络请求 | 达到 batchSize 后一次提交 | 低流量日志会等待成批 |
| 限制等待时间 | flushEvery 到期时提交残留 | 批次可能小于 batchSize |
| 不静默丢日志 | 有界队列满时阻塞 Handle | 日志高峰会反压业务协程 |
| 业务协程绝不阻塞 | 非阻塞入队并统计丢弃量 | 必须接受并监控日志损失 |
| 进程退出前尽量提交 | Close 关闭入口并排空队列 | 关闭过程可能等待远端 Sink |
没有一种队列策略适合所有业务。审计日志通常宁愿阻塞或落盘,也不能静默丢弃;高频调试日志则可能更适合采样或丢弃。策略应由日志用途决定,而不是藏在 Handler 内部成为偶然行为。
参数设计:先生成事件快照,再交给工作协程
slog.Record.Clone 会生成不共享内部状态的副本,但异步 Handler 还要考虑 Attr 中的 LogValuer 或可变对象。本文示例在 Handle 内完成 Resolve 和 JSON 安全转换,把值固定成字符串、数字、布尔值或普通 JSON 数据,再入队。这样工作协程只处理自己的 Event,不会晚一步读取业务对象。
分组顺序也不能被压扁成“所有 attrs 加所有 groups”。例如 WithGroup("request").With("id", 7).WithGroup("db") 中,id 属于 request,不属于 request.db。因此代码使用有序的 scopeItem 保存每次 WithAttrs 或 WithGroup。

完整示例:按条数或周期批量提交
下面的代码把传输抽象成 Sink。示例选择可靠性优先的背压:队列已满时,Handle 等待工作协程腾出空间。异步 Sink 错误记录为首个错误,并在 Close 时返回;示例不会自动重试失败批次,生产环境可在 Sink 层增加有限重试或磁盘缓冲。
package batchslog
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
)
var ErrClosed = errors.New("batch slog handler is closed")
// Event 是提交给远端的不可变结构化事件。
type Event struct {
Time time.Time `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
Fields map[string]any `json:"fields"`
}
// Sink 负责同步消费一个批次;返回前不得继续持有 batch 切片。
type Sink interface {
WriteBatch(ctx context.Context, batch []Event) error
}
type scopeItem struct {
group string
attrs []slog.Attr
}
type core struct {
sink Sink
queue chan Event
done chan struct{}
batchSize int
flushEvery time.Duration
mu sync.RWMutex
closed bool
stop sync.Once
errMu sync.Mutex
err error
}
type BatchHandler struct {
core *core
minLevel slog.Level
state []scopeItem
}
// New 创建共享提交核心,并启动唯一的批量工作协程。
func New(sink Sink, queueSize, batchSize int, flushEvery time.Duration) *BatchHandler {
c := &core{
sink: sink,
queue: make(chan Event, queueSize),
done: make(chan struct{}),
batchSize: batchSize,
flushEvery: flushEvery,
}
go c.run()
return &BatchHandler{core: c, minLevel: slog.LevelInfo}
}
func (h *BatchHandler) Enabled(_ context.Context, level slog.Level) bool {
// 尽早过滤低级别日志,避免构造字段快照。
return level >= h.minLevel
}
func (h *BatchHandler) Handle(_ context.Context, r slog.Record) error {
// 在调用协程中解析所有值,工作协程不再访问业务对象。
e := makeEvent(r.Clone(), h.state)
h.core.mu.RLock()
defer h.core.mu.RUnlock()
if h.core.closed {
return ErrClosed
}
// 有界队列满时显式背压;不要用 context 取消来跳过日志。
h.core.queue = c.batchSize {
flush()
}
case
错误处理:Handle 成功不等于远端已经写入
异步 Handler 的 Handle 最多只能说明“事件已快照并进入本地队列”。真正的远端错误发生在另一个 goroutine,无法原样同步返回给当前日志调用。示例把首个异步错误保存到 core,并由 Close 返回;实际服务还应把提交失败数、队列深度、批次大小和提交耗时暴露成指标。
示例在 Sink 失败后丢弃该内存批次,因此属于有限可靠性的骨架。如果日志必须持久保存,可在 Sink 中增加有上限的退避重试,或先写本地 WAL 再确认消费。不要无限重试同一失败批次,否则队列会被一个永久错误堵死。
兼容策略:不要破坏 WithAttrs 与 WithGroup
Go 官方 Handler 指南强调,WithAttrs 和 WithGroup 要返回新的 Handler,原 Handler 保持不变,而且两种调用的顺序必须被保留。本文让派生视图共享 core,但复制 state 切片,因此新 Logger 的分组与固定属性不会污染旧 Logger。
示例把分组键扁平化为 request.db.duration。如果远端支持嵌套 JSON,可以把 makeEvent 改成构造嵌套 map;只要不同分组序列最终产生不同键空间,并保持空组、重复键和 Group 的约定一致即可。
调用示例与关闭顺序
// HTTPBatchSink 是业务提供的远端批量提交实现。
sink := NewHTTPBatchSink("https://logs.example.internal/v1/batch")
handler := batchslog.New(sink, 2048, 100, 2*time.Second)
logger := slog.New(handler)
// 固定属性与分组会保存在派生 Handler 中,不修改原 logger。
requestLog := logger.WithGroup("request").With(
slog.String("service", "checkout"),
)
requestLog.Info("payment accepted",
slog.String("order_id", "A-1024"),
slog.Int64("cost_ms", 38),
)
// 先停止产生新日志的 goroutine,再关闭并检查最后一次批量提交。
if err := handler.Close(); err != nil {
// 关闭阶段只能使用独立的兜底输出,不能再次写入已关闭的 handler。
fmt.Fprintf(os.Stderr, "flush structured logs: %v\n", err)
}
如果派生 Logger 仍可能在其他 goroutine 中使用,应先停止这些生产者,再调用根 Handler 的 Close。否则关闭会与新日志竞争,调用方只能收到 ErrClosed,而 slog.Logger 的便捷日志方法不会把 Handler 错误返回给业务代码。
测试时至少覆盖这些边界
- 达到
batchSize时立即提交,低流量时由flushEvery提交。 - 并发调用
Handle不出现数据竞争,关闭后不发生向已关闭 channel 发送。 WithAttrs和WithGroup的交错顺序保持正确,派生 Handler 不修改原 Handler。LogValuer在入队前解析,可变业务对象稍后变化不会改写已入队事件。- Sink 失败能从指标和
Close看见,失败批次的重试或丢弃策略符合业务约定。 - 用
testing/slogtest配合内存 Sink 检查 Handler 对 Record、Group 和 Attr 的处理。
相关问题
为什么不直接把 slog.Record 放进 channel?
至少要先调用 Clone,避免共享内部状态;如果 Attr 含 LogValuer 或可变引用,还应在调用协程建立序列化快照,否则后台读取时值可能已经变化。
队列满时返回 error 可以防止丢日志吗?
不一定。常用的 slog.Logger 日志方法不会把 Handler 错误返回给业务调用方,所以非阻塞丢弃还必须配套计数器、告警或同步兜底。
batchSize 越大越好吗?
不是。大批次能减少请求次数,却会增加内存占用和低流量等待时间。应同时观察批次填充率、p95 提交延迟、队列深度和失败重试成本。
Handler 里能因为 context 取消而跳过日志吗?
不建议。官方接口说明上下文主要用于向 Handler 传递信息,取消本身不应阻止日志记录;如果业务允许丢弃,应通过明确的采样或队列策略表达。
参考资料
- Go log/slog:
https://pkg.go.dev/log/slog - Go slog Handler 指南:
https://go.dev/s/slog-handler-guide - Go 官方博客 Structured Logging with slog:
https://go.dev/blog/slog
pkg.go.dev 开放 API 后包生态数据可以怎样使用
- 上一篇
- pkg.go.dev 开放 API 后包生态数据可以怎样使用
- 下一篇
- slog 日志级别动态修改后为何部分请求未生效
-
- Golang · Go教程 | 18分钟前 | 文件上传 · Go教程 · net/http · 接口安全 · MaxBytesReader io.LimitReader 流式上传 Go MultipartReader multipart字段限制
- 用 MultipartReader 限制每个表单字段的读取量
- 305浏览 收藏
-
- Golang · Go教程 | 53分钟前 |
- OpenTelemetry Go 怎样处理乱序 Span 并还原服务调用关系
- 148浏览 收藏
-
- Golang · Go教程 | 1小时前 | go · 错误排查 · 错误日志 Go slog HandlerOptions AddSource 调用位置
- 用 slog HandlerOptions 为错误日志补充调用位置
- 465浏览 收藏
-
- Golang · Go教程 | 2小时前 | go ·
- log/slog 如何为一次请求绑定嵌套属性组
- 311浏览 收藏
-
- Golang · Go教程 | 2小时前 |
- slices.Chunk 分组后如何避免保留多余底层数组
- 276浏览 收藏
-
- Golang · Go教程 | 2小时前 |
- 用 slices.Collect 接收惰性迭代结果
- 144浏览 收藏
-
- Golang · Go教程 | 3小时前 | 迭代器 · Go教程 · 批处理 · 批量写入 iter.Seq Go slices.Chunk 切片分组
- slices.Chunk 如何把批量写入拆成固定大小分组
- 229浏览 收藏
-
- Golang · Go教程 | 3小时前 | 迭代器 · Go教程 · iter.Seq slices.Sorted Go maps.Keys 稳定键顺序
- maps.Keys 与 slices.Sorted 怎样输出稳定键顺序
- 294浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 391次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 471次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 478次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 421次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 246次使用
-
- Java 性能优化上线清单:从定位、改造到灰度发布
- 2026-06-11 860浏览
-
- Spring Boot 压测验证:Gatling、JMeter 与性能回归门禁
- 2026-06-11 843浏览
-
- Java NMT 非堆内存排查:Direct Buffer、线程栈与 Metaspace 分析
- 2026-06-11 826浏览
-
- Spring Boot 容器内存优化:JVM 堆、非堆与 MaxRAMPercentage
- 2026-06-11 809浏览
-
- Tomcat 连接与线程参数调优:maxThreads、acceptCount 与 KeepAlive
- 2026-06-11 792浏览

