Meet Solr Lucene Codec

小试 Solr/Lucene Codec

Codec 是什么

Lucene 从4.0版本开始提供codec api,简单的来说Lucene通过codec机制来读写索引文件,说白了就是一层数据访问层的api,正常来说我们在使用Lucene/Solr的时候是不用关心这一层细节的,因为默认的codec实现已经经过了大量的细节优化,但是如果你需要修改索引的存储方式,那么codec就是你的入手之处。
因为这里涉及到很多细节,希望大家先不用关心代码细节,而是关注具体的流程和原理,最后对照代码来理解这篇博客,当然如果你还有不明白的地方,欢迎邮件我fengqingleiyue@163.com

自定义Codec

既然我们知道了codec是什么,那么作为程序员的我们,就会想到-> 我们怎么自定义一个codec呢?

这里为了和笔者所遇到的实际场景相结合,笔者使用Solr(7.7.2)作为例子进行讲解。但在我们开始讲解如何实现自定义codec之前,我们可以看下solr官方文档中的SimpleTextCodecFactory,这个codec是Lucene自带的,主要作用其实是通过可读的文本格式用来描述Lucene是怎么存储索引文件的,当然启用的方式也比较简单,修改solrconfig中的SchemaCodecFactory为:

1
<codecFactory class="solr.SimpleTextCodecFactory"/>

