likes
comments
collection
share

客户端日志&埋点&上报的性能优化

作者站长头像
站长
· 阅读数 39

引子

高性能 I/O

有时候为了排查线上偶现问题,会尽可能地在关键业务点打 Log 并文件化,再上传到云以便排查。

在一些高强度业务场景中的高频模块,比如直播间中的 IM,瞬间产生几百上千条 Log 是家常便饭,这就对 Log 库的性能提出了要求(CPU 和内存)。

Okio

如何高性能地 I/O?

第一个想到的是Okio。关于为啥 Okio 的性能与好于 java.io 包的流,之后会专门写一篇分析文章。

重新写一个 Okio 版本的日志拦截器:

class OkioLogInterceptor private constructor(private var dir: String) : LogInterceptor {
    private val handlerThread = HandlerThread("log_to_file_thread")
    private val handler: Handler
    private val dispatcher: CoroutineDispatcher
    // 写日志的开始时间
    var startTime = System.currentTimeMillis()

    companion object {
        @Volatile
        private var INSTANCE: OkioLogInterceptor? = null
        fun getInstance(dir: String): OkioLogInterceptor =
            INSTANCE ?: synchronized(this) {
                INSTANCE ?: OkioLogInterceptor(dir).apply { INSTANCE = this }
            }
    }

    init {
        handlerThread.start()
        handler = Handler(handlerThread.looper)
        dispatcher = handler.asCoroutineDispatcher("log_to_file_dispatcher")
    }

    override fun log(priority: Int, tag: String, log: String, chain: Chain) {
        if (!handlerThread.isAlive) handlerThread.start()
        GlobalScope.launch(dispatcher) {
            // 使用 Okio 写文件
            val file = File(getFileName())
            file.sink(true).buffer().use {
                it.writeUtf8("[$tag] $log")
                it.writeUtf8("\n")
            }
            //  写日志结束时间
            if (log == "work done") Log.v("ttaylor1","log() work is done=${System.currentTimeMillis() - startTime}")
        }
        chain.proceed(priority, tag, log)
    }

    private fun getToday(): String =
        SimpleDateFormat("yyyy-MM-dd").format(Calendar.getInstance().time)

    private fun getFileName() = "$dir${File.separator}${getToday()}.log"
}

FileWriter vs Okio 性能 PK

为了测试 Okio 和 FileWriter 的性能差异,编写了如下测试代码:

class LogActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 添加 FileWriter 或者 Okio 日志拦截器
        EasyLog.addInterceptor(OkioLogInterceptor.getInstance(this.filesDir.absolutePath))
//        EasyLog.addInterceptor(FileWriterLogInterceptor.getInstance(this.filesDir.absolutePath))
        // 重复输出 1 万条短 log
        MainScope().launch(Dispatchers.Default) { count ->
            repeat(10000){
                EasyLog.v("test log count=$count")
            }
            EasyLog.v("work done")
        }
    }
}

测试方案:重复输入 1 万条短 log,并且从写第一条 log 开始计时,直到最后一条 log 的 I/O 完成时输出耗时。

Okio 和 FileWriter 三次测试的对比耗时如下:

// okio
ttaylor1: log() work is done=9600
ttaylor1: log() work is done=9822
ttaylor1: log() work is done=9411

// FileWriter
ttaylor1: log() work is done=10688
ttaylor1: log() work is done=10816
ttaylor1: log() work is done=11928

看上去 Okio 在耗时上有微弱的优势。

但当我把单条 Log 的长度增加 300 倍之后,测试结果出现了反转:

// FileWriter
ttaylor1: log() work is done=13569
ttaylor1: log() work is done=12654
ttaylor1: log() work is done=13152

// okio
ttaylor1: log() work is done=14136
ttaylor1: log() work is done=15451
ttaylor1: log() work is done=15292

也就是说 Okio 在高频少量 I/O 场景性能好于 FileWriter,而高频大量 I/O 场景下没有性能优势。

这个结果让我很困惑,于是乎我在 Github 上提了 issue:

Okio is slower when writing long strings into file frequently compared with FileWriter · Issue #1098 · square/okio

没想到瞬间就被回复了:

