Presto 標量函式註冊和呼叫過程簡述

McHades發表於2020-09-28

Presto 函式開發一文中已經介紹過如何進行函式開發,本文主要講述標量函式(Scalar Function)實現之後,是如何在Presto內部進行註冊和被呼叫的。主要講述標量函式是因為:三類函式的註冊和呼叫過程略有不同,而實際查詢中呼叫最多的是標量函式。

標量函式註冊

函式在能夠呼叫之前,首先要進行註冊,上一篇文章已經介紹過函式註冊的方法,那麼函式在註冊時究竟註冊了哪些資訊呢?函式註冊實際上是維護FunctinoRegistry類中的一個 MultiMap,Key 為函式的限定名(QualifiedName,可以簡單地理解為函式名),Value 為SqlFunction介面的實現類,實際主要為SqlAggregationFunctionSqlWindowFunctionSqlScalarFunction這三個類的子類。SqlScalarFunction是一個抽象類,定義如下:

public abstract class SqlScalarFunction
        implements SqlFunction
{
    private final Signature signature;

    protected SqlScalarFunction(Signature signature)
    {
        this.signature = requireNonNull(signature, "signature is null");
        checkArgument(signature.getKind() == SCALAR, "function kind must be SCALAR");
    }

    @Override
    public final Signature getSignature()
    {
        return signature;
    }

    public abstract ScalarFunctionImplementation specialize(BoundVariables boundVariables, int arity, TypeManager typeManager, FunctionRegistry functionRegistry);

    public static PolymorphicScalarFunctionBuilder builder(Class<?> clazz)
    {
        return new PolymorphicScalarFunctionBuilder(clazz);
    }
}

可以看出,其子類需要獲取Signature和實現specialize方法。

首先來看Signature

public final class Signature
{
    private final String name;
    private final FunctionKind kind;
    private final List<TypeVariableConstraint> typeVariableConstraints;
    private final List<LongVariableConstraint> longVariableConstraints;
    private final TypeSignature returnType;
    private final List<TypeSignature> argumentTypes;
    private final boolean variableArity;

    ....
}

類的成員變數說明如下:

  • name:函式名,不包括引數型別和結果型別,例如:函式isnull(T):boolean的函式名為isnull
  • kind:列舉型別,有 SCALAR、AGGREGATE 和 WINDOW三種取值,用於區分函式型別
  • typeVariableConstraints:型別變數約束,記錄函式中的型別變數名,以及型別變數所需要滿足的約束條件:型別是否為comparable、orderable 和是否繫結具體型別。例如:contains<T:comparable>(array(T),T):boolean函式要求型別T滿足comparablearray_sort<E:orderable>(array(E)):array(E)函式要求型別E滿足orderable;判斷兩個ROW型別是否相等的操作符(操作符也屬於標量函式)$operator$EQUAL<T:comparable:row<*>>(T,T):boolean要求型別T為ROW型別。
  • LongVariableConstraint:長整型變數約束,記錄函式中帶有約束的長整型變數的計算表示式(一般用於計算返回型別中的長整型變數)。例如:函式concat<u:x + y>(char(x),char(y)):char(u)的返回型別中長整型變數u的計算表示式為x + y
  • returnType:函式的返回型別
  • argumentTypes:函式引數型別
  • variableArity:標記是否為變長引數

以上成員變數都可以從函式實現的類物件中,根據註解規則解析獲得。除了獲取Signature,由於同一個函式可能會有多個實現(例如上一篇文章介紹的isnull<T>(T):boolean函式,因為傳入的引數型別可能不同,所以有五個實現方法),所以還要記錄函式的實現方法。原始碼中將實現方法分為三類:

  • exactimplementation:函式中不包含型別變數,即函式的引數型別和返回型別都是確定的
  • specializedImplementation:函式中包含型別變數,但型別變數作用在具體的 Java 型別(Native Container Type)上
  • genericImplement:函式中包含型別變數,但是型別變數作用在 Object 型別上

Presto 儲存的是實現方法的MethodHandle,通過反射獲取Method,再儲存Method對應的MethodHandleMethodHandle在JDK1.7引入,呼叫的效率比反射高),如果該方法不是靜態方法,還要將MethodHandle的中的this引數改為Object來避免呼叫時的類載入問題。所以,抽象方法specialize的本質是通過傳入的引數,來獲取匹配到的MethodHandle,這部分放到下一節的標量函式呼叫中進行講解。