接下来直接往solr中添加文档就行(这里我们不需要修改任何一行索引代码),但是这里要注意,因为SimpleTextCodecFactory的主要目的是用于演示Lucene是如何存储索引文件的,所以千万不要索引太多的文档(毕竟人家的目的是demo),当索引完成,我们可以查看index目录下的_XXX_XXX.fld文件的内容(这里笔者只索引了两个文档,这里看的是.fld文件,当然也可以看其他文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
doc 0
field 0
name id
type string
value 1
field 1
name text
type string
value 如果想指定结果到特定内存,我们可以使用前面介绍的索引来进行替换操作
field 2
name textCNNew
type string
value 我们需要先调用attach_grad函数来申请存储梯度所需要的内存
END
checksum 00000000001449379451

与使用SchemaCodecFactory不同的是,这次我们看到了我们输入的原始内容,但是在SchemaCodecFactory的情况下,我们是无法看到这些信息的,因为SchemaCodecFactory在存储的时候都是以二进制形式,而SimpleTextCodecFactory使用的是文本格式。到这里我们演示了Lucene自带的SimpleTextCodec,相信大家应该对codec有一个比较形象的理解了。那么接下来我们开始实现自己的codec。

自定义DocValuesFormat

之前我们说过,codec是Lucene读写索引文件的一种机制,而细心的人一定发现Lucene的索引是有很多文件的,即使我们优化了,也绝对不可能只有一个文件,关于Lucene各种文件的描述,大家可以参考Summary of File Extensions,其实codec是一套api,一套对Lucene各种类型文件进行读写的api,而每种不同类型的文件也是由codec中定义的各种Format来实现的,例如,以solr7.7.2中默认的Lucene70Codec为例:

索引文件类型 主要用途 涉及到的文件类型
FieldInfosFormat 用来Encode/Decode org.apache.lucene.index.FieldInfos,主要是关于文档中文件的信息,如是否索引,是否有payload等信息 .fnm文件
DocValuesFormat 用来Encodes/decodes per-document values,主要是对每个文档的信息进行编码和解码(Docvalues 其实是一种正排索引) .dvd & .dvm
PostingsFormat 用来Encodes/decodes 倒排表,词频,位置等信息 .pos .tip .tim .pay .doc
….

而Solr中开放了两种最常用的Format,DocValuesFormatPostingsFormat供我们进行自定义(其实就是倒排索引和正排索引),这里我们以自定义DocValuesFormat来演示如何在Solr中使用我们自定义的DocValuesFormat,废话不多说,我们开始:

  • 第一步: 移花接木
    一般来说Lucene/Solr提供的默认的DocValuesFormat已经经过了大量的代码优化和实战验证,如果不是因为特定的需求,我们一般不会也不需要进行100%的自定义的DocValuesFormat,所以这么为了演示,我们使用”移花接木”的方式来自定义DocValuesFormat-> copy官方的代码然后改掉类名->然后就是我们自己的了。例如笔者这里自定义了FqlDocValuesFormat
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    package org.fql.codec.docvalues;

    import org.apache.lucene.codecs.DocValuesConsumer;
    import org.apache.lucene.codecs.DocValuesFormat;
    import org.apache.lucene.codecs.DocValuesProducer;
    import org.apache.lucene.index.SegmentReadState;
    import org.apache.lucene.index.SegmentWriteState;

    import java.io.IOException;

    /**
    * Created by fengqinglei on 2019/11/27.
    * see {@link org.apache.lucene.codecs.lucene70.Lucene70DocValuesFormat}
    */
    public final class FqlDocValuesFormat extends DocValuesFormat {

    public FqlDocValuesFormat(){
    super("Fql");
    }
    @Override
    public DocValuesConsumer fieldsConsumer(SegmentWriteState state) throws IOException {
    return new FqlDocValuesConsumer(state, DATA_CODEC, DATA_EXTENSION, META_CODEC, META_EXTENSION);
    }

    @Override
    public DocValuesProducer fieldsProducer(SegmentReadState state) throws IOException {
    return new FqlDocValuesProducer(state, DATA_CODEC, DATA_EXTENSION, META_CODEC, META_EXTENSION);
    }

    static final String DATA_CODEC = "FqlDocValuesData";
    static final String DATA_EXTENSION = "dvd";
    static final String META_CODEC = "FqlDocValuesMetadata";
    static final String META_EXTENSION = "dvm";
    static final int VERSION_START = 0;
    static final int VERSION_CURRENT = VERSION_START;

    // indicates docvalues type
    static final byte NUMERIC = 0;
    static final byte BINARY = 1;
    static final byte SORTED = 2;
    static final byte SORTED_SET = 3;
    static final byte SORTED_NUMERIC = 4;

    static final int DIRECT_MONOTONIC_BLOCK_SHIFT = 16;

    static final int NUMERIC_BLOCK_SHIFT = 14;
    static final int NUMERIC_BLOCK_SIZE = 1 << NUMERIC_BLOCK_SHIFT;

    static final int TERMS_DICT_BLOCK_SHIFT = 4;
    static final int TERMS_DICT_BLOCK_SIZE = 1 << TERMS_DICT_BLOCK_SHIFT;
    static final int TERMS_DICT_BLOCK_MASK = TERMS_DICT_BLOCK_SIZE - 1;

    static final int TERMS_DICT_REVERSE_INDEX_SHIFT = 10;
    static final int TERMS_DICT_REVERSE_INDEX_SIZE = 1 << TERMS_DICT_REVERSE_INDEX_SHIFT;
    static final int TERMS_DICT_REVERSE_INDEX_MASK = TERMS_DICT_REVERSE_INDEX_SIZE - 1;
    }

大家可以和源码对比下,真的就是copy出来改了下类名。当然由于Lucene部分代码没有开放public权限,所以我们必须自定义FqlDocValuesConsumerFqlDocValuesProducer,完整的代码可以参考apache-solr-plugins/customized-codec/src/main/java/org/fql/codec/docvalues

  • 第二步: 开启docValues
    当我们完成了自定义的FqlDocValuesFormat之后,我们就可以在solr中启用了,启用的方式也比较简单,可以在solr的官方文档中直接检索DocValuesFormat关键字就可以找到相关的用法,这里直接给出改动的点:
1
2
3
<field name="type" type="FQL" indexed="false" stored="false" required="true" multiValued="false" docValues="true"/>
<fieldType name="FQL" class="solr.StrField" sortMissingLast="true" docValuesFormat="Fql">
<!-- 这里的Fql就是FqlDocValuesFormat#super方法中定义的名称-->

很遗憾,当我们打完包放到solr_home的对应的core的lib目录下之后重启Solr会发现在日志中出现以下的错误:
(solr_home/CODEC/lib,这里的CODEC就是core的名称,并且这里的jar不需要包含任何依赖)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Caused by: java.lang.IllegalArgumentException: An SPI class of type org.apache.lucene.codecs.PostingsFormat with name 'Fql' does not exist.  You need to add the corresponding JAR file supporting this SPI to your classpath.  The current classpath supports the following names: [MockRandom, RAMOnly, LuceneFixedGap, LuceneVarGapFixedInterval, LuceneVarGapDocFreqInterval, TestBloomFilteredLucenePostings, Asserting, BlockTreeOrds, BloomFilter, Direct, FSTOrd50, FST50, Memory, Lucene50, IDVersion, completion]
at org.apache.lucene.util.NamedSPILoader.lookup(NamedSPILoader.java:116) ~[java/:?]
at org.apache.lucene.codecs.PostingsFormat.forName(PostingsFormat.java:112) ~[java/:?]
at org.apache.lucene.codecs.perfield.PerFieldPostingsFormat$FieldsReader.<init>(PerFieldPostingsFormat.java:280) ~[java/:?]
at org.apache.lucene.codecs.perfield.PerFieldPostingsFormat.fieldsProducer(PerFieldPostingsFormat.java:363) ~[java/:?]
at org.apache.lucene.index.SegmentCoreReaders.<init>(SegmentCoreReaders.java:113) ~[java/:?]
at org.apache.lucene.index.SegmentReader.<init>(SegmentReader.java:83) ~[java/:?]
at org.apache.lucene.index.ReadersAndUpdates.getReader(ReadersAndUpdates.java:172) ~[java/:?]
at org.apache.lucene.index.ReadersAndUpdates.getReadOnlyClone(ReadersAndUpdates.java:214) ~[java/:?]
at org.apache.lucene.index.StandardDirectoryReader.open(StandardDirectoryReader.java:106) ~[java/:?]
at org.apache.lucene.index.IndexWriter.getReader(IndexWriter.java:525) ~[java/:?]
at org.apache.lucene.index.DirectoryReader.open(DirectoryReader.java:103) ~[java/:?]
at org.apache.lucene.index.DirectoryReader.open(DirectoryReader.java:79) ~[java/:?]
at org.apache.solr.core.StandardIndexReaderFactory.newReader(StandardIndexReaderFactory.java:39) ~[java/:?]
at org.apache.solr.core.SolrCore.openNewSearcher(SolrCore.java:2101) ~[java/:?]
at org.apache.solr.core.SolrCore.getSearcher(SolrCore.java:2257) ~[java/:?]
at org.apache.solr.core.SolrCore.initSearcher(SolrCore.java:1106) ~[java/:?]
at org.apache.solr.core.SolrCore.<init>(SolrCore.java:993) ~[java/:?]
at org.apache.solr.core.SolrCore.<init>(SolrCore.java:874) ~[java/:?]
at org.apache.solr.core.CoreContainer.createFromDescriptor(CoreContainer.java:1187) ~[java/:?]
...

这是因为codec采用了java的Service Provider Interface (SPI)来加载,这是为了保证codec可以做成可插拔的模式。当然既然知道为什么出错,解决方案自然也就比较简单了,直接在项目的resources目录下建立以下的结构和文件:

1
2
3
./META-INF
./META-INF/services
./META-INF/services/org.apache.lucene.codecs.DocValuesFormat

这里需要注意的是文件名org.apache.lucene.codecs.DocValuesFormat是不可以随便修改的,另外文件org.apache.lucene.codecs.DocValuesFormat的内容为

1
2
3
$ cat org.fql.codec.docvalues.FqlDocValuesFormat
org.fql.codec.docvalues.FqlDocValuesFormat
# 文件的内容就是我们自定义的DocValuesFormat的类完整类名(包含包路径)

重新打包更新jar包并且重启solr之后会发现之前的错误已经消失了,并且可以通过索引代码更新数据。细心的读者可能会问,貌似我的客户端(index程序)一行代码也没有改,确实,客户端的代码是不需要任何改动的,我去,貌似搞了这么长的时间啥动静也没有,改了这么多有啥用?貌似并不能改变世界…. 是的,但是当你打开索引目录你会发现除了常见的
_xx_Lucene70_0.dvd_xx_Lucene70_0.dvm文件,多了 _xx_Fql_0.dvd_xx_Fql_0.dvm文件,额。。。 貌似还是没啥鸟用。。。 不要着急,下一章就是见证奇迹的时刻!!!

让solr支持按字段更新

假设你维护的solr有以下几个特点:

  • 对实时没有太高的要求,数据最迟可以按天更新
  • 索引容量庞大(过亿的文档总数,过TB的索引大小,并且每条数据都可能被用户检索到,都是热数据),为了节约成本,你每次都会将索引优化之后上线
  • 用户的某种设置可以造成万甚至百万级的数据更新,例如用户可以根据已有文档的某个属性某一些值(这里的一些可能是几个也可能是几百个到千个)给已有的几万或者几百万的文档打上标签(其实就是就是增加一个属性),要求可以检索到这个属性,也可以对这个属性做一维或者二维的分析(solr的facet的功能),并且这个属性的值是用户手动输入的。

这个需求还是比较变态的,如果我们很有钱,可以怼上几万台机器,管你几百万的更新量,反正给加机器,加到秒回就行了。(现实是我们只有买个位数台服务器的钱,但是还是得满足变态客户的需求)。
好吧,既然我们无法做到实时更新,我们只能做近实时或者按照天更新了,那么如果是按照天更新我们该怎么做?大概的解决想法大家肯定能想到:

  • 既然有用户的输入的数据,那么我们至少需要一张表来存储用户输入和已有文档属性的映射关系,例如: 假设上面提到某个文档的属性我们成为Field_A,该字段可能取值为a1,a2,a3…an,用户打上的标签的字段我们称作为CUSTOM_FIELD,那么可能用户可能的操作为,将Field_A值为a1,a100,a10000….ak的文档的CUSTOM_FIELD的值定义为:”自定义标签T”,那么一种可能的schema设计为(这里以dynamodb为例子,笔者所在的公司使用的aws的dynamodb服务)
Field_A_VALUE(HashKey) UserID(RangeKey) CUSTOM_FIELD_VALUE
a1 123456 自定义标签T
a100 123456 自定义标签T
123456 自定义标签T
aK 123456 自定义标签T
  • 在每天的数据更新的时候我们通过文档Field_A的值(一般来说这个不会改变,因为是文档的固有属性),查询上述的表结构,然后将UserIDCUSTOM_FIELD_VALUE的值拼接起来作为字段CUSTOM_FIELD字段的值,例如可能的SolrInputDocument为:
DocUnique(文档唯一健,与业务强相关) Field_A CUSTOM_FIELD
1 a1 123456_自定义标签T,34553_自定义标签R…
1 ak,a100 123456_自定义标签T,89882_自定义标签M…

那么问题来了,为了尽可能的缩小数据更新的范围,我可定得知道哪一些文档会受到用户打上的标签的影响,这时候可能有人会说,这个简单,用户打标签的时候,通过query: Field_A:(a1 OR a100 OR ..) 查询后导出不就行了嘛,理论上确实可行,但是我们在之前的描述中说过,用户打标签这个动作可能会影响几万或者几百万的文档,几万的文档我们还可能导出,但是几百万的文档,我相信几台服务器肯定挂了。,那么剩下的解决方案只能是全量更新索引,如果你维护的索引比较小只有几个GB的话,这种方法确实没有什么不好的,重新更新索引也就是分分钟的事情,但是如果你维护的索引有几个TB的大小,重新做一遍的话可能需要几天或者几十个小时的时间,就算我们复制一份线上的数据到单独的服务器,然后更新用户的标签的变更找到搜到影响的数据,可能这批数据的总量也将近全量的数据了,那么这种情况我们怎么处理?难道我们真的得每天重新更新一下可能有几个TB的索引?

通过Codec单独更新CUSTOM_FIELD字段

在之前的文章中我们介绍了Lucene的codec,那么我们能不能通过codec的方式来解决问题呢?(这里给出的解决方案是笔者使用的方式,欢迎大家提出更加完美的解决方案),我们先来分析分析问题:

  • 其实我们需要更新的只有一个CUSTOM_FIELD的字段
  • 这个CUSTOM_FIELD的值是根据Field_A字段生成出来的,而Field_A的数据我们可以通过读索引得到
  • 有没有办法只更新CUSTOM_FIELD字段?

当然solr是支持只更新某个字段的,但是前提是其他字段都得存储下来(stored=”true”),如果我们的索引容量比较小的话还是可以这么玩的,但是如果我们维护的是TB级别的索引,所有字段设置stored=”true”估计容量就得翻倍了,本来就没钱买服务器,现在更加。。。。,那么我们有没有办法既能不用存储其他字段也能做到只更新某个字段呢?答案肯定是有的。我们下面来分析下:

  • Solr/Lucene的索引本质实际上就是docid(注意这里的docid是Lucene的唯一健,和Solr的uniquekey注意区分)和term的映射关系,要们倒过来叫倒排索引,要么正过来叫正排索引(docvalues)
  • 所谓的更新数据要们就是删除/修改原来的docid和term的映射关系,也就是说理论上来说如果我们知道这些数据在索引文件中是怎么存储的,我们应该可以重建这样的映射关系然后在覆盖原来的数据

    爬坑指南

    之前我们说过,如果我们能知道docid和term的关系是如何在索引中保存的,那么我们就能通过代码按照格式修改/删除docid和term的映射关系,就相当于我们可以只更新某一个字段了,为了能做到只更新某一个字段,我们第一步我们需要将这个字段和其他字段的索引隔离开来
  • 隔离需要单独更新的字段的索引数据
    这一步比较简单,参考上面的例子,我们可以通过codec来隔离例如:(这里的type字段就是上面我们举的例子的CUSTOM_FIELD字段)
    1
    2
    3
    <field name="type" type="FQL" indexed="false" stored="false" required="true" multiValued="false" docValues="true"/>
    <fieldType name="FQL" class="solr.StrField" sortMissingLast="true" docValuesFormat="Fql">
    <!-- 这里的Fql就是FqlDocValuesFormat#super方法中定义的名称-->

通过这样的修改我们就会在优化之后的索引文件中发现多出了_xx_Fql_0.dvd和_xx_Fql_0.dvm,其中dvm是docvalues的metadata信息,dvd文件就是保存docvalues的data信息。而type字段的docvalues数据其实已经和其他的字段的docvalues信息隔离开来了。

  • 使用Lucene的代码生成新的索引,这里我们给出伪代码,最终版本的代码比较复杂,我们不再这里亮出
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    Directory directory = FSDirectory.open(Paths.get(original_index_location))
    //original_index_location是指我们已经有的索引文件的路径,需要保证已经优化了
    IndexReader reader = DirectoryReader.open(directory)
    Directory newDir = FSDirectory.open(Paths.get(new_index_location))
    IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer())
    config.setUseCompoundFile(false);//因为我们需要读取原始数据,所以必须关闭compoundfile
    config.setCodec(customCodec);//使用我们自定义的codec(包含我们自定义的docValuesFormat)
    config.setOpenMode(IndexWriterConfig.OpenMode.CREATE);// 创建新索引
    IndexWriter writer = new IndexWriter(newDir,config)
    //new_index_location是指我们要生成的新的索引文件的路径
    for(int i=0;i<reader.macDoc();i++){
    Document document = new Document();
    // 这里添加我们的业务代码,主要是给type字段进行赋值
    writer.addDocument(document);
    }
    writer.commit()
    writer.forceMerge(1)
    ....

