比開源快30倍的自研SQL Parser設計與實踐

簡介: SQL做爲一種領域語言,最先用於關係型數據庫,方便管理結構化數據;SQL由多種不一樣的類型的語言組成,包括數據定義語言,數據控制語言、數據操做語言;各數據庫產品都有不一樣的聲明和實現;用戶能夠很方便的使用SQL操做數據,數據庫系統中的詞法語法分析器負責分析和理解SQL文本的含義,包括詞法分析、語法分析、語義分析3部分。
image.pngjava

做者 | 林夕
來源 | 阿里技術公衆號node

SQL(Structured Query Language)做爲一種領域語言(編程語言),最先用於關係型數據庫,方便管理結構化數據;SQL由多種不一樣的類型的語言組成,包括數據定義語言,數據控制語言、數據操做語言;各數據庫產品都有不一樣的聲明和實現;用戶能夠很方便的使用SQL操做數據,數據庫系統中的詞法語法分析器負責分析和理解SQL文本的含義,包括詞法分析、語法分析、語義分析3部分。通過詞法語法分析器生成AST(Abstract Syntax Tree),會被優化器處理生成生成執行計劃,再由執行引擎執行,下圖以MySQL架構爲例展現詞法語法分析器所處的位置。算法

image.png
本文經過介紹詞法語法分析器技術和業界的作法,以及過去使用自動生成的詞法語法分析器遇到的問題,分享自研SQL Parser的設計與實踐,以及其帶來的性能和功能的提高。數據庫

一 業界產品如何開發SQL Parser?

按照解析器代碼開發方式,可分爲如下兩種:編程

1 自動生成

爲方便開發詞法、語法分析的過程,業界有許多詞法、語法分析工具,例如:Flex、Lex、Bison工具經常使用於生成以C、C++做爲目標語言的詞法、語法代碼;若是以Java做爲目標語言,可使用比較流行的ANTLR和JavaCC等工具,ANTLR、JavaCC工具都以用戶編寫的詞法語法規則文件做爲輸入,其中語法文件須要知足EBNF(extended Backus–Naur form)[1]語法規則, 這2個工具使用LL(k) (Left-to-right, Leftmost derivation)[2] 算法「自頂向下[3]」解析SQL文本並構建SQL AST, Presto,Spark、Hive等數據庫和大數據系統多采用該方式生成。生成的代碼包含詞法和語法解析部分,語義分析還須要結合Meta數據,各數據庫內核本身處理;更多自動生成工具的功能和算法對比[4]在參考文獻中。性能優化

2 手工編寫

與自動生成工具不一樣,InfluxDB、H二、Clickhouse等流行的數據庫的SQL Parser組件均是手工編寫而成。數據結構

優勢:架構

  • 代碼邏輯清晰,方便開發人員調試和排錯;
  • 性能更好:有更多代碼優化的空間交給開發人員,可使用更優秀的算法和數據結構提高性能;
  • 自主可控:無licence約束,可讀性和可維護性更高;
  • 不須要額外依賴第三方詞法語法代碼生成工具。

不足:併發

  • 對開發人員的技術要求較高,需瞭解編譯原理技術;
  • 開發工做量較大,實現MySQL經常使用語法的各種分支,須要投入不少時間和精力;
  • 須要長時間、大規模測試纔會趨於穩定。

二 問題與挑戰

1 複雜查詢的性能問題

在實時分析型數據庫的實際生產環境中,常常須要處理數以千行的複雜查詢請求或者深層嵌套的查詢請求,自動生成的解析器,因爲狀態機管理複雜,線程堆棧太深,致使個別查詢請求在詞法語法解析階段性能降低嚴重。編程語言

2 大批量寫入吞吐問題

分析型數據庫要穩定處理大批量、高併發寫入的場景,要求SQL Parser組件有很好的性能和穩定性,咱們嘗試使用過ANTLR,JavaCC等工具生成SQL 的詞法語法解析器,大批量寫入時,values子句在解析過程會產生太多AST臨時對象,致使垃圾回收耗時的問題。

3 Query Rewriting的靈活性問題

須要快速方便的遍歷AST樹,找到符合某種規則的葉子節點,修改改節點,自動生成的解析器並不能很靈活的支持。

自動生成的代碼可讀性差,排查問題成本高,複雜查詢場景下,性能不足,影響系統穩定性和版本迭代速度;在設計之初,咱們放棄了自動生成的技術方案,徹底手工編寫詞法語法解析器。

三 自研詞法語法分析器的技術要點

自動生成工具主要處理生成下圖中左側的 SQL Parser Core和 SQL Tree Nodes的部分,右側featrues的部分須要開發同窗處理,當右側功能(例如:SQL rewriting)對左側有的Tree nodes的更改功能有更多的需求時,想修改自動生成的代碼,則無從下手。

image.png

