审计日志不能直接用 c.logger().info() 同步写,因其仅记录基础请求信息且共享 writer 易阻塞主流程;应使用 slog + 带缓冲 channel 异步写入,由独立 goroutine 消费 auditlog 结构体并写文件。

为什么审计日志不能直接用 c.Logger().Info() 同步写
因为 Gin 内置的 Logger() 中间件只记录基础请求信息(method/path/status/latency),不包含 body、user_id、trace_id 等审计必需字段;更重要的是,它和业务 handler 共享同一 writer(比如文件句柄),一旦日志写入阻塞(如磁盘 IO 高、lumberjack 切割卡顿),整个 HTTP 请求会被拖慢甚至超时。审计日志的核心诉求是「不拖累主流程」,不是「实时可见」。
用 log/slog + channel 实现轻量异步写入
不用引入 zap 或 logrus,Go 1.21+ 自带的 slog 足够支撑审计日志场景。关键在于把日志结构体塞进带缓冲的 channel,由单独 goroutine 消费写入文件。
- 定义审计日志结构体,含
trace_id、user_id、method、path、status_code、req_size、resp_size、client_ip、latency - 启动一个常驻 goroutine:
go func() { for log := range auditLogCh { file.Write(slogJSON(log)) } }(),其中auditLogCh是chan AuditLog类型,缓冲大小建议设为 1024 - 中间件里构造日志后,直接
select { case auditLogCh —— 非阻塞发送,满则丢弃,避免压垮内存
如何确保 req_size 和 resp_size 准确且不破坏响应流
HTTP 响应体大小不能靠 c.Writer.Size() 取,因为它是写入缓冲区的字节数,受 gzip、chunked 编码影响;而请求体大小必须在预读时就捕获,否则 c.Request.Body 已被重置无法再读。
- 请求体大小:在中间件开头调用
c.GetRawData()后立即记录len(data),然后用io.NopCloser(bytes.NewReader(data))重置 Body - 响应体大小:需包装
c.Writer,实现自定义ResponseWriter接口,在Write()方法中累加字节数,并透传给原始 writer - 别用
c.Writer.Status()判断是否已写响应 —— 它只反映状态码是否已发,body 可能还没开始写;要监听WriteHeader和Write两个动作
trace_id 透传与 user_id 注入时机必须早于日志构造
审计日志的 trace_id 和 user_id 不是可选字段,而是关联链路和责任人的唯一依据。它们必须在中间件最前端就确定,且不能依赖后续 handler 的解析结果。
-
trace_id:优先取c.GetHeader("X-Trace-ID")或c.GetHeader("traceparent"), fallback 才用uuid.NewString();生成后立刻c.Set("trace_id", id) -
user_id:不能等 JWT 解析中间件执行完才取 —— 那个中间件可能在日志中间件之后注册。正确做法是把认证中间件放在日志中间件之前(r.Use(AuthMiddleware, AuditLogMiddleware)),并在 Auth 中统一c.Set("user_id", uid) - 所有字段都应从
c.GetString("xxx")获取,而不是在 handler 里临时塞值 —— 日志中间件执行时 handler 还没跑
异步写入真正的难点不在 channel 或 goroutine,而在于「数据采集点必须前置、不可补救」:body 大小错过预读时机就永远拿不到,trace_id 和 user_id 没在上下文里存好,日志里就是空字符串。这些操作一旦漏掉,后面加多少层封装都救不回来。











