Elasitcsearch 底層系列 Lucene 內核解析之 Doc Value

背景

       Elasticsearch 支持行存和列存,行存用於以文檔爲單位順序存儲多個文檔的原始內容,在 Elasitcsearch 底層系列 Lucene 內核解析之 Stored Fields 文章中介紹了行存的細節。列存則以字段爲單位順序存儲多個文檔同一字段的內容,主要用於排序、聚合、範圍查詢等場景,新版本的 ES 絕大部分字段都會保存 doc value,可以顯示指定關閉。今天我們就來剖析 ES 列存(doc value)的細節。代碼解析基於 ES 6.3/Lucene 7.3 的版本。

       我們在騰訊雲提供了原生的ES服務(CES)及CTSDB時序數據庫服務,歡迎各位交流底層技術。

Doc value 的官方介紹:https://www.elastic.co/guide/en/elasticsearch/reference/current/doc-values.html

本文主要分以下幾個部分介紹:

  • 基本框架
  • 文件結構
  • 寫入流程
  • 讀取流程
  • 合併流程

基本框架

       進入各個流程之前,我們先來看一下 doc Value 相關的類結構。下圖藍色是 doc value 的寫入部分主要框架,文檔的寫入入口在 DefaultIndexingChain,每一個 field 都有對應的 PerField 對象,包含 field info 以及相關的寫入類。寫入的時候根據字段類型,例如 Binary、Numeric、StoredNumeric、SortedSet 等選擇對應的 Writer進行處理。各個 Writer 負責內存中的寫入及數據結構整理壓縮邏輯,Lucene70DocValuesConsumer 負責底層文件的寫入。紅色部分是讀取框架,同樣也是按照不同類型分別處理讀取,Lucene70DocValuesProducer 負責文件讀取解析。後面的寫入及讀取流程我們再來詳細剖析。

Doc Value 類結構圖

文件結構

       Doc value 的 lucene 文件主要是 dvd 和 dvm 後綴文件,dvd 文件爲數據文件,保存各種值, dvm 文件爲數據文件的索引文件,便於快速解析查找數據文件。dvd 文件一般都比較大,dvm 文件都很小,如下圖所示:

文件結構

我們先來總體看一下文件的內容結構,後面再結合代碼詳細分析內容的生成和讀取過程。

dvd 和 dvm 都有如下公共的文件頭信息:

dvd dvm 公共文件頭

dvm 索引文件,除頭尾信息以外,中間的部分主要是順序保存每個字段編碼相關的元數據信息,以及切分 block 的信息。

dvm 文件結構

dvd 數據文件,除頭尾信息以外,中間的部分主要是順序保存每個字段編碼壓縮後的內容:

dvd 文件結構

dvd 等值及 Multiple block 的場景:

dvd 等值及 Multiple block 場景結構

當字段不是數值類型,會保存 value 的 hash 映射,該字段會分三層依次保存,第一層是每個 value 的 hash 位置,第二層是每個 value 的原始值,第三層是原始值的索引項。其中第一層結構和上述結構一致,第二、三層 dvm、dvd 結構如下所示,前半部分爲 terms,後半部分爲 terms 索引信息:

doc value 字符串類型數據結構

接下來結合這些文件結構,我們來分析代碼是如何產生和讀取這些內容的。

寫入流程

先來看如下示例數據:

{
    "@timestamp":"2017-03-23T13:00:00",
    "accept":36320,
    "deny":4156,
    "host":"server_2",
    "response":2.4558210155,
    "service":"app_3",
    "total":40476
}

mapping 自動生成,ES 將會產生如下類型的字段:

字段類型

本次重點關注標紅的 DocValue 對象。

PackedInts(long)

       在正式進入 doc value 剖析之前,我們先來看一個數據類型:PackedInts。它是 doc value 數值存儲壓縮使用的主要類型。數值類型列存有很大的壓縮空間,可以節省很多內存開銷。這種壓縮是基於數據運算或者類型壓縮實現的。

       例如,假設某個列的值全是一樣的(例如內置的 _version, _primary_term 字段,極有可能全一樣),此時 PackedInt 可以簡單的用一個整型對象存一個值即可。假設某個列的數值最大存儲只需要 10 個 bit,我們直接用 short 存儲會浪費6個 bit,內存浪費接近一半。

       Lucene 中實現的 PackInts 對象會將內存劃分爲邏輯上的多個 block,每個 block 一定是8位內存對齊的,最常用的就是直接利用一個 long 對象作爲一個 block,充分利用每個類型的每一個 bit,避免浪費。假設我們每個列的 value 用10個 bit 就可以存儲,用 long 對象來儲存多個 value 如下所示:

Packints 結構

注意 value n會跨兩個 block(long) 對象。

寫入調用鏈時序

       寫入流程分爲內存寫入流程和刷新流程,以下是寫入調用鏈時序:

寫入調用鏈時序

       入口在 DefaultIndexingChian,內存寫入主要在各類型 DocValuesWriter中,flush 落盤主要在 Lucene70DocValuesConsumer中。接下來我們分別分析內存寫入和刷新流程。

內存寫入流程

       在前面我們講 Stored Fields 的時候,有提到 Lucene 的 index 動作是在 DefaultIndexingChain 類裏面完成的,今天我們直接跳到對應的 doc value 處理的邏輯:

DefaultIndexingChain.processDocument() 

DocValuesType dvType = fieldType.docValuesType();
if (dvType == null) {
  throw new NullPointerException("docValuesType must not be null (field: \"" + fieldName + "\")");
}
if (dvType != DocValuesType.NONE) {
  if (fp == null) {
    fp = getOrAddField(fieldName, fieldType, false);
  }
  indexDocValue(fp, dvType, field); // 內存中處理每個 field 的 doc value
}

       這裏的 indexDocValue 函數完成了 doc value 的保存邏輯。進到該函數裏面,會對每個字段的 doc value 類型做分類處理,如下的每個分支就對應着上述各字段類型的寫入操作。每個字段都會對應一個 DocValueWriter。

DefaultIndexingChain.indexDocValue

