淺談Flink對象重用(object reuse)

前言

今天是大年初一,祝各位虎年大吉大利~

近期受工作變動影響,博客又荒廢了許久。今天難得有空,就前段時間內部技術分享裏提到的一個小知識點來寫幾筆。

對象重用(object reuse)在Flink文檔的Execution Configuration一節中並不起眼,並且關於它的說明也語焉不詳,如下:

enableObjectReuse() / disableObjectReuse() By default, objects are not reused in Flink. Enabling the object reuse mode will instruct the runtime to reuse user objects for better performance. Keep in mind that this can lead to bugs when the user-code function of an operation is not aware of this behavior.

那麼,"reuse"的具體操作是什麼?爲什麼可能會造成bug?什麼時候可以安全地啓用它呢?本文來簡單聊一聊。

算子鏈與DataStream API對象重用

筆者之前講過,算子鏈(operator chaining)是StreamGraph向JobGraph轉化過程中的主要優化措施。經過此優化,所有chain在一起的sub-task都會在同一個TaskManager slot中執行,能夠減少不必要的數據交換、序列化(注意這點)和上下文切換,從而提高作業的執行效率。

算子鏈內部的簡單示意圖如下。

但是,將chained operators連接在一起的ChainingOutput實際上有兩種,即ChainingOutputCopyingChainingOutput。查看OperatorChain類中對應的代碼:

if (containingTask.getExecutionConfig().isObjectReuseEnabled()) {
    currentOperatorOutput = new ChainingOutput<>(operator, outputTag);
} else {
    TypeSerializer<IN> inSerializer =
            operatorConfig.getTypeSerializerIn1(userCodeClassloader);
    currentOperatorOutput = new CopyingChainingOutput<>(operator, inSerializer, outputTag);
}

也就是說,如果啓用了對象重用,構造算子鏈時採用的是ChainingOutput,反之則是CopyingChainingOutput。它們唯一的不同點就是將StreamRecord推到下游算子時的處理方式,做個對比:

// ChainingOutput#pushToOperator()
protected <X> void pushToOperator(StreamRecord<X> record) {
    try {
        // we know that the given outputTag matches our OutputTag so the record
        // must be of the type that our operator expects.
        @SuppressWarnings("unchecked")
        StreamRecord<T> castRecord = (StreamRecord<T>) record;
        numRecordsIn.inc();
        input.setKeyContextElement(castRecord);
        input.processElement(castRecord);
    } catch (Exception e) {
        throw new ExceptionInChainedOperatorException(e);
    }
}

// CopyingChainingOutput#pushToOperator()
protected <X> void pushToOperator(StreamRecord<X> record) {
    try {
        // we know that the given outputTag matches our OutputTag so the record
        // must be of the type that our operator (and Serializer) expects.
        @SuppressWarnings("unchecked")
        StreamRecord<T> castRecord = (StreamRecord<T>) record;
        numRecordsIn.inc();
        StreamRecord<T> copy = castRecord.copy(serializer.copy(castRecord.getValue()));
        input.setKeyContextElement(copy);
        input.processElement(copy);
    } catch (ClassCastException e) {
        if (outputTag != null) {
            // Enrich error message
            ClassCastException replace =
                    new ClassCastException(
                            String.format(
                                    "%s. Failed to push OutputTag with id '%s' to operator. "
                                            + "This can occur when multiple OutputTags with different types "
                                            + "but identical names are being used.",
                                    e.getMessage(), outputTag.getId()));
            throw new ExceptionInChainedOperatorException(replace);
        } else {
            throw new ExceptionInChainedOperatorException(e);
        }
    } catch (Exception e) {
        throw new ExceptionInChainedOperatorException(e);
    }
}

可見,對象重用的本質就是在算子鏈中的下游算子使用上游對象的淺拷貝。若關閉對象重用,則必須經過一輪序列化和反序列化,相當於深拷貝,所以就不能100%地發揮算子鏈的優化效果。

