货拉拉客户端通用日志组件 - Glog

语言: CN / TW / HK

Glog 是货拉拉移动端监控系统中的日志存储组件,Glog 意即 General log - 通用日志。为了满足我们对日志格式的多种需求,我们在存储方式、归档方式上做了一些探索和实践,使得 Glog 的通用性和性能优于常见的日志方案。Glog 已经在货拉拉全线 App 中稳定运行了 1 年多,现在将其开源,我们希望 Glog 的开源能够为移动开发者提供一种更加通用的日志方案,同时希望 Glog 能够从社区中汲取养分,不断得到优化和完善。GitHub 地址:https://github.com/huolalatech/hll-wp-glog

背景简介

移动端日志系统通常来说,主要的目标是辅助开发同学排查线上问题,这些问题包括但不限于 1. 客诉渠道反馈的 App 使用问题; 2. Crash 系统上报的崩溃问题; 3. 其他线上冒烟问题。

为了能够尽快定位问题,我们希望能够快速、详细的还原问题现场,这就需要在代码中 App 运行的关键节点埋入日志,将出现问题时的运行状态快速上报。这对日志系统提出了两个关键的要求,信息完整性以及实时性。 在移动端,公司之前存在一套简单的日志系统,收集的日志信息比较有限,我们通过 App 的常见使用流程来看其覆盖的关键节点

old-log-cover

另外,之前的日志系统只能通过下发任务回捞,实时性较差,还存在 I/O 性能等问题。

为了解决这些问题,我们开发了新的移动端日志系统,覆盖了上面 App 使用流程的全节点信息

new-log-cover

另一方面,为了提升日志的实时性,我们增加了实时日志,通过短轮询来进行定时上报,此外还补充了监控日志来支撑 App 的横向数据对比、评估 App 的性能指标,最终的方案如下

log-system

系统主要组成包括 - Android/iOS 上层日志采集 SDK - 跨平台的储存组件 Glog - 负责日志存储过滤的日志后端 - 负责日志展示的日志前端

新的监控系统包括实时性要求较高的实时日志,信息较完整的离线日志以及为大盘数据服务的监控日志 - 实时日志,快速上传,信息精简,能够接近实时的查看,快速定位、排查用户反馈的问题; - 离线日志,通过后台任务触发上传,按天归档,作为实时日志的兜底,要求信息完整详尽; - 监控日志,支持采样,作为监控大盘的信息源,实时性要求最高,日志只包括监控信息。

为了适配不同日志的存储格式,我们希望存储组件能够在格式上尽量通用,最好做到格式无关;另一方面我们也希望其性能、可靠和安全方面能够对齐一线水平,在调研了市面上流行的日志组件后,我们发现并没有现成方案满足我们的需求,因此我们自研了自己的日志存储组件 Glog。

glog-arch

方案概览

应用上层对不同类型的日志序列化(推荐 Protobuf)之后,将二进制数据存储到 Glog,对于上传频次较高的实时日志和监控日志,采用重命名缓存的方式快速归档;对于信息较全而上传频次不高的离线日志,我们采用 mmap 偏移映射的方式归档,相较标准 I/O 复制归档的方式,提升了性能。在可靠性和安全性方面我们也借鉴了当前的流行方案,例如 mmap I/O 提升性能和可靠性、流式的加密和压缩防止 CPU 突发峰值,另外我们在日志中加入了同步标记支持读取容错。

存储方式

为了适应不同的日志格式,Glog 存储二进制数据,上层依据自己的需要,将数据序列化后交给 Glog

glog-flow

具体的文件格式:使用 2 个字节描述每条日志长度,在每条日志末尾加入一个同步标志,用于文件损坏时的读取容错。

glog-format

归档方式

回顾一下常见的日志组件中 mmap 的使用方式,首先 mmap I/O 需要映射一片大小为 page size (通常为 4KB) 整数倍大小的缓存,随着数据的写入将这片空间耗尽后,我们无法持续扩展这片空间的大小(由于它占用的是 App 的运行内存空间),因此需要将其中的数据归档,常见的方式是将其中内容 flush 追加到另一个归档文件当中,之后再清空 mmap 缓存,这个 flush 的过程一般使用标准 I/O

normal-format