switch(dvType) {

      case NUMERIC:
        if (fp.docValuesWriter == null) {
          fp.docValuesWriter = new NumericDocValuesWriter(fp.fieldInfo, bytesUsed);
        }
        if (field.numericValue() == null) {
          throw new IllegalArgumentException("field=\"" + fp.fieldInfo.name + "\": null value not allowed");
        }
        ((NumericDocValuesWriter) fp.docValuesWriter).addValue(docID, field.numericValue().longValue());
        break;

      case BINARY:
        if (fp.docValuesWriter == null) {
          fp.docValuesWriter = new BinaryDocValuesWriter(fp.fieldInfo, bytesUsed);
        }
        ((BinaryDocValuesWriter) fp.docValuesWriter).addValue(docID, field.binaryValue());
        break;

      case SORTED:
        if (fp.docValuesWriter == null) {
          fp.docValuesWriter = new SortedDocValuesWriter(fp.fieldInfo, bytesUsed);
        }
        ((SortedDocValuesWriter) fp.docValuesWriter).addValue(docID, field.binaryValue());
        break;
        
      case SORTED_NUMERIC:
        if (fp.docValuesWriter == null) {
          fp.docValuesWriter = new SortedNumericDocValuesWriter(fp.fieldInfo, bytesUsed);
        }
        ((SortedNumericDocValuesWriter) fp.docValuesWriter).addValue(docID, field.numericValue().longValue());
        break;

      case SORTED_SET:
        if (fp.docValuesWriter == null) {
          fp.docValuesWriter = new SortedSetDocValuesWriter(fp.fieldInfo, bytesUsed);
        }
        ((SortedSetDocValuesWriter) fp.docValuesWriter).addValue(docID, field.binaryValue());
        break;

      default:
        throw new AssertionError("unrecognized DocValues.Type: " + dvType);
    }

       最常使用的類型是 SortedNumericDocValuesWriter 和 SortedSetDocValuesWriter ,因爲 doc value 主要用在聚合排序等操作上,上述兩種類型的 writer 分別對應了數值類型和字符類型的 doc value 排序寫操作。這裏的 Sorted 關鍵字排序是指“同一個文檔中該字段的多個 value (數組)之間進行排序“,不是指“多個文檔按照該字段進行排序”。多個文檔之間的排序由 index level sorting 決定。接下來我們重點分析這兩種數據類型的寫入。

SortedNumericDocValuesWriter

       數值類型 doc value 的寫操作。從前面的 case 分支中可以看到,每一個字段的 DocValueWriter 會在第一次進來的時候被初始化,一個 field 對應一個 docValuesWriter:

DefaultIndexingChain.indexDocValue

case SORTED_NUMERIC:
if (fp.docValuesWriter == null) {
  fp.docValuesWriter = new SortedNumericDocValuesWriter(fp.fieldInfo, bytesUsed);
}
((SortedNumericDocValuesWriter) fp.docValuesWriter).addValue(docID, field.numericValue().longValue());
break;

       SortedNumericDocValuesWriter 對象的初始化邏輯:

SortedNumericDocValuesWriter.java 

  public SortedNumericDocValuesWriter(FieldInfo fieldInfo, Counter iwBytesUsed) {
    this.fieldInfo = fieldInfo;
    this.iwBytesUsed = iwBytesUsed;
    // 保存 value 對象,頁滿時 pack,一頁最多1024個 value ,pack 後放到 values 對象中,在 flush 的時候會通過 build 函數取出
    pending = PackedLongValues.deltaPackedBuilder(PackedInts.COMPACT); 
    // 保存 每個文檔中當前字段 value 的數量,單個 field 每個文檔可能存在多個 doc value
    pendingCounts = PackedLongValues.deltaPackedBuilder(PackedInts.COMPACT);
    // 保存 docId,這裏的 docId 只記錄最大值,取的時候順序+1取
    docsWithField = new DocsWithFieldSet();
    bytesUsed = pending.ramBytesUsed() + pendingCounts.ramBytesUsed() + docsWithField.ramBytesUsed() + RamUsageEstimator.sizeOf(currentValues);
    iwBytesUsed.addAndGet(bytesUsed);
  }

       Number 類型的載體對象都是 PackedLongValues, 該對象的構造過程:

  public static PackedLongValues.Builder deltaPackedBuilder(float acceptableOverheadRatio) {
	  // 默認頁大小是 1024
	  // 這裏 acceptableOverheadRatio 取值默認爲0,表示最佳壓縮模式,充分利用每個 bit
    return deltaPackedBuilder(DEFAULT_PAGE_SIZE, acceptableOverheadRatio);
  }

       在前面有看到傳入構造的參數是:PackedInts.COMPACT,表示最佳壓縮,不浪費一個 bit。這裏Packed等級有四種,不同的等級表示可以允許多少內存的浪費率,浪費的空間會自動內存補齊。浪費多效率高,浪費少效率低,這裏是時間換空間的概念。

  /**
   * At most 700% memory overhead, always select a direct implementation.
   */
  public static final float FASTEST = 7f;

  /**
   * At most 50% memory overhead, always select a reasonably fast implementation.
   */
  public static final float FAST = 0.5f;

  /**
   * At most 25% memory overhead.
   */
  public static final float DEFAULT = 0.25f;

  /**
   * No memory overhead at all, but the returned implementation may be slow.
   */
  public static final float COMPACT = 0f;

       相關的初始化工作只在字段第一次處理 doc value 的時候進行,初始化完成之後就進入添加值階段。在上述 indexDocValue 函數中的 case 語句中,根據每個類型進來調用對應 writer 的 addValue 方法保存 doc value。addValue 的邏輯都差不多,以 SortedNumericDocValuesWriter 爲例如下所示:

