fan-in模式是日志聚合的底线设计,因其为每个日志源配备独立goroutine拉取并转发至带缓冲的统一通道,避免单点阻塞导致全链路日志丢失、goroutine泄漏和内存暴涨。

直接结论:不用 log.Printf 或裸写文件,必须用结构化日志库(如 zap)主动推,配合客户端缓冲 + grpc 流式上报 + otelcol 接收,否则日志在节点重启、网络抖动、服务扩缩容时必然丢失。
为什么 fan-in 模式是日志聚合的底线设计
多个服务实例、多个 goroutine、多个模块同时打日志,若共用一个 chan LogEntry 并由单个 goroutine range,一旦某个源卡住(比如下游 grpc 连接未就绪),整个通道阻塞,所有日志堆积、goroutine 泄漏、内存暴涨。
- 每个日志源(如一个服务模块的
chan *zapcore.Entry)必须配独立 goroutine 拉取:go func() { for e := range src { outChan -
outChan必须带缓冲(建议make(chan LogEntry, 1024)),否则消费慢会反压上游,导致发送端 panic 或丢弃 - 不要用
sync.WaitGroup等所有源关闭再关通道——分布式日志是长生命周期流,节点随时上下线,关通道只应响应ctx.Done()
zap + grpc 上报时,重试和缓冲必须自己实现
zapcore.AddSync 套 grpc.ClientConn 看似能发日志,但它只是同步写、无重试、无缓冲、无错误反馈。otelcol 一重启,日志就静默丢光。
- 初始化
grpc.DialContext时必须加grpc.WithBlock()和超时:grpc.DialContext(ctx, addr, grpc.WithBlock(), grpc.WithTimeout(5*time.Second)) - 发送逻辑外层包
backoff.Retry,失败时把LogEntry写入内存队列(如带 TTL 的map[string]*LogEntry或 ringbuffer) - 别依赖默认
ClientStreaming:它不保活、不重连、不缓存;应自己实现WriteEntry方法,在内部做队列 + 异步 flush
trace_id 透传失败,90% 是因为没真正“流动”
日志里有 "trace_id": "xxx" 字段 ≠ 链路能串起来。它必须随请求上下文一起从入口流到所有子调用,否则服务 A 和服务 B 的日志永远对不上。
- HTTP 中间件中从
c.Request.Header.Get("X-Trace-ID")提取,注入到 logger:logger.With(zap.String("trace_id", tid)) - gRPC server 端用
metadata.FromIncomingContext(ctx)拿 key,client 端调用前必须用metadata.AppendToOutgoingContext(ctx, key, value) - 避免在 defer 里调用阻塞日志函数;如果必须记录结束状态,用
log.With(zap.String("status", "ok")).Info("done")后立刻返回
本地落盘仍是兜底,但不能只靠它
容器环境下,os.Stdout 才会被 kubelet 或 docker logs 捕获并转发;写文件再让 Filebeat 拉,会导致时间戳乱、上下文丢失、部署耦合。
- 用
lumberjack.Logger配合zapcore.AddSync同时写文件,设MaxSize: 100,MaxBackups: 7,Compress: true - 日志字段必须含
service_name、host、env、timestamp(用time.Now().UTC()),否则查问题时无法反查来源 - 时间戳必须是
time.Time类型(zap.Time("timestamp", time.Now())),Loki 的__line__解析才不会丢精度
最容易被忽略的是:日志上报不能阻塞主流程。很多团队把 logger.Info 直接改成发 grpc,结果下游一卡,整个 API 请求超时。根本原因是没区分「同步打点」和「异步上报」——所有日志写入必须先走内存 channel,由独立 goroutine 消费发送。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