而我们的实时、监控日志为了快速上传保证数据实时性,采用间隔较短的轮询来触发 flush 并上传,这将导致 flush 频率变得很高;而通常的 flush 方式采用标准 I/O 来复制数据,性能相对较低,后续的日志写入需要等待 flush 完成,这将影响我们的写入性能,因此我们考虑两种方案来提升 flush 速度以优化写入性能 1. mmap 偏移映射,通过 mmap 映射归档文件的末尾,之后通过内存拷贝将 mmap 缓存追加到归档文件末尾。这种方式将文件复制变成内存复制,性能较好。 2. 文件重命名,对于可以快速上传并删除的日志,我们可以在需要时将 mmap 缓存重命名成归档文件,之后重建缓存。这种方式直接去除了复制的环节,但是在日志量较大时,可能产生很多零碎的归档文件。

glog-archive

这两种方案可以在我们的不同日志场景应用,对于实时、监控日志来说,对性能要求最高,选用第 2 种方案,这个方案带来的零碎归档文件问题,由于上传和删除较快,在这里并不会堆积,另一方面,考虑到实时、监控日志上传周期较短,零碎的归档文件也便于控制上传的数据量;而离线日志选用第 1 种方案,可以将每天的日志归档在一个文件中,相对常见的标准 I/O 也有性能上的优势。

加密方式

Glog 使用了 ECDH + AES CFB-128,对每条日志进行单独加密。具体来说通过 ECDH 协商加密秘钥,之后 AES CFB-128 进行对称加密。

glog-encrypt

选择 CFB-128 是因为 AES 通用性和安全性较好,加解密只需执行相同块加密算法,对 IV 随机性要求低,ECC 相对 RSA 在加密强度相同的前提下,秘钥更短。

| Security(In Bits) | RSA Key Length Required(In Bits) | ECC Key Length Required(In Bits) | | --- | --- | --- | | 80 | 1024 | 160-223 | | 112 | 2048 | 224-255 | | 128 | 3072 | 256-383 | | 192 | 7680 | 384-511 | | 256 | 15360 | 512+ |

压缩方式

考虑到解压缩的便捷性和通用性,Glog 使用了常见的 Deflate 无损压缩算法,对日志进行流式压缩,即以每条日志为压缩单元,在每次写入时进行同步压缩。这样避免了归档时对整个 mmap 缓存做压缩带来的 CPU 波峰,具体的原理下面做一些解释。

Deflate 算法是 LZ77 与哈夫曼编码的组合

LZ77

LZ77 将数据(短语)通过前向缓冲区,然后移动到滑动窗口中成为字典的一部分,之后从字典中搜索能与前向缓冲区匹配的最长短语,如果能够命中,则成为短语标记作为结果保存起来,不能命中则作为字符标记保存。 解压时,如果是字符标记则直接拷贝到滑动窗口中,如果是短语标记则在滑动窗口中查找相应的偏移量,之后将滑动窗口中相应长度的短语拷贝到结果中。

短语标记包括了 1. 滑动窗口中的偏移量 2. 匹配命中的字符长度 3. 匹配结束后前向缓冲区的第一个字符

下面展示了对字符 LABLALALABLA 进行 LZ77 压缩和解压缩的过程,

glog-lz77

接下来霍夫曼编码对 LZ77 的处理结果(包括前面提到的偏移量、长度、字符),按照出现频率越高,占用空间越少的方式进行编码存储。 在简要说明原理之后,我们知道影响压缩率的几个因素:滑动窗口(字典)大小,输入的数据(短语)长度、以及短语中字符的重复率。字典越大、短语越长,越容易从字典中找到匹配短语进而变成短语标记,那么流式压缩以每条日志作为压缩单元,输入数据长度变短,我们如何保证压缩率呢? 这里我们能做的是尽量保证字典的大小,不频繁重置字典,具体做法是只在 mmap 缓存归档时重置字典,对于归档前 mmap 缓存的数据,复用字典来保证压缩率。

消息队列

mmap 相对标准 I/O 在性能上有较大优势,主要是由于其减少了内核空间与用户空间的拷贝、以及 write lseek 系统调用带来的上下文切换开销

glog-mmap

但在系统资源不足时 mmap 仍有可能出现性能问题,举个例子,我们知道 mmap 与标准 I/O 一样也需要通过 Page Cache 回写到磁盘

