零開銷、編譯時動態 SQL ORM 方面的探索

零開銷、編譯時動態 SQL ORM 方面的探索 (Rbatis ORM(v2.0))

在某種高級語言中,如果嵌入了 SQL 語句,而這個 SQL 語句的主體結構已經明確,例如在 Java 的一段代碼中有一個待執行的 SQL“select * from t1 where c1>5”,在 Java 編譯階段,就可以將這段 SQL 交給數據庫管理系統去分析,數據庫軟件可以對這段 SQL 進行語法解析,生成數據庫方面的可執行代碼,這樣的 SQL 稱爲靜態 SQL,即在編譯階段就可以確定數據庫要做什麼事情。而如果嵌入的 SQL 沒有明確給出,如在 Java 中定義了一個字符串類型的變量 sql:String sql;,然後採用 preparedStatement 對象的 execute 方法去執行這個 sql,該 sql 的值可能等於從文本框中讀取的一個 SQL 或者從鍵盤輸入的 SQL,但具體是什麼,在編譯時無法確定,只有等到程序運行起來,在執行的過程中才能確定,這種 SQL 叫做動態 SQL

前言

筆者曾經在 2020 年發佈基於 rust 的 orm 第一版,參見文章 https://rustcc.cn/article?id=1f29044e-247b-441e-83f0-4eb86e88282c

v1.8 版本依靠 rust 提供的高性能,sql 驅動依賴 sqlx-core,未作特殊優化性能即超過了 go、java 之類的 orm v1.8 版本一經發布,受到了許多網友的肯定和採納,並應用於諸多生產系統之上。v1.8 版本借鑑了 mybatis plus 同時具備的基本的 crud 功能並且推出 py_sql 簡化組織編寫 sql 的心理壓力,同時增加一系列常用插件,極大的方便了廣大網友。

同時 1.8 版本也具備了某些網友提出的問題,例如:

經過一段時間的思考和整理,於是推出 v2.0 版本,實現完全 0 開銷的動態 sql,sql 構建性能提高 N 倍(只生成 sql),完整查詢 QPS(組織 sql 到得到結果)性能提高至少 2 倍以上,並解決以上問題

兼顧方便和性能,例如這裏使用 html_sql 查詢 (v2.0 版本) 分頁代碼片段:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "https://github.com/rbatis/rbatis_sql/raw/main/mybatis-3-mapper.dtd">
<mapper>
    <select id="select_by_condition">
        select * from biz_activity where
        <if test="name != ''">
            name like #{name}
        </if>
    </select>
</mapper>
    
