正文
有了这两个基本的设计,
append(event)
操作就显而易见:
-
找到日志中 pos 指向的位置,写入 varint 长度和数据本身。写完后更新 pos。
-
找到索引中 total 指向的位置,记录写入的是哪个日志文件,以及写之前的 pos。写完后更新 total。
get(idx)
操作也很直观:
-
找到 idx 对应的 IndexEntry(
INDEX_HEADER + idx * ENTRY_SIZE
),取出 file 和 pos
-
找到对应的日志文件从 pos 里用 varint decode 出长度,然后读出整个 event
iterator
实现类似,这里就不赘述。
由于文件 mmap 后,拿到的就是一个容量很大的
&[u8]
,所以各种操作爽到飞起,全程零拷贝,比如需要修改某个
IndexEntry
,可以直接
data.align_to_mut::
()
,然后就往里灌数据。效率高到不能更高了。
然而,这里有一个待解决的问题:什么时候把 mmap 内存中修改的数据刷到磁盘上?间隔太短会影响性能,间隔太长万一进程崩溃,丢失的数据就太多(linux 4 以后支持 MAP_SYNC,理论上可以在崩溃后依然把内存内容写入文件)。不管怎么说,我们需要一定的刷新策略。我实现了一个简单的刷新策略:在配置文件中配置 flush size,比如 1024,那么每写入 1024 项,就会刷新一下索引文件和当前日志文件。
这会带来一个新的问题:每次刷新会急剧拉低整体的性能。
这是因为,对 mmap 的无脑刷新会导致 mmap 的所有内存页面被刷新到硬盘。我本以为 mmap 会更聪明一些,看看哪些页面是 dirty 再真正去刷新,但实际不是。由于我们写入的文件都是 append only 的,除了文件头,刷新过的部分就不会修改,因此就无需重新刷新。所以,我们只需要额外记录一下上次刷新到哪个位置,这样,刷新的时候只需要刷文件头(因为一直在修改),以及上次刷新的位置到当前写入的位置即可。做了这个调整后,写入性能急剧提升。我使用 criterion.rs [5] 做了这么几组 benchmark(所有刷新策略都是写入 1024 个事件就刷新一下):
随后,我又做了一些其它的优化和功能上的增减(比如代码上对 Writer / Reader 做了分离,日志支持了 varint,以及把索引和日志结合起来提供完整方案的 logger),并没有明显变化:
在真实的使用场景中,添加一条事件,会写入日志和索引,所以 benchmark 把索引和日志结合起来提供完整方案的 logger 更有意义。根据上图,Logger 一次
append
1k 左右的数据,大概需要 1.59us,这意味着单核每秒钟可以写入 630k 条日志,或者 630MBps(我的 mbp 2019 SSD 写入速度在 2.6GBps 左右),而
append
16k 左右的数据,需要 18.3us,大概每秒 55k 条日志,或者约 900MBps。
这个设计存在一个架构上的问题 — 索引文件会随着日志数量的增加而不断增大。当 repo 中有 1M 条事件时,索引占用的内存将达到 8M,加上每个 repo 当前正在写入的日志(假设每 4M 我们 rotate 一次日志文件,内存中每个 repo 就会有 4M 日志 mmap 的内存),这样一共是 12M。乍一看这似乎没什么,占用内存很小啊,但如果同一台机器上要处理 1000 个活跃 repo 的数据(每个 repo 平均有 1M 条事件),内存的消耗就在 12G。如何在保持相同的读写性能的情况下,降低内存的消耗呢?