customCodec

  • 使用新的生成的数据覆盖原始索引中对应的文件
    笔者最开始的时候就直接cat new_index.dvd >old_index.dvd && new_index.dvm > old_index.dvm,结果发现Solr都无法启动,当然原因也比较简单,是因为Lucene会为生成的索引文件加上一些指纹信息,如果segment的信息和对应的索引文件中的指纹信息不一致,那么就会认为索引文件已经破损。其实就是我们之前说的,我们得知道索引文件是格式是啥,这个我们可以通过读源代码来获取,例如dvd文件的格式我们可以通过查看org.apache.lucene.codecs.lucene70.Lucene70DocValuesProducer的源码了解数据是如何读取的(生成那就是倒过来的步骤)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    String dataName = IndexFileNames.segmentFileName(state.segmentInfo.name, state.segmentSuffix, dataExtension);
    this.data = state.directory.openInput(dataName, state.context);
    boolean success = false;
    try {
    final int version2 = CodecUtil.checkIndexHeader(data, dataCodec,
    Lucene70DocValuesFormat.VERSION_START,
    Lucene70DocValuesFormat.VERSION_CURRENT,
    state.segmentInfo.getId(),
    state.segmentSuffix);
    if (version != version2) {
    throw new CorruptIndexException("Format versions mismatch: meta=" + version + ", data=" + version2, data);
    }

    // NOTE: data file is too costly to verify checksum against all the bytes on open,
    // but for now we at least verify proper structure of the checksum footer: which looks
    // for FOOTER_MAGIC + algorithmID. This is cheap and can detect some forms of corruption
    // such as file truncation.
    CodecUtil.retrieveChecksum(data);

    success = true;
    } finally {
    if (!success) {
    IOUtils.closeWhileHandlingException(this.data);
    }
    }
    .... CodecUtil.checkIndexHeader 为 ....
    public static int checkIndexHeader(DataInput in, String codec, int minVersion, int maxVersion, byte[] expectedID, String expectedSuffix) throws IOException {
    int version = checkHeader(in, codec, minVersion, maxVersion);
    checkIndexHeaderID(in, expectedID);
    checkIndexHeaderSuffix(in, expectedSuffix);
    return version;

通过源代码我们可以了解到dvd的文件格式为:
index_file_format.png
这样的话,我们就可以使用老的索引文件的指纹信息,和新生成的索引文件的数据信息合成一份一新的索引,然后使用新生成的索引文件覆盖老的索引文件,应该就可以了。那废话不多说,这里放出读取原始(老)索引的指纹信息的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private static void processCodecCheckList(IndexInput sourceInput,IndexInput desInput,IndexOutput output) throws IOException
{
// 这里的sourceInput 就是指原始(老)的索引文件
// 这里的desInput 就是指 新的索引文件
// 这里的output就是指最终生成的索引文件
/* process CODEC_MAGIC*/
output.writeInt(sourceInput.readInt());
System.out.println("Skip codec magic "+(desInput.readInt()== CodecUtil.CODEC_MAGIC));
/* process codec name*/
output.writeString(sourceInput.readString());
System.out.println("Skip codec name " +desInput.readString());
/* process codec index version*/
output.writeInt(sourceInput.readInt());
System.out.println("Skip codec index version" +desInput.readInt());

/** process index header ID (byte array with length 16)
* here we need the source index header ID
*/
byte id[] = new byte[StringHelper.ID_LENGTH];
sourceInput.readBytes(id,0,id.length);
output.writeBytes(id,id.length);
desInput.readBytes(id,0,id.length);
System.out.println("Skip codec index header id "+StringHelper.idToString(id));

/* process the suffix*/
byte suffixLength = sourceInput.readByte();
byte suffixBytes[] = new byte [suffixLength];
sourceInput.readBytes(suffixBytes,0,suffixBytes.length);
output.writeByte(suffixLength);
output.writeBytes(suffixBytes,suffixBytes.length);

suffixLength = desInput.readByte();
suffixBytes = new byte[suffixLength];
desInput.readBytes(suffixBytes,0,suffixBytes.length);
System.out.println("Skip codec index suffix "+new String(suffixBytes,0,suffixLength, StandardCharsets.UTF_8));
}

当然,我们这里只是使用了索引文件的指纹信息,具体的索引数据还是需要借用新生成的索引文件,这里给出部分代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*copy the index data */
long currentFilePointer = desInput.getFilePointer();
while(currentFilePointer!=(desInput.length()-CodecUtil.footerLength())){
output.writeByte(desInput.readByte());
currentFilePointer = desInput.getFilePointer();
}
System.out.println("Skip original indexing data");
currentFilePointer = sourceInput.getFilePointer();
while(currentFilePointer!=(sourceInput.length()-CodecUtil.footerLength())){
sourceInput.readByte();
currentFilePointer = sourceInput.getFilePointer();
}
// 根据上面的索引文件格式分析,我们需要覆盖FOOTER_MAGIC,algorithmID和 checksum
/* process FOOTER_MAGIC*/
output.writeInt(sourceInput.readInt());
/* process algorithmID*/
output.writeInt(sourceInput.readInt());
output.writeLong(output.getChecksum());
output.close();
sourceInput.close();
desInput.close();

很遗憾当我们使用上述的方式通过原始索引文件指纹信息骗过了Solr/Lucene启动时候的文件检查,成功的可以让Solr启动成功,但是当我们运行对type字段的查询或者统计的时候(如请求为http://127.0.0.1:8983/solr/CODEC/select?q=xx&facet=true&facet.field=type)会遇到: java.lang.NullPointerException当然如果你非常非常幸运,你也不会遇到,这里我们先放出错误的信息,然后再解释如果解决这类错误,然后再解释为啥有的时候你不会遇到

  • 错误信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    10:58:52.418 [qtp752316209-18] ERROR org.apache.solr.handler.RequestHandlerBase - org.apache.solr.common.SolrException: Exception during facet.field: type
    at org.apache.solr.request.SimpleFacets.lambda$getFacetFieldCounts$0(SimpleFacets.java:832)
    at java.util.concurrent.FutureTask.run(FutureTask.java:266)
    at org.apache.solr.request.SimpleFacets$3.execute(SimpleFacets.java:771)
    at org.apache.solr.request.SimpleFacets.getFacetFieldCounts(SimpleFacets.java:841)
    at org.apache.solr.handler.component.FacetComponent.getFacetCounts(FacetComponent.java:329)
    at org.apache.solr.handler.component.FacetComponent.process(FacetComponent.java:273)
    at org.apache.solr.handler.component.SearchHandler.handleRequestBody(SearchHandler.java:298)
    at org.apache.solr.handler.RequestHandlerBase.handleRequest(RequestHandlerBase.java:199)
    at org.apache.solr.core.SolrCore.execute(SolrCore.java:2551)
    at org.apache.solr.servlet.HttpSolrCall.execute(HttpSolrCall.java:711)
    at org.apache.solr.servlet.HttpSolrCall.call(HttpSolrCall.java:516)
    at org.apache.solr.servlet.SolrDispatchFilter.doFilter(SolrDispatchFilter.java:395)
    at org.apache.solr.servlet.SolrDispatchFilter.doFilter(SolrDispatchFilter.java:341)
    at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1602)
    at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:540)
    at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:146)
    at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:548)
    at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:132)
    at org.eclipse.jetty.server.handler.ScopedHandler.nextHandle(ScopedHandler.java:257)
    at org.eclipse.jetty.server.session.SessionHandler.doHandle(SessionHandler.java:1588)
    at org.eclipse.jetty.server.handler.ScopedHandler.nextHandle(ScopedHandler.java:255)
    at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1345)
    at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:203)
    at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:480)
    at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:1557)
    at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:201)
    at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1247)
    at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:144)
    at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:132)
    at org.eclipse.jetty.server.Server.handle(Server.java:502)
    at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:364)
    at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:260)
    at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:305)
    at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:103)
    at org.eclipse.jetty.io.ChannelEndPoint$2.run(ChannelEndPoint.java:118)
    at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:765)
    at org.eclipse.jetty.util.thread.QueuedThreadPool$2.run(QueuedThreadPool.java:683)
    at java.lang.Thread.run(Thread.java:748)
    Caused by: java.lang.NullPointerException
    at org.fql.codec.docvalues.FqlDocValuesProducer.getSorted(FqlDocValuesProducer.java:785)
    at org.fql.codec.docvalues.FqlDocValuesProducer.getSorted(FqlDocValuesProducer.java:781)
    at org.apache.lucene.codecs.perfield.PerFieldDocValuesFormat$FieldsReader.getSorted(PerFieldDocValuesFormat.java:329)
    at org.apache.lucene.index.CodecReader.getSortedDocValues(CodecReader.java:157)
    at org.apache.solr.uninverting.UninvertingReader.getSortedDocValues(UninvertingReader.java:360)
    at org.apache.lucene.index.FilterLeafReader.getSortedDocValues(FilterLeafReader.java:378)
    at org.apache.lucene.index.MultiDocValues.getSortedValues(MultiDocValues.java:566)
    at org.apache.solr.index.SlowCompositeReaderWrapper.getSortedDocValues(SlowCompositeReaderWrapper.java:141)
    at org.apache.solr.request.DocValuesFacets.getCounts(DocValuesFacets.java:83)
    at org.apache.solr.request.SimpleFacets.getTermCounts(SimpleFacets.java:586)
    at org.apache.solr.request.SimpleFacets.getTermCounts(SimpleFacets.java:426)
    at org.apache.solr.request.SimpleFacets.lambda$getFacetFieldCounts$0(SimpleFacets.java:826)
    ... 37 more
  • 为什么会出现这中错误,以及如何解决
    如果你认真阅读org.apache.lucene.codecs.lucene70.Lucene70DocValuesConsumer这个类的源代码,你会发现在每一个addXXXField方法(例如addBinaryField)方法都会有一个meta.writeInt(field.number);的调用,如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Override
    public void addBinaryField(FieldInfo field, DocValuesProducer valuesProducer) throws IOException {
    meta.writeInt(field.number);
    meta.writeByte(Lucene70DocValuesFormat.BINARY);

    BinaryDocValues values = valuesProducer.getBinary(field);
    long start = data.getFilePointer();
    meta.writeLong(start);
    ...

其实这非常好解释,Lucene是面向列存储的,而每种类型的索引又都存储在一个文件中(优化索引后),所以各个域需要有唯一标识符来表示,在Lucene中这个唯一标示符号就是field.number,所以在我们新生成的索引中,字段的field.number必须要和原始索引中的字段的field.number保持一致才行

  • 为什么如果我们运气好的话,这个问题就不会出现?
    上面讲到了,如果新生成的字段的field.number与原始索引中字段的field.number不一致的话,在试图读取数据的时候就会报java.lang.NullPointerException,那么如果字段一致是不是就不会报了呢? 答案是:确实如此,例如如果我们的索引代码中solrinputDocument中添加字段的顺序为:
    1
    2
    3
    4
    5
    6
    7
    8
    SolrInputDocument document = new SolrInputDocument();
    document.setField("id",dataArray[0]); //字段id 的field.number 为0
    document.setField("type",dataArray[1]); // 此时字段type的field.number 为1
    document.setField("country",dataArray[3]);
    document.setField("date",Integer.valueOf(dataArray[4].replaceAll("-","")));
    document.setField("abstract",dataArray[5]);
    document.setField("title",dataArray[6]);
    document.setField("kind",dataArray[7]);

并且在生成新索引的时候Document中添加字段的顺序为:

1
2
3
Document doc = new Document();
doc.add(new StringField("id",String.valueOf(i), Field.Store.YES));
doc.add(new StringField("type",......)) // 字段type的field.number 为1

那么这中场景就是我们说的lucky场景,原始索引和新生成的索引的field.number是一致的,程序不会有任何问题,但是如果我们修改了type字段在代码中出现的顺序那么程序就可能出错了,实际上Lucene的field.number的生成机制为: 按照字段出现的顺序为每个字段编号,从0到n,在多线程场景中,以优先处理的文档的字段顺序为标准(这也是为什么我们说如果我们非常非常lucky的话上面的错误我们就不会遇到了),既然我们知道了原因那么解决方案也比较简单了

  • 如果解决此类错误
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // step 1, 获得原始索引中的某个字段的field.number
    int number = sourceReader.leaves().get(0).reader().getFieldInfos().fieldInfo(fieldName).number;
    // step 2, 随机添加fake字段使得目标字段的field.number与原始字段一致
    if(isFirstTime){
    for(int fieldNumber=1;fieldNumber<number;fieldNumber++){
    doc.add( new StringField("xxx_"+fieldNumber,"xxx_"+fieldNumber , Field.Store.NO));
    }
    isFirstTime =false;
    }
    //这里解释下为什么fieldNumber要从1开始,其实很简单,因为0被我们代码中的doc.add(new StringField("id",String.valueOf(i), Field.Store.YES)); id 字段使用了,这里的id和uniquekey没有任何关系,如果你把id字段写在添加fake字段的代码后面,那么fieldNumber从0开始也是对的

当我们补上这些代码之后我们重新生成并覆盖数据之后发现我们可以正常的查询或者统计目标字段了(这里就是type字段)。貌似我们成功了,哈哈,当然不是,如果你只是跟着这篇博客处理个几十条数据,我保证你一辈子都不会遇到下面的问题,但是如果你的索引很大,新索引的生成时间不是1s而是几分钟生成的时候,你就会发现这个坑爹的问题。

  • 最蛋疼的问题->数据错位
    之前我们说过,我们之所以这么做的原因是因为Lucene的本质其实就是docid和term的关系,我们也是通过修改docid和term的关系是现实这种非官方的按字段更新,目前为止我们也通过代码生成了新的索引而且通过覆盖原始索引的方式实现的按照字段的更新,那么这里的数据错位打底是指为什么?我们先来回顾下docid是怎么生成的,翻看Lucene的源码你会发现,Lucene会为每一个接受到的文档赋予一个integer的id,而这个id就是docid,这里再强调以下,Lucene中并没有类似solr的UniqueKey这种东西,docid才是Lucene的唯一键,docid标识了每一个document(即使document的内容没有任何差别),而在实际应用中,我们几乎不会将docid放到业务中使用和存储—> 因为docid会发生变化—> ???? 说好的唯一键呢,唯一键都变了这是要闹哪样,尼玛坑爹中的战斗机啊。。。。。

    • 为什么唯一键会变?
      了解Lucene的原理的人都知道,Lucene在写入数据的时候是写入内存的,当内存满了或者其他条件达到了,Lucene会将内存中的文件刷到磁盘上并称为一个segment,那么如果文档非常多的话,就会有非常多的段,所以Lucene又有一个merge的操作,类似于后台线程,不断将新生成的segment合并成一个新的大的段来保持段的数量在合理的范围,因为段太多会导致查询速度不断的降低。而docid的改变就发生在segment的合并中(具体个变化和merge policy有关,我会在另外一篇文章中详细介绍)
    • 唯一键变了会有什么影响
      注意到新生成的索引文件是按照docid的顺序读取的,而且是单线程的,也就是说我们通过这种方式来使得新加入的数据的docid和原始的docid保持一致,而达到数据修改和替换的目的,但是由于Lucene并没有在api成面给我们设置或修改docid,所以我们只能在添加文档的顺序上来模拟内部的docid的顺序从而保证我们的机制能够运行,但是我们忘记了一个重要的索引生成过程—> segment的合并过程,Lucene 7默认的合并策略是TieredMergePolicy,而这个合并策略并不能保证相邻的段会合并 (如果不是相邻的段合并,那么docid可能就被在合并的过程中被改变),所以唯一键的改变会导致成数据错位
    • 如何解决数据错位的问题
      既然我们知道了原因,那么解决方案就比较简单,Lucene中的LogByteSizeMergePolicy可以保证永远都是合并相邻的segments,具体为什么是LogByteSizeMergePolicy,我会在后续的文章中给出解释,目前大家可以记住LogByteSizeMergePolicy可以保证段的合并是相邻的进行合并,代码改动点则为:

      1
      2
      3
      4
      5
      6
      IndexWriterConfig writerConfig = new IndexWriterConfig();
      writerConfig.setUseCompoundFile(false);
      writerConfig.setCodec(customCodec);
      writerConfig.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
      /*we need use LogMergePolicy since we need keep the doc Order */
      writerConfig.setMergePolicy(new LogByteSizeMergePolicy());

      到这里我们才完成了所有的改动点,也使用了这种黑科技的方法做到了solr按照字段更新。

总结

  • codec是Lucene用开控制索引读写的一层api,如果我们要自定义索引的格式,我们可以通过自定义的codec来实现
  • 这里介绍的Solr按字段更新数据并不是solr官方的方式,这其实是一种黑科技,我们先在来梳理下这个黑科技是如何想到的,并且如何一步一步的解决其中的坑的
    • 我们想出这种类似移花接木的方案的根本来源是因为Lucene的索引文件的本质就是存储了docid和term的映射关系
    • 刚开始的时候我们什么指纹信息都没有改动,直接使用新索引覆盖原始索引,这才发现了原来lucene写入了这些指纹信息来方式索引文件发生损坏,其实这里少了一步,最早的时候笔者是使用SimpleTextCodecFactory来测试的,而这个codec不会写入任何指纹信息,所以刚开始直接覆盖没有任何问题,直接成功了,这也是为什么后来再改动codec之后上来就直接使用文件覆盖的方式来验证想法的原因,虽然这是最笨的方式,确实能够暴露问题
    • field.number的信息也不算隐蔽,但是当我们hack了指纹信息以为万事大吉的时候,最终的查询还是告诉我们,还有信息没有注意到。
    • Merge的问题确实是最难发现的,如果数据容量不到一定规模,根本不会发现问题,因为测试的时候笔者使用的数量很少,这也是当时困扰笔者最长的时间的一个细节点,很隐蔽,但是经过大量的源码debug还是发现了问题所在并且成功了解决了现实业务中的问题。
  • 由于篇幅关系,笔者没有直接复制粘贴所有的代码,而是希望通过各种关节点的描述来分享给问题是什么,笔者是如何想到解决方案的,在实施解决方案中的关键问题是什么,笔者相信,知道为什么比知道怎么做更加重要。
  • 所有的代码可以在customized-codec找到,可以使用demo.sh来进行本地环境的复现。这里给一些简单的说明
    • 测试的数据是patent.tsv.zip总条数大概是7百万条
    • 测试在centos环境中验证通过,其他环境欢迎大家尝试
    • 为了代码简单,我这里的修改只是将原始索引的某个字段(type字段)的值修改成了另外一个值(将utility改成了fql_demo)
    • 本篇博客中只描述了DocValuesFormat的hack方式,其实solr中的PostingsFormat也可以按照相同的方式进行hack,笔者给的例子就是同时自定了DocValuesFormat和PostingsFormat。基本上这两个Format解决了查询和分析的需求。

      参考文章

  • https://www.elastic.co/blog/what-is-an-apache-lucene-codec
  • https://en.wikipedia.org/wiki/Service_provider_interface