自制单机日志解决方案 2. 日志索引

上一篇文章简要介绍了一下 Talog 的核心原理,接下来将会对 Talog 的各个模块进行详细的介绍,本文先来介绍一下 Talog 中的日志索引。
太长不看
本文的介绍可能会较为繁琐、不好理解,因此先总结以下几个要点:
Talog 追求简洁的 api 设计,
newTalogger().CreateIndexer("index name")
即可创建日志索引器Talog 能保证日志数据不会丢失,但是可能导致多次索引
Talog 默认的 Indexer 只支持单行日志
使用 HeaderIndexer 来支持多行日志
单线程一秒钟大致能索引 1100+ 条日志,多线程一秒钟大致能索引 1400 条左右
V.Talog.Core 核心包的索引过程
为了让 Talog 的 api 尽可能简洁,我设计了一个 Talogger 类,该类包含所有使用者可能会用到的入口,只要知道 Talogger 类的存在,就可以了解到 Talog 提供的所有功能。比如想要索引日志,只需要初始化 Talogger 类,然后调用 CreateIndexer 即可,而不需要关心用于索引日志的类名是什么。以下为具体的代码展示:
以上代码中 Tag 方法以及 Data 方法都是在日志索引器(Indexer,后续为了方便主要使用该类名)内部实现的,真正与日志索引存储相关的是 Save 方法。调用 Save 时,Indexer 会将日志的标签列表以及日志文本发送给 Index 类,Index 的概念在上一篇文章中有提到,可以理解为是日志的逻辑集合,Index 对象由 Talogger 生成并传递给 Indexer,关于 Index 对象的初始化较为复杂后续会详细介绍,在此我们先关注日志索引过程。Indexer 把日志数据发送给 Index 是通过调用 Push 接口,Index 的 Push 接口虽然无法保证日志数据立即索引成功,但是可以保证日志数据不会丢失,这主要是因为 Push 接口会先将日志数据保存在 Index 的未索引日志列表(unsavedLogs)中,并立即将 unsavedLogs 序列化到磁盘中。
在将日志数据保存到 unsavedLogs 之后,Index 会先根据日志的标签列表获取对应的 Bucket 对象,Bucket 的概念上一篇文章也有介绍过,就是用于存储具有相同标签的日志数据的文件。
在 Index 获取到 Bucket 对象之后,会调用 Append 接口,将日志文本内容索引到磁盘中。到此,日志数据的索引就完成了一半,后续只需要在 Index 中对日志的元数据做维护即可。
元数据主要包括两部分:Bucket 以及字典树(Trie),Trie 的概念上一篇文章中也有提及,主要就是使用某一种标签的所有枚举值去构建一棵字典树。对于 Bucket 的维护相对比较简单,就是根据 Bucket 的 key 判断是否已存在于 Index 中(就是 Index 是否已经知道 Bucket 的存在),若不存在则添加到 Bucket 列表中。对于 Trie 的维护,就是遍历日志的标签列表,根据标签名从 Index 的 Tries 字典中获取对应的字典树(不存在的话,直接调用newTrie()
初始化),然后调用 Trie 的 Append 方法,将标签值以及日志存放的 Bucket 对象存到字典树中。Trie.Append 会根据标签值找到对应的节点,并将 Bucket 存储在 Bucket 字典中(根据 Bucket.Key 去重)。
关于字典树的用途以及结构,读者可以自行搜索,该数据结构我是在大三找暑假实习的时候,一位百度面试官跟我说的,不然大学课程并没有教过该数据结构,在此感谢一下这位面试官,因为当时我的能力明显不合格,但是面试官还耐心地跟我介绍了一下这个数据结构。
至此,一个完整的 Talog 日志索引过程就介绍完毕了。
Index 的初始化
Index 的初始化主要依赖于使用者传递给 Talogger 的 index name,若 Index 是第一次初始化,会自动生成元数据文件,若不是第一次初始化,则会从磁盘中读取元数据文件,还原之前所保存的状态,并且会自动对上次未索引完成的数据进行再次索引(该机制有可能会导致日志数据多次索引,比如日志索引成功后,但是元数据还未保存成功时,程序意外中断)。以下将对这两种情况分别进行详细介绍。
假设首次运行依赖 Talog 的程序并且初始化名为 test 的 Index,则首先会创建/data
目录,该目录名由 Talogger.Config.DataPath 指定,随后会创建/data/test
目录,该目录用于存放 Index 的所有关联文件,然后会初始化 Index.Tries、Index.Buckets、Index.unsavedLogs 三个属性。
假设非首次初始化名为 test 的 Index,则会直接从/data/test/index.json
读取元数据,该文件反序列化后主要有两部分数据:Index.Tries、Index.Buckets,Index.unsavedLogs 数据是单独存放在/data/test/unsaved.json
文件中的,分成两个文件存储的主要原因是存储时机不同。Index.Tries、Index.Buckets 的存储发生在 Index.Save 方法被调用的时候,该方法主要是由 Talogger 的自动保存线程所调用。Index.unsavedLogs 数据的存储是发生在日志索引时,前面有提到,Talog 的日志索引第一件事,就是将日志数据保存在 Index.unsavedLogs 中,同时将数据序列化到磁盘中。
HeaderIndexer
HeaderIndexer 类是在 V.Talog.Extension 包中实现,主要是为了支持多行日志而设计,但其实与 Indexer 的区别只在于 HeaderIndexer 会在每一条日志添加一个[{head}]
前缀,用于标识新日志文本的开头。以下为使用 Talogger 对象创建 HeaderIndexer 的扩展方法签名:
使用以下代码来快速开始使用 HeaderIndexer:
JsonIndexer
JsonIndexer 本质上与 Indexer 一致,因为正常 json 序列化都是采用压缩格式,字符串只占一行,应该不会有人不是采用压缩格式做 json 序列化的吧,不会吧,不会吧。如果是那在 Talog 中应该使用 HeaderIndexer 进行索引。主要是为了与 JsonSearcher 相对应,所以特意创建了 JsonIndexer 类,该类在 Indexer 的基础上仅增加了一个接口,用于支持传入复杂日志对象。
索引后的日志文件结构展示

