Fork me on GitHub

版权声明 本站原创文章 由 萌叔 发表
转载请注明 萌叔 | https://vearne.cc

1. 前言

在复杂的业务逻辑中,一个请求就有可能带来10 ~ 30条日志的写入。打日志造成的开销很大,是无法被忽略的一环。怎样才能提高日志的写入速度?

2. 实验

首先来看几个实验
完整程序见 vearne/golab/zaplog
中的log.go log2.go log3.go

2.1 打印到文件

打印到指定文件中,并做归档

package zaplog

import (
    "go.uber.org/zap"
    "go.uber.org/zap/zapcore"
    "gopkg.in/natefinch/lumberjack.v2"
)

func InitLogger() *zap.Logger {
    // 动态调整日志级别
    alevel := zap.NewAtomicLevel()

    hook := lumberjack.Logger{
        Filename:   "/tmp/tt1.log",
        MaxSize:    1024, // megabytes
        MaxBackups: 3,
        MaxAge:     7,    //days
        Compress:   true, // disabled by default
    }
    w := zapcore.AddSync(&hook)

    alevel.SetLevel(zap.InfoLevel)
    encoderConfig := zap.NewProductionEncoderConfig()
    encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder

    core := zapcore.NewCore(
        zapcore.NewConsoleEncoder(encoderConfig),
        w,
        alevel,
    )

    return zap.New(core)
}

2.2 打印到终端

这应该是官方推荐的方式,特别是对于使用docker容器的场景,应用开发者的配置非常简单,无需指定日志文件路径,由容器管理服务采集容器的标准输出并归档。

package zaplog

import (
    "go.uber.org/zap"
    "log"
)

func InitLogger2() *zap.Logger {
    // 默认是Info级别
    logcfg := zap.NewProductionConfig()
    // 关闭日志采样采样
    logcfg.Sampling = nil
    //logcfg.Encoding = "json"
    logcfg.Encoding = "console"

    logger, err := logcfg.Build()
    if err != nil {
        log.Println("error", err)
    }

    return logger
}

2.3 实验结果

测试环境:

  • MacBook: 10.12.6
  • 处理器: 2.3GHz Intel Core i5
  • 内存 8GB 2133MHz LPDDR3
goarch: amd64
pkg: github.com/vearne/golab/zaplog
BenchmarkZapFile-4        308242          4141 ns/op
BenchmarkZapStdout-4
1.577931741156899e+09   info    zaplog/log_test.go:23   hello world!
... 
  265311          4225 ns/op

写文件和写控制台的性能差不多, 每秒钟差不多30w条。假定一个请求产生30条日志,也就是说即使处理这个请求没有任何逻辑,光是打日志,QPS也就差不多1w左右。(这也是很多服务压测的QPS无法超过1w的原因)。那么能否将日志打印的性能能否再提高一点呢?

2.4 异步写入

追踪代码,萌叔发现对文件的写入逻辑在此函数中

// Write implements io.Writer.
func (fd *FD) Write(p []byte) (int, error) {
    // 注意: 对文件描述符有读写锁控制
    if err := fd.writeLock(); err != nil {
        return 0, err
    }
    defer fd.writeUnlock()
    if err := fd.pd.prepareWrite(fd.isFile); err != nil {
        return 0, err
    }
    var nn int
    for {
        max := len(p)
        if fd.IsStream && max - nn > maxRW {
            max = nn + maxRW
        }
        // 执行"系统调用"将将数据从用户空间写入(拷贝)到内核缓冲区
        // 数据最终落盘,由内核负责
        n, err := syscall.Write(fd.Sysfd, p[nn:max])
        if n > 0 {
            nn += n
        }
        if nn == len(p) {
            return nn, err
        }
        if err == syscall.EAGAIN && fd.pd.pollable() {
            if err = fd.pd.waitWrite(fd.isFile); err == nil {
                continue
            }
        }
        if err != nil {
            return nn, err
        }
        if n == 0 {
            return nn, io.ErrUnexpectedEOF
        }
    }
}

每次打印日志,都会执行syscall.Write,频繁执行系统调用开销很大,完全可以改为批量执行。因此这里把对文件的写入改为批量、异步逻辑。

package zaplog

import (
    "bytes"
    "github.com/natefinch/lumberjack"
    "go.uber.org/zap"
    "go.uber.org/zap/zapcore"
    "time"
)

// 实现WriteSyncer接口
/*
type WriteSyncer interface {
    io.Writer
    Sync() error
}
*/
type FileWriteAsyncer struct {
    innerLogger *lumberjack.Logger
    ch          chan []byte
    syncChan    chan struct{}
}