SortedNumericDocValuesWriter.java

 public void addValue(int docID, long value) {
    assert docID >= currentDoc;
    if (docID != currentDoc) { // 新進來 doc 先結束上次的 doc
      finishCurrentDoc();
      currentDoc = docID;
    }

    addOneValue(value); // 添加值
    updateBytesUsed();
  }

       addOneValue 只是簡單的將值添加到一個自擴容的 long 型數組中:

  private void addOneValue(long value) {
    if (currentUpto == currentValues.length) {
      // 空間不夠就擴容
      currentValues = ArrayUtil.grow(currentValues, currentValues.length+1);
    }
    
    currentValues[currentUpto] = value; //long currentValues[] 
    currentUpto++; // 更新值下標
  }

       finishCurrentDoc 的邏輯,主要是將上述添加的數組保存到 pending 中,pending 是一個 PackedLongValues 的 builder 對象,其內部會判斷是否達到 pack 的條件,達到就進行 pack。

  private void finishCurrentDoc() {
    if (currentDoc == -1) {
      return;
    }
    // 這裏是對同一個 doc 中的該字段的多個 doc value 進行內部排序,SortedNumeric 的 Sort 就在這裏體現
    Arrays.sort(currentValues, 0, currentUpto); 
    for (int i = 0; i < currentUpto; i++) {
      pending.add(currentValues[i]); // PackedLongValues
    }
    // record the number of values for this doc
    pendingCounts.add(currentUpto); // 當前 doc 中該字段的 doc value 數量,一般情況是 1
    currentUpto = 0;

    docsWithField.add(currentDoc); // 保存當前 doc id
  }
 

       接下來我們看一下上述 pending.add 函數的詳細實現 :

 PackedLongValues.java 
 
     /** Add a new element to this builder. */
    public Builder add(long l) {
      if (pending == null) {
        throw new IllegalStateException("Cannot be reused after build()");
      }
      if (pendingOff == pending.length) { // 達到 1024 個對象,pack 一次
        // check size
        if (values.length == valuesOff) { // values 保存 pack 後的對象,默認長度 16,不夠自動擴容
          final int newLength = ArrayUtil.oversize(valuesOff + 1, 8);
          grow(newLength);
        }
        pack(); // 壓縮處理,處理 pending 中的內容,pack 完畢之後 pendingOff 會置零
      }
      
      pending[pendingOff++] = l; // 簡單的添加對象到 pending 中保存,pending 最大 1024
      size += 1;
      return this;
    }
 

       接着看 pack 的具體邏輯,它是實現壓縮的主要函數:

 PackedInts.java
 
 void pack(long[] values, int numValues, int block, float acceptableOverheadRatio) {
      assert numValues > 0;
      // compute max delta
      long minValue = values[0];
      long maxValue = values[0];
      for (int i = 1; i < numValues; ++i) {
        minValue = Math.min(minValue, values[i]);
        maxValue = Math.max(maxValue, values[i]);
      }

      // build a new packed reader
      if (minValue == 0 && maxValue == 0) {
    	// 數值類的對象進來後先求最小最大值,如果全部都是相同的值,比如 version 全爲1,primary term 全爲 0 等場景,直接保存一個值即可
        this.values[block] = new PackedInts.NullReader(numValues);
      } else {
    	// 計算最大值所需的 bit 數量
        final int bitsRequired = minValue < 0 ? 64 : PackedInts.bitsRequired(maxValue); 
        // 根據大小分配一個合適可變對象,後面詳述
        final PackedInts.Mutable mutable = PackedInts.getMutable(numValues, bitsRequired, acceptableOverheadRatio); 
        for (int i = 0; i < numValues; ) {
          i += mutable.set(i, values, i, numValues - i); // 將 values 對象 pack 到 mutable 對象中,後面詳述
        }
        this.values[block] = mutable; // pack 後的對象保存到 values 數組中,後面會寫入磁盤
      }
    }

       PackedInts.getMutable 的實現邏輯:

PackedInts.java

  public static Mutable getMutable(int valueCount,
      int bitsPerValue, float acceptableOverheadRatio) {
	// 根據配置的壓縮比的類型(COMPACT、FASTEST等)計算壓縮時採取的 bitsPerValue 數量,
    // 以及是否有必要壓縮,返回的 formatAndBits.format 參數一般情況取值爲 Format.PACKED 表示壓縮。
    final FormatAndBits formatAndBits = fastestFormatAndBits(valueCount, bitsPerValue, acceptableOverheadRatio);
    // 根據類型和值的 bit 數量選取合適的 Pakced 對象,如果所需 bit 數剛好是 8 的整數倍,
    // 則直接用 Direct8、Direct16、Direct32、Direct64 來存儲,否則會用 Packed64 對象(long)存儲。
    return getMutable(valueCount, formatAndBits.bitsPerValue, formatAndBits.format);
  }

       我們拿 Packed64 爲例講一下上述 pack 中的 set 邏輯:

Packed64.java

@Override
  public int set(int index, long[] arr, int off, int len) {
    // of 函數裏面的重點是根據 bitsPerValue 即 doc value 中最大的值所需的 bit 數量,
    // 來確定寫的 encode 對象,例如 BulkOperationPacked10 表示最大的需要 10 個 bit
    ...
    final PackedInts.Encoder encoder = BulkOperation.of(PackedInts.Format.PACKED, bitsPerValue);
    ...
    // 編碼的邏輯就在對應的 encode 函數中,後面詳述
    encoder.encode(arr, off, blocks, blockIndex, iterations);
    ...
  }

       BulkOperationPacked10(最大到24)對象構造函數調用 BulkOperationPacked 傳遞對應的 bit 數:

 public BulkOperationPacked10() {
    super(10); // 調用父類 BulkOperationPacked 構造函數,下面詳述
  }

       BulkOperationPacked 的構造函數邏輯:

public BulkOperationPacked(int bitsPerValue) {
    this.bitsPerValue = bitsPerValue; // value 需要的最大 bit 數
    assert bitsPerValue > 0 && bitsPerValue <= 64;
    int blocks = bitsPerValue;
    // 這裏算需要多少個 block 即 long 對象能夠完整的保存 n 個 value (簡單的判斷能被2整除就行)
    // 例如 bitsPerValue 是10,則至少需要5個 long 對象纔不需要跨 long 保存 (5*32=320 纔剛好被10整除,能保存32個 value 對象)
    while ((blocks & 1) == 0) {
      blocks >>>= 1;
    }
    this.longBlockCount = blocks;
    this.longValueCount = 64 * longBlockCount / bitsPerValue; // 根據算好的 long block 數量計算能保存的 value 數量
    ...
  }

       上面講的 BulkOperationPacked10 是繼承至 BulkOperationPacked 類,主要的壓縮編碼邏輯都在 BulkOperationPacked 類中的 encode 函數中實現,將多個 value 保存到連續的 long 對象中,這個函數是整個壓縮編碼的核心:

