背景
随着业务的不断扩张,⽤户体量增⼤,很多线上⽤户反馈的问题我们无法在本地复现,那么用户反馈的问题现场就显得尤为重要,所以日志库则是一个必不可少的基础库,快看应用每天产生的日志数据十分庞大,为了解决日志存在的丢失、写入效率、占用空间大以及Android、iOS平台差异等问题,KKLog日志库就此产生。传统日志缺陷
最佳实践
- 数据完整性:任意时刻上传或者从本地磁盘拉取出的⽇志⽂件都可以完整的被解析处理
- 空间占⽤少:尽量较少冗余数据占⽤的空间,在相同⼤⼩⽇志⽂件下,尽可能多的存储⽇志内容
- 获取便利性:除了App⽤户反馈上报,⽤户也可以很容易的找到⽇志⽂件发送给开发⼈员,避免应⽤遇到⽆法启动问题时,⽆法上传⽇志的问题
- ⽇志中可能会涉及到⼀些⽤户隐私数据,例如imei等,需要确保这些隐私数据不被别的应⽤获取就必须满⾜以下其中⾄少⼀点
- ⽇志⽂件放在应⽤私有⽬录,可以确保别的应⽤⽆法读取⽇志⽂件,但是⽤户也⽆法获取这个⽂件,⽆法⼿动发送⽇志⽂件
- ⽇志⽂件放在公有⽬录,那就要求对⽇志⽂件压缩加密,这样会严重降低压缩率基于以上两种⽅式的缺点
- 对写⼊临时⽂件的每条⽇志内容做简单加密,当临时⽂件写满时,还原⽇志内容,并将这个临时⽂件压缩加密存⼊最终的⽇志⽂件中
架构
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
- KKLogger 对外暴露接⼝,只有基本的⽅法,和系统Log⽅法⼀致,外部使⽤⽅式为KKLogger.with("biz").i("tag","content")
- LoggerManager为管理类,管理writer⽅式,上传、以及是否需要Hook Native的⽇志,使⽤时可以根据是debug还是release选择不同的writer
- Writer/Upload/Hooker等,这些是负责专⻔的写操作和上传等逻辑操作
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
存储结构
⽇志相关⽂件
- 这是⼀个mmap的内存⽂件映射,⽤于提升⽇志写⼊速度,同时尽可能保证⽇志内容不会因为进程被杀⽽丢失。
- 临时⽂件中的⽇志内容是没有压缩的,⽤于积攒⾜够多的⽇志内容,⼀次性压缩和加密,经验上看⽇志内容越多,压缩的效率会更⾼。
- version: ⽇志协议版本,⽤于服务端解析⽇志⽂件时,选择对应版本的解析⽅式。
- string pool: ⽇志中的业务类型和tag信息是重复率⾮常⾼的内容,并且⻓度可能较⻓,没必要每条⽇志都重复写,因此使⽤⼀个string pool,建⽴所有⽇志内容的业务类型和tag的索引,使⽤1字节或2字节表示索引,最多可引⽤32768个字符串。
- time_ofset: 由于正常的时间戳需要8个字节才能存储,⽽⽤户使⽤的时间段⽤4个字节就⾜够覆盖了,因此time_ofset记录第⼀条⽇志产⽣的时间,后续⽇志只记录相对该时间的偏移即可。
- 当临时⽂件(临时⽂件可⼤⼩通过配置)写满或者执⾏上传操作时,将临时⽂件中的数据压缩加密后追加写⼊⽇志⽂件。
- 当每个⽇志⽂件⼤⼩超出限制(⽇志⽂件⼤⼩可通过配置)后开启新的⽂件,同时只保留最新的两个⽇志⽂件。
⽇志格式
- ⽇志级别: Error, Warning, Info, Debug
- 业务类型: ⽤于区分不同的业务线或⼤的业务模块(如图⽚,⽹络)
{
"level": "D",
"tag": "Test",
"biz": "Image",
"content": "load staic image: http://xxxx/index.png",
"pid": 123,
"tid": 456,
"time": 123123
}
其中level, tag, biz, content是调⽤⽇志写⼊接⼝时传⼊的,pid, tid, timestamp是⽇志模块⾃动⽣成的⽇志内容格式
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
- pid/tid: 默认pid, tid分别占两个字节, 考虑到相同pid/tid连续打⽇志的概率⾮常⾼,如果两条相邻⽇志的pid/tid相同,则使⽤0表示新⽇志的pid/tid, 共占⽤两个字节。
- time: 相对于time_ofset的时间偏移,4个字节
- biz: 业务类型在string pool在string - pool中的索引,如果索引值<=127, 则使⽤1个字节,否则使⽤两个字节表示,且最⾼位置1,表示是两个字节的⻓度
- tag: log tag在string pool中的索引,编码规则同biz
- len: 内容⻓度,⻓度<=127时1个字节,否则两个字节,最⻓不超过32766字节, 超过则分成两条⽇志这⾥不清楚字节的分配占⽤,可以了解⼀下以下知识:Int8,Int16,Int32,Int64,后⾯的数字就代表这个数据类型占据的空间。
- Int8 等于Byte, 占1个字节. -128~127
- Int16 意思是16位整数(16bit integer),相当于short 占2个字节 -32768 ~ 32767
- Int32 意思是32位整数(32bit integer), 相当于 int 占4个字节 -2147483648 ~ 2147483647
- Int64 意思是64位整数(64bit interger), 相当于 long 占8个字节 -9223372036854775808 ~9223372036854775807
- WORD 等于 unsigned short 0 ~ 65535
模型
线程模型
采⽤单线程池模型,写操作和上传操作均通过该线程池完成,确保不阻塞当前线程.写⼊流程
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
- ⾸先获取到原始信息,然后⾃动补全tid、pid以及time
- 写⼊bufer时会序列化计算每个位置的字节数,从meta中找到元信息,也就是len、version、time_ofset、string_pool等数据
- 接着将补全后的数据简单加密后存储到临时⽂件,也就是mmap的⽂件
- 如果此时临时⽂件不⾜以写⼊这条数据,会将临时⽂件还原,然后压缩、aes加密,写⼊log_fle⽂件中
多进程⽀持(仅Android)
Android⽀持多进程,为了尽可能保证多进程⽇志的时序正确,以及分析时可以综合多进程⽇志⼀起分析,将所有进程的⽇志写⼊到同⼀个⽇志⽂件中。使⽤fe_lock, 在⽇志信息开始准备写⼊临时⽂件时,就获取⽂件锁,写⼊完成后再释放锁。- 获取⽂件锁涉及内核调⽤,频繁的操作可能会有⼀定的性能消耗, 测试10w次lock+unlock, ⼤约耗时100-200ms, 是可接受的范围
- 由于每个进程中的⽇志信息是在独⽴线程中异步写⼊的,因此并不能绝对保证多进程的⽇志时序正确。
数据压缩和加密
压缩
使⽤系统提供的gzip压缩,针对整个临时⽂件进⾏压缩(使⽤zlib)加密
使⽤aes加密,密钥和初始化向量由初始化时传⼊,需要保证密钥和向量不变。存储模块接⼝协议
#ifndef KKLOG_LOG_CORE_H
#define KKLOG_LOG_CORE_H
#include <map>
/**
* 初始化日志缓存文件和日志文件
* @param aesKey
* @param aesIV
* @param tempFileSize
* @param MAX_LOG_FILE_SIZE
* @param MAX_LOG_FILE_SIZE
* @return
*/
int init(const char* aesKey, const char* aesIV, uint32_t tempFileSize, uint32_t MAX_LOG_FILE_SIZE, const char* logDir);
/**
* 写入日志内容
* @param level
* @param bizType
* @param tag
* @param tid
* @param timestamp 时间戳,毫秒
* @return 写入结果,0成功,其他表示具体错误码
*/
int writeLog(int level, const char* bizType, const char* tag, const char* content, uint32_t tid, long timestamp);
/**
* 将临时文件中的日志强制写入到日志文件中
* @return 写入结果,0成功,其他表示具体错误码
*/
int flush();
/**
* 组装Log File,将临时文件强制写入日志文件,将元信息文件追加到日志文件中
* @return 合并生成的日志文件路径
*/
const char* assembleLogFile();
/**
* 组装Log File, 写入到指定日志文件
* @param logDir
* @return 是否组装成功
*/
bool assembleLogFile(const char* targetPath);
bool clearLog(const char* logDir);
#endif //KKLOG_LOG_CORE_H
上层主要使⽤的是writeLog,不需要调⽤fush,如果做⽇志上报时,可强⾏调⽤assembleLogFile将临时⽂件写⼊⽇志⽂件,同时将元信息追加到⽇志⽂件中,返回⽇志存储地址性能展示
为了检测KKLog的性能效果,我们对比了内存以及cpu的使用情况,写入10万条数据,每次间隔3ms,依次写入日志' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
可以看出来传统方式写入文件GC的非常频繁,内存抖动非常非常厉害,而kklog则不会出现,接下来我们对比一下CPU的使用情况
从上图我们可以看出来传统写法cpu使用率在12%左右,而kklog的写法cpu使用率在2%~3%左右⽇志解析
⽇志⽂件存储在本地,以及上传服务器,因此⽇志⽂件都是经过压缩和加密的,不可以直接查看,需要有特定的解析⼯具进⾏解析。⽇志⽬录结构
- log_fle.lock: ⽤于多进程间⽇志同步锁,不需要关注
- log_fle.mmap: 临时⽇志⽂件,⽤于提升写⽇志的性能,以及⽇志压缩率
- log_fle.meta: ⽇志相关的⼀些meta信息,如字符串表,时间偏移等
- log_fle.log: 聚合了log_fle_1, log_fle_2, log_fle.meta,log_fle.mmap的完整⽇志,⽤于上传到服务端,研发可以从服务端下载完整⽇志⽂件
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
⽇志解析⼯具
日志导出后可能是一个目录也可能是一个压缩过的文件,如果是从用户手机直接手动导出的,这个时候就是一个目录,如果是通过用户反馈上传的则是一个压缩好的文件- ⾸先读取meta信息,将version、time_ofset、string_pool读取出来
- 最后根据写⼊时的顺序按照字节数读取,顺序分别为1字节的level、2字节的pid、2字节的tid、4字节的time等等
- 这样每次读取⼀条log信息,然后就是遍历所有读取所有内容
private static Tuple<LogItem, Boolean> nextLogInner(InputStream logStream, MetaData metaData) throws IOException {
if (logStream == null || logStream.available() <= 0) {
return null;
}
LogItem item = new LogItem();
item.level = logStream.read();
int pid = IOUtils.readShort(logStream);
if (pid == 0) {
item.pid = LogItem.lastPid;
item.tid = LogItem.lastTid;
} else {
item.pid = pid;
item.tid = IOUtils.readShort(logStream);
LogItem.lastPid = item.pid;
LogItem.lastTid = item.tid;
}
item.time = metaData.getTimeOffSet() + IOUtils.readLong32(logStream);
int bizIndex = IOUtils.readVarientShort(logStream);
int tagIndex = IOUtils.readVarientShort(logStream);
item.biz = metaData.getStringTable().get(bizIndex);
item.tag = metaData.getStringTable().get(tagIndex);
Tuple<String, Boolean> content = IOUtils.readString(logStream);
item.content = content.getFirst();
return new Tuple<>(item, content.getSecond());
}
- 先看是否有完整⽇志⽂件,如果找到了直接解析⽇志⽂件,和上⾯⼀样解析
- 如果没有完整⽇志⽂件,则分别解析不同⽂件meta、mmap以及log_fle_1、log_fle_2等
- log_fle_1、log_fle_2和上⾯步骤1是⼀样的解法
- mmap⽂件解析不同之处在于不需要解压,不过因为mmap⽂件是做了简单加密的,所以查看时需要解密还原回来
日志解析工具使用
kklog [-b biz] [-t tag] [-l Level] [-p pid] [-tid tid] [-o outputPath] logPath
-b biz: filter log by biz
-t tag: filter log by tag
-l Level: filter log by level, options: D/I/W/E
-p pid: filter log by pid
-tid tid: filter log by tid
-o outputPath: output parsed result to outputPath
其中logPath可以是完整⽇志⽂件的路径,或者是⼀个⽬录,如果是⽬录,则优先查找⽬录中的完整⽇志⽂件,若没找到,则解析log_fe_1, log_fe_2, log_fe.meta, log_fe.mmap.biz/tag⽀持通配,如kklog -t Activity.*, 仅显示Activity开头的tag的⽇志我们这里将日志解析导出一个文件kklog -o outputPath logPath
最终解析出来的⽇志格式如下' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)
用户反馈后台
后台收到的用户反馈,我们可以根据用户id查询到用户日志,然后还原用户当时的现场
' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)