本文来自于腾讯bugly开发者社区,未经作者同意,请勿转载,原文地址:http://dev.qq.com/topic/581c2c46bef1702a2db3ae53
Dev Club 是一个交流移动开发技术,结交朋友,扩展人脉的社群,成员都是经过审核的移动开发工程师。每周都会举行嘉宾分享,话题讨论等活动。
本期,我们邀请了 腾讯 WXG Android 高级工程师“闫国跃”,为大家分享《微信mars 的高性能日志模块 xlog》。
大家好 我是来自腾讯微信的闫国跃,很荣幸能给大家做这个分享,我今天主要给大家分享微信mars 的高性能日志模块 xlog
首先介绍一下mars 是什么。
mars 是微信官方的终端基础组件,是一个使用 C++ 编写的业务性无关,平台性无关的基础组件。
可以看一下mars 简单的架构图:
从图中就可以看出它主要包括以下几个部分:
目前接入平台:Android、iOS、Mac、Windows、WP等 。现正在筹备开源中。可以这么说,接入 mars 之后,开发一个应用只需要把开发重心放在业务层和 UI 层上,底层的日志模块和网络模块在 mars 中都已经提供。
在使用用户数上有月活跃8亿的微信用户帮忙背书(数据来源于财报)。 在数据监控上,纯网络监控,长连接有18项 短连接7项。
接下来我重点讲今天的主角mars的 xlog 部分。我们先来思考一下为什么需要日志,日志什么时候能显示其作用。
我们来看一下微信早期跟进问题的流程是怎么样的:
当用户反馈或者我们发现问题时,我们需要联系用户,用户答应配合后,然后修改代码打开日志重新编包让用户试图重现问题,重现之后才能继续排查。这个流程是由当时使用的日志方案所决定的。例如 Android 平台使用 java 实现日志模块,每有一句日志就加密写进文件。这样在使用过程中不仅存在大量的 GC,更致命的是因为有大量的 IO 需要写入,影响程序性能很容易导致程序卡顿。
选择这种方案,在 release 版本只能选择把日志关掉。不仅定位问题的效率低下,而且并不能保证每个需要定位的问题都能重现。这个方案可以说主要是为程序发布前服务的。
在接着往下讲之前,我们先来分析一下这个日志方案所存在的问题。这个日志方案主要的问题就是性能太差。主要性能瓶颈是出现在频繁写文件上。写文件的大致流程如下图:
当写文件的时候,并不是把数据直接写入了磁盘,而是先把数据写入到系统的缓存(dirty page)中,系统一般会在下面几种情况把 dirty page 写入到磁盘:
数据从程序写入到磁盘的过程中,其实牵涉到两次数据拷贝:一次是用户空间内存拷贝到内核空间的缓存,一次是回写时内核空间的缓存到硬盘的拷贝。当发生回写时也涉及到了内核空间和用户空间频繁切换。
dirty page 回写的时机对应用层来说又是不可控的,所以性能瓶颈就出现了。
而且相对于机械硬盘,SSD 存储还有一个“写入放大”的问题。这个问题主要和 SSD 存储的物理结构有关。当 SSD被全部写过一遍之后,再写入的数据是不可以直接更新,只可以通过覆盖重写,在覆盖之前需要先擦除数据。但写入的最小单位是 Page,擦除的最小单位是 Block,而 Block 远大于 Page,所以在写入新数据时就需要先把Block 上的数据读出来和要写入的数据合并在一起,再把 Block 擦除,最后把读出来的数据重新写入到存储上,这样导致实际写入的数据可能远远大于最开始需要写入的数据。
举个最简单的例子:
当要写入一个 4KB 的数据时,最坏的情况是一个块里已经没有干净空间了,但有无效的数据可以擦除,所以主控就把所有的数据读到缓存,擦除块,缓存里 更新整个块的数据,再把新数据写回去,这个操作带来的写入放大就是: 实际写 4K 的数据,造成了整个块(共 512KB)的写入操作,那就是放大了 128 倍。同时还带来了原本只需要简单一步写入 4KB 的操作变成:闪存读取 (512KB)→缓存改(4KB)→闪存擦除(512KB)→闪存写入(512KB),共四步操作,造成延迟大大增加,速度变慢。
只是简单的写文件就牵涉到这么多的倒腾,这个时候我们开始认识到一个高性能日志模块的重要性,既然每个平台都需要打印日志,那为什么不开发一个通用的日志模块呢。
在做之前,我们要思考的一个比较重要的问题就是一个高性能日志模块需要实现什么功能?需要有哪些方面的保证?是否已经有现有轮子可用了?
首先来看一下比较流行的服务端日志框架都提供了哪些功能,如 Log4j, LOGBack 支持socket读写 支持直接写数据库 使用XML配置 针对一种日志抽象层实现(如 SLF4J) ……
但是 由于终端设备的碎片化,用户的多元化,使用场景的复杂化,我们需要的日志组件: 首先是保证流畅性,使用过程中不能影响程序的性能。因为对于一个 App 来说,流畅性尤为重要,流畅性直接影响用户体验,最基本的流畅性的保证是使用了日志不会导致卡顿,但是流畅性不仅包括了系统没有卡顿,还要尽量保证没有 CPU 峰值。 而且要保证日志的完整性,任何时刻都有日志可查。不能因为程序被操作系统杀掉或者发生了未捕捉到的 Crash 就丢了部分日志。 还有比较强的容错性,当日志文件中的部分日志数据损坏时应该尽量最小化对整个日志文件的影响。 最后保证必要的安全性,日志内容需要进行加密。 以上可以总结我们需要一个 保证流畅性的前提下,高完整性,强容错性,必要的安全性的日志组件。
服务端日志框架提供的功能和我们需要的功能对比可以看出,现有的日志框架很难满足我们终端设备的需求,所以我们开始着手造轮子。为了兼容多平台,我们选用了 C++进行开发,虽然并不是所有的函数都在 Android、iOS、Windows 等系统上通用,但绝大多数接口其实是通用的,我们只需要封装个别的平台相关接口就行了。
还记得最简单的日志方案是什么样的:对每一行日志加密写文件。 在这个方案中因为要写入大量的 IO 导致程序卡顿,那是否可以先把日志缓存到内存中,当到一定大小时再加密写进文件,为了进一步减少需要加密和写入的数据,在加密之前可以先进行压缩。
针对这个想法就提出了xlog V1.0的方案。
方案描述:把日志写入到作为 log 中间 buffer 的内存中,达到一定条件后压缩加密写进文件。 这个方案的基本流程图如下:
这个方案基本可以解决 release 版本因为流畅性不敢打日志的问题,并且对于流畅性解决了最主要的部分:由于写日志导致的程序卡顿的问题。但是因为压缩不是 realtime compress,所以仍然存在 CPU 峰值。
但这个方案却存在一个致命的问题:丢日志。
理想中的情况:
当程序 crash 时, crash 捕捉模块捕捉到 crash, 然后调用日志接口把内存中的日志刷到文件中。但是实际使用中会发现程序被系统杀死不会有事件通知,而且很多异常退出,crash 捕捉模块并不一定能捕捉到。而这两种情况恰恰是平时跟进的重点,因为没有 crash 堆栈辅助定位问题,所以丢日志的问题这个时候显得尤为凸显。
在实际实践中,Android 可以使用共享内存做中间 buffer 防止丢日志,但其他平台并没有太好的办法,而且 Android 4.0 以后,大部分手机不再有权限使用共享内存, 即使在 Android 4.0 之前,共享内存也不是一个公有接口,使用时只能通过系统调用的方式来使用。
所以这个方案仍然存在不足:
这个方案微信使用了很长的时间,但随着 Android系统的升级,该方案已经不能满足使用需求了。再回头看前面两个方案,直接写文件虽然不会丢日志但会影响性能,使用内存做中间 buffer 缓存日志可能会丢日志。
如果可以把这两个方案的优点糅合在一块,就是我们真正需要的一个完整的日志方案了。一个既有直接写内存的性能,又有直接写文件的可靠性的方案。也就是微信目前在用的xlog的方案。
为了兼顾流畅性和完整性,我们引入了 mmap,mmap 是使用逻辑内存对磁盘文件进行映射,中间只是进行映射没有任何拷贝操作,避免了写文件的数据拷贝。 操作内存就相当于在操作文件,避免了内核空间和用户空间的频繁切换。
为了验证 mmap 是否真的有直接写内存的效率,我们写了一个简单的测试用例:把512 Byte的数据分别写入150 kb大小的内存和 mmap,以及磁盘文件100w次并统计耗时
从上图看出mmap几乎和直接写内存一样的性能,而且 mmap 既不会丢日志,回写时机对我们来说又基本可控。 mmap 的回写时机:
如果可以通过引入 mmap 既能保证高性能又能保证强完整性,那么还存在的其他问题呢?比如集中压缩导致 CPU 短时间飙高,这个问题从上个方案就一直存在。而且使用 mmap 后又引入了新的问题, 可以看一下使用 mmap 之后的日志模块流程:
前面已经介绍了,当程序被系统杀掉会把逻辑内存中的数据写入到 mmap 文件中,这时候数据是明文的,很容易被窥探,可能会有人觉得那在写进 mmap 之前先加密不就行了,但是这里又需要考虑,是压缩后再加密还是加密后再压缩的问题,很明显先压缩再加密效率比较高,这个顺序不能改变。而且在写入 mmap 之前先进行压缩,也会减少所占用的 mmap 的大小,进而减少 mmap 所占用内存的大小。所以最终只能考虑:是否能在写进逻辑内存之前就把日志先进行压缩,再进行加密,最后再写入到逻辑内存中。问题明确了:就是怎么对单行日志进行压缩,也就是其他模块每写一行日志日志模块就必须进行压缩。
带着这个问题 我们去看一下压缩,比较通用的压缩方案是先进行短语式压缩, 短语式压缩过程中有两个滑动窗口,历史滑动窗口和前向缓存窗口,在前向缓存窗口中通过和历史滑动窗口中的内容进行匹配从而进行编码。
比如这句绕口令:吃葡萄不吐葡萄皮,不吃葡萄倒吐葡萄皮。中间是有两块重复的内容“吃葡萄”和“吐葡萄皮”这两块。第二个“吃葡萄”的长度是 3 和上个“吃葡萄”的距离是 10 ,所以可以用 (10,3) 的值对来表示,同样的道理“吐葡萄皮”可以替换为 (10,4 )
这些没压缩的字符通过 ascci 编码其实也是 0-255 的整数,所以通过短语式压缩得到的结果实质上是一堆整数。对整数的压缩最常见的就是 huffman 编码。通用的压缩方案也是这么做的,当然中间还掺杂了游程编码,code length 的转换。但其实这个不是关注的重点。我们只需要明白整个压缩过程中,短语式压缩也就是 LZ77 编码完成最大的压缩部分也是最重要的部分就行了,其他模块的压缩其实是对这个压缩结果的进一步压缩,进一步压缩的方式主要使用 huffman 压缩,所以这里就需要基于数字出现的频率进行统计编码,也就是说如果滑动窗口大小没上限的前提下,越多的数据集中压缩,压缩的效果就越好。日志模块使用这个方案时也就是xlog V1.0方案时压缩效果可以达到 86.3%。
既然 LZ77 编码已经完成了大部分压缩,那么是否可以弱化 huffman 压缩部分,比如使用静态 huffman 表,自定义字典等。于是我们测试了四种方案:
这里可以看出来后两种方案明显优于前两种,压缩率都可以达到 83.7%。第三种是把整个 app 生命周期作为一个压缩单位进行压缩,如果这个压缩单位中有数据损坏,那么后面的日志也都解压不出来。但其实在短语式压缩过程中,滑动窗口并不是无限大的,一般是 32kb ,所以只需要把一定大小作为一个压缩单位就可以了。这也就是第四个方案, 这样的话即使压缩单位中有部分数据损坏,因为是流式压缩,并不影响这个单位中损坏数据之前的日志的解压,只会影响这个单位中这个损坏数据之后的日志。
对于使用流式压缩后,我们采用了三台安卓手机进行了耗时统计,和之前使用通用压缩的的日志方案进行了对比(耗时为单行日志的平均耗时):
通过横向对比,可以看出虽然使用流式压缩的耗时是使用多条日志同时压缩的 2.5 倍左右,但是这个耗时本身就很小,是微秒级别的,几乎不会对性能造成影响。最关键的,多条日志同时压缩会导致 CPU 曲线短时间内极速升高,进而可能会导致程序卡顿,而流式压缩是把时间分散在整个生命周期内,CPU 的曲线更平滑,相当于把压缩过程中使用的资源均分在整个 app 生命周期内。
总结一下方案,也就是xlog 的最终日志方案:
使用流式压缩方式对单行日志进行压缩,压缩加密后写进作为 log 中间 buffer的 mmap 中,当 mmap 中的数据到达一定大小后再写进磁盘文件中
虽然使用流式压缩并没有达到最理想的压缩率,但和 mmap 一起使用能兼顾流畅性 完整性 容错性 的前提下,83.7%的压缩率也是能接受的。使用这个方案,除非 IO 损坏或者磁盘没有可用空间,基本可以保证不会丢失任何一行日志。
在架构设计上也考虑了扩展性,比如日志头部的结构体是可以随意修改的
输出到文件的主要实现是在 Appender 模块也是可插拔的,如果对默认的策略不满意可以自己实现一套。
xlog还存在一些其他策略:
在使用的接口方面支持多种匹配方式:
最后, 对于终端设备来说,打日志并不只是把日志信息写到文件里这么简单。除了前文提到的流畅性 完整性 容错性,还有一个最重要的是安全性。基于不怕被破解,但也不能任何人都能破解的原则, 对日志的规范比加密算法的选择更为重要,所以这里并没有讨论这一点。
从前面可以看出,一个优秀的终端日志模块无论怎么设计都必须做到:
上面这几点也即一直强调的 安全性 流畅性 完整性 容错性, 它们之间存在着矛盾关系:
- 如果直接写文件会卡顿,但如果使用内存做中间 buffer 又可能丢日志
- 如果不对日志内容进行压缩会导致 IO 卡顿影响性能,但如果压缩,部分损坏可能会影响整个压缩块,而且为了增大压缩率集中压缩又可能导致 CPU 短时间飙高。
mars 计划在年底开源,目前在走审核流程。运作模式上面,会保证开放出去的代码和微信在使用的代码是同源的。具体开源时间以微信终端官方公众账号为准。
我的分享就到这里,谢谢大家。
Q1:crash捕捉模块具体能解释下嘛?
crash捕捉模块不在mars开源之列,可以线下交流,如果想捕捉C++ crash 建议看Android 源码 backtrace和libunwind方面。 如果是Java的Crash 我不大擅长,就不作答了。
Q2:应用这个日志对服务器端有什么要求?
所有的日志行为都是在终端上,严格说来和服务端没有任何关系。
Q3: 安卓上调用C++打日志还有没有JNI的性能问题呢
在早期的Android 系统上JNI的性能的确是有点问题的,但是随着谷歌认识到C++高性能的特性一直在这方面做相关优化。 现在Java调用C++性能损耗基本可以不考虑。而且其实Java的应用层接口调用到底层也基本都是C来实现的。
Q4:日志头部的magic number 有什么作用?
这里有两个作用,1. 可以看出日志头部是没设置版本号的,所以是根据magic num做了版本区分。 2. 压缩加密后的日志存到文件里,再去解压,是要区分日志起始位置,以及是否损坏的。
Q5:感谢嘉宾精彩分享,受益很多,我的问题是,日志存储到sdcard后还会发送到服务端吗,如果发送在什么时机,如果不发crash信息如何及时了解。
客户端的日志绝大部分时间应该安安静静的在用户手机上等待超时被删除,如果某个用户有反馈,因为日志本身是个文件,用户可以通过应用把这个文件主动上传到服务器。 比如 微信 也有特定的指令用户输入后会触发上报。 至于crash信息,crash捕捉模块捕捉到 可以crash的时候同样打印到日志文件里,而且crash信息也应该独立于日志的一个模块,这个应该是必须上报的。
Q6:这个log主要是存储哪些重要信息,是否可以自定义一些数据存储? 比如app有一些特殊的数据也想写进log
不可以的。 除非自己转换成string的描述。即使一个对象 也可以把对象里的关键性属性打印到日志里。 还是强调的一个点:日志规范很重要,不仅在于安全还在于 只打有用的信息。
Q7:我想问下加密这个环节,是对(多条日志压缩后的结果)进行加密吗,也就是说压缩后的日志要达到一定的大小才会进行加密吗?如果是crash的时候,压缩后的日志没有达到这个大小,是怎么处理的呢?
不是的 你可能理解错了。你说的这个方案是xlog V1.0的方案,你说的那个情况也正是这个方案被抛弃的原因,在V2.0方案会每写一行日志都会直接压缩加密写进mmap中。
Q8:关于日志的上传,是否在此开源之中?关于上报日志,有怎样的思考?比如时间,传输优化,收到后的解压等问题?
不在 毕竟外部也不可能把你们的日志放心交给我们。关于上报日志,要考虑上报有失败的可能,所以需要重试,但是牵涉到比较大的数据,所以重试要有上限。考虑到服务器接收到数据需要存储,使用多线程上报速度会有所提升。因为数据量比较大 最好把文件分片后再上传, 甚至可以考虑断点续传。
Q9:请问下mars和bugly有什么异同?各自有什么优势?
Bugly目前主要是异常上报服务,就是Crash监控。这一块是不包含在mars里的。两个是互补的关系。 mars主要包含的功能是 日志 信令网络通道,网络检测以及一些跨平台C++的基础库。都是在微信内使用的源码。
Q10:xlog log存储到内存中,大小怎么计算,会根据手机适配吗?
目前分配内存150kb,不会根据手机进行适配。 这个计算方式是我们根据之前的测试的压缩数据来反推的。
Q11:对于xlog中的加密以及压缩可以单独接口使用吗?
加密部分我不希望大家关注,这也是我分享中没有分享的原因。因为本身我们不应该把用户的隐私数据打印到日志里。 所以最终开源我们不提供加密算法,但会提供自己实现加密的接口。 这两块都不提供单独的接口使用,压缩除了极端情况下并没有这么用的必要,毕竟大多数情况下是已知数据之后才进行的要说。
Q12:xlog是平台无关的,为什么介绍提到Android的优化
了解安卓和iOS两个平台的人会知道最难伺候的是安卓平台,给后台运行权限又保留随时杀掉你的权利。 丢日志在安卓平台更为频繁。
Q13:请问下本地存储日志时可否选择数据库而不用文件?需要传给服务器时再查询数据库?
不太建议客户端的日志存在数据库里,有些服务器的日志放在数据库是因为为了后续分析使用。 但是我们知道稍微大点的数据都不要放到数据库 更何况是日志文件呢。而且存到数据库会导致有大量的数据库操作,这个性能要考虑。 最后 一个日志模块 我还需要拖一个sqlite的源码进去 o(╯□╰)o,我们希望这个模块精简到无可精简的地步, 目前xlog 的so大小是120kb
Q14:单行压缩的第四个方案,说累积压缩后到一定大小,作为一个压缩单位,是何意啊?mmap回写 是系统的行为?需要我们也为这个过程做些工作吗?
前面有张图说短语式压缩其实是有两个滑动窗口,其实是要根据历史数据进行匹配。但是考虑到性能滑动窗口并不是无限大,所以历史数据也没必要给太多。一个压缩单位意思就是压缩状态初始化到结束。 回写基本可以全部交给系统,我们需要做的每次启动程序去读取mmap文件看是否有上个程序生命周期没写进文件的数据
更多精彩内容欢迎关注bugly的微信公众账号:
腾讯 Bugly是一款专为移动开发者打造的质量监控工具,帮助开发者快速,便捷的定位线上应用崩溃的情况以及解决方案。智能合并功能帮助开发同学把每天上报的数千条 Crash 根据根因合并分类,每日日报会列出影响用户数最多的崩溃,精准定位功能帮助开发同学定位到出问题的代码行,实时上报可以在发布后快速的了解应用的质量情况,适配最新的 iOS, Android 官方操作系统,鹅厂的工程师都在使用,快来加入我们吧!