客户端日志&埋点&上报的性能优化 我的提问的本意是想确认下使用 Okio 的姿势是否得当,但没想到官方回答却是:“Okio 库就是这样的,你的测试数据是符合预期的。我们在“用自己的 UTF-8 编码以减少大量垃圾回收”和性能上做了权衡。所以导致有些测试场景下性能好,有些场景下性能没那么好。我们的期望是在真实的业务场景下 Okio 的性能会表现的好。”

不知道我翻译的准不准确,若有误翻望英语大佬指点~

这样的结果不能让我满意,隐约觉得 Okio 的使用方式有优化空间,于是我一直凝视下面这段代码:

override fun log(priority: Int, tag: String, log: String) {
    GlobalScope.launch(dispatcher) {
        val file = File(getFileName())
        file.sink(true).buffer().use {
            it.writeUtf8("[$tag] $log")
            it.writeUtf8("\n")
        }
    }
    return false
}

代码洁癖告诉我,这里有几个可以优化的地方:

  1. 不用每次打 Log 都新建 File 对象。
  2. 不用每次打 Log 都新建输出流,每次打完就关闭流。

于是我改造了 Okio 日志拦截器:

class OkioLogInterceptor private constructor(private var dir: String) : LogInterceptor {
    private val handlerThread = HandlerThread("log_to_file_thread")
    private val handler: Handler
    private val dispatcher: CoroutineDispatcher
    private var bufferedSink: BufferedSink? = null
    // 初始化时,只新建一次文件
    private var logFile = File(getFileName())

    var startTime = System.currentTimeMillis()

    companion object {
        @Volatile
        private var INSTANCE: OkioLogInterceptor? = null

        fun getInstance(dir: String): OkioLogInterceptor =
            INSTANCE ?: synchronized(this) {
                INSTANCE ?: OkioLogInterceptor(dir).apply { INSTANCE = this }
            }
    }

    init {
        handlerThread.start()
        handler = Handler(handlerThread.looper)
        dispatcher = handler.asCoroutineDispatcher("log_to_file_dispatcher")
    }

   
    override fun log(priority: Int, tag: String, log: String, chain: Chain){
        if (!handlerThread.isAlive) handlerThread.start()
        GlobalScope.launch(dispatcher) {
            val sink = checkSink()
            sink.writeUtf8("[$tag] $log")
            sink.writeUtf8("\n")
            if (log == "work done") Log.v("ttaylor1","log() work is ok done=${System.currentTimeMillis() - startTime}")
        }
      chain.proceed(priority, tag, log)
    }

    private fun getToday(): String =
        SimpleDateFormat("yyyy-MM-dd").format(Calendar.getInstance().time)

    private fun getFileName() = "$dir${File.separator}${getToday()}.log"
    
    // 不关流,复用 bufferedSink 对象
    private fun checkSink(): BufferedSink {
        if (bufferedSink == null) {
            bufferedSink = logFile.appendingSink().buffer()
        }
        return bufferedSink!!
    }
}

新增了成员变量 logFile 和 bufferedSink,避免了每次打 Log 时重新构建它们。而且我没有在每次打完 Log 就把输出流关闭掉。

重新跑一下测试代码,奇迹发生了:

ttaylor1: log() work is done=832

1万条 Log 写入的时间从 9411 ms 缩短到 832 ms,整整缩短了 11 倍!!

这个结果有点难以置信,我连忙从手机中拉取了日志文件,非常担心是因为程序 bug 导致日志输出不全。

果不其然,正确的日志文件应该包含 1 万行,而现在只有 9901 行。

转念一下,不对啊,一次flush()都没有调用,为啥文件中会有日志?

哦~,肯定是因为内存中的输出缓冲区满了之后自动进行了 flush 操作。

然后我用同样的思路写了一个 FileWriter 的版本,跑了一下测试代码:

ttaylor1: FileWriter log() work is done=1239

这下可把 FileWriter 和 Okio 的差距显现出来了,将近 50 %的速度差距。

但是。。。我才意识到这个比对是不公平的。Okio 使用了缓存,而 java.io 没使用。

