登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go Zap 怎样把不同等级日志分流到两个文件

来源:17golang原创

时间:2026-10-29 10:55:47 213浏览 收藏

Zap 按等级分流到两个文件,需要为两个 Core 分别设置等级条件,再用 zapcore.NewTee 组合。若希望 Info、Warn 只进入 app.log,Error 及以上只进入 error.log,普通日志条件必须同时设置下界和上界;仅把普通 Core 的最低等级设为 Info,会让 Error 也被它接受。

Zap 官方项目:https://github.com/uber-go/zap

数据来源:先把业务记录和日志系统错误分开

以一个后台任务服务为例,任务接收与等待属于普通记录,处理失败属于错误记录。维护者希望日常检索先看 app.log,失败定位先看 error.log,同时用 task_id 关联同一项任务。这里要求的是互斥分流,而不是把错误额外复制一份。

还有一种容易混淆的错误:文件写入失败、磁盘异常等来自日志系统本身。Zap 的 ErrorOutput 用于报告这类内部错误,它与调用 logger.Error 生成的业务记录不是同一个出口。把两个概念分开,后面遇到错误日志缺失时才知道应到哪里找原因。

等级判断:NewTee 组合 Core,不替你做互斥选择

NewTee 不是“第一个匹配就停止”的路由器。各个 Core 独立判断记录等级,条件重叠就可能同时接收;条件之间有空隙,某些等级就没有对应出口。本文选定两个范围:普通分支接受 Info 到 Error 之前的等级,即 Info、Warn;错误分支接受 Error 及以上;Debug 不写入这两个文件。

可以把结构分成日志入口组、普通分支组和错误分支组。日志入口组包含 Logger 与 NewTee,普通分支组包含普通 Core 与 app.log,错误分支组包含错误 Core 与 error.log。Logger 使用组合 Core,每个分支各自连接文件,等级标签说明各自接收范围。

Zap Logger组合两个等级互斥Core并连接不同日志文件的结构图
分流结构图:两个 Core 独立配置,普通分支接受 Info/Warn,错误分支接受 Error 及以上;连线表示组合与输出关系,不是先后匹配顺序。

存储模型:每个文件绑定自己的编码器和写入器

下面给出完整初始化代码。两个文件使用追加写入;每个 Core 单独创建 JSON 编码器,再把文件包装成带锁的 WriteSyncer。代码通过 errors.Join 汇总收尾错误,需要 Go 1.20 或以上版本;项目还应满足自己选定的 Zap 版本要求。

package main

import (
    "errors"
    "fmt"
    "os"
    "path/filepath"
    "sync"

    "go.uber.org/zap"
    "go.uber.org/zap/zapcore"
)