Page Cache 的生命周期: 当用户通过标准 I/O 从用户缓冲区向内核空间拷贝数据时,如果内核缓冲区中没有这个 Page,将发生缺页中断分配一个 Page,之后拷贝数据,结束后这个 Page Cache 变成一个脏页,然后该脏页同步到磁盘中,同步结束后,这个 Page Cache 变成 Clean Page 保存在系统中。

Android 中可以通过 showmap 命令观察 mmap 写入了 Page Cache

glog-pagecache

当系统内存不足时,系统将回收 Page Cache 来释放内存,引起频繁的磁盘回写,mmap 性能也会受到影响。 另一方面由于实时日志、监控日志需要高频归档,而归档会阻塞后续的写入。因此我们在 Glog 底层加入了消息队列来处理写入和归档等操作,进一步提升性能,避免卡顿。

glog-message-queue

性能对比

| 手机型号 | 日志 SDK | 1w 条日志耗时 | 10w 条日志耗时 | | --- | --- | --- | --- | | Samsung Galaxy S10+ Android 11 | glog | 21 ms | 182 ms | || glog+pb | 54 ms | 335 ms | || xlog | 207 ms | 1961 ms | || logan | 250 ms | 6469 ms | | Huawei Honor Magic 2 Android 10 | glog | 38 ms | 286 ms | || glog+pb | 84 ms | 505 ms | || xlog | 263 ms | 2165 ms | || logan | 242 ms | 3643 ms | |Xiaomi 10 Android 11| glog | 27 ms | 244 ms | || xlog | 198 ms | 1863 ms | || logan | 210 ms | 4238 ms | | Huawei Mate 40 pro HarmonyOS 2.0.0 | glog | 30 ms | 257 ms | || xlog | 275 ms | 2484 ms | || logan | 260 ms | 4020 ms | | OPPO R11 Android 8.1.0 | glog | 63 ms | 324 ms | || glog+pb | 234 ms | 1611 ms | || xlog | 464 ms | 3625 ms | || logan | 430 ms | 5355 ms | | iPhone 12 128G iOS 14.8 | glog | 7 ms | 29 ms | || xlog | 152 ms | 1079 ms | || logan | 162 ms | 12821 ms | | iPhone 8 64G iOS 13.7 | glog | 12 ms | 50 ms | || xlog | 242 ms | 2106 ms | || logan | 251 ms | 38312 ms |

Glog 使用异步模式、按天归档

通过对比数据来看,Glog 异步模式由于使用了消息队列,即使累加上 Protobuf 的序列化时间,写入性能相对来说依然有较大优势。

遇到的问题

  • 使用 mmap 偏移映射方式拷贝数据时,需要通过 mmap 映射文件末尾,其偏移量也需要是 page size 的整数倍,而归档文件和复制数据大小通常情况下都不是 page size 的整数倍,需要做额外的计算;
  • 如果只对归档文件总体积作为阈值来清理,在重命名归档这种情况下零碎文件较多,可能在收集文件列表的过程中导致 JNI 本地引用超限,需要限制文件总个数、及时回收 JNI 本地引用;
  • 在跨天写入日志的情况下,mmap 缓存中的数据可能无法及时归档,造成部分日志误写入次日的归档文件当中,需要在归档轮询中增加时间窗口的判定;
  • 为了便于上层上传日志,在底层需要添加日志解析模块。

总结

通过上面的介绍,可以看到 Glog 相较其他流行方案的主要区别是: - 存储的是格式无关的二进制数据,具有更好的定制性; - 底层实现的消息队列,性能更优使用也更方便; - 新的归档方式一方面提升性能,另一方面也便于高频读取。

当然这些手段也带来了一些妥协,比如由于存储的是二进制数据,使用 Glog 需要额外添加序列化代码;异步模式下,消息队列中的任务在 Crash 或断电时可能丢失,这些问题在我们的使用场景基本可以忽略。 为了实现货拉拉的业务需求,我们参考流行的日志方案,站在巨人的肩膀上,在移动端存储组件高性能、可靠、安全的基本要求之外,提供了更多的特性和额外的优化。在开源之后,也希望能够反哺社区,为移动开发者提供一种更为通用的日志方案。

以 Glog 为存储模块的日志系统,目前已经接入了公司的全线 app,实时日志的单日日志量达到数十亿条,稳定运行在百万级别的 App 上。为线上用户反馈问题解决、App 崩溃排查提供了有力的帮助,除此之外,还为风控系统、监控大盘提供了数据支撑。

glog-app