改了下测试代码,在 FileWriter 外套了一层 BufferedWriter,再跑一下:

ttaylor1: BufferedWriter log() work is done=1023

速度果然有提升,但还是比 Okio 慢了 25% 左右。

算是为技术选型 Okio 提供了数据支持!

感知日志打印结束?

但还有一个问题急需解决:Log 输出不全。

这是因为当最后一条日志写入缓冲区时,若缓冲区未满就不会执行 flush 操作。这种情况下需要手动 flush

如何感知到最后一条 Log?做不到!因为打 Log 是业务层行为,底层 Log 库无法感知上层行为。

这个场景让我联想到 “搜索框防抖”,即当你在键入关键词后,自动发起搜索的行为。

用流的思想理解上面的场景:输入框是流数据的生产者,其内容每变化一次,就是在流上生产了一个新数据。但并不是每一个数据都需要被消费,所以得做“限流”,即丢弃一切发射间隔过短的数据,直到生产出某个数据之后一段时间内不再有新数据。

Flow 的操作符debounce()就非常契合这个场景。

它的背后机制是:每当流产生新数据时,开启倒计时,如果在倒计时归零之前没有新数据,则将最后那个数据发射出去,否则重新开启倒计时。关于 Flow 限流的应用场景及原理分析可以点击

知道了背后的机制,就不需要拘泥于具体的实现方式,使用 Android 中的消息机制也能实现同样的效果(日志拦截器正好使用了 HandlerThread,现成的消息机制)。

每当新日志到来时,将其封装为一条消息发送给 Handler,紧接着再发送一条延迟消息,若有后续日志,则移除延迟消息,并重发一条新延迟消息。若无后续日志,Handler 终将收到延迟消息,此时就执行 flush 操作。

