Android 高性能日志写入方案

前言

网易考拉做为一款超级电商应用,天天都会产生海量日志信息,对日志的写入性能和完整性都有更高的要求。缓存

常规方案

Android 中记录日志一般的方式是经过 Java Api 操做文件,当有一条日志要写入的时候,首先,打开文件,而后写入日志,最后关闭文件。使用这种方案虽然当前看上去对程序的影响不大,可是随着日志量的增长,在 Java 中频繁的 IO 操做,容易致使 gc,频繁打开文件,容易引起 CPU 峰值。安全

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

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

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

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

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

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

这样确实可以大量减小 IO 次数,可是却会引起另外一个更严重的问题——丢日志测试

把日志缓存在内存中,当程序发生 Crash 或进程被杀后就没法保证日志的完整性。优化

一个完善的日志方案,须要知足ui

  • 高效,不能影响系统性能,不能由于引入了日志模块而形成应用卡顿
  • 保证日志的完整性,若是不能保证日志完整,那么日志收集就没有意义了
  • 若是有必要,对日志进行压缩、加密

高性能方案

既然没法减小写入次数,那么咱们能不能在写文件的过程当中去优化呢?

答案是能够的,使用 mmap

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

#include <sys/mman.h>

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

参数说明

mmap 映射模型

示例代码

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
main(){
    int fd;
    void *start;
    struct stat sb;
    fd = open("/etc/passwd", O_RDONLY); /*打开/etc/passwd */
    fstat(fd, &sb); /* 取得文件大小 */
    start = mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if(start == MAP_FAILED) /* 判断是否映射成功 */
        return;
    printf("%s", start); munma(start, sb.st_size); /* 解除映射 */
    closed(fd);
}
复制代码

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

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

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

unmap 函数原型

#include <sys/mman.h>

int munmap(void *addr, size_t length);
复制代码

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

有一点须要注意,对于多进程操做文件,使用 Java Api 能够经过 FileLock 同步,而 mmap 不适用于多进程操做同一个文件。对于多进程应用,须要按需映射多个文件。

继续优化

根据上述方案,设计 jni 接口,打包 so,引入项目。

考虑到安装包大小,能不能不用 so 呢?

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

MappedByteBuffer 位于 Java NIO 包下,用于将文件内容映射到缓冲区,使用的便是 mmap 技术。经过 FileChannel 的 map 方法能够建立缓冲区

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

有一点比较坑,Java 虽然提供了 map 方法,可是并无提供 unmap 方法,经过 Google 得知 unmap 方法是有的,不过是私有的

// FileChannelImpl.class
private static void unmap(MappedByteBuffer var0) {
    Cleaner var1 = ((DirectBuffer)var0).cleaner();
    if (var1 != null) {
        var1.clean();
    }
}

// Cleaner.class
public void clean() {
    if (remove(this)) {
        try {
            this.thunk.run();
        } catch (final Throwable var2) {
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                public Void run() {
                    if (System.err != null) {
                        (new Error("Cleaner terminated abnormally", var2)).printStackTrace();
                    }
                    System.exit(1);
                    return null;
                }
            });
        }
    }
}
复制代码

这时咱们天然想到了反射调用

public static void unmap(MappedByteBuffer buffer) {
    if (buffer == null) {
        return;
    }
    try {
        Class<?> clazz = Class.forName("sun.nio.ch.FileChannelImpl");
        Method m = clazz.getDeclaredMethod("unmap", MappedByteBuffer.class);
        m.setAccessible(true);
        m.invoke(null, buffer);
    } catch (Throwable e) {
        e.printStackTrace();
    }
}
复制代码

因为 Android P 已经限制私有 API 的访问,这里仍须要优化以适配 Android P。

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

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

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

展望

目前日志模块仅对日志写入性能和完整性提供了保障,对于日志的压缩和加密目前尚未实现,目前缓存的日志都是脱敏数据,后期若是有业务要求安全存储,会考虑添加加密功能。

总结

本文主要分析了直接写文件记录日志方式存在的问题,并引伸出高性能文件写入方案 mmap,兼顾了写入性能和完整性。最后介绍了内存映射在 Java 层的实现,避免了引入 so。

更多技术文章,能够访问 考拉移动端团队技术博客

相关文章
相关标签/搜索