#[crud_table]
    #[derive(Clone, Debug)]
    pub struct BizActivity {
        pub id: Option<String>,
        pub name: Option<String>,
        pub pc_link: Option<String>,
        pub h5_link: Option<String>,
        pub pc_banner_img: Option<String>,
        pub h5_banner_img: Option<String>,
        pub sort: Option<String>,
        pub status: Option<i32>,
        pub remark: Option<String>,
        pub create_time: Option<NaiveDateTime>,
        pub version: Option<i32>,
        pub delete_flag: Option<i32>,
    }
    
    #[html_sql(rb, "example/example.html")]
    async fn select_by_condition(rb: &mut RbatisExecutor<'_>, page_req: &PageRequest, name: &str) -> Page<BizActivity> { todo!() }
    
    #[async_std::main]
    pub async fn main() {
        fast_log::init_log("requests.log", 1000, log::Level::Info, None, true);
        //use static ref
        let rb = Rbatis::new();
        rb.link("mysql://root:123456@localhost:3306/test")
            .await
            .unwrap();
        let a = select_by_condition(&mut (&rb).into(), &PageRequest::new(1, 10), "test")
            .await
            .unwrap();
        println!("{:?}", a);
    }

介紹 Java 最普遍的 ORM 框架前世今生 - Mybatis、MybatisPlus,XML,OGNL 表達式,dtd 文件

文檔類型定義(DTD)可定義合法的 XML 文檔構建模塊。它使用一系列合法的元素來定義文檔的結構。同樣,它可以作用於 xml 文件也可以作用於 html 文件. Intellij IDEA,CLion,VSCode 等等 ide 均具備該文件合法模塊,標籤智能提示的能力 例如:

<?xml version="1.0" encoding="UTF-8" ?>
        <!ELEMENT mapper (sql* | insert* | update* | delete* | select* )+>
        <!ATTLIST mapper
                >
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "https://github.com/rbatis/rbatis_sql/raw/main/mybatis-3-mapper.dtd">
<mapper>
</mapper>

OGNL(Object-Graph Navigation Language) 大概可以理解爲: 對象圖形化導航語言。是一種可以方便地操作對象屬性的開源表達式語言. Rbatis 在 html,py_sql 內部借鑑部分 ognl 表達式的設計,但是 rbatis 實際操作的是 json 對象。

例如 (#{name}, 表示從參數中獲取 name 參數,# 符號表示放如預編譯 sql 參數並替換爲 mysql 的'?'或者 pg 的‘$1’,如果是 $ 符號表示直接插入並替換 sql):

<select id="select_by_condition">select * from table where name like #{name}</select>

探索實現架構走彎路 - 最初版本基於 AST + 解釋執行

AST 抽象語法樹,可以參考其他博客 https://blog.csdn.net/weixin_39408343/article/details/95984062 

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Node {
    pub left: Option<Box<Node>>,
    pub value: Value,
    pub right: Option<Box<Node>>,
    pub node_type: NodeType,
}
impl Node{
    #[inline]
    pub fn eval(&self, env: &Value) -> Result<Value, crate::error::Error> {
        if self.equal_node_type(&NBinary) {
            let left_v = self.left.as_ref().unwrap().eval(env)?;
            let right_v = self.right.as_ref().unwrap().eval(env)?;
            let token = self.to_string();
            return eval(&left_v, &right_v, token);
        } else if self.equal_node_type(&NArg) {
            return self.value.access_field(env);
        }
        return Result::Ok(self.value.clone());
    }
}

表達式是如何運行的?

結論:這種架構下,其實存在一些弊端,例如存在很多不必要的 clone 操作,node 需要在程序運行階段 解析 -> 生成 AST-> 逐行解釋執行 AST。這些都是存在一些時間和 cpu、內存開銷的

探索實現架構走彎路 - 嘗試基於 wasm

rust 也有一些 wasm 運行時,這類框架可以進行某些 JIT 編譯優化工作。例如 wasmtime/cranelift/ 曾經發現調用 cranelift 運行時調用開銷 800ns/op,對於頻繁進出宿主 - wasm 運行時調用的話,似乎並不是特別適合 ORM。況且接近 800ns 的延遲,說實話挺難接受的。參見 issues https://github.com/bytecodealliance/wasmtime/issues/2644 經過一些時間等待,該問題被解決後,仍然需要耗費至少 50ns 的時間開銷。對於 sql 中出現參數動則 20 次的調用,時間延遲依然會進一步拉大

探索實現架構 - 真正的 0 開銷抽象,嘗試過程宏,是元編程也是高性能的關鍵

我們一直在說 0 開銷,C++ 的實現遵循 “零開銷原則”:如果你不使用某個抽象,就不用爲它付出開銷 [Stroustrup,1994]。而如果你確實需要使用該抽象,可以保證這是開銷最小的使用方式。— Stroustrup

過程宏框架,syn 和 quote(分別解析和生成詞條流)

我們知道 syn 和 quote 結合起來是實現過程宏的主要方式,但是 syn 和 quote 僅支持 rust 語法規範。如何讓它能變相解析我們自定義的語法糖呢?

關於擴展性 - 包裝 serde_json 還是拷貝 serde_json 源碼?

我們執行的表達式參數都是 json 參數,這裏涉及使用到 serde_json。但是 serde_json 其實不具備 類似 serde_json::Value + 1 的語法規則,你會得到編譯錯誤!

語法糖語義和實現 trait 支持擴展

自定義的結構體大概長這樣

#[derive(Eq, PartialEq, Clone, Debug)]
pub struct Value<'a> {
    pub inner: Cow<'a, serde_json::Value>,
}

性能優化 1 - 寫時複製 Cow - 避免不必要的克隆

實現表達式執行時,並不是所有操作都存在‘寫’的,大部分場景是基於‘讀’ 例如表達式:

<if test="id > 0 || id == 1">
            id = ${id}
</if>

性能優化 2 - 重複變量利用優化

<select id="select_by_condition">
        select * from table where
        id != #{id}
        and 1 != #{id}
</select>

性能優化 3-sql 預編譯參數替換算法優化

預編譯的 sql 需要把參數替換爲例如 mysql:'?',postgres:'$1'等符號。

rust 的 String 對象是支持變長的字符串,我們知道 Vec 是存儲於堆內存(因爲計算機堆內存容量更大,而棧空間是有限的)大概長這樣

#[stable(feature = "rust1", since = "1.0.0")]
pub struct String {
    vec: Vec<u8>,
}
macro_rules! push_index {
     ($n:expr,$new_sql:ident,$index:expr) => {
                  {
                     let mut num=$index/$n;
                     $new_sql.push((num+48) as u8 as char);
                     $index % $n
                  }
              };
    ($index:ident,$new_sql:ident) => {
                if  $index>=0 && $index<10{
                    $new_sql.push(($index+48)as u8 as char);
                }else if $index>=10 && $index<100 {
                    let $index = push_index!(10,$new_sql,$index);
                    let $index = push_index!(1,$new_sql,$index);
                }else if $index>=100 && $index<1000{
                    let $index = push_index!(100,$new_sql,$index);
                    let $index = push_index!(10,$new_sql,$index);
                    let $index = push_index!(1,$new_sql,$index);
                }else if $index>=1000 && $index<10000{
                    let $index = push_index!(1000,$new_sql,$index);
                    let $index = push_index!(100,$new_sql,$index);
                    let $index = push_index!(10,$new_sql,$index);
                    let $index = push_index!(1,$new_sql,$index);
                }else{
                     use std::fmt::Write;
                     $new_sql.write_fmt(format_args!("{}", $index))
                    .expect("a Display implementation returned an error unexpectedly");
               }
       };
    }
    
        for x in sql.chars() {
        if x == '\'' || x == '"' {
            if string_start == true {
                string_start = false;
                new_sql.push(x);
                continue;
            }
            string_start = true;
            new_sql.push(x);
            continue;
        }
        if string_start {
            new_sql.push(x);
        } else {
            if x=='?' && #format_char != '?' {
                index+=1;
                new_sql.push(#format_char);
                push_index!(index,new_sql);
            }else{
                new_sql.push(x);
            }
        }
    }

最後的驗證階段,(零開銷、編譯時動態 SQL)執行效率壓測

v2.0 請求耗時 耗時: 3923900800 耗時: 3576816000 耗時: 3248177800 耗時: 3372922200

v1.8 請求耗時 耗時: 6372459300 耗時: 7709288000 耗時: 6739494900 耗時: 6590053200

結論:v2.0 相對於老版本,qps 至少快一倍

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/d9gXtzYGppGnL47CCN7V2g