(Android 中判定 Activity 生命周期超时也是用这套机制,感兴趣的可以搜索com.android.server.wm.ActivityStack.STOP_TIMEOUT_MSG

改造后的日志拦截器如下:

class OkioLogInterceptor private constructor(private var dir: String) : LogInterceptor {
    private val handlerThread = HandlerThread("log_to_file_thread")
    private val handler: Handler
    private var startTime = System.currentTimeMillis()
    private var bufferedSink: BufferedSink? = null
    private var logFile = File(getFileName())

    // 日志消息处理器
    val callback = Handler.Callback { message ->
        val sink = checkSink()
        when (message.what) {
            // flush 日志
            TYPE_FLUSH -> {
                sink.use {
                    it.flush()
                    bufferedSink = null
                }
            }
            // 写日志
            TYPE_LOG -> {
                val log = message.obj as String
                sink.writeUtf8(log)
                sink.writeUtf8("\n")
            }
        }
        // 统计耗时
        if (message.obj as? String == "work done") Log.v(
            "ttaylor1",
            "log() work is done=${System.currentTimeMillis() - startTime}"
        )
        false
    }

    companion object {
        private const val TYPE_FLUSH = -1
        private const val TYPE_LOG = 1
        // 若 3000 ms 内没有新日志请求,则执行 flush
        private const val FLUSH_LOG_DELAY_MILLIS = 3000L

        @Volatile
        private var INSTANCE: OkioLogInterceptor? = null

        fun getInstance(dir: String): OkioLogInterceptor =
            INSTANCE ?: synchronized(this) {
                INSTANCE ?: OkioLogInterceptor(dir).apply { INSTANCE = this }
            }
    }

    init {
        handlerThread.start()
        handler = Handler(handlerThread.looper, callback)
    }

    override fun log(priority: Int, tag: String, log: String, chain: Chain) {
        if (!handlerThread.isAlive) handlerThread.start()
        handler.run {
            // 移除上一个延迟消息
            removeMessages(TYPE_FLUSH)
            // 将日志作为一条消息发送出去
            obtainMessage(TYPE_LOG, "[$tag] log").sendToTarget()
            // 构建延迟消息并发送
            val flushMessage = handler.obtainMessage(TYPE_FLUSH)
            sendMessageDelayed(flushMessage, FLUSH_LOG_DELAY_MILLIS)
        }
        chain.proceed(priority, tag, log)
    }

    private fun getToday(): String =
        SimpleDateFormat("yyyy-MM-dd").format(Calendar.getInstance().time)

    private fun getFileName() = "$dir${File.separator}${getToday()}.log"

    private fun checkSink(): BufferedSink {
        if (bufferedSink == null) {
            bufferedSink = logFile.appendingSink().buffer()
        }
        return bufferedSink!!
    }
}

压缩日志

将日志日内容压缩不仅能进一步提升I/O性能,减少客户端日志上传的流量,还能为公司省钱(云存储都挺贵的)。

Gzip 是一种常用的压缩格式。Linux 就用这种格式压缩文件。除了用在文件压缩,Gzip 还用于网络压缩,它是在RFC 2016中规定的三种标准HTTP压缩格式之一。

关于 Gzip 压缩格式的详细介绍可以点击这里

借助于装饰者模式、适配器模式、以及 Kotlin 的扩展方法语法,为原先的输出流新增 Gzip 功能很简单。

原先构建输出流的代码如下:

val bufferedSink = logFile.appendingSink().buffer()

其中appendingSink()buffer()都是扩展方法:

fun File.appendingSink(): Sink = FileOutputStream(this, true).sink()
fun OutputStream.sink(): Sink = OutputStreamSink(this, Timeout())
fun Sink.buffer(): BufferedSink = RealBufferedSink(this)

即先构建了 FileOutputStream,然后将其适配成 OutputStreamSink,再装饰成 RealBufferedSink,最终形成 RealBufferedSink( OutputStreamSink( FileOutputStream( File ) ) )这样套娃的结构。

Okio 中实现 Gzip 压缩输出的类叫GzipSink,它也有类似的装饰构造方法:

inline fun Sink.gzip() = GzipSink(this)

只需在原有调用链上插入 gzip() 即可实现压缩:

val bufferedSink = logFile.appendingSink().gzip().buffer()

现在的套娃结构变成RealBufferedSink( GzipSink( OutputStreamSink( FileOutputStream( File ) ) ) )

跑了一下测试程序,测试方法为连续输出1万条长log,不使用 Gzip 时,日志文件大小为 251 MB,加了 Gzip 之后,只有 2.1 MB。整整缩小了是 100+ 倍

把日志文件的后缀改成gz,这样从云端下载之后就能直接只用压缩软件解压看到原始日志。

为了对比加入压缩后 Okio 和 java.io 的速度性能差异,重写了 java.io 版压缩输出流的代码:

val outputStream = logFile.outputStream().gzip().writer().buffered()

其中outputStream()buffered()是系统预定的装饰流扩展方法:

 // 构造 FileOutputStream 并持有 File 实例
public inline fun File.outputStream(): FileOutputStream {
    return FileOutputStream(this)
}
// 构造 BufferedWriter 并持有 Writer 实例
public inline fun Writer.buffered(bufferSize: Int = DEFAULT_BUFFER_SIZE): BufferedWriter =
    if (this is BufferedWriter) this else BufferedWriter(this, bufferSize)

gzip()writer()是自定义的装饰流扩展方法:

// 构建 GZIPOutputStream 并持有 OutputStream
fun OutputStream.gzip() = GZIPOutputStream(this)
// 构建 OutputStreamWriter 并持有 OutputStream
fun OutputStream.writer(charset: Charset = Charsets.UTF_8) = OutputStreamWriter(this, charset)

GZIPOutputStream是针对 OutputStream 的,所以不得不使用 OutputStreamWrite 将 Writer 接口适配成 OuptStream 接口。

完整的 java.io 版压缩日志拦截器代码如下:

class FileWriterLogInterceptor private constructor(private var dir: String) : LogInterceptor {
    private val handlerThread = HandlerThread("log_to_file_thread")
    private val handler: Handler
    // 统计耗时起点
    private var startTime = System.currentTimeMillis()
    // 用于记录平均内存的列表
    private val memorys = mutableListOf<Long>()
    private var fileWriter: Writer? = null
    private var logFile = File(getFileName())

    val callback = Handler.Callback { message ->
        val sink = checkFileWriter()
        // 每来一条日志,记录此时的内存占用
        memorys.add(Runtime.getRuntime().totalMemory()/(1024*1024))
        when (message.what) {
            // 输出日志结束的标记
            TYPE_FLUSH -> {
                sink.use {
                    it.flush()
                    fileWriter = null
                }
                // 统计耗时即内存终点
                Log.v(
                    "test",
                    "fileWriter work is ok done=${System.currentTimeMillis() - startTime, memory=${memorys.average()}"
                )
            }
            // 正常写日志
            TYPE_LOG -> {
                val log = message.obj as String
                sink.write(log)
                sink.write("\n")
            }
        }
        false
    }

    companion object {
        private const val TYPE_FLUSH = -1
        private const val TYPE_LOG = 1
        // 若 300 ms 无日志请求,则进行冲刷
        private const val FLUSH_LOG_DELAY_MILLIS = 300L

        // 设计单例,防止启动多个写日志线程
        @Volatile
        private var INSTANCE: FileWriterLogInterceptor? = null
        fun getInstance(dir: String): FileWriterLogInterceptor =
            INSTANCE ?: synchronized(this) {
                INSTANCE ?: FileWriterLogInterceptor(dir).apply { INSTANCE = this }
            }
    }

    // 启动写日志线程
    init {
        handlerThread.start()
        handler = Handler(handlerThread.looper, callback)
    }

    override fun log(priority: Int, tag: String, log: String, chain: Chain) {
        if (!handlerThread.isAlive) handlerThread.start()
        handler.run {
            removeMessages(TYPE_FLUSH)
            obtainMessage(TYPE_LOG, "[$tag] $log").sendToTarget()
            val flushMessage = handler.obtainMessage(TYPE_FLUSH)
            // 倒计时,用于判断“已经无新日志”
            sendMessageDelayed(flushMessage, FLUSH_LOG_DELAY_MILLIS)
        }
        chain.proceed(priority, tag, log)
    }

    override fun enable(): Boolean { return true }
    
    // 以今天日期为文件名
    private fun getToday(): String = SimpleDateFormat("yyyy-MM-dd").format(Calendar.getInstance().time)
    private fun getFileName() = "$dir${File.separator}${getToday()}.log"

    // 构建 java.io 压缩文件输出流
    private fun checkFileWriter(): Writer {
        if (fileWriter == null) {
            fileWriter = logFile.outputStream().gzip().writer().buffered()
        }
        return fileWriter!!
    }
    
    // 自定义装饰流构造方法,以简化流构建代码
    private fun OutputStream.gzip() = GZIPOutputStream(this)
    private fun OutputStream.writer(charset: Charset = Charsets.UTF_8) = OutputStreamWriter(this, charset)
}

下面是测试代码:

// 分别给 EasyLog 配置 Okio 日志拦截器和 FileWriter 日志拦截器
//EasyLog.addInterceptor(FileWriterLogInterceptor.getInstance(this.filesDir.absolutePath))
EasyLog.addInterceptor(OkioLogInterceptor.getInstance(this.filesDir.absolutePath))
MainScope().launch(Dispatchers.Default) {
    // 连续输出1万条日志并压缩
    repeat(10_000) {
        EasyLog.v(str4, "test")
    }
}

输出日志如下:

fileWriter work is ok done=5130, memory=160.70305938812237
fileWriter work is ok done=5172, memory=157.5844831033793
fileWriter work is ok done=5155, memory=168.01649670065987

Okio work is ok done=4765, memory=130.96940611877625
Okio work is ok done=4752, memory=130.21985602879425
Okio work is ok done=4779, memory=135.28374325134973

Okio 有 8% 左右的速度优势,及 20% 左右的内存优势。

总结

  • 压缩日志是提升日志库性能的手段之一,常用的 Gzip 是压缩手段之一,Okio 和 java.io 都提供了对 Gzip 的支持,不过 Okio 在速度和内存上都稍好于 java.io。

talk is cheap, show me the code

wisdomtl/EasyLog: An easy way to customize your log in Android,including output to console, writing log to file in high performance way and so on (github.com)

推荐阅读

面试系列文章如下:

转载自:https://juejin.cn/post/7083634028640731143
评论
请登录