Android 高性能日志写入方案

5,430 阅读6分钟

前言

公司目前在做一款企业级智能客服系统,对于系统稳定性要求很高,不过难保用户在使用中不会出现问题,而 Android SDK 集成在客户的 APP 中,同时由于 Android 碎片化的问题,对于 SDK 的问题排查就显得尤为困难,因此记录下用户的操作日志就显得极为重要。

初始方案

一开始,SDK 记录日志的方式是直接通过写文件,当有一条日志要写入的时候,首先,打开文件,然后写入日志,最后关闭文件。这样做的问题就在于频繁的IO操作,影响程序的性能,而且 SDK 为了保证消息的及时性,还维护了一个后台进程,当其中一个进程进行日志写入时,另一个就会被锁在门外等着,问题就愈发严重。使用这种方案虽然当前看上去对程序的影响不大,但是随着日志量的增加,更多的IO操作,一定会造成性能瓶颈。

下面我们来分析下直接写入文件的流程:

  1. 用户发起 write 操作
  2. 操作系统查找页缓存 a.若未命中,则产生缺页异常,然后创建页缓存,将用户传入的内容写入页缓存 b.若命中,则直接将用户传入的内容写入页缓存
  3. 用户 write 调用完成
  4. 页被修改后成为脏页,操作系统有两种机制将脏页写回磁盘 a.用户手动调用 fsync() b.由 pdflush 进程定时将脏页写回磁盘

可以看出,数据从程序写入到磁盘的过程中,其实牵涉到两次数据拷贝:一次是用户空间内存拷贝到内核空间的缓存,一次是回写时内核空间的缓存到硬盘的拷贝。当发生回写时也涉及到了内核空间和用户空间频繁切换。

而且相对于机械硬盘,SSD 存储还有一个“写入放大”的问题。这个问题主要和 SSD 存储的物理结构有关。当 SSD 被全部写过一遍之后,再写入的数据是不可以直接更新,只可以通过覆盖重写,在覆盖之前需要先擦除数据。但写入的最小单位是 Page,擦除的最小单位是 Block,而 Block 远大于 Page,所以在写入新数据时就需要先把 Block 上的数据读出来和要写入的数据合并在一起,再把 Block 擦除,最后把读出来的数据重新写入到存储上,这样导致实际写入的数据可能远远大于最开始需要写入的数据。

没想到简单的写文件竟然涉及了这么多操作,只是对于应用层透明而已。

既然每写一次文件会执行这么多次操作,那么我们能不能将日志缓存起来,当达到一定的数量后再一次性的写入磁盘中呢?

这样确实能够大量减少 IO 次数,但是却会引发另一个更严重的问题——丢日志

把日志缓存在内存中,当程序发生 Crash 或进程被杀后就无法保证日志的完整性,而且由于 SDK 存在多进程,也无法保证多进程下日志的顺序。

一个完善的日志方案,需要满足

  • 高效,不能影响系统性能,不能因为引入了日志模块而造成应用卡顿
  • 保证日志的完整性,如果不能保证日志完整,那么日志收集就没有意义了
  • 对于多进程应用,要保证最终看到的日志顺序的准确性

高性能方案

既然无法减少写入次数,那么我们能不能在写文件的过程中去优化呢?

答案是可以的,使用 mmap

mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系,函数原型如下

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

mmap操作提供了一种机制,让用户程序直接访问设备内存,这种机制,相比较在用户空间和内核空间互相拷贝数据,效率更高。在要求高性能的应用中比较常用。

同时 mmap 能够保证日志的完整性,mmap 的回写时机:

  • 内存不足
  • 进程退出
  • 调用 msync 或者 munmap
  • 不设置 MAP_NOSYNC 情况下 30s-60s(仅限FreeBSD)

当映射一个文件后,程序就会在 native 内存中申请一块相同大小的空间,因此建议每次映射一小段内容,如 64k,写满后再重新映射文件后面的内容。

日志写入性能和完整性的问题解决了,那么如何保证多进程下日志的顺序呢?

由于 mmap 是采用共享内存的方式写入数据,如果两个进程同时映射一个文件,那么一定会造成日志覆盖的问题。

既然不能直接保证顺序,那我们只能退而求其次,两个进程分别映射不同的文件,每天合并一次,合并时对日志进行排序。

继续优化

根据上述方案,设计 jni 接口,打包 so,引入 SDK,看似没什么问题了,但是作为一款 SDK,总觉得包含 so 不太友好,在一定程度上会增加接入的难度。

那么能不能不用 so 呢?

其实 Java 中已经提供了内存映射的实现——MappedByteBuffer

MappedByteBuffer 位于 Java NIO 包下,用于将文件内容映射到缓冲区,使用的即是 mmap 技术。通过 FileChannel 的 map 方法可以创建缓冲区

RandomAccessFileraf = new RandomAccessFile(file, "rw");
MappedByteBuffer buffer = raf.getChannel().map(FileChannel.MapMode.READ_WRITE, position, size);

为了测试 MappedByteBuffer 的效率,我们把 64byte 的数据分别写入内存、MappedByteBuffer 和磁盘文件 50 万次,并统计耗时

方法 耗时
内存 384ms
MappedByteBuffer 700ms
磁盘文件 16805ms

可以看出 MappedByteBuffer 虽然不及写入内存的性能,但是相比较写入磁盘文件,已经有了质的提升。

总结

本文主要分析了直接写文件记录日志方式存在的问题,并引申出高性能文件写入方案 mmap,兼顾了写入性能和完整性,并通过补偿方案确保多进程下日志的顺序。最后发现了内存映射在 Java 层的实现,避免了引入 so。

迁移自我的简书 2018.01.28