BulkOperationPacked.java
/**
   * values: 被壓縮的數組對象
   * valuesOffset: 被壓縮數組對象的偏移(index),順序加一取 values
   * blocks: 壓縮此數組對象所需的 long 對象數組,目標輸出對象
   * blcoksOffset:block 對象的 index
   * iterations:longValueCount * iterations = 總的 values 的長度
   * 
   * 示例如下:
   * 假設 values 數組有1024個元素,bitsPerValue = 10(即最大的元素需要10個 bit 存儲),
   * 那麼共需要 1024*10=10240 個 bit,10240/8=1280 個 byte,1280/8=160 個 long, blocks 的長度就是160
   */
  @Override
  public void encode(long[] values, int valuesOffset, long[] blocks,
      int blocksOffset, int iterations) {
    long nextBlock = 0;
    int bitsLeft = 64;
    // 遍歷待壓縮的 values 對象
    for (int i = 0; i < longValueCount * iterations; ++i) {
      bitsLeft -= bitsPerValue; // 每個對象都佔用  bitsPerValue 位
      if (bitsLeft > 0) { // 直到一個 long 對象分配完畢
        nextBlock |= values[valuesOffset++] << bitsLeft; // 移位操作將多個 values 壓縮成一個 long
      } else if (bitsLeft == 0) { // 剛好用完
        nextBlock |= values[valuesOffset++];
        blocks[blocksOffset++] = nextBlock;
        nextBlock = 0;
        bitsLeft = 64;
      } else { // bitsLeft < 0  某個 values 對象跨兩個 long 
        nextBlock |= values[valuesOffset] >>> -bitsLeft;
        blocks[blocksOffset++] = nextBlock;
        nextBlock = (values[valuesOffset++] & ((1L << -bitsLeft) - 1)) << (64 + bitsLeft);
        bitsLeft += 64;
      }
    }
  }

       上面就是SortedNumericDocValuesWriter寫入的過程,經過 PackedInt 壓縮編碼之後,數據會以相對節省的形式存放在內存中。接下來我們看可能看字符類型的寫入流程。

SortedSetDocValuesWriter

       該對象主要處理字符類型的 doc value 寫邏輯。其內部會用一個 BytesRefHash 對象保存字符的 byte 數組,以及對應的 hash 位置(termId),termId 會像上述 NumericDocValue 一樣採用 PackedInts 壓縮。BytesRefHash 內部有一個 ByteBlockPool,其成員變量 byte[] buffer 中保存了字符 byte 數組。我們看一下 SortedSetDocValuesWriter 的添加值的邏輯:

SortedSetDocValuesWriter.java

private void addOneValue(BytesRef value) {
    int termID = hash.add(value); // BytesRefHash 對象,add 動作添加 byte 數組並計算對應的 hash 值並返回
  ......
    currentValues[currentUpto] = termID; // 添加字符對象的 hash 值
    currentUpto++;
  }

       以上就是內存寫入流程,採用 PackedInts 類型,可以最大程度的節省內存。內存寫入後,doc value 對象都是以該類型保存在內存中,後面的刷新流程會將內存中的 doc value 反編碼解壓,之後以緊湊型 byte 數組寫入 segment 文件(dvd)。

刷新流程

       刷新流程的入口在 DefaultIndexingChain.writeDocValues 中。writeDocValues 只是 DefaultIndexingChain.flush 的一個步驟,flush 函數包含了其它類型例如 stored fields,norms,point 等類型的刷新邏輯。DocValue刷新的時候會將各個字段順序刷到 dvd、dvm 文件。下面是 writeDocValues 的詳細分析:

DefaultIndexingChain.java