自動生成工具是面向生成通用語法解析器而設計的,針對SQL這一特定領域語言,有特定的優化技術提高穩定性和性能,從設計之初,咱們選擇LL(k)做爲語法分析的算法,其自頂向下的特性,在手工編寫分析器時,邏輯清晰,代碼易讀,方便開發和維護,LL(k)的「左遞歸」問題,可經過手工斷定循環編程的方式避免。

1 詞法和語法分析

詞法分析中,Lexer持續讀取連續SQL 文本,將具備某特徵的一段連續文本標識爲Token,並標識Token的類別,好比賦值語句 x = 30,通過詞法分析後x, = , 30 分別被標識爲ID、等號操做符、數值常量;尤爲在識別標識符(變量,表名,列名等)和保留字(TABLE,FROM,SELECT等)須要對字符串進行反覆對比。自動生成工具在這一階段使用DFA(Deterministic Finite Automaton)和預先定義的詞法文件,肯定每一個Token的值和類型,手工編寫解析器不須要額外維護一個狀態機,使用分支預測,減小計算量和調用堆棧的深度。

語法分析器會使用詞法分析中的Token做爲輸入,以SQL語法描述做爲規則,自頂向下,依次將非葉子節點展開,構建語法樹,整個過程就像是走迷宮,只有一個正確入口和出口,走完迷宮後,會生成一個正確的AST。

快速Token比較

selECT c1 From T1;

因爲大部分數據庫系統對大小寫不敏感,上述query中 selECT 和 From 會被識別爲保留字,c1和T1會被識別爲標識符。識別2者的類型不一樣,字符串匹配操做是必不可少的,一般將字符統一轉爲大寫或者小寫,再比較字面值,是一個可行的方案。首先把數據庫保留字按照Map< String, Token >初始化在內存裏,key是保留字的大寫字符串,value是Token類型;其中key在做大寫轉化時,可以使用ASCII值+32的方法取代toUpperCase()方法,在不影響正確性的前提下,得到數倍性能提高。

快速數值分析

在解析常量值時,一般的作法是讀取SQL中的字符串,把字符串做爲參數,調用Java自帶的Integer.parseInt() / Float.parseFloat() / Long.parseLong(),能夠直接在原文本上邊讀取邊計算數值,該過程只使用基礎類型,避免構造字符串,能夠節省內存,又提高了解析速度,該優化對大批量寫入數值的場景優化很是明顯。

避免回溯[5]

SQL語法解析過程當中,一般只須要預讀一個Token,就能夠決定如何構建語法節點的關係,或者構建哪一種語法節點,有些語法分支較多,須要預讀2個及以上的Token才能夠作出判斷,預讀多個Token能夠下降回溯帶來的性能消耗,極少狀況下,2個以上的Token預讀都也沒有匹配到正確的語法分支,須要撤銷預讀,走其餘分支;爲了提升撤銷的速度,能夠在預讀前保存Token位點,撤銷時,能夠快速回到保存點。

在insert into values語句中,出現常量字面值的機率比出現其餘的token要高,經過分支預測能夠減小判斷邏輯,避免回溯,提高性能。

表達式替換

Query rewriting[6]技術基於「關係代數」修改AST,保證正確性的前提下,使新的AST在具有更好的執行性能,例如:A,B兩張表的大小相差懸殊,並且錯誤的Join順序對數據庫系統不友好,經過更改A,B表的Join順序能夠得到更高的執行性能。使用工具生成的解析器,一般不容許直接更改AST的節點,每次更改AST某個節點都須要從新構建整個AST,性能並很差。自研的Parser中,每一個AST節點類實現都replace接口,只須要修改AST中的子樹就能夠達到改寫的目的。

public interface Replaceable {
    boolean replace(Node expr, Node target);
}

public class BetweenNode implements Replaceable {
    public Node            beginExpr;
    public Node            endExpr;
    
    @Override
    public int hashCode(){...}
    @Override
    public boolean equals(Object obj) {...}
    
    @Override
    public boolean replace(SQLExpr expr, SQLExpr target) {
        if (expr == beginExpr) {
            setBeginExpr(target);
            return true;
        }

        if (expr == endExpr) {
            setEndExpr(target);
            return true;
        }

        return false;
    }
}

其餘優化

支持AST Clone:若是保持原AST結構不變,克隆出一個新的AST,在新的AST修改節點結構,好比:增長Hint,刪減where條件,增長limit 限制等。
維護AST 父子關係:自動生成的解析器維護了父到子節點的關係,是單向的引用關係。手寫代碼能夠增長子節點對父節點的引用,構建AST節點的雙向引用關係,實現節點的快速「回跳」,使得AST的遍歷效率更高。

public abstract class Node {
    public abstract List< Node> getChildren()
}

public class BetweenNode extends Node {
    public Node            beginExpr;
    public Node            endExpr;
    
    @Override
    public List< Node> getChildren() {
        return Arrays.< Node>asList(beginExpr, this.endExpr);
    }
    