但正所謂魚與熊掌不可兼得,若啓用了對象重用,那麼我們的業務代碼中必然不能出現以下兩種情況,以免造成混亂:

  • 在下游修改上游發射的對象,或者上游存入其State中的對象;
  • 同一條流直接對接多個處理邏輯(如stream.map(new AFunc())的同時還有stream.map(new BFunc()))。

總之,在enableObjectReuse()之前,需要謹慎評估業務代碼是否會帶來副作用。社區大佬David Anderson曾在Stack Overflow上給出了一個簡單明晰的回答,可參見這裏

Flink SQL中的對象重用

另一位社區大佬Nico Kruber曾經寫過一篇名爲<<A Journey to Beating Flink's SQL Performance>>的文章,其中說啓用對象重用可以爲Blink Planner帶來可觀的性能收益,並且還相當安全。爲什麼?

我們知道,Flink SQL的類型系統與DataStream Runtime原生的類型系統有一定區別,故某些基礎數據類型的序列化器的實現也有不同。以最常見的字符串類型爲例,DataStream原生的StringSerializercopy()方法如下。

@Override
public String copy(String from) {
    return from;
}

可見是能夠利用String類型本身的不可變性(immutability)來避免真正的複製。所以,若DataStream API程序中的複雜數據類型越少,序列化成本就越低,打開對象重用的收益也就越小。前述的文章也說明了這一點。

Flink SQL體系中的StringDataSerializer#copy()方法則完全不一樣,如下(實際上是BinaryStringData#copy())。

public BinaryStringData copy() {
    ensureMaterialized();
    byte[] copy =
            BinarySegmentUtils.copyToBytes(
                    binarySection.segments, binarySection.offset, binarySection.sizeInBytes);
    return new BinaryStringData(
            new MemorySegment[] {MemorySegmentFactory.wrap(copy)},
            0,
            binarySection.sizeInBytes,
            javaObject);
}

可見是要實打實地複製底層的MemorySegment,此時對象重用的優點就很明顯了。

如何保證這邊不會有像DataStream API同樣的隱患?答案在(之前講過的)代碼生成階段。例如,在查詢維表的CommonExecLookupJoin執行節點中,生成訪問輸入字段的代碼時,會判斷是否要強制深拷貝(當允許對象重用時,deepCopy就爲true):

  def generateFieldAccess(
    ctx: CodeGeneratorContext,
    inputType: LogicalType,
    inputTerm: String,
    index: Int,
    deepCopy: Boolean): GeneratedExpression = {
    val expr = generateFieldAccess(ctx, inputType, inputTerm, index)
    if (deepCopy) {    // 
      expr.deepCopy(ctx)
    } else {
      expr
    }
  }

如果結果類型是可變(mutable)類型的話,就會生成新的拷貝代碼,防止出問題。

def deepCopy(ctx: CodeGeneratorContext): GeneratedExpression = {
  // only copy when type is mutable
  if (TypeCheckUtils.isMutable(resultType)) {
    // if the type need copy, it must be a boxed type
    val typeTerm = boxedTypeTermForType(resultType)
    val serTerm = ctx.addReusableTypeSerializer(resultType)
    val newResultTerm = ctx.addReusableLocalVariable(typeTerm, "field")
    val newCode =
      s"""
         |$code
         |$newResultTerm = $resultTerm;
         |if (!$nullTerm) {
         |  $newResultTerm = ($typeTerm) ($serTerm.copy($newResultTerm));
         |}
      """.stripMargin
    GeneratedExpression(newResultTerm, nullTerm, newCode, resultType, literalValue)
  } else {
    this
  }
}

The End

邊看《開端》邊寫的這一篇,三心二意,有錯誤請批評指正(

京東物流人工智能與大數據部持續招人中,各位有意年後換工作的大佬儘管丟簡歷過來,JDL歡迎你~

民那晚安(

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