背景


在 0.12 及之前的合并中,当合并结束以后会将 ChunkCache 和 TimeseriesMetadataCache 清空。这么做的主要原因是:在 TimeseriesMetadataCache 中,一个缓存的 Key 是由文件前缀(假设一个文件的路径是 顺序(或乱序)/逻辑存储组 / 虚拟存储组 id / 时间分区 id / 文件名,那么它的前缀就是 逻辑存储组 + 虚拟存储组 id + 时间分区 id)、文件版本号、时间序列的 Device 和 Measurement 四项组成的。但是在合并后生成的目标文件,这四项可能都与原来的源文件相同(例如 1-1-0-0.tsfile,2-2-0-0.tsfile,3-3-0-0.tsfile,...,6-6-0-0.tsfile 通过层级合并会生成目标文件 1-1-1-0.tsfile,那么这个目标文件与源文件 1-1-0-0.tsfile 的文件前缀、文件版本号都相同,如果此时时间序列的 Device 和 Measurement 也相同,那么就会得到完全一样的 key ),为了避免查询出现错误,只能在合并结束后将 TimeseriesMetadataCache 清空。在 ChunkCache 中的情况也类似,因此 ChunkCache 也需要一并清空。


但是显然这样做对于查询是不利的,因为每次合并结束后查询的缓存都需要经历一个冷启动的过程,此时查询的速度会变慢。因此,在 master 的最新版本中,ChunkCache 和 TimeseriesMetadataCache 的 Key 加入了空间内合并次数以及跨空间合并次数。这样一来在合并结束后无需清空缓存,也可以保证查询时缓存数据的正确性(因为目标文件和源文件的合并次数不一样)。于是,PR 4315 (https://github.com/apache/iotdb/pull/4315便将合并后清除缓存这一操作删除。随后测试组对这一 PR 进行了测试。

由于不清除缓存避免了缓存频繁冷启动,是有利于提高缓存命中率的,因此我们预期这个 PR 会带来查询性能的上升。但是测试组却得到了完全相反的结果:这个 PR 会导致查询性能下降,甚至性能弱于不开启缓存的情况。




问题排查


出现了这个情况后我们首先怀疑是缓存在查询中没有起到作用,于是我们在日志中加入了对缓存命中率的记录。在拿到测试组的相同负载后,我在本机进行了复现。



测试结果和预料的一致,在相同负载下运行了近 10 个小时后,日志显示缓存的命中率仅有 10^-4 级,这就意味着缓存基本上没有产生任何作用。经过研究后发现,benchmark 下的查询负载是 Recent Query,即查询最新写入的数据。由于数据是新写的,每次查询时缓存中都没有包含对应的内容,因此缓存命中率极低,也就对查询几乎没有任何作用。仔细观察测试组得到的结果,我们发现其实三条曲线的差别并不是很大,也侧面佐证了在这种负载下缓存对查询没有帮助的这一事实。

随后我们就想在 benchmark 中关闭 RecentQuery ,使用正常的查询负载进行测试。但是结果仍然不符合预期:运行数小时后查询的缓存命中率依旧只有 10^-4 级别。于是我们打开了 benchmark 的 verbose 模式,将查询所使用的语句打印出来,并手动输入 CLI 中进行测试。结果我们发现,benchmark 所使用的语句在所查到的结果是空,进一步研究后发现是查询过滤使用的时间戳大于已经写入的数据中最大的时间戳。我们推测是因为 benchmark 中查询所用的时间戳和写入所用的时间戳是分开维护并且随着 benchmark 的运行不断自增的,并且测试组的配置中使用了 200W 条时间序列,导致写入较慢。如果此时查询较快,那么会导致查询的时间戳自增速度可能会大于写入的时间戳,造成不断查询空数据的现象。由于查询的是空数据,因此缓存命中率非常低也就解释的通了。

由于 PR 4315 是为了提高查询缓存命中率进而加速查询,但是在前面的两种负载中缓存几乎对查询没有任何帮助,显然这样的测试意义不大。因此我们决定抛弃 benchmark ,自己写验证程序。


实验


我们先生成了一堆测试文件,有 50 个存储组, 1000 个 Device, 每个 device 有 50 条时间序列,总共有 5 w 条时间序列。我们向每个时间序列写入 230520 个点,并且调小内存,使得每个文件的体积都比较小。最终我们得到了 6 w 多个文件,每个文件的体积为几百 KB 。这么做的目的是为了让合并的速度变快,使得清除缓存的操作变得频繁,增加实验的对比效果。在这种场景,合并的速度为几毫秒,但是因为合并任务的提交间隔为 5 s ,且每次默认提交 10 个合并任务,因此我们可以认为每 5 s 进行 10 次合并。

我们选取了 root.test.g_0.d_3800.s_1 这条时间序列进行实验。这条时间序列总共有 230520 个点,实验中我们每次查询都查询其中固定的 10 w 个点。



对应的 sql 语句为

select count(s_1) from root.test.g_0.d_3800 where time >= 1537459201175 and time <= 1537959201175

我们执行这条查询 100 次,并且记录每次查询的耗时,统计缓存命中率的变化。得到的结果如下:


由于第一次查询时需要对缓存进行冷启动,耗时约为 40 秒,而后续的查询则为几十毫秒,如果一并画出会导致两组曲线看不出区别,因此图中都去掉了第一次查询的耗时。

我们可以看到,这次实验的结果符合了我们的预期。合并后不清除缓存的查询耗时平均为 18 毫秒,而清除缓存的平均耗时为 52 毫秒,前者耗时仅为后者的约 1/3 。并且从缓存的命中率上我们也可以看出,不清除缓存组的命中率要远远高于清除缓存组的命中率。

当然,用户的实际情况不一定会这么极端。因为在这个实验条件下,由于文件体积小,合并会频繁进行,缓存也会被频繁清除。在实际的线上环境中,一次合并可能需要耗费数百乃至数千秒,缓存也不会被清空的那么频繁。我们选取的查询语句能够充分利用缓存进行加速,用户的负载不一定对缓存有如此高的利用率。但是上面的实验结果也证明,在查询语句可以有效利用缓存的情况下,合并后不清除缓存是可以对查询带来较大的提升的。


拓展

我在拿到实验结果的时候,其实还是有一点疑惑的。因为清除缓存的对照组几乎每次查询都没有用到缓存,按理来说每次查询应该都特别慢。但实际上除了第一次查询用了 40 多秒外,后续的查询几乎都在 50 ms 左右,在我的预期中每次查询应该都在 40 秒左右。因此我又进行了进一步的实验。我将 IoTDB 的查询缓存关闭,模拟查询缓存命中率为 0 的情况,然后执行实验中用到的查询。我发现除了第一次的查询用了 40 秒以外,后续的查询也都只用了几十毫秒。于是我便将怀疑的目光放到了操作系统的缓存身上。果不其然,在第一次查询后将操作系统的缓存清空,后续的查询果然就变慢了。



那 IoTDB 中的缓存到底对查询的提升产生帮助没有呢?于是我又打开了 IoTDB 的缓存,然后在冷启动后清除了操作系统的缓存,对比查询的速度。结果发现,即使清除了操作系统的缓存,后续的查询速度依旧很快。这说明 IoTDB 的缓存还是起了很大的作用的。

  • No labels