零開銷、編譯時動態 SQL ORM 方面的探索
零開銷、編譯時動態 SQL ORM 方面的探索 (Rbatis ORM(v2.0))
- 什麼是動態 SQL?
在某種高級語言中,如果嵌入了 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 版本也具備了某些網友提出的問題,例如:
-
by_id*() 的方式,侷限性很大,只能操作具有該 id 的表,能否更改爲 by_column*(column:&str,arg:xxx);傳入需要操作的 column 的形式?
-
CRUDTable trait 能否不要指定 id 主鍵(因爲有的表有可能不止一個主鍵)?
-
當使用 TxManager 外加 tx_id 管理事務的方式,因爲用到了鎖,似乎影響性能
-
py_sql 使用 ast + 解釋執行的方式,不但存在 運行時,運行時解析階段,運行時解釋執行階段,能否優化爲完全 0 開銷的方式?
-
能否加入 xml 格式的動態 sql 存儲,實現 sql 和代碼解耦分離,不要使用 CDATA 轉義(太麻煩了),適當兼容從 java 遷移過來的系統並適當複用之前的 mybais xml?
經過一段時間的思考和整理,於是推出 v2.0 版本,實現完全 0 開銷的動態 sql,sql 構建性能提高 N 倍(只生成 sql),完整查詢 QPS(組織 sql 到得到結果)性能提高至少 2 倍以上,並解決以上問題
兼顧方便和性能,例如這裏使用 html_sql 查詢 (v2.0 版本) 分頁代碼片段:
- html 文件
<!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>
- main.rs 文件
#[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 文件
-
MyBatis 在 java 和 sql 之間提供更靈活的映射方案, MyBatis 將 sql 語句和方法實現,直接寫到 xml 文件中,實現和 java 程序解耦 爲何這樣說, MyBatis 將接口和 SQL 映射文件進行分離, 相互獨立, 但又通過反射機制將其進行動態綁定。其實它底層就是 Mapper 代理工廠 [MapperRegistry] 和 Mapper 標籤映射[MapperStatement], 它們兩個說穿了就是 Map 容器, 就是我們常見的 HashMap、ConcurrentHashMap。所以說, MyBatis 使用面向接口的方式這種思想很好的實現瞭解耦和的方式, 同時易於開發者進行定製和擴展, 比如我們熟悉的通用 Mapper 和分頁插件 pageHelper, 方式也非常簡單。
-
什麼是 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 表達式?
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
- AST 結構體大概長這樣
#[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());
}
}
表達式是如何運行的?
-
例如執行表達式‘1+1’,首先經過框架解析成 3 個 Node 節點的二叉樹,‘+’符號節點左葉子節點爲 1,右葉子節點爲 1
-
執行時,執行‘+’節點的 eval 方法,這時它會執行葉子節點的 eval()方法得到 2 給值 (這裏 eval 方法實際執行了 clone 操作),並根據符號‘+’對 2 給值累加,並返回。
結論:這種架構下,其實存在一些弊端,例如存在很多不必要的 clone 操作,node 需要在程序運行階段 解析 -> 生成 AST-> 逐行解釋執行 AST。這些都是存在一些時間和 cpu、內存開銷的
探索實現架構走彎路 - 嘗試基於 wasm
- 什麼是 wasm?WebAssembly/wasm WebAssembly 或者 wasm 是一個可移植、體積小、加載快並且兼容 Web 的全新格式。
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
- 如果我們使用過程宏直接把表達式編譯爲純 rust 函數代碼,那麼就實現了真正意義上令人興奮的 0 開銷!不但降低 cpu 使用率,同時提升性能
過程宏框架,syn 和 quote(分別解析和生成詞條流)
我們知道 syn 和 quote 結合起來是實現過程宏的主要方式,但是 syn 和 quote 僅支持 rust 語法規範。如何讓它能變相解析我們自定義的語法糖呢?
- 答案就是讓我們的語法糖轉換爲符合 rust 規範的語法,讓 syn 和 quote 能夠正常解析和生成詞條流
關於擴展性 - 包裝 serde_json 還是拷貝 serde_json 源碼?
我們執行的表達式參數都是 json 參數,這裏涉及使用到 serde_json。但是 serde_json 其實不具備 類似 serde_json::Value + 1 的語法規則,你會得到編譯錯誤!
-
(語法不支持)解決方案:impl std::ops::Add for serde_json::Value{} 實現標準庫的接口即可支持。
-
但是礙於 孤兒原則(當你爲某類型實現某 trait 的時候,必須要求類型或者 trait 至少有一個是在當前 crate 中定義的。你不能爲第三方的類型實現第三方的 trait )你會得到編譯錯誤!
語法糖語義和實現 trait 支持擴展
- (孤兒原則)解決方案: 實現自定義結構體,並依賴 serde_json::Value 對象,並實現該結構體的語法規則支持!
自定義的結構體大概長這樣
#[derive(Eq, PartialEq, Clone, Debug)]
pub struct Value<'a> {
pub inner: Cow<'a, serde_json::Value>,
}
性能優化 1 - 寫時複製 Cow - 避免不必要的克隆
- 科普:寫時複製(Copy on Write)技術是一種程序中的優化策略,多應用於讀多寫少的場景。主要思想是創建對象的時候不立即進行復制,而是先引用(借用)原有對象進行大量的讀操作,只有進行到少量的寫操作的時候,才進行復制操作,將原有對象複製後再寫入。這樣的好處是在讀多寫少的場景下,減少了複製操作,提高了性能。
實現表達式執行時,並不是所有操作都存在‘寫’的,大部分場景是基於‘讀’ 例如表達式:
<if test="id > 0 || id == 1">
id = ${id}
</if>
- 這裏,讀取 id 並判斷是否大於 0 或等於 1
性能優化 2 - 重複變量利用優化
- 表達式定義了變量參數 id,進行 2 次訪問,那我們生成的 fn 函數中即要判斷是否已存在變量 id,第二次直接訪問而不是重複生成 例如:
<select id="select_by_condition">
select * from table where
id != #{id}
and 1 != #{id}
</select>
性能優化 3-sql 預編譯參數替換算法優化
預編譯的 sql 需要把參數替換爲例如 mysql:'?',postgres:'$1'等符號。
- 字符串替換性能的關鍵 - rust 的 string 存儲於堆內存
rust 的 String 對象是支持變長的字符串,我們知道 Vec 是存儲於堆內存(因爲計算機堆內存容量更大,而棧空間是有限的)大概長這樣
#[stable(feature = "rust1", since = "1.0.0")]
pub struct String {
vec: Vec<u8>,
}
-
性能優化 - 不使用 format!宏等生成 String 結構體的函數,減少訪問堆內存。
-
巧用 char 進行字符串替換,因爲單個 char 存儲於棧,棧的速度快於堆
-
替換算法優化內容長這樣.(這裏我們使用
new_sql.push(char)
, 只訪問棧內存空間)
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