背景
lucene的众多索引文件中包含了docValues和norm两种索引数据。docValues是字段值的列式存储,用以高效支持聚合统计类查询,而norm则是每篇文档中每个字段的一个标准化参数,最终计算相关性得分会用到。在索引构建过程中,对于同个字段的docValues和norm是存储在一起的。另外,对于某个字段,不是每个文档都包含该字段,因此,docValues和norm索引文件中就要记录包含对应字段的文档id集合,而存在性判断我们可以想到使用位图来解决,那是不是可以用之前介绍的几种位图实现来处理呢?理论上是可以的,但是我们想想会存在哪些问题?
- 每个字段都需要在内存中维护一份完整的位图,如果索引比较多,字段比较多,那维护位图的内存就会很大。
- 同一个字段的数据都是存储在一起的(简单理解成数组),因此我们需要知道要查找的目标id是第几个数据(索引下标)。如果用前面介绍的那些位图,则需要一个个遍历计算,性能比较低下。
那我们要怎么基于现有的位图实现来优化上面的两个问题呢?
- 可以按需加载部分位图数据,控制内存的使用。部分数据其实就是分块。
- 记录当前访问的index,表示第几个docId。这么做有个限制条件需要保证docId访问是递增的。
上面说的两点的实现就是本文要介绍的IndexedDISI。本文分析基于lucene源码版本9.1.0。
IndexedDISI介绍
IndexedDISI的位图结构和之前介绍的RoaringDocIdSet非常像,IndexedDISI也是对整个位图拆分成多个block,每个block是65536个bit。IndexedDISI的对block中的有效位存储方式按场景区分,有以下三种:
- sparse场景:block的有效位小于4096,使用short数组
- all场景:block是满的,则无需存储任何有效位
- dense场景:其他情况使用FixedBitSet
IndexedDISI和RoaringDocIdSet最大的不同是,RoaringDocIdSet是全内存的,而IndexedDISI是支持持久化到磁盘的。在构建IndexedDISI时,每完成一个block,就把block的相关信息都持久化。在读取的时候,IndexedDISI在内存中最多只存在一个block(dense情况甚至不是一个完整的block),需要哪个block才加载相关的block信息,并且为了避免多次IO(一个个block遍历或者是dense场景在block内部遍历FixedBitSet),IndexedDISI引入了两个表来加速查询:
jumps
jumps其实就是有两个作用:
- 用来加速定位目标block在文件中的位置。
- 获取到某个block为止,有效位的上限(后面介绍查找的时候会用到,用作遍历停止的条件)
jumps的结构如上图所示,jumps就是一个int数组,blocki对应jumps的i*2和i*2+1两个元素,i*2存放是到blocki为止累计的有效位,i*2+1存储的是blocki在文件的起始位置。jumps是完全加载在内存中的,因此可以快速定位到每个block的起始位置,读取block的相关信息。
rank
在介绍FixedBitSet的时候,我们已经从源码层面解读了如何定位一个位置。所以,如果只是判断某个doc是否存在,则不需要rank来辅助。但是,IndexedDISI中维护一个index,表示当前doc的序号,如果没有rank的话,那就需要遍历所有的long来更新维护index变量,这样效率就比较低。
rank是一个字节数组,每两个元素组成一个short,因为一个block最多的bit是 2^16,为什么不直接用short数组呢,源码注释说byte数组持久化性能比较高,但是我们去翻源码发现其实底层实现应该是没有差别的,这个确实没理解。 rank存储的是当前block中累计的有效位数量,rank的结构依赖一个denseRankPower参数,表示的每1 << denseRankPower个bit计算一个rank值(两个字节),当denseRankPower=7的时候,表示每128位(2个long)计算一个rank值。
如上图所示,对于图中所示的位图,我们看看rank是怎么得来的。我们例子中denseRanPower是7,所以两个long一个rank值。我们看到bits[0]和bits[1]中有效位一共是5,所以rank的第一个值是5,高8位0在第0个字节,低8位5在第1个字节。bits[2]和bits[3]中有效位一共是1,加上前一个rank值5,所以第二个rank值是6,高8位0在第2个字节,低8位6在第3个字节。bits[4]和bits[5]中有效位一共是2,加上前一个rank值6,所以第三个rank值是8,高8位0在第4个字节,低8位8在第5个字节。
我们看个例子,如果没有rank的话,我们当前要查找260,则index的维护需要从bits[0]遍历到bits[4]。但是有了rank之后,我们可以得到260所属的rank的前一个rank值,也就是6,然后再计算bits[4]就可以了。
IndexedDISI构建
IndexedDISI构建是一次性的,也就是说根据已知的所有docId集合来进行构建。
构建逻辑
整体调度
如上图所示,IndexedDISI构建就是按递增顺序遍历每个docId,判断docId是否属于当前block,如果是则把docId加入当前block中,否则就持久化当前block,开始下一个block的处理。要注意的是,在构建的过程中,每个block一开始都是用FixedBitSet来存储的,在flush的时候,会判断block的有效位如果小于4096,则转成short数组。
// 入口:从it中获取文档id,构建IndexedDISI,并持久化到out中
static short writeBitSet(DocIdSetIterator it, IndexOutput out) throws IOException {
return writeBitSet(it, out, DEFAULT_DENSE_RANK_POWER);
}
// 重载方法,可以自定义denseRankPower
static short writeBitSet(DocIdSetIterator it, IndexOutput out, byte denseRankPower)
throws IOException {
// 记录起始的文件位置,后续jumps中记录的都是基于这个位置的偏移量
final long origo = out.getFilePointer();
if ((denseRankPower < 7 || denseRankPower > 15) && denseRankPower != -1) {
throw new IllegalArgumentException(
"Acceptable values for denseRankPower are 7-15 (every 128-32768 docIDs). "
+ "The provided power was "
+ denseRankPower
+ " (every "
+ (int) Math.pow(2, denseRankPower)
+ " docIDs)");
}
// 总的文档总数
int totalCardinality = 0;
// block中的文档总数
int blockCardinality = 0;
final FixedBitSet buffer = new FixedBitSet(1 << 16);
// Integer.BYTES * 2 是因为把index和offset存在一起
int[] jumps = new int[ArrayUtil.oversize(1, Integer.BYTES * 2)];
int prevBlock = -1;
int jumpBlockIndex = 0;
// 从it遍历所有的文档id
for (int doc = it.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; doc = it.nextDoc()) {
// 计算doc属于哪个block
final int block = doc >>> 16;
if (prevBlock != -1 && block != prevBlock) { // 当前doc属于下一个block
// 更新jumps
jumps =
addJumps(
jumps,
out.getFilePointer() - origo,
totalCardinality,
jumpBlockIndex,
prevBlock + 1);
jumpBlockIndex = prevBlock + 1;
// 持久化block
flush(prevBlock, buffer, blockCardinality, denseRankPower, out);
// 重置一些信息为下个block做准备
buffer.clear(0, buffer.length());
totalCardinality += blockCardinality;
blockCardinality = 0;
}
buffer.set(doc & 0xFFFF);
blockCardinality++;
prevBlock = block;
}
if (blockCardinality > 0) { // 如果还有没有处理的数据
jumps =
addJumps(
jumps, out.getFilePointer() - origo, totalCardinality, jumpBlockIndex, prevBlock + 1);
totalCardinality += blockCardinality;
flush(prevBlock, buffer, blockCardinality, denseRankPower, out);
buffer.clear(0, buffer.length());
prevBlock++;
}
final int lastBlock = prevBlock == -1 ? 0 : prevBlock;
// 最后一个block是空的
jumps =
addJumps(jumps, out.getFilePointer() - origo, totalCardinality, lastBlock, lastBlock + 1);
buffer.set(DocIdSetIterator.NO_MORE_DOCS & 0xFFFF);
flush(DocIdSetIterator.NO_MORE_DOCS >>> 16, buffer, 1, denseRankPower, out);
// 持久化jumps
return flushBlockJumps(jumps, lastBlock + 1, out);
}
// offset: 当前block在out中的偏移量
// index:当前block的第一个文档的序号
private static int[] addJumps(int[] jumps, long offset, int index, int startBlock, int endBlock) {
assert offset < Integer.MAX_VALUE
: "Logically the offset should not exceed 2^30 but was >= Integer.MAX_VALUE";
jumps = ArrayUtil.grow(jumps, (endBlock + 1) * 2);
for (int b = startBlock; b < endBlock; b++) {
jumps[b * 2] = index;
jumps[b * 2 + 1] = (int) offset;
}
return jumps;
}
private static short flushBlockJumps(int[] jumps, int blockCount, IndexOutput out)
throws IOException {
if (blockCount== 2) { // 如果只有一个block,则不需要jumps
blockCount = 0;
}
for (int i = 0; i < blockCount; i++) {
out.writeInt(jumps[i * 2]); // index
out.writeInt(jumps[i * 2 + 1]); // offset
}
// As there are at most 32k blocks, the count is a short
// The jumpTableOffset will be at lastPos - (blockCount * Long.BYTES)
return (short) blockCount;
}
block持久化
块的持久化逻辑主要是判断要使用什么结构来存储,具体逻辑如下图所示:
// 持久化block
private static void flush(
int block, FixedBitSet buffer, int cardinality, byte denseRankPower, IndexOutput out)
throws IOException {
assert block >= 0 && block < 65536;
// 持久化block id
out.writeShort((short) block);
assert cardinality > 0 && cardinality <= 65536;
// 因为一个block最多有65536个文档,减1是为了避免short溢出,读取的时候+1就能还原
out.writeShort((short) (cardinality - 1));
if (cardinality > MAX_ARRAY_LENGTH) {
if (cardinality != 65536) { // 不是所有的doc都存在
if (denseRankPower != -1) {
final byte[] rank = createRank(buffer, denseRankPower);
out.writeBytes(rank, rank.length);
}
// 记录docid位图
for (long word : buffer.getBits()) {
out.writeLong(word);
}
}
} else { // 稀疏情况,直接用short数组存储
BitSetIterator it = new BitSetIterator(buffer, cardinality);
for (int doc = it.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; doc = it.nextDoc()) {
out.writeShort((short) doc);
}
}
}
private static byte[] createRank(FixedBitSet buffer, byte denseRankPower) {
// 几个long记录一个rank
final int longsPerRank = 1 << (denseRankPower - 6);
final int rankMark = longsPerRank - 1;
// 用来确定当前long属于哪个rank
final int rankIndexShift = denseRankPower - 7; // 6 for the long (2^6) + 1 for 2 bytes/entry
final byte[] rank = new byte[DENSE_BLOCK_LONGS >> rankIndexShift];
final long[] bits = buffer.getBits();
int bitCount = 0;
// 遍历block中所有的long
for (int word = 0; word < DENSE_BLOCK_LONGS; word++) {
if ((word & rankMark) == 0) {
rank[word >> rankIndexShift] = (byte) (bitCount >> 8);
rank[(word >> rankIndexShift) + 1] = (byte) (bitCount & 0xFF);
}
// bitCount是累加的
bitCount += Long.bitCount(bits[word]);
}
return rank;
}
持久化结构
从上面的构建逻辑,我们可以得到IndexedDISI的整体结构。IndexedDISI是有多个blockInfo和jumps组成的。每个blockInfo都有一下信息:
- blockId:block的编号
- cardinality-1:block中有效位总数-1
- blockData:三种场景有三种block结构
- dense:rank数组+FixedBitSet
- sparse:short数组
- all:空
IndexedDISI查找
成员变量
// block的起始偏移量,因为在block中存储的都是doc的偏移量,真正的doc是起始偏移量+偏移量
int block = -1;
// block在文件中结束的位置
long blockEnd;
// dense块中位图的起始位置
long denseBitmapOffset = -1;
int nextBlockIndex = -1;
// 枚举类,主要就是三种block存储格式的读取方法
Method method;
// 当前读到的doc
int doc = -1;
// 当前读到的doc是第几个doc
int index = -1;
// SPARSE 块读取使用,表示block中是否存在要查找的目标
boolean exists;
// dense块中位图的一个word
long word;
// dense块第几个word
int wordIndex = -1;
// number of one bits encountered so far, including those of `word`
int numberOfOnes;
// Used with rank for jumps inside of DENSE as they are absolute instead of relative
int denseOrigoIndex;
// ALL variables
int gap;
查找目标docId
要查找目标docId,必须先知道要找的docId属于哪个block,然后把block加载到内存中,再根据不同的block存储结构实现block内的查找。
// 查找满足大于等于target的第一个docId
public int advance(int target) throws IOException {
// 目标targe所在的block
final int targetBlock = target & 0xFFFF0000;
if (block < targetBlock) { // 需要往后面找block
advanceBlock(targetBlock);
}
if (block == targetBlock) { // 如果当前在处理的block就是目标block
if (method.advanceWithinBlock(this, target)) { // 如果在当前block中找到了
return doc;
}
// 读取下一个block的头部,准备在下一个block中查找
readBlockHeader();
}
boolean found = method.advanceWithinBlock(this, block);
assert found;
return doc;
}
// 查找target
public boolean advanceExact(int target) throws IOException {
// 目标targe所在的block
final int targetBlock = target & 0xFFFF0000;
if (block < targetBlock) {
advanceBlock(targetBlock);
}
boolean found = block == targetBlock && method.advanceExactWithinBlock(this, target);
this.doc = target;
return found;
}
查找目标block
private void advanceBlock(int targetBlock) throws IOException {
// 计算blockId
final int blockIndex = targetBlock >> 16;
// 如果目标的block 大于等于 当前block+2,则使用jumps查找
if (jumpTable != null && blockIndex >= (block >> 16) + 2) {
// 防止超出jumps范围
final int inRangeBlockIndex =
blockIndex < jumpTableEntryCount ? blockIndex : jumpTableEntryCount - 1;
final int index = jumpTable.readInt(inRangeBlockIndex * Integer.BYTES * 2);
final int offset = jumpTable.readInt(inRangeBlockIndex * Integer.BYTES * 2 + Integer.BYTES);
this.nextBlockIndex = index - 1; // -1 to compensate for the always-added 1 in readBlockHeader
// 定位到文件中block所在的位置
slice.seek(offset);
// 读取新block的头部
readBlockHeader();
return;
}
// 作为兜底,使用迭代的方式一个个block往后找
do {
slice.seek(blockEnd);
readBlockHeader();
} while (block < targetBlock);
}
读取block头部
private void readBlockHeader() throws IOException {
// block编号左移16位
block = Short.toUnsignedInt(slice.readShort()) << 16;
assert block >= 0;
// 还记得构建的时候为了防止short溢出,做了-1的操作,这边+1还原
final int numValues = 1 + Short.toUnsignedInt(slice.readShort());
index = nextBlockIndex;
nextBlockIndex = index + numValues;
if (numValues <= MAX_ARRAY_LENGTH) {
method = Method.SPARSE;
blockEnd = slice.getFilePointer() + (numValues << 1);
} else if (numValues == 65536) {
method = Method.ALL;
blockEnd = slice.getFilePointer();
gap = block - index - 1;
} else {
method = Method.DENSE;
denseBitmapOffset =
slice.getFilePointer() + (denseRankTable == null ? 0 : denseRankTable.length);
// 1 << 13 是因为一个block是1 << 16个bit,文件的偏移量是字节,所以是 1 << 13
blockEnd = denseBitmapOffset + (1 << 13);
if (denseRankPower != -1) { // 需要读取rank信息
slice.readBytes(denseRankTable, 0, denseRankTable.length);
}
wordIndex = -1;
numberOfOnes = index + 1;
denseOrigoIndex = numberOfOnes;
}
}
块内查找
因为block有三种存储格式,sparse,dense和all,因此就对应有三种不同的查找策略。三种查找策略都实现了Method的枚举。
块内查找的接口Method
enum Method {
// 在block中查找大于等于target的第一个doc,至少需要执行一次查找,即使当前doc >= target
abstract boolean advanceWithinBlock(IndexedDISI disi, int target) throws IOException;
// 在block中查找target,注意如果当前doc就是target,则无需查找
abstract boolean advanceExactWithinBlock(IndexedDISI disi, int target) throws IOException;
}
sparse块查找
sparse块的存储结构是short数组,目前的实现是遍历查找,但是注释了todo以后预计会改成二分查找。
SPARSE {
@Override
boolean advanceWithinBlock(IndexedDISI disi, int target) throws IOException {
// 目标在block中的id,也就是偏移量
final int targetInBlock = target & 0xFFFF;
// TODO: binary search
for (; disi.index < disi.nextBlockIndex; ) {
int doc = Short.toUnsignedInt(disi.slice.readShort());
disi.index++;
if (doc >= targetInBlock) { // 如果找到了
// 因为在block中存的是doc在block中的偏移量,所以需要还原
disi.doc = disi.block | doc;
disi.exists = true;
return true;
}
}
return false;
}
@Override
boolean advanceExactWithinBlock(IndexedDISI disi, int target) throws IOException {
final int targetInBlock = target & 0xFFFF;
// TODO: binary search
if (target == disi.doc) {
return disi.exists;
}
for (; disi.index < disi.nextBlockIndex; ) {
int doc = Short.toUnsignedInt(disi.slice.readShort());
disi.index++;
if (doc >= targetInBlock) {
if (doc != targetInBlock) {
disi.index--;
disi.slice.seek(disi.slice.getFilePointer() - Short.BYTES);
break;
}
disi.exists = true;
return true;
}
}
disi.exists = false;
return false;
}
}
dense块查找
为了快速维护index这个变量,dense的逻辑会显得比较复杂。
DENSE {
@Override
boolean advanceWithinBlock(IndexedDISI disi, int target) throws IOException {
// 目标在block中的id,也就是偏移量
final int targetInBlock = target & 0xFFFF;
// 目标在block中的第几个long
final int targetWordIndex = targetInBlock >>> 6;
// (1 << (disi.denseRankPower - 6) 表示的几个word一个rank
// targetWordIndex - disi.wordIndex 表示当前的word和目标word的距离
// 所以这个条件是说 当前的word和目标word的距离 超过一个 rank 包含的word数,就使用rank来加速查询
if (disi.denseRankPower != -1
&& targetWordIndex - disi.wordIndex >= (1 << (disi.denseRankPower - 6))) {
rankSkip(disi, targetInBlock);
}
// 目标word和当前word在同一个rank范围中的话,就需要遍历
for (int i = disi.wordIndex + 1; i <= targetWordIndex; ++i) {
disi.word = disi.slice.readLong();
disi.numberOfOnes += Long.bitCount(disi.word);
}
disi.wordIndex = targetWordIndex;
// 这里需要注意下java的>>>运算符, x >>> y相当于是 x >>> (y % 64)
long leftBits = disi.word >>> target;
if (leftBits != 0L) {
disi.doc = target + Long.numberOfTrailingZeros(leftBits);
disi.index = disi.numberOfOnes - Long.bitCount(leftBits);
return true;
}
// 如果在指定的word中没有找到,则需要以遍历的方式往后查找
while (++disi.wordIndex < 1024) { // 一个block最多1024个long
disi.word = disi.slice.readLong();
if (disi.word != 0) {
disi.index = disi.numberOfOnes;
disi.numberOfOnes += Long.bitCount(disi.word);
disi.doc = disi.block | (disi.wordIndex << 6) | Long.numberOfTrailingZeros(disi.word);
return true;
}
}
// No set bits in the block at or after the wanted position.
return false;
}
@Override
boolean advanceExactWithinBlock(IndexedDISI disi, int target) throws IOException {
final int targetInBlock = target & 0xFFFF;
final int targetWordIndex = targetInBlock >>> 6;
// If possible, skip ahead using the rank cache
// If the distance between the current position and the target is < rank-longs
// there is no sense in using rank
if (disi.denseRankPower != -1
&& targetWordIndex - disi.wordIndex >= (1 << (disi.denseRankPower - 6))) {
rankSkip(disi, targetInBlock);
}
for (int i = disi.wordIndex + 1; i <= targetWordIndex; ++i) {
disi.word = disi.slice.readLong();
disi.numberOfOnes += Long.bitCount(disi.word);
}
disi.wordIndex = targetWordIndex;
long leftBits = disi.word >>> target;
disi.index = disi.numberOfOnes - Long.bitCount(leftBits);
return (leftBits & 1L) != 0;
}
}
// targetInBlock是在block中编号
private static void rankSkip(IndexedDISI disi, int targetInBlock) throws IOException {
assert disi.denseRankPower >= 0 : disi.denseRankPower;
// 计算targetInBlock的rank下标
final int rankIndex = targetInBlock >> disi.denseRankPower;
// 获取rank的值
final int rank =
(disi.denseRankTable[rankIndex << 1] & 0xFF) << 8
| (disi.denseRankTable[(rankIndex << 1) + 1] & 0xFF);
// targetInBlock是第几个long
final int rankAlignedWordIndex = rankIndex << disi.denseRankPower >> 6;
disi.slice.seek(disi.denseBitmapOffset + rankAlignedWordIndex * Long.BYTES);
long rankWord = disi.slice.readLong();
int denseNOO = rank + Long.bitCount(rankWord);
disi.wordIndex = rankAlignedWordIndex;
disi.word = rankWord;
disi.numberOfOnes = disi.denseOrigoIndex + denseNOO;
}
all块查找
最简单了,因为block中肯定是存在目标docId的。
ALL {
@Override
boolean advanceWithinBlock(IndexedDISI disi, int target) {
disi.doc = target;
disi.index = target - disi.gap;
return true;
}
@Override
boolean advanceExactWithinBlock(IndexedDISI disi, int target) {
disi.index = target - disi.gap;
return true;
}
};