    @Override
    public BetweenNode clone() {
        BetweenNode x = new BetweenNode();
        if (beginExpr != null) {
            x.setBeginExpr(beginExpr.clone());
        }
        if (endExpr != null) {
            x.setEndExpr(endExpr.clone());
        }
        return x;
    }
    
    public void setBeginExpr(Node beginExpr) {
        if (beginExpr != null) {
            beginExpr.setParent(this);
        }
        this.beginExpr = beginExpr;
    }
    
    public void setEndExpr(Node endExpr) {
        if (endExpr != null) {
            endExpr.setParent(this);
        }
        this.endExpr = endExpr;
    }
}

2 語義分析

寫入事件回調

前面提到大批量導入數據時,詞法語法分析階段會產生不少AST小對象,給垃圾回收帶來壓力,解決這個問題的核心是要儘可能使用基礎數據類型,儘可能不要產生AST 節點對象。須要從詞法分析階段入手,避免進入語法分析階段。在詞法分析階段,容許外部註冊實現了寫入接口的類,每當詞法分析器解析出values中的每一個具體值,或者完整解析出values中的一行,同時回調寫入接口,實現數據庫寫入邏輯。

public interface InsertValueHandler {
    Object newRow() throws SQLException;
    void processInteger(Object row, int index, Number value);
    void processString(Object row, int index, String value);
    void processDate(Object row, int index, String value);
    void processDate(Object row, int index, java.util.Date value);
    void processTimestamp(Object row, int index, String value);
    void processTimestamp(Object row, int index, java.util.Date value);
    void processTime(Object row, int index, String value);
    void processDecimal(Object row, int index, BigDecimal value);
    void processBoolean(Object row, int index, boolean value);
    void processNull(Object row, int index);
    void processFunction(Object row, int index, String funcName, Object... values);
    void processRow(Object row);
    void processComplete();
}

public class BatchInsertHandler implements InsertValueHandler {
    ...
}

public class Application {
    BatchInsertHandler handler = new BatchInsertHandler();
    parser.parseInsertHeader(); // 頭部:解析 insert into xxx values 部分
    parser.parseValues(handler); // 批量值:values (xxx), (xxx), (xxx) 部分
}
Query Rewriting

手動編寫的SQL Parser能夠更靈活的與優化器配合,將Query rewriting 的部分優化能力前置化到SQL Parser中實現,使得優化器能更加專一於基於代價和成本的優化上。Parser能夠結合Meta信息,利用「等價關係代數」,在AST上低成本實現Query Rewriting功能,以提高查詢性能,例如:常量摺疊、函數變換、條件下推或上提、類型推導、隱式轉化、語義去重等。

首先,須要設計一個結構存儲catalog和table結構信息,包括庫名,表名,列名,列類型等。

而後,使用「訪問者模式」編寫Visitor程序,經過「深度優先」遍歷AST,對字段、函數、表達式、操做符進行分析,結合表結構和類型信息,推斷表達式類型,注意,嵌套的查詢語句中,相同的表達式出現的位置不一樣,所屬的做用域也不一樣。

最後,AST會通過使用「等價關係代數」編寫的RBO(Rule-Based Optimization)規則處理,達到優化器的目的。

-- 常量摺疊示例
SELECT * FROM T1
WHERE c_week
  BETWEEN CAST(date_format(date_add('day', -day_of_week('20180605'),
                                   date('20180605')), '%Y%m&d') as bigint)
  AND CAST(date_format(date_add('day', -day_of_week('20180606'),
                                   date('20180606')), '%Y%m&d') as bigint)
                                   
------------摺疊後-----------
SELECT * from T1
WHERE c_week BETWEEN 20180602 and 20180603
-- 函數轉換示例
SELECT * FROM T1
WHERE DATE_FORMAT(t1."pay_time", '%Y%m%d') >= '20180529'
    AND DATE_FORMAT(t1."pay_time", '%Y%m%d') <= '20180529'
    
-----------轉化後, 更好利用索引------------
SELECT * FROM T1
WHERE t1."pay_time" >= '2018-05-29 00:00:00'
  AND t1."pay_time" < '2018-05-30 00:00:00'

四 最後

優化後的SQL Parser的性能和穩定性提高明顯,以TPC-DS[7] 99個Query對比來看,手工編寫的SQL Parser比ANTLR Parser(使用ANTLR生成)速度快20倍,比JSQLParser(使用JavaCC生成)速度快30倍,在批量Insert場景下,速度提高30~50倍。

本文經過介紹自動生成工具生成的詞法語法分析器和自研分析器的利弊權衡和思考,結合OLAP的大吞吐,處理複雜SQL的業務特性,選擇手工編寫解析器。性能優化手段貼近SQL解析的特色;在語義分析層面,結合Schema信息沉澱了不少語義分析工具,在離線或在線SQL統計和特徵分析方面更輕量化、便捷。

原文連接本文爲阿里雲原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索