/** Writes all buffered doc values (called from {@link #flush}). */
  private void writeDocValues(SegmentWriteState state, Sorter.DocMap sortMap) throws IOException {
    int maxDoc = state.segmentInfo.maxDoc(); // 這個 segment 當前在內存中的文檔數
    DocValuesConsumer dvConsumer = null;
    boolean success = false;
    try {
      for (int i=0;i<fieldHash.length;i++) { 
        // 遍歷每一個 field 逐個順序刷盤,PerField 裏面保存的 fieldInfo,fieldInfo 包含了字段名、類型等基本信息
        PerField perField = fieldHash[i];
        while (perField != null) {
          if (perField.docValuesWriter != null) { // 如果是 doc value 類型的,則之前肯定用 docValuesWriter(例如 NumericDocValuesWriter) 寫過數據進內存
            if (perField.fieldInfo.getDocValuesType() == DocValuesType.NONE) {
              // BUG
              throw new AssertionError("segment=" + state.segmentInfo + ": field=\"" + perField.fieldInfo.name + "\" has no docValues but wrote them");
            }
            if (dvConsumer == null) {
              // lazy init
              DocValuesFormat fmt = state.segmentInfo.getCodec().docValuesFormat();
              // 初始化 Lucene70DocValuesConsumer ,調用 Lucene70DocValuesConsumer 的構造函數創建(若未創建)dvd,dvm文件,並寫入 header 信息
              dvConsumer = fmt.fieldsConsumer(state); 
            }

            if (finishedDocValues.contains(perField.fieldInfo.name) == false) {
              perField.docValuesWriter.finish(maxDoc); // 調用 DocValueWriter 的finish,對未完成的值做一輪 pack
            }
            perField.docValuesWriter.flush(state, sortMap, dvConsumer); // 主要的刷新調用邏輯,後面詳細分析
            perField.docValuesWriter = null;
          } else if (perField.fieldInfo.getDocValuesType() != DocValuesType.NONE) {
            // BUG
            throw new AssertionError("segment=" + state.segmentInfo + ": field=\"" + perField.fieldInfo.name + "\" has docValues but did not write them");
          }
          perField = perField.next;
        }
      }

       上面主要的 flush 函數是由各個類型的 DocValuesWriter 來實現的,常用的 writer 類型:

  • NumericDocValuesWriter (數字類型)
  • SortedNumericDocValuesWriter (多值內部排序的數值類型)
  • SortedDocValuesWriter (排序的字符類型,保存原始值及 hash 位置)
  • SortedSetDocValuesWriter (排序的字符數組類型,保存原始值及 hash 位置)

       每種類型的 flush 函數的結構都是類似的,分爲三部分:

  • build 緩存在 pending 中的對象,生成 PackedLongValues。PackedLongValues 對象包含兩個最主要的數組成員,一個是 mins,保存每個 pack 後對象的最小值(每個 value 會算差值);另一個是 values,保存實際 pack 後的對象,例如 Packed64, DirectInt 等,取決於 doc value bit 使用數量。
  • 根據索引排序字段順序對 doc value 進行排序。
  • 寫處理好的 value 進 dvd 文件,同時寫 dvm 索引文件。

       以 SortedNumericDocValuesWriter 爲例:

SortedNumericDocValuesWriter.java

 @Override
  public void flush(SegmentWriteState state, Sorter.DocMap sortMap, DocValuesConsumer dvConsumer) throws IOException {
    // build 緩存在 pending 中的對象,生成 PackedLongValues
    final PackedLongValues values;
    final PackedLongValues valueCounts;
    if (finalValues == null) {
      values = pending.build();
      valueCounts = pendingCounts.build();
    } else {
      values = finalValues;
      valueCounts = finalValuesCount;
    }

    // 排序,這裏的排序是 index sorting 指定的排序,會按照排序的字段傳進來一個 sortMap,這個 sortMap 就是按照排序字段排好的 docId
    final long[][] sorted;
    if (sortMap != null) {
      sorted = sortDocValues(state.segmentInfo.maxDoc(), sortMap,
          new BufferedSortedNumericDocValues(values, valueCounts, docsWithField.iterator()));
    } else {
      sorted = null;
    }

    // 寫 dvd dvm 文件,後面詳細描述
    dvConsumer.addSortedNumericField(fieldInfo,
                                     new EmptyDocValuesProducer() {
                                       @Override
                                       public SortedNumericDocValues getSortedNumeric(FieldInfo fieldInfoIn) {
                                         if (fieldInfoIn != fieldInfo) {
                                           throw new IllegalArgumentException("wrong fieldInfo");
                                         }
                                         // 讀取內存中緩存的 values
                                         final SortedNumericDocValues buf =
                                             new BufferedSortedNumericDocValues(values, valueCounts, docsWithField.iterator());
                                         if (sorted == null) {
                                           return buf;
                                         } else {
                                           return new SortingLeafReader.SortingSortedNumericDocValues(buf, sorted);
                                         }
                                       }
                                     });
  }

       上面讀取內存緩存的 values 主要用到 BufferedSortedNumericDocValues 類,該類構造方法傳入我們之前壓縮的 values (Packed64, DirectInt等)。在構造函數中會對壓縮的內容進行解壓,主要調用 BulkOperationPacked10(例)decode 函數解壓,解壓邏輯是每次將一個 block(long)偏移10位計算對應的值放到 values 數組中。

       接下來我們看看 dvConsumer.addSortedNumericField 的實現邏輯,該函數中主要的邏輯是調用 writeValues 函數實現的:

Lucene70DocValuesConsumer.java

 private long[] writeValues(FieldInfo field, DocValuesProducer valuesProducer) throws IOException {
    SortedNumericDocValues values = valuesProducer.getSortedNumeric(field);
    int numDocsWithValue = 0;
    MinMaxTracker minMax = new MinMaxTracker();
    MinMaxTracker blockMinMax = new MinMaxTracker();
    long gcd = 0;
    Set<Long> uniqueValues = new HashSet<>();
    // 下面這個 for 循環計算 segment 所有 value 的最小最大,以及每個 block 的最小最大,並記錄最大公約數和唯一值,便於後面選擇壓縮策略
    for (int doc = values.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; doc = values.nextDoc()) {
      for (int i = 0, count = values.docValueCount(); i < count; ++i) {
        long v = values.nextValue();

        if (gcd != 1) {
          if (v < Long.MIN_VALUE / 2 || v > Long.MAX_VALUE / 2) {
            // in that case v - minValue might overflow and make the GCD computation return
            // wrong results. Since these extreme values are unlikely, we just discard
            // GCD computation for them
            gcd = 1;
          } else if (minMax.numValues != 0) { // minValue needs to be set first
            gcd = MathUtil.gcd(gcd, v - minMax.min);
          }
        }

        minMax.update(v);
        blockMinMax.update(v);
        if (blockMinMax.numValues == NUMERIC_BLOCK_SIZE) {//達到一個 block size 的時候 reset 一下
          blockMinMax.nextBlock();
        }

        // 記錄不重複值的數量,如果小於 256 個,則稍後採用 unique 壓縮方法,去掉不必要的重複值
        if (uniqueValues != null
            && uniqueValues.add(v)
            && uniqueValues.size() > 256) {
          uniqueValues = null;
        }
      }

      numDocsWithValue++; //含有值的文檔數量
    }

    minMax.finish();
    blockMinMax.finish();

    final long numValues = minMax.numValues; // 值的數量
    long min = minMax.min;
    final long max = minMax.max;
    assert blockMinMax.spaceInBits <= minMax.spaceInBits;

    if (numDocsWithValue == 0) {
      // 包含值的文檔數爲0,即該 segment 中所有文檔中都不包含該字段值
      meta.writeLong(-2);
      meta.writeLong(0L);
    } else if (numDocsWithValue == maxDoc) {
      // 滿值的場景,segment 文檔數量剛好和含有值的文檔數量相等
      meta.writeLong(-1);
      meta.writeLong(0L);
    } else {
      // 稀疏場景,segment 中有部分文檔不包含值,這裏要用 bit set 來記錄哪些文檔包含值
      long offset = data.getFilePointer();
      meta.writeLong(offset);
      values = valuesProducer.getSortedNumeric(field);
      IndexedDISI.writeBitSet(values, data);
      meta.writeLong(data.getFilePointer() - offset);
    }

    meta.writeLong(numValues); // 記錄值的數量
    final int numBitsPerValue;
    boolean doBlocks = false;
    Map<Long, Integer> encode = null;
    if (min >= max) {
      // 最小值和最大值相等的場景,meta 標記一下,稍後 data 直接寫一個最小值即可
      numBitsPerValue = 0;
      meta.writeInt(-1);
    } else {
      if (uniqueValues != null
          && uniqueValues.size() > 1
          && DirectWriter.unsignedBitsRequired(uniqueValues.size() - 1) < DirectWriter.unsignedBitsRequired((max - min) / gcd)) {
    	  // 唯一值的數量小於 256 的場景,這裏會先在 meta 中直接記錄排序後的不重複值,後面 data 中記錄值的位置即可
        numBitsPerValue = DirectWriter.unsignedBitsRequired(uniqueValues.size() - 1);
        final Long[] sortedUniqueValues = uniqueValues.toArray(new Long[0]);
        Arrays.sort(sortedUniqueValues);
        meta.writeInt(sortedUniqueValues.length);
        for (Long v : sortedUniqueValues) {
          meta.writeLong(v);
        }
        encode = new HashMap<>();
        for (int i = 0; i < sortedUniqueValues.length; ++i) {
          encode.put(sortedUniqueValues[i], i); // encode 保存值的索引,用於在 data 中記錄位置
        }
        min = 0;
        gcd = 1;
      } else {
        uniqueValues = null;
        // 這裏檢查每個 block 的使用空間加起來的大小和不劃分 block 整體的使用空間大小,差別太大就劃分 block
        // we do blocks if that appears to save 10+% storage
        doBlocks = minMax.spaceInBits > 0 && (double) blockMinMax.spaceInBits / minMax.spaceInBits <= 0.9;
        if (doBlocks) {
          numBitsPerValue = 0xFF;
          meta.writeInt(-2 - NUMERIC_BLOCK_SHIFT); // 多 block 標記
        } else {
          numBitsPerValue = DirectWriter.unsignedBitsRequired((max - min) / gcd);
          if (gcd == 1 && min > 0
              && DirectWriter.unsignedBitsRequired(max) == DirectWriter.unsignedBitsRequired(max - min)) {
            min = 0; // 最小最大值差異太大,差值沒法改善壓縮,例如 1,3,9...45664545,53545465,46567677。如果都是很大的值則都減掉最小值可以起到壓縮作用。
          }
          meta.writeInt(-1); // 單個 block 標記
        }
      }
    }

    meta.writeByte((byte) numBitsPerValue); // 記錄每個值所需的 bit 數,同一個 block 中每個值所需 bit 數相同
    meta.writeLong(min); // 最小值
    meta.writeLong(gcd); // 最大公約數
    long startOffset = data.getFilePointer();
    meta.writeLong(startOffset);
    if (doBlocks) {
      // 寫多個 block 
      writeValuesMultipleBlocks(valuesProducer.getSortedNumeric(field), gcd);
    } else if (numBitsPerValue != 0) {
      // 寫單個 block 
      writeValuesSingleBlock(valuesProducer.getSortedNumeric(field), numValues, numBitsPerValue, min, gcd, encode);
    }
    meta.writeLong(data.getFilePointer() - startOffset);

    return new long[] {numDocsWithValue, numValues};
  }

       在寫單個或多個 block 的時候都會初始化一個 DirectWriter 來執行直接按 byte 寫的邏輯,該函數的構造方法:

DirectWriter.java

DirectWriter(DataOutput output, long numValues, int bitsPerValue) {
    this.output = output;
    this.numValues = numValues;
    this.bitsPerValue = bitsPerValue;
    encoder = BulkOperation.of(PackedInts.Format.PACKED, bitsPerValue);
    iterations = encoder.computeIterations((int) Math.min(numValues, Integer.MAX_VALUE), PackedInts.DEFAULT_BUFFER_SIZE);// 計算在不超過 1k 內存的情況下需要多少輪迭代
    nextBlocks = new byte[iterations * encoder.byteBlockCount()]; // byteBlockCount: 多少個 byte 存 bitsPerValue 對象,例如 bitsPerValue = 24,則 byteBlockCount = 24/8=3
    nextValues = new long[iterations * encoder.byteValueCount()]; // byteValueCount: byteBlockCount 個 byte 能存多少個 value
    /**
    舉例如下:
     * *  - 16 bits per value -&gt; b=2, v=1   2*8 = 16/16 = 1
    *  - 24 bits per value -&gt; b=3, v=1   3*8 = 24/24 = 1
    *  - 50 bits per value -&gt; b=25, v=4  25*8 = 200/50 = 4
    *  - 63 bits per value -&gt; b=63, v=8  63*8 = 504/63 = 8
     */
  }

       寫單個 block 的邏輯,在下面的 writer.add 函數中添加值到內部的 nextValues 數組中(數組長度就是上面的 iterations * byteValueCount),滿了就逐個 byte 刷一次盤。

Lucene70DocValuesConsumer.java

  private void writeValuesSingleBlock(SortedNumericDocValues values, long numValues, int numBitsPerValue,
      long min, long gcd, Map<Long, Integer> encode) throws IOException {
    DirectWriter writer = DirectWriter.getInstance(data, numValues, numBitsPerValue);
    for (int doc = values.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; doc = values.nextDoc()) {
      for (int i = 0, count = values.docValueCount(); i < count; ++i) {
        long v = values.nextValue();
        if (encode == null) {
          // 值減掉最小值再除以最大公約數
          writer.add((v - min) / gcd);
        } else {
          // 很多 unique value,保存 meta 中存的 value 的位置
          writer.add(encode.get(v));
        }
      }
    }
    writer.finish();
  }
 

       寫多個 block 的場景,只是按 block 分開保存相應的 bitPerValue,以及meta 中多一些標記位。目的是爲了降低存儲空間。特別是值的大小差異很大的時候,拆分成多個 block 每個 block 按照自己的 bitPerValue 要比直接按整個 segment 所有 value 算 bitPerValue 節省空間。可以參考前面文件結構中 multiple block 寫的場景結構,以及 Lucene70DocValuesConsumer 類的 Lucene70DocValuesConsumer 函數。

       前面是 SortedNumericDocValuesWriter 的刷新邏輯,接下來我們看一下 SortedSetDocValuesWriter 的刷新邏輯。它主要處理字符數組類型的字段。SortedSet 字段默認會將 value 按 byte 排序,並生成新的 docId 映射,見下面 flush 函數中的 ordMap:

SortedSetDocValuesWriter.java

 @Override
  public void flush(SegmentWriteState state, Sorter.DocMap sortMap, DocValuesConsumer dvConsumer) throws IOException {

    ......
      ords = pending.build(); // 每個值在 hash 中對應的位置,和 docId 順序一致
      ordCounts = pendingCounts.build(); // 數組的場景,記錄該文檔該字段中的值數量
      sortedValues = hash.sort(); // 對值進行排序,返回值對應的新的位置列表,此 hash 中既保存的了原始的 bytes,也保存的位置
      ordMap = new int[valueCount];
      for(int ord=0;ord<valueCount;ord++) { // 這裏對排好序的位置做一個映射,映射之後的 ordMap 順序和 docId 順序一致
        ordMap[sortedValues[ord]] = ord;
      }
   ......

       SortedSet 字段寫 dvd、dvm 的邏輯主要在 Lucene70DocValuesConsumer.doAddSortedField 函數中。主要分爲三層,第一層是每個 value 的 hash 位置,第二層是每個 value 的原始值,第三層是原始值的索引項。每層依次保存,並有對應的偏移量保存在元數據中。

       第一層:

Lucene70DocValuesConsumer.java

 private void doAddSortedField(FieldInfo field, DocValuesProducer valuesProducer) throws IOException {

     ......
      values = valuesProducer.getSorted(field);
      for (int doc = values.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; doc = values.nextDoc()) {
        writer.add(values.ordValue()); // 第一層,這裏寫入的是每個 value 對應 hash 中的位置信息
      }
      writer.finish();
      meta.writeLong(data.getFilePointer() - start); // 元數據保存偏移量
    ......

    // 第二層,添加每個 value 的 term,保存原始值及索引
    addTermsDict(DocValues.singleton(valuesProducer.getSorted(field))); 
  }

       第二層邏輯:

Lucene70DocValuesConsumer.java
/**
   * SortedSet 對象,這裏保存 value 的 terms dict,採用前綴壓縮方法
   * @param values
   * @throws IOException
   */
  private void addTermsDict(SortedSetDocValues values) throws IOException {
    final long size = values.getValueCount();
    meta.writeVLong(size);
    meta.writeInt(Lucene70DocValuesFormat.TERMS_DICT_BLOCK_SHIFT); // 劃分 block,一個 block 最大16個對象

    RAMOutputStream addressBuffer = new RAMOutputStream();
    meta.writeInt(DIRECT_MONOTONIC_BLOCK_SHIFT);
    long numBlocks = (size + Lucene70DocValuesFormat.TERMS_DICT_BLOCK_MASK) >>> Lucene70DocValuesFormat.TERMS_DICT_BLOCK_SHIFT;// values 切成多少個 block
    DirectMonotonicWriter writer = DirectMonotonicWriter.getInstance(meta, addressBuffer, numBlocks, DIRECT_MONOTONIC_BLOCK_SHIFT);

    BytesRefBuilder previous = new BytesRefBuilder();
    long ord = 0;
    long start = data.getFilePointer();
    int maxLength = 0;
    TermsEnum iterator = values.termsEnum();
    for (BytesRef term = iterator.next(); term != null; term = iterator.next()) {
      if ((ord & Lucene70DocValuesFormat.TERMS_DICT_BLOCK_MASK) == 0) {// block 滿了記錄長度,當前 term 直接寫入
        writer.add(data.getFilePointer() - start); // 這裏記錄每個 block 的長度,會作數值壓縮保存並記錄 meta, data 先存 addressBuffer ,稍後寫入 data 文件
        data.writeVInt(term.length);
        data.writeBytes(term.bytes, term.offset, term.length);
      } else {
        final int prefixLength = StringHelper.bytesDifference(previous.get(), term);// 和前值比較,計算出相同前綴長度
        final int suffixLength = term.length - prefixLength; // 後綴長度
        assert suffixLength > 0; // terms are unique

        // 用一個 byte 的高4位和低4位分別保存前後綴長度,如果前綴超過15,或者後綴超過16,單獨記錄超過數量
        data.writeByte((byte) (Math.min(prefixLength, 15) | (Math.min(15, suffixLength - 1) << 4)));
        if (prefixLength >= 15) {
          data.writeVInt(prefixLength - 15);
        }
        if (suffixLength >= 16) {
          data.writeVInt(suffixLength - 16);
        }
        data.writeBytes(term.bytes, term.offset + prefixLength, term.length - prefixLength); // 寫後綴內容
      }
      maxLength = Math.max(maxLength, term.length);
      previous.copyBytes(term); // 保存當前值便於和下一個值比較
      ++ord;
    }
    writer.finish();
    meta.writeInt(maxLength); // value 的最大長度
    meta.writeLong(start); // 起始位置
    meta.writeLong(data.getFilePointer() - start); // 結束位置
    start = data.getFilePointer();
    addressBuffer.writeTo(data); // 將每個 block 的長度信息寫入 data 文件
    meta.writeLong(start); // 寫入長度信息的起始位置
    meta.writeLong(data.getFilePointer() - start); // 寫入長度信息的結束位置

    // 第三層,記錄 term 字典的索引,values 是按照值 hash 排過序的,這裏每 1024 條抽取一個作爲索引,加速查詢
    writeTermsIndex(values); 
  }

       第三層邏輯:

Lucene70DocValuesConsumer.java

private void writeTermsIndex(SortedSetDocValues values) throws IOException {
    final long size = values.getValueCount();
    meta.writeInt(Lucene70DocValuesFormat.TERMS_DICT_REVERSE_INDEX_SHIFT); // 索引抽取粒度,1024
    long start = data.getFilePointer();

    long numBlocks = 1L + ((size + Lucene70DocValuesFormat.TERMS_DICT_REVERSE_INDEX_MASK) >>> Lucene70DocValuesFormat.TERMS_DICT_REVERSE_INDEX_SHIFT);
    RAMOutputStream addressBuffer = new RAMOutputStream();
    DirectMonotonicWriter writer = DirectMonotonicWriter.getInstance(meta, addressBuffer, numBlocks, DIRECT_MONOTONIC_BLOCK_SHIFT);

    TermsEnum iterator = values.termsEnum();
    BytesRefBuilder previous = new BytesRefBuilder();
    long offset = 0;
    long ord = 0;
    for (BytesRef term = iterator.next(); term != null; term = iterator.next()) {
      if ((ord & Lucene70DocValuesFormat.TERMS_DICT_REVERSE_INDEX_MASK) == 0) {
        writer.add(offset);
        final int sortKeyLength;
        if (ord == 0) {
          // no previous term: no bytes to write
          sortKeyLength = 0;
        } else {
          sortKeyLength = StringHelper.sortKeyLength(previous.get(), term);
        }
        offset += sortKeyLength;
        data.writeBytes(term.bytes, term.offset, sortKeyLength); // 索引項也採用前綴壓縮
      } else if ((ord & Lucene70DocValuesFormat.TERMS_DICT_REVERSE_INDEX_MASK) == Lucene70DocValuesFormat.TERMS_DICT_REVERSE_INDEX_MASK) {
        previous.copyBytes(term); // 每到達 1024 的位置抽取值
      }
      ++ord;
    }
    writer.add(offset);
    writer.finish();
    meta.writeLong(start); // 保存索引項的起始位置
    meta.writeLong(data.getFilePointer() - start); // 保存索引項總的長度
    start = data.getFilePointer();
    addressBuffer.writeTo(data); // 保存每個索引項的長度信息
    meta.writeLong(start); // 索引項長度起始位置
    meta.writeLong(data.getFilePointer() - start); // 索引項長度信息的總大小
  }

       以上就是 SortedSet 類型的刷新落盤邏輯。至此,整個寫入、刷新流程就分析到這裏,接下來繼續看合併流程。

合併流程

       合併流程邏輯主要是讀取待合併的每個 segment 的 doc value,然後在做一次寫入流程。調用時序如下:

合併流程調用時序

       週期性的合併或者 indexing 過程中的合併,最終的入口在 SegmentMerger.merge(),裏面包含各個數據結構的合併邏輯,segmentWriteState 包含了待 merge 的所有 segment 信息。簡化之後的代碼:

SegmentMerger.java 

 MergeState merge() throws IOException {
    
    mergeTerms(segmentWriteState);

    if (mergeState.mergeFieldInfos.hasDocValues()) {
      mergeDocValues(segmentWriteState); // doc value 的合併
    }
    if (mergeState.mergeFieldInfos.hasPointValues()) {
      mergePoints(segmentWriteState);
    }
    if (mergeState.mergeFieldInfos.hasNorms()) {
      mergeNorms(segmentWriteState);
    }
    if (mergeState.mergeFieldInfos.hasVectors()) {
      numMerged = mergeVectors();
    }
    
    // write the merged infos
    codec.fieldInfosFormat().write(directory, mergeState.segmentInfo, "", mergeState.mergeFieldInfos, context);
    return mergeState;
  }

       mergeDocValues 會調用 DocValuesConsumer.merge 函數,遍歷每個 field 在各 segement 裏面的 doc values,逐個讀取在內存中合併,然後寫入新的 segment。

DocValuesConsumer.java

 public void merge(MergeState mergeState) throws IOException {

    for (FieldInfo mergeFieldInfo : mergeState.mergeFieldInfos) {
      DocValuesType type = mergeFieldInfo.getDocValuesType();
      if (type != DocValuesType.NONE) {
        if (type == DocValuesType.NUMERIC) {
          mergeNumericField(mergeFieldInfo, mergeState);
        } else if (type == DocValuesType.BINARY) {
          mergeBinaryField(mergeFieldInfo, mergeState);
        } else if (type == DocValuesType.SORTED) {
          mergeSortedField(mergeFieldInfo, mergeState);
        } else if (type == DocValuesType.SORTED_SET) {
          mergeSortedSetField(mergeFieldInfo, mergeState);
        } else if (type == DocValuesType.SORTED_NUMERIC) {
          mergeSortedNumericField(mergeFieldInfo, mergeState);
        } else {
          throw new AssertionError("type=" + type);
        }
      }
    }
  }

       例如,合併 numeric field:

DocValuesConsumer.java

public void mergeNumericField(final FieldInfo mergeFieldInfo, final MergeState mergeState) throws IOException {
    addNumericField(mergeFieldInfo,  // 調 Lucene70DocValuesConsumer 的寫入邏輯
                    new EmptyDocValuesProducer() {
                      @Override
                      public NumericDocValues getNumeric(FieldInfo fieldInfo) throws IOException {
                       
                        for (int i=0;i<mergeState.docValuesProducers.length;i++) { // 遍歷該 field 在每個 segment 裏面的 doc value
                          NumericDocValues values = null;
                          DocValuesProducer docValuesProducer = mergeState.docValuesProducers[i];
                          if (docValuesProducer != null) {
                            FieldInfo readerFieldInfo = mergeState.fieldInfos[i].fieldInfo(mergeFieldInfo.name);
                            if (readerFieldInfo != null && readerFieldInfo.getDocValuesType() == DocValuesType.NUMERIC) {
                              values = docValuesProducer.getNumeric(readerFieldInfo);
                            }
                          }
                          if (values != null) {
                            cost += values.cost();
                            subs.add(new NumericDocValuesSub(mergeState.docMaps[i], values)); // 合併稍後一起讀取
                          }
                        }
                        ......
}

讀取流程

       在 ES 節點啓動之後,會讀取 segment meta data,之後在需要查詢某個字段的 doc value 的時候,會先將對應的內容映射到內存,然後順序獲取對應的值。如果是字符或字符數組類型,則還會調用獲取 hash 值位置以及對應 term 的函數得到原始數據。在排序、聚合、範圍查詢等場景可能會使用到 doc value,這取決於對應查詢條件的 cost 權重。

讀取流程調用時序

       讀取邏輯的代碼幾乎都在 Lucene70DocValuesProducer 類中,這裏就不展開描述了,大家可以對照上述調用時序看一下代碼。

       至此,doc value 的寫入、合併、讀取流程及其文件數據結構就分析完了,本文只分析了主要的正常流程,暫未考慮其它異常分支流程。歡迎各位提出意見,一起交流學習!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章