新吸收了个单词 Monotonic(单调的),MonotonicBlockPackedWriter
和MonotonicBlockPackedReader
类主要用于对于单调递增的数据进行编解码。
举个例子,现在有一批数据100个,是单调递增的,首元素是Interger.MAX_VALUE-88888
,后面每个元素比前一个元素大1-10
之间,如果不进行压缩编码的话,需要100*4=400个字节。
如果编码的话,如果按照增量编码的话,基准元素需要4字节(最小元素,也是首元素), 100个源数据都用增量来表达,假设每个增量需要1字节来表达,那么总共需要4 + 100 * 1=104个字节来表达就可以了。
那么什么是增量呢?增量是相对谁而言的增量呢?源数据一定要严格单调递增吗?
编码原理
这里画了一张图,假设有a,b,c,d,e,f 6个源数据,它们并非严格地单调递增,但是总体趋势是单调向上递增的,起始元素a值(我把它叫做baseValue
)很大,那么如何编码才能高效压缩呢?
lucene 是这样做的, 连接a,f两点构成一条直线,认为是这些源数据分布的拟合直线l1
,平均斜率avg
, 拟合直线上对应x的y值我们认为是预期值expectedVale
, 但是真实数据actualValue
,可能分布直线的上方或者下方, 我们期待我们构造的l1
在所有源数据的上方。
如何做呢?平移, 遍历源数据每个元素,每当expectedValue > actualValue
时, baseValue
就要-(expectedValue-actualValue)
,最终就有了拟合直线l2
。 l2
与l1
斜率一致,起始值不一致而已, 所有源数据都在l2的上方
。剩下就只需要编码 baseValue
,avg
和delta
(expectedValue-actualValue)就可以了。
前言中提到的问题应该就全回答了,核心代码如下
//MonotonicBlockPackedWriter.java
protected void flush() throws IOException {
assert off > 0;
// 第一个元素到最后一个元素的斜率, 拟合直线l1
final float avg = off == 1 ? 0f : (float) (values[off - 1] - values[0]) / (off - 1);
long min = values[0];
// adjust min so that all deltas will be positive
// 调整,生成拟合直线l2, 使所有的delta都是正值
for (int i = 1; i < off; ++i) {
final long actual = values[i];
final long expected = expected(min, avg, i);
if (expected > actual) {
min -= (expected - actual);
}
}
long maxDelta = 0;
for (int i = 0; i < off; ++i) {
values[i] = values[i] - expected(min, avg, i);
maxDelta = Math.max(maxDelta, values[i]);
}
// min是最小值,values[i]是斜线上方的真实数据点到拟合直线l2上点的差值
out.writeZLong(min);
out.writeInt(Float.floatToIntBits(avg));
if (maxDelta == 0) {
out.writeVInt(0);
} else {
// 根据最大差值,来确定差值使用几个bit位表示
final int bitsRequired = PackedInts.bitsRequired(maxDelta);
out.writeVInt(bitsRequired);
writeValues(bitsRequired);
}
off = 0;
}
解码
解码的核心代码在MonotonicBlockPackedReader
类的构造函数上,比较简单,注释如下
private MonotonicBlockPackedReader(IndexInput in, int packedIntsVersion, int blockSize, long valueCount, boolean direct) throws IOException {
this.valueCount = valueCount;
blockShift = checkBlockSize(blockSize, MIN_BLOCK_SIZE, MAX_BLOCK_SIZE);
blockMask = blockSize - 1;
// 写入时每blockSize个源数据就要flush一次, 每flush一次就形成一条拟合之前
// 所以解码时,会有numBlocks条拟合曲线,斜率,基准值是不同的,所以下面要解码numBlocks次
final int numBlocks = numBlocks(valueCount, blockSize);
minValues = new long[numBlocks];
averages = new float[numBlocks];
subReaders = new PackedInts.Reader[numBlocks];
long sumBPV = 0;
for (int i = 0; i < numBlocks; ++i) {
minValues[i] = in.readZLong();
averages[i] = Float.intBitsToFloat(in.readInt());
final int bitsPerValue = in.readVInt();
sumBPV += bitsPerValue;
if (bitsPerValue > 64) {
throw new IOException("Corrupted");
}
if (bitsPerValue == 0) {
subReaders[i] = new PackedInts.NullReader(blockSize);
} else {
final int size = (int) Math.min(blockSize, valueCount - (long) i * blockSize);
if (direct) {
final long pointer = in.getFilePointer();
// subReader里记录着预期值与拟合值的差值,getDirectReaderNoHeader读取delta
// 可以随机访问,是需要磁盘寻址的
subReaders[i] = PackedInts.getDirectReaderNoHeader(in, PackedInts.Format.PACKED, packedIntsVersion, size, bitsPerValue);
in.seek(pointer + PackedInts.Format.PACKED.byteCount(packedIntsVersion, size, bitsPerValue));
} else {
// getReaderNoHeader是一把就将所有delta读取到内存里了
subReaders[i] = PackedInts.getReaderNoHeader(in, PackedInts.Format.PACKED, packedIntsVersion, size, bitsPerValue);
}
}
}
this.sumBPV = sumBPV;
}
// 获取源数据值
@Override
public long get(long index) {
assert index >= 0 && index < valueCount;
final int block = (int) (index >>> blockShift);
final int idx = (int) (index & blockMask);
// 拟合直线上的拟合值 + delta
return expected(minValues[block], averages[block], idx) + subReaders[block].get(idx);
}
其他编解码(大值)
如果有一批源数据,不是单调递增,但是数据都很大,怎么编解码呢?
入上图所示,使用BlockPackedWriter
,它以这批数据中最小的值作为baseValue
,编码其他数据与baseValue
之间的差量delta
,关键代码如下:
protected void flush() throws IOException {
assert off > 0;
long min = Long.MAX_VALUE, max = Long.MIN_VALUE;
for (int i = 0; i < off; ++i) {
min = Math.min(values[i], min);
max = Math.max(values[i], max);
}
final long delta = max - min;
int bitsRequired = delta == 0 ? 0 : PackedInts.unsignedBitsRequired(delta);
if (bitsRequired == 64) {
// no need to delta-encode
min = 0L;
} else if (min > 0L) {
// make min as small as possible so that writeVLong requires fewer bytes
min = Math.max(0L, max - PackedInts.maxValue(bitsRequired));
}
final int token = (bitsRequired << BPV_SHIFT) | (min == 0 ? MIN_VALUE_EQUALS_0 : 0);
out.writeByte((byte) token);
if (min != 0) {
writeVLong(out, zigZagEncode(min) - 1);
}
if (bitsRequired > 0) {
if (min != 0) {
for (int i = 0; i < off; ++i) {
values[i] -= min;
}
}
writeValues(bitsRequired);
}
off = 0;
}
其实最开始的单调递增编码可以看作是大值编码的一种特例,认为是其特殊实现,其实lucene中也确实是这样处理的,继续看下面的章节
PackedLongValues
PackedLongValues将源数据(一批long型数据)进行压缩编码,提供了读写的方法,其压缩方式主要有两种,一种是大值编码,没有严格单调递增性质;一种是大值编码,且具有严格单调递增性质。编解码的原理上文已经提到,这里不再解释,附上一张类图。
1⃣️使用了Builder设计模式,LongValues
的生成是依赖builder.build()
模式产出。
2⃣️MonotonicLongValues
是DeltaPackedLongValues
的子类,其实很好理解,其本身就是父类的一个特殊实现。