除了存放日志的文件是使用 Bucket 的 key 命名的,其他文件结构、文件内容均是人类可读的,这有利于了解 Talog 的运行机制以及异常排查。
索引效率
以下为我在笔记本上做的不完全测试,因为同时还有其他程序在运行,所以以下结果仅供参考。测试过程中的每一条日志平均为 80 字节,CPU 为 Intel i7-1065G7 @ 1.30GHz 4 核 8 线程,硬盘为东芝 256G 固态硬盘。

单线程多线程索引 1W 条日志耗时 21s(476 条/s)耗时 17s(588 条/s)索引 5W 条日志耗时 1m53s(442 条/s)耗时 1m34s(531 条/s)索引 10W 条日志耗时 4m3s(411 条/s)耗时 3m11s(523 条/s)索引 20W 条日志耗时 7m44s(431 条/s)耗时 6m49s(488 条/s)
从以上数据可以看出单线程一秒钟大致能索引 400+ 条日志,多线程一秒钟大致能索引 500 条左右,从单线程变更到多线程,似乎索引速度并没有得到太大的提升,这个可能是因为 Talog 的索引瓶颈在于文件的读写。这速度估计是根本无法和市面上的日志平台相比的,但是对于 Talog 的定位(面向小微项目的单机日志解决方案)而言,这个索引效率应该是已经达到可以使用的水平了。
优化索引速度
在测试了索引效率之后,并不是很满意这个索引速度,并且在知道优化方向的情况下,我果断又花了点时间去优化了下代码。

并且换了一台笔记本进行测试,CPU 为 Intel i5-12450H 8 核 12 线程,硬盘为西部数据 512G 固态硬盘。

单线程多线程索引 1W 条日志耗时 8s(1250 条/s)耗时 5s(2000 条/s)索引 5W 条日志耗时 45s(1111 条/s)耗时 34s(1470 条/s)索引 10W 条日志耗时 1m30s(1111 条/s)耗时 1m8s(1470 条/s)索引 20W 条日志耗时 2m56s(1136 条/s)耗时 2m27s(1360 条/s)索引 30W 条日志耗时 4m27s(1123 条/s)耗时 3m29s(1435 条/s)
可以看到优化后的效果比之前的版本好了许多,主要是通过使用 FileStream 代替 System.IO.File,并且使用读写锁代替 lock 的方式来提高效率。目前单线程一秒钟大致能索引 1100+ 条日志,多线程一秒钟大致能索引 1400 条左右,这个速度对于小微项目绝对是达到可以用的水平了吧。
最后
本文介绍了 Talog 的日志索引过程的所有细节,也许随便一个开源项目的复杂度就能够吊打 Talog,但是 Talog 设计之初就明确了目标——小微项目、够用就行。下一篇文章将会介绍一下 Talog 的日志查询部分。