func NewFileWriteAsyncer(filepath string) *FileWriteAsyncer {
    fa := &FileWriteAsyncer{}
    fa.innerLogger = &lumberjack.Logger{
        Filename:   filepath,
        MaxSize:    1024, // megabytes
        MaxBackups: 3,
        MaxAge:     7,    //days
        Compress:   true, // disabled by default
    }
    fa.ch = make(chan []byte, 10000)
    fa.syncChan = make(chan struct{})
    // 批量异步写入到文件中
    go batchWriteLog(fa)
    return fa

}

func (fa *FileWriteAsyncer) Write(data []byte) (int, error) {
    fa.ch <- data
    return len(data), nil
}

func (fa *FileWriteAsyncer) Sync() error {
    fa.syncChan <- struct{}{}
    return nil
}

func batchWriteLog(fa *FileWriteAsyncer) {
    buffer := bytes.NewBuffer(make([]byte, 0, 10240))

    ticker := time.NewTicker(time.Millisecond * 200)
    //var record []byte
    var err error
    for {
        select {
        case <-ticker.C:
            if len(buffer.Bytes()) > 0 {
                _, err = fa.innerLogger.Write(buffer.Bytes())
                if err != nil {
                    panic(err)
                }
                buffer.Reset()
            }

        case record := <-fa.ch:
            buffer.Write(record)
            if len(buffer.Bytes()) >= 1024*4 {
                _, err = fa.innerLogger.Write(buffer.Bytes())
                if err != nil {
                    panic(err)
                }
                buffer.Reset()
            }
        case <-fa.syncChan:
            if len(buffer.Bytes()) > 0 {
                _, err = fa.innerLogger.Write(buffer.Bytes())
                if err != nil {
                    panic(err)
                }
                buffer.Reset()
            }
            break
        }
    }

}

func InitLogger3() *zap.Logger {
    alevel := zap.NewAtomicLevel()
    alevel.SetLevel(zap.InfoLevel)
    encoderConfig := zap.NewProductionEncoderConfig()
    encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder

    core := zapcore.NewCore(
        zapcore.NewConsoleEncoder(encoderConfig),
        NewFileWriteAsyncer("/tmp/tt3.log"),
        alevel,
    )

    return zap.New(core)
}

实验结果:

goarch: amd64
pkg: github.com/vearne/golab/zaplog
BenchmarkZapFile-4        260256          4377 ns/op         112 B/op          4 allocs/op
BenchmarkZapStdout-4
1.577936240870677e+09   info    zaplog/log_test.go:23   hello world!
...
  248214          4372 ns/op         336 B/op          7 allocs/op
BenchmarkAsync-4          722610          1666 ns/op         112 B/op          4 allocs/op

总结

日志写入逻辑改为批量异步写入之后,性能提升了一倍多。日志同步变异步虽然能提高日志的写入速度,但是它带来一个风险点。比如程序突然崩溃,然而日志数据还在channel里,这样就会导致日志消息丢失,因此是否要使用异步写入的方式,还请读者权衡使用。另外建议大家还是通过在生产、测试、开发环境设置不同的日志级别,来控制日志的数据量,提高服务的吞吐能力。

后记

2021年5月10日

提高打印日志的速度的一些方法

1)由于golang在操作文件描述符时有锁(写文件时有加锁),可以将日志打印到不同的日志文件中。
2)观察io.wait,如果io.wait值较大,则可能磁盘IO有瓶颈,可以考虑将日志文件分布到不同的磁盘上。

2021年5月11日
如果使用channel来cache日志,还应当考虑高负载情况下,channel被打满的情况

func (fa *FileWriteAsyncer) Write(data []byte) (int, error) {
    select {
    case fa.ch <- data:
        return len(data), nil
    default:
        // 直接丢弃多余的日志
        // 注意: 需要在metric中记录丢弃的日志数量
        return 0, errors.New("Log channel is full")
    }
}

2022年9月14日
也可以使用bufio库, 增加缓冲区的大小,减少系统调用syscall.Write

func NewWriterSize(w io.Writer, size int) *Writer

微信公众号

6 对 “玩转高性能日志库ZAP(5)-异步写日志”的想法;

    1. 多个协程同时写channel,还是会有竞争锁的操作,当然是无法保证有序的。
      另外建议google–分布式链路追踪。

  1. 这种写法是有问题的,zap那个write的[]byte是复用的,你直接塞会出现塞的都是同一个指针的情况

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注