可以看出,標量函式註冊的本質是儲存函式的SignatureMethodHandle。開發者根據註解框架實現的標量函式,註冊時再根據註解解析出SignatureMethodHandle,封裝在ParametricScalar物件中。當然,開發者也可以自行繼承SqlScalarFunction,自己定義Signature和實現specialize方法。

標量函式呼叫

標量函式呼叫的入口為InterpretedFunctionInvoker類的public Object invoke(Signature function, ConnectorSession session, List<Object> arguments)方法,形參裡的Signature是由語義分析時,根據詞法分析得到函式QualifiedName和語法分析得到的引數型別,呼叫FunctionRegistry中的public Signature resolveFunction(QualifiedName name, List<TypeSignatureProvider> parameterTypes)方法得到。所以,標量函式呼叫的關鍵是resolveFunction方法和invoke方法。

首先來看resolveFunction方法,該方法主要通過函式名和函式引數型別來確定Signature,流程如下:

虛線紅框中的三個匹配過程實際上是呼叫了同一個方法:Optional<Signature> matchFunction(Collection<SqlFunction> candidates, List<TypeSignatureProvider> parameters, boolean coercionAllowed),其中的coercionAllowed為是否將實參型別轉化為形參型別的標識。matchFunction方法等價於為Signature中的變數尋找賦值,不僅要滿足變數型別是對應的實際引數型別的超類,而且對應的實際引數還要滿足Signature中宣告的變數約束。將形參型別和實參進行繫結時,還會做一些約定性的檢查:

  • 一個型別不能既賦給型別變數(type parameter),又賦給字面變數(literal parameter,如varchar(x)中的x
  • 字面變數不允許跨型別使用

為了便於理解第二個規定,下面例舉幾個字面變數跨型別使用的例子:

  • x 出現在不同的基本型別中:char(x)和varchar(x)
  • x 出現在同一種基本型別的不同位置:decimal(x,y) 和 decimal(z,x)
  • p 與不同的字面量、型別或者字面變數組合使用:decimal(p,s1) and decimal(p,s2)

還有一個限制是,如果嘗試將實際引數型別decimal(1,0)賦給Signature中宣告的decimal(x,2),會失敗,但是使用decimal(3,1)可以賦值成功。因為根據decimal的定義,precision 必須大於 scale,即x必須大於2。

經過一系列的規則匹配和變數求解,最終會返回一個具體的函式函式簽名,簽名中的型別都是具體型別(即不含變數)。比如簡單 SQLselect isnull('a'),最終得到的Signatureisnull(varchar(1)):boolean,實參中的型別varchar(1)賦給了原先註冊的isnull<T>(T):boolean中的型別變數T

再來看invoke方法,該方法首先會根據傳入的Signature呼叫FunctionRegistry中的getScalarFunctionImplementation來獲取最終的MethodHandle,然後使用具體的引數值來進行實際方法的呼叫(方法中若需要ConnectorSession,也在此進行注入)。因為函式註冊維護的是QualifiedName->SqlFunction的對映關係,而呼叫getScalarFunctionImplementation時傳入的Signature並沒有記錄變數與實參的繫結關係,所以這裡需要再進行一次型別變數的求解,這一步的計算其實是可以避免的,因為在resolveFunction中其實已經拿到了變數繫結的關係,可以進行復用,所以340版本中已改為傳入帶繫結關係的FunctionBinding。函式註冊時說明了一個函式可能有多個實現方法,接下來就是根據形參和實參的繫結關係,呼叫SqlFunctionspecialize方法進行對應引數的 Java 型別的匹配,按照exactimplementation型別->specializedImplementation型別->genericImplement型別的順序進行匹配,一旦匹配成功則直接返回匹配到的實現方法,如果方法中需要傳入依賴變數,也在此步驟中根據繫結關係對MethodHandle進行引數值注入。因為對MethodHandle的反覆編譯會導致full GC(懷疑是觸發了 JVM Bug),所以 Presto 在FunctionRegistry中為三類函式分別做了個大小為1000,有效時長為1小時的快取來避免這個問題。

至此,函式的註冊和呼叫的過程已經完成。熟悉這兩個過程可以幫助我們在函式開發和呼叫中快速地定位問題,除此之外,求解Signature時的型別轉換匹配可以作為型別隱式轉換的一個入口。

相關文章