前言
本文学习下 Lucene 在存储大量整数时使用到的编码方法。
介绍
DirectWriter 用 bit 编码方式进行数组压缩的功能,它在整个数组的所有元素都不大的情况下能带来不错的压缩效果。
DirectWriter 是 Lucene 为整型数组重编码成字节数组的工具,它的底层包含一系列编码器,将整型数组的所有元素按固定位长度的位存储。它按 Bit 存储,预留长度过长会浪费空间,短了会因为截断导致错误。因此需要在数组中查找最大值,由它的长度作为存储的长度。
假设有一组数据{4,5,9,0},它们的二进制表示是{100, 101, 1001, 0}。占用有效位最长的是 1001(4 个 bit),因此需要用 4 个 bits 来表示一个数值,得到如下结果。
正好占用了 16 位,两个 byte 的数据。
由于 DirectWriter 在写完后会写入三个 byte 的 0 值,因此上面的数据写入文件之后,使用 xxd 命令查看文件内容为:
很巧合有没有,使用十六进制读取文件,和我们的原始值竟然一样。其实是因为 16 进制每个进位是 4 个 bit, 正好和我们的数据一样而已。
源码分析
带有注释源码可以查看 org.apache.lucene.util.packed.DirectWriter
属性
1 | // 每一个值需要几个 bit |
注释的比较详细,就不多少了。
构造方法
1 | DirectWriter(DataOutput output, long numValues, int bitsPerValue) { |
interations
这里着重解释一下,属性中interations
的作用。构造函数中对他的初始化也不是特别容易懂。
DirectWriter 是按照位对数字进行存储,那就有所谓的block
(块)的概念。
设想下,你想让每个数字用 12 个 bit 存储。而且你只写入一个数字,也就是总共只用 12 位,这时候怎么办?还能向文件中写入 1.5 个字节么?因此,通过计算bitsPerValue
和byte-bits=8
的最小公倍数,来形成一个block
概念。
比如每个数字使用 12 位存储,每个 byte 是 8 个 bit, 那么最小公倍数是 24, 也就是 3 个 byte 为一个 block, 用来存储 2 个 12 位的数字。
申请空间时,直接按照 block 为单位进行申请,如果能写满,就写满。写不满剩余的 bit 位使用 0 填充。
当你仅写入一个 12bit 数字时,实际上会写入三个字节,共 24bit. 前 12bit 是你的数字,后 12bit 用 0 填充。
那么直接按 block 进行写入不就完事了么?为什么需要interations
参数呢?
众所周知,每次都写文件很慢,一般的写文件都使用内存进行 buffer. 缓冲一部分的数据在内存,等到 buffer 满了之后一次性写入一堆数据,这样可以提高吞吐量。
对于 DirectWriter 而言,buffer 多少个数据是个问题。因此每个数字可能是 1bit, 也可能是 64bit, 使用固定的数量来缓冲,内存占用很不稳定,差异可能达到 64 倍。一来占用内存不稳定,容易造成 OOM. 二来作为一个 Writer. 占用内存忽大忽小的,很不帅气。
因此 DirectWriter 使用固定大小的 buffer. 一般设定为 1024 字节。也就是 1KB 数据进行一次实际的写入磁盘操作。
上面说了,DirectWriter 写入数据必须按照 block 来写入,那么由于每个数字使用 bit 数量不同,block 的内存大小也是不确定的,1024 个字节能够包含多少个 block. 也是不确定的,需要根据bitsPerValue
来进行计算,而不是可以直接定义成静态常量。
内存中缓冲一个 block. 需要:
- 保存原始数据。需要保存
byteValueCount
个 long 型数据,占用内存为byteValueCount * 8
个字节。 - 保存编码后的数据。需要保存
byteBlockCount
个字节的数据。占用内存为:byteBlockCount
.
那么 1024 个字节,能够 buffer 多少个 block 呢。1024 / (byteBlockCount + 8 * byteValueCount)
.
我们查看一下interations
的计算方法。
1 |
|
可以看到和我们分析一致。
因此,当你需要写的数据很多,DirectWriter 类内部nextValues
和nextBlocks
两个属性总共占用的内存,应该很接近于 1024bytes.
add 方法
1 | /** Adds a value to this writer |
比较简单,将要添加的 long, 写进内存中的数组里,之后检查 buffer 是否满了,满了就写一次磁盘。调用 flush 方法。
flush 方法
1 | private void flush() throws IOException { |
把当前缓冲的原始数据值,调用 encoder 进行编码,按照 bitsPerValue 编码后,写入输出文件。
finish 方法
在所有数据写完之后,buffer 可能有一些不满的数据,要调用 finish 进行处理。
1 | /** finishes writing |
首先进行了一些参数的 check. 然后把当前内存里 buffer 的数据调用 flush 写入磁盘。之后写入了 3 个字节的 0 值。具体用来做什么,未知。
总结
它对一个整数数组进行编码,之后写入文件。
它使用数组中最大的数字需要的 bit 数量进行编码。因此在数组整体比较小,且标准差也很小的时候(就是最大的别太大), 可以起到不错的压缩写入效果。
阅读源码需要注意的是,DirectWriter 在内存中进行了 buffer. 不论你的数据集是什么,都使用固定的 1024byte 进行 buffer. 因此有一些针对 buffer 大小的计算需要了解下。
此类为写入方,具体的读取方:org.apache.lucene.util.packed.DirectReader
, 虽然有一些代码组织上的不同,但是底层思想是一样的,就不再赘述了。
完。
联系我
最后,欢迎关注我的个人公众号【 呼延十 】,会不定期更新很多后端工程师的学习笔记。
也欢迎直接公众号私信或者邮箱联系我,一定知无不言,言无不尽。
以上皆为个人所思所得,如有错误欢迎评论区指正。
欢迎转载,烦请署名并保留原文链接。
更多学习笔记见个人博客或关注微信公众号 < 呼延十 >——>呼延十