func newSplitLogger(dir string) (*zap.Logger, func() error, error) {
    // 示例目录和文件使用较收敛的权限;已有文件的权限不会由 OpenFile 重设。
    if err := os.MkdirAll(dir, 0700); err != nil {
        return nil, nil, fmt.Errorf("创建日志目录: %w", err)
    }
    appFile, err := os.OpenFile(filepath.Join(dir, "app.log"),
        os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
    if err != nil {
        return nil, nil, fmt.Errorf("打开普通日志: %w", err)
    }
    errorFile, err := os.OpenFile(filepath.Join(dir, "error.log"),
        os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
    if err != nil {
        // 第二个文件打开失败,也要收回已经打开的第一个文件。
        return nil, nil, errors.Join(fmt.Errorf("打开错误日志: %w", err), appFile.Close())
    }

    appLevels := zap.LevelEnablerFunc(func(level zapcore.Level) bool {
        // 用上下界排除 Error,避免两个 Core 的等级范围重叠。
        return level >= zapcore.InfoLevel && level = zapcore.ErrorLevel
    })
    cfg := zap.NewProductionEncoderConfig()
    cfg.EncodeTime = zapcore.ISO8601TimeEncoder
    // 每个 Core 创建独立编码器;文件写入器按 Zap 的要求包装并发锁。
    appCore := zapcore.NewCore(zapcore.NewJSONEncoder(cfg),
        zapcore.Lock(zapcore.AddSync(appFile)), appLevels)
    errorCore := zapcore.NewCore(zapcore.NewJSONEncoder(cfg),
        zapcore.Lock(zapcore.AddSync(errorFile)), errorLevels)
    logger := zap.New(zapcore.NewTee(appCore, errorCore),
        zap.AddCaller(),
        // 这里输出的是日志系统自身的错误,不是业务 Error 日志。
        zap.ErrorOutput(zapcore.Lock(zapcore.AddSync(os.Stderr))))

    var once sync.Once
    var closeErr error
    cleanup := func() error {
        once.Do(func() {
            // 调用前先停止所有业务写入;先同步,再关闭两个文件。
            closeErr = errors.Join(logger.Sync(), appFile.Close(), errorFile.Close())
        })
        return closeErr
    }
    return logger, cleanup, nil
}

func run() error {
    logger, cleanup, err := newSplitLogger("./logs")
    if err != nil {
        return err
    }
    logger.Info("任务已接收", zap.String("task_id", "demo-task"))
    logger.Warn("任务进入等待", zap.String("task_id", "demo-task"))
    logger.Error("任务处理失败", zap.String("task_id", "demo-task"))
    // 示例没有后台写入协程,三次记录结束后即可收尾。
    return cleanup()
}

func main() {
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

示例安装与调用方式如下。本文未运行这段代码,下面的归属表是由条件表达式推导的预期行为。

# 在新的示例目录初始化模块;已有模块只需按项目策略添加依赖。
go mod init example.com/splitlog
go get go.uber.org/zap

# 保存 main.go 后执行;程序只在当前目录创建 logs 和两个日志文件。
go run .

# 在本机查看两个文件,核对等级字段与任务标识;以下不是本文实测记录。
cat ./logs/app.log
cat ./logs/error.log

这里没有直接套用生产预设构造器,而是使用自定义 Core,因此不隐含启用生产预设的采样规则。若后续加入采样,需要另外决定哪类记录允许丢弃;仅为分文件输出,不必把采样也混入第一版实现。

查询路径:看文件归属,也保留关联字段

等级或来源本例出口判断依据
Debug两个文件都不接收低于普通分支下界
Infoapp.log满足普通分支范围
Warnapp.log仍低于 Error
Errorerror.log只满足错误分支
更高等级按等级进入错误分支还需考虑该日志方法的 panic 或退出行为
Zap 内部写入错误标准错误输出由 ErrorOutput 指定

两类文件保留同样的任务标识,才能在发现失败后回看此前的普通记录。不要把“按等级分流”当成日志关联机制;同一个 task_id、request_id 或业务事件标识仍需要由应用明确写入。

如果业务目标是 app.log 保存全部 Info 及以上记录,同时 error.log 再保存一份错误,那么允许范围重叠反而是正确设计。要先写清“互斥分流”还是“错误复制”,再决定是否给普通分支添加上界。

异常处理:区分初始化失败和运行时写入失败

初始化时,目录和两个文件都可能创建失败。示例在第二个文件打开失败时立即关闭第一个文件,并把相关错误返回给调用方,避免只构造出一半的日志系统后继续运行。

运行时,Info、Warn、Error 等常用记录方法通常不会把底层文件写入错误作为返回值交给业务调用方。示例将内部错误交给 stderr;容器或进程管理器需要真正收集这个出口,否则磁盘已满时,业务逻辑可能继续运行而日志没有完整保存。Sync 的错误也应保留并报告,不能一律忽略。

不要使用当前可能已经失效的 logger 再记录同一个底层写入故障,否则会把问题送回相同的失败出口。示例收尾失败后直接写 stderr,就是为了让故障报告与两个业务日志文件保持分离。

清理策略:先停止写入,再同步并关闭文件

Logger.Sync 负责向底层写入器请求同步,不会替应用关闭通过 os.OpenFile 打开的文件。本例由 cleanup 统一负责同步和 Close;sync.Once 保证同一个收尾函数不会重复关闭文件。这个幂等收尾不表示可以一边调用 cleanup、一边继续让业务协程写日志,应用仍应先停止并等待所有写入者。

收尾结构包含资源所有者、Logger.Sync、app 文件句柄、error 文件句柄、Close 和 stderr。资源所有者掌握两个句柄及收尾入口;同步依赖日志器中的输出对象,关闭由持有句柄的一层执行,异常报告保留独立出口。图中静态关系不能代替正文所述的收尾顺序。

Zap日志同步、文件句柄关闭与独立错误报告的资源归属结构图
资源结构图:Logger.Sync 与文件 Close 是不同职责;资源所有者负责停写后的收尾,stderr 用于独立报告故障。

示例只演示固定文件,不包含按日期或体积轮转、压缩归档、保留天数和跨进程协调。长期运行服务需要另行配置轮转写入器或明确的外部轮转协议;外部把文件改名后,已打开的句柄也不会自动改为新文件名所指的对象。不要直接把不受控字符串拼进日志路径。

只用 io.MultiWriter 能实现按等级分流吗?

它面向字节复制,不知道 Zap 的日志等级。相同字节写往多个文件属于复制;要在编码和写入之前按等级判断,应使用不同 Core 的等级条件。

logger.Fatal 能依靠 defer 完成文件关闭吗?

不要这样设计。Fatal 涉及进程退出,普通 defer 不能作为该路径的可靠清理机制。示例只调用 Info、Warn 和 Error,并通过正常返回完成 cleanup。

为什么两个 Core 不共享一个可变编码器?

每个分支各自创建编码器,更容易保持字段、格式配置和并发边界清楚。编码器不是文件句柄,写入器的锁也不能代替编码器自身的正确使用。

参考:Zap API 与高级配置示例、Core、WriteSyncer 和 NewTee 文档、组合 Core 实现。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>