本文學習下 Lucene 在存儲大量整數時使用到的編碼方法。java
DirectWriter 用 bit 編碼方式進行數組壓縮的功能,它在整個數組的全部元素都不大的狀況下能帶來不錯的壓縮效果。git
DirectWriter 是 Lucene 爲整型數組重編碼成字節數組的工具,它的底層包含一系列編碼器,將整型數組的全部元素按固定位長度的位存儲。它按 Bit 存儲,預留長度過長會浪費空間,短了會由於截斷致使錯誤。所以須要在數組中查找最大值,由它的長度做爲存儲的長度。github
假設有一組數據{4,5,9,0},它們的二進制表示是{100, 101, 1001, 0}。佔用有效位最長的是 1001(4 個 bit),所以須要用 4 個 bits 來表示一個數值,獲得以下結果。apache
正好佔用了 16 位,兩個 byte 的數據。數組
因爲 DirectWriter 在寫完後會寫入三個 byte 的 0 值,所以上面的數據寫入文件以後,使用 xxd 命令查看文件內容爲:緩存
很巧合有沒有,使用十六進制讀取文件,和咱們的原始值居然同樣。實際上是由於 16 進制每一個進位是 4 個 bit, 正好和咱們的數據同樣而已。微信
帶有註釋源碼能夠查看 org.apache.lucene.util.packed.DirectWritermarkdown
// 每個值須要幾個 bit
final int bitsPerValue;
// 總數
final long numValues;
// 輸出方
final DataOutput output;
// 當前寫了多少
long count;
boolean finished;
// for now, just use the existing writer under the hood
// 當前寫入了多少個,在 nextValues 裏面的偏移
int off;
// 這兩個是符合對應關係的,所以 nextValues.length * bitsPerValue = nextBlocks.length * 8
// 編碼後的全部數據
final byte[] nextBlocks;
// 全部的原始數據,打算存這麼多數字,每一個數字用 bitsPerValue. 那麼總共須要 nextValues.length * bitsPerValue.
// 這些都要存在 nextBlocks 裏面,因此除以 8 就是 nextBlocks 的長度。
final long[] nextValues;
// 編碼器
final BulkOperation encoder;
// 1024 內存可以緩存多少個完整的塊。
final int iterations;
複製代碼
註釋的比較詳細,就很少少了。函數
DirectWriter(DataOutput output, long numValues, int bitsPerValue) {
this.output = output;
this.numValues = numValues;
this.bitsPerValue = bitsPerValue;
// 由於你須要的位不同,那麼須要的順序讀寫的編碼器就不同,爲了性能吧,搞了不少東西
// 搞了不少個編碼解碼器,根據存儲的位數不同而不同
encoder = BulkOperation.of(PackedInts.Format.PACKED, bitsPerValue);
// 這裏計算一下的目的是,內存 buffer 儘可能剛恰好用 1024 字節,不要過小,致使吞吐量下降,不要太大,致使 oom.
// 用 1024 字節的內存,能緩存多少個編碼塊。若是用不了 1024, 就只申請剛剛的大小。
iterations = encoder.computeIterations((int) Math.min(numValues, Integer.MAX_VALUE), PackedInts.DEFAULT_BUFFER_SIZE);
// 申請內存裏的對應 buffer array.
nextBlocks = new byte[iterations * encoder.byteBlockCount()];
nextValues = new long[iterations * encoder.byteValueCount()];
}
複製代碼
這裏着重解釋一下,屬性中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
的計算方法。
/** * For every number of bits per value, there is a minimum number of * blocks (b) / values (v) you need to write in order to reach the next block * boundary: * - 16 bits per value -> b=2, v=1 * - 24 bits per value -> b=3, v=1 * - 50 bits per value -> b=25, v=4 * - 63 bits per value -> b=63, v=8 * - ... * * A bulk read consists in copying <code>iterations*v</code> values that are * contained in <code>iterations*b</code> blocks into a <code>long[]</code> * (higher values of <code>iterations</code> are likely to yield a better * throughput): this requires n * (b + 8v) bytes of memory. * * This method computes <code>iterations</code> as * <code>ramBudget / (b + 8v)</code> (since a long is 8 bytes). * * @param ramBudget : 每一個 budget 的字節數?, 一般爲 1024 */
// Bulk 操做的時候,內存裏面有 buffer. 這個 buffer 一共只有 1024bytes.
// 可是有兩個變量。
// 注意:這裏是算內存的,也就是算,1024 個 byte 的內存,夠幹啥。固然同時要知足 pack 自己的要求。
// 也就是 一個塊要能正好寫到邊界,別多了少了 bit 位。
public final int computeIterations(int valueCount, int ramBudget) {
// 1024 個字節的內存。有兩個變量,都要用。
// 1. 原始數據。一個完整的塊,要 byteValueCount() 個原始數據,每一個數據用 long 存儲。因此一個完整的塊,要 8 * byteValueCount() 個字節。
// 2. 編碼後的數據。一個完整的塊,要 byteBlockCount() 個字節。
// 因此 iterations. 表明的是,1024 個字節的內存,夠緩存多少個完整的塊。
final int iterations = ramBudget / (byteBlockCount() + 8 * byteValueCount());
// 至少緩存一個
if (iterations == 0) {
// at least 1
return 1;
// 塊的數量 * 每塊裏面原始數據的數量 > 你要存儲的總數,也就是說,總共也用不了 1024 字節,申請多了。
} else if ((iterations - 1) * byteValueCount() >= valueCount) {
// don't allocate for more than the size of the reader
// 因此只緩存,(總共要存的數量 / 每塊裏面能存的數量 ) 個完整的塊。所以總共也用不完 1024 字節嘛
return (int) Math.ceil((double) valueCount / byteValueCount());
} else {
return iterations;
}
}
複製代碼
能夠看到和咱們分析一致。
所以,當你須要寫的數據不少,DirectWriter 類內部nextValues
和nextBlocks
兩個屬性總共佔用的內存,應該很接近於 1024bytes.
/** Adds a value to this writer * 添加一個值 * */
public void add(long l) throws IOException {
// 幾個校驗
assert bitsPerValue == 64 || (l >= 0 && l <= PackedInts.maxValue(bitsPerValue)) : bitsPerValue;
assert !finished;
if (count >= numValues) {
throw new EOFException("Writing past end of stream");
}
// 當前緩衝的數量,夠了就 flush
nextValues[off++] = l;
if (off == nextValues.length) {
flush();
}
count++;
}
複製代碼
比較簡單,將要添加的 long, 寫進內存中的數組裏,以後檢查 buffer 是否滿了,滿了就寫一次磁盤。調用 flush 方法。
private void flush() throws IOException {
// 把當前緩衝的值,編碼起來到 nextBlocks
// 當前緩衝的在 nextValues,把他按照編碼,搞到 nextBlocks 裏面
// 反正就是存儲啦,編碼沒搞懂,草
encoder.encode(nextValues, 0, nextBlocks, 0, iterations);
final int blockCount = (int) PackedInts.Format.PACKED.byteCount(PackedInts.VERSION_CURRENT, off, bitsPerValue);
// 寫入到磁盤
output.writeBytes(nextBlocks, blockCount);
// 緩衝歸 0
Arrays.fill(nextValues, 0L);
off = 0;
}
複製代碼
把當前緩衝的原始數據值,調用 encoder 進行編碼,按照 bitsPerValue 編碼後,寫入輸出文件。
在全部數據寫完以後,buffer 可能有一些不滿的數據,要調用 finish 進行處理。
/** finishes writing * * 檢查數據,檢查完最後一次 flush 掉 */
public void finish() throws IOException {
if (count != numValues) {
throw new IllegalStateException("Wrong number of values added, expected: " + numValues + ", got: " + count);
}
assert !finished;
flush();
// pad for fast io: we actually only need this for certain BPV, but its just 3 bytes...
for (int i = 0; i < 3; i++) {
output.writeByte((byte) 0);
}
finished = true;
}
複製代碼
首先進行了一些參數的 check. 而後把當前內存裏 buffer 的數據調用 flush 寫入磁盤。以後寫入了 3 個字節的 0 值。具體用來作什麼,未知。
它對一個整數數組進行編碼,以後寫入文件。
它使用數組中最大的數字須要的 bit 數量進行編碼。所以在數組總體比較小,且標準差也很小的時候(就是最大的別太大), 能夠起到不錯的壓縮寫入效果。
閱讀源碼須要注意的是,DirectWriter 在內存中進行了 buffer. 不論你的數據集是什麼,都使用固定的 1024byte 進行 buffer. 所以有一些針對 buffer 大小的計算須要瞭解下。
此類爲寫入方,具體的讀取方:org.apache.lucene.util.packed.DirectReader
, 雖然有一些代碼組織上的不一樣,可是底層思想是同樣的,就再也不贅述了。
完。
以上皆爲我的所思所得,若有錯誤歡迎評論區指正。
歡迎轉載,煩請署名並保留原文連接。
更多學習筆記見我的博客或關注微信公衆號 < 呼延十 >------>呼延十