代码实现sql编译器_TiDB-Wasm 原理与实现 | Hackathon 优秀项目介绍
作者:Ti-Cool
上周我們推送了《讓數據庫運行在瀏覽器里?TiDB + WebAssembly 告訴你答案》,向大家展示了 TiDB-Wasm 的魅力:TiDB-Wasm 項目是 TiDB Hackathon 2019 中誕生的二等獎項目,實現了將 TiDB 編譯成 Wasm 運行在瀏覽器里,讓用戶無需安裝就可以使用 TiDB。本文由 Ti-Cool 隊成員主筆,為大家詳細介紹 TiDB-Wasm 設計與實現細節。
10 月 27 日,為期兩天的 Hackathon 落下帷幕,我們用一枚二等獎為此次上海之行畫上了圓滿的句號,不枉我們風塵仆仆跑去異地參賽(強烈期待明年杭州能作為賽場,主辦方也該鼓勵鼓勵杭州當地的小伙伴呀 :D )。
我們幾個 PingCAP 的小伙伴找到了 Tony 同學一起組隊,組隊之后找了一個周末進行了“秘密會晤”——Hackathon kick off。想了 N 個 idea,包括使用 unikernel 技術將 TiDB 直接跑在裸機上,或者將網絡協議棧做到用戶態以提升 TiDB 集群性能,亦或是使用異步 io 技術提升 TiKV 的讀寫能力,這些都被一一否決,原因是這些 idea 不是和 Tony 的工作內容相關,就是和我們 PingCAP 小伙伴的日常工作相關,做這些相當于我們在 Hackathon 加了兩天班,這一點都不酷。本著「與工作無關」的標準,我們想了一個 idea:把 TiDB 編譯成 Wasm 運行在瀏覽器里,讓用戶無需安裝就可以使用 TiDB。我們一致認為這很酷,于是給隊伍命名為 Ti-Cool(太酷了)。
WebAssembly 簡介
這里插入一些 WebAssembly 的背景知識,讓大家對這個技術有個大致的了解。
WebAssembly 的 官方介紹 是這樣的:WebAssembly(縮寫為 Wasm)是一種為基于堆棧的虛擬機設計的指令格式。它被設計為 C/C++/Rust 等高級編程語言的可移植目標,可在 web 上部署客戶端和服務端應用程序。
從上面一段話我們可以得出幾個信息:
可執行指令格式
看到上面的三個信息我們可能又有疑問:什么是指令格式?
我們常見的 ELF 文件 就是 Unix 系統上最常用的二進制指令格式,它被 loader 解析識別,加載進內存執行。同理,Wasm 也是被某種實現了 Wasm 的 runtime 識別,加載進內存執行,目前常見的實現了 Wasm runtime 的工具有各種主流瀏覽器,nodejs,以及一個專門為 Wasm 設計的通用實現:Wasmer,甚至還有人給 Linux 內核提 feature 將 Wasm runtime 集成在內核中,這樣用戶寫的程序可以很方便的跑在內核態。
各種主流瀏覽器對 WebAssembly 的支持程度:
圖 1 主流瀏覽器對 WebAssembly 的支持程度從高級語言到 Wasm
有了上面的背景就不難理解高級語言是如何編譯成 Wasm 的,看一下高級語言的編譯流程:
圖 2 高級語言編譯流程我們知道高級編程語言的特性之一就是可移植性,例如 C/C++ 既可以編譯成 x86 機器可運行的格式,也可以編譯到 ARM 上面跑,而我們的 Wasm 運行時和 ARM,x86_32 其實是同類東西,可以認為它是一臺虛擬的機器,支持執行某種字節碼,這一點其實和 Java 非常像,實際上 C/C++ 也可以編譯到 JVM 上運行(參考:compiling-c-for-the-jvm)。
各種 runtime 以及 WASI
再啰嗦一下各種環境中運行 Wasm 的事,上面說了 Wasm 是設計為可以在 web 中運行的程序,其實 Wasm 最初設計是為了彌補 js 執行效率的問題,但是發展到后面發現,這玩意兒當虛擬機來移植各種程序也是很贊的,于是有了 nodejs 環境,Wasmer 環境,甚至還有內核環境。
這么多環境就有一個問題了:各個環境支持的接口不一致。比如 nodejs 支持讀寫文件,但瀏覽器不支持,這挑戰了 Wasm 的可移植性,于是 WASI (WebAssembly System Interface) 應運而生,它定義了一套底層接口規范,只要編譯器和 Wasm 運行環境都支持這套規范,那么編譯器生成的 Wasm 就可以在各種環境中無縫移植。如果用現有的概念來類比,Wasm runtime 相當于一臺虛擬的機器,Wasm 就是這臺機器的可執行程序,而 WASI 是運行在這臺機器上的系統,它為 Wasm 提供底層接口(如文件操作,socket 等)。
Example or Hello World?
程序員對 Hello World 有天生的好感,為了更好的說明 Wasm 和 WASI 是啥,我們這里用一個 Wasm 的 Hello World 來介紹(例程來源:chai2010-golang-wasm.slide#27):
(module;; type iov struct { iov_base, iov_len int32 };; func fd_write(id *iov, iovs_len int32, nwritten *int32) (written int32)(import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))(memory 1)(export "memory" (memory 0));; The first 8 bytes are reserved for the iov array, starting with address 8(data (i32.const 8) "hello worldn");; _start is similar to main function, will be executed automatically(func $main (export "_start")(i32.store (i32.const 0) (i32.const 8)) ;; iov.iov_base - The string address is 8(i32.store (i32.const 4) (i32.const 12)) ;; iov.iov_len - String length(call $fd_write(i32.const 1) ;; 1 is stdout(i32.const 0) ;; *iovs - The first 8 bytes are reserved for the iov array(i32.const 1) ;; len(iovs) - Only 1 string(i32.const 20) ;; nwritten - Pointer, inside is the length of the data to be written)drop ;; Ignore return value) )具體指令的解釋可以參考 這里。
這里的 test.wat 是 Wasm 的文本表示,wat 之于 Wasm 的關系類似于匯編和 ELF 的關系。
然后我們把 wat 編譯為 Wasm 并且使用 Wasmer(一個通用的 Wasm 運行時實現)運行:
圖 3 Hello World改造工作
恐懼來自未知,有了背景知識動起手來才無所畏懼,現在可以開啟 TiDB 的瀏覽器之旅。
瀏覽器安全限制
我們知道,瀏覽器本質是一個沙盒,是不會讓內部的程序做一些危險的事情的,比如監聽端口,讀寫文件。而 TiDB 的使用場景實際是用戶啟動一個客戶端通過 MySQL 協議連接到 TiDB,這要求 TiDB 必須監聽某個端口。
考慮片刻之后,我們認為即便克服了瀏覽器沙盒這個障礙,真讓用戶用 MySQL 客戶端去連瀏覽器也并不是一個優雅的事情,我們希望的是用戶在頁面上可以有一個開箱即用的 MySQL 終端,它已經連接好了 TiDB。
于是我們第一件事是給 TiDB 集成一個終端,讓它啟動后直接彈出這個終端接受用戶輸入 SQL。所以我們需要在 TiDB 的代碼中找到一個工具,它的輸入是一串 SQL,輸出是 SQL 的執行結果,寫一個這樣的東西對于我們幾個沒接觸過 TiDB 代碼的人來說還是有些難度,于是我們想到了一個捷徑:TiDB 的測試代碼中肯定會有輸入 SQL 然后檢查輸出的測試。那么把這種測試搬過來改一改不就是我們想要的東西嘛?然后我們翻了翻 TiDB 的測試代碼,發現了大量的這樣的用法:
result = tk.MustQuery("select count(*) from t group by d order by c") result.Check(testkit.Rows("3", "2", "2"))所以我們只需要看看這個 tk 是個什么東西,借來用一下就行了。這是 tk 的主要函數:
// Exec executes a sql statement. func (tk *TestKit) Exec(sql string, args ...interface{}) (sqlexec.RecordSet, error) {var err errorif tk.Se == nil {tk.Se, err = session.CreateSession4Test(tk.store)tk.c.Assert(err, check.IsNil)id := atomic.AddUint64(&connectionID, 1)tk.Se.SetConnectionID(id)}ctx := context.Background()if len(args) == 0 {var rss []sqlexec.RecordSetrss, err = tk.Se.Execute(ctx, sql)if err == nil && len(rss) > 0 {return rss[0], nil}return nil, errors.Trace(err)}stmtID, _, _, err := tk.Se.PrepareStmt(sql)if err != nil {return nil, errors.Trace(err)}params := make([]types.Datum, len(args))for i := 0; i < len(params); i++ {params[i] = types.NewDatum(args[i])}rs, err := tk.Se.ExecutePreparedStmt(ctx, stmtID, params)if err != nil {return nil, errors.Trace(err)}err = tk.Se.DropPreparedStmt(stmtID)if err != nil {return nil, errors.Trace(err)}return rs, nil }剩下的事情就非常簡單了,寫一個 Read-Eval-Print-Loop (REPL) 讀取用戶輸入,將輸入交給上面的 Exec,再將 Exec 的輸出格式化到標準輸出,然后循環繼續讀取用戶輸入。
編譯問題
集成一個終端只是邁出了第一步,我們現在需要驗證一個非常關鍵的問題:TiDB 能不能編譯到 Wasm,雖然 TiDB 是 Golang 寫的,但是中間引用的第三方庫沒準哪個寫了平臺相關的代碼就沒法直接編譯了。
我們先按照 Golang 官方文檔 編譯:
圖 4 按照 Golang 官方文檔編譯(1/2)果然出師不利,查看 goleveldb 的代碼發現,storage 包下面的代碼針對不同平臺有各自的實現,唯獨沒有 Wasm/js 的:
圖 5 按照 Golang 官方文檔編譯(2/2)所以在 Wasm/js 環境下編譯找不到一些函數。所以這里的方案就是添加一個 file_storage_js.go,然后給這些函數一個 unimplemented 的實現:
package storageimport ("os""syscall" )func newFileLock(path string, readOnly bool) (fl fileLock, err error) {return nil, syscall.ENOTSUP }func setFileLock(f *os.File, readOnly, lock bool) error {return syscall.ENOTSUP }func rename(oldpath, newpath string) error {return syscall.ENOTSUP }func isErrInvalid(err error) bool {return false }func syncDir(name string) error {return syscall.ENOTSUP }然后再次編譯:
圖 6 再次編譯的結果emm… 編譯的時候沒有函數可以說這個函數沒有 Wasm/js 對應的版本,沒有 body 是個什么情況?好在我們有代碼可以看,到 arith_decl.go 所在的目錄看一下就知道怎么回事了:
圖 7 查看目錄然后 arith_decl.go 的內容是一些列的函數聲明,但是具體的實現放到了上面的各個平臺相關的匯編文件中了。
看起來還是和剛剛一樣的情況,我們只需要為 Wasm 實現一套這些函數就可以了。但這里有個問題是,這是一個代碼不受我們控制的第三方庫,并且 TiDB 不直接依賴這個庫,而是依賴了一個叫 mathutil 的庫,然后 mathutil 依賴這個 bigfft。悲催的是,這個 mathutil 的代碼也不受我們控制,因此很直觀的想到了兩種方案:
方案一的問題很明顯,整個周期較長,等作者接受 PR 了我們的 Hackathon 都涼涼了(而且還不一定會接受);方案二的問題也不小,這會導致我們和上游脫鉤。那么有沒有第三種方案呢,即在編譯 Wasm 的時候不依賴這兩個庫,在編譯正常的二進制文件的時候又用這兩個庫?經過搜索發現,我們很多代碼都用到了 mathutil,但是基本上只用了幾個函數:MinUint64,MaxUint64,MinInt32,MaxInt32 等等,我們想到的方案是:
這樣,mathutil 目錄對外提供了原來 mathutil 包的函數,同時整個項目只有 mathutil 目錄引入了這個不兼容 Wasm 的第三方包,并且只在 mathutil_linux.go 中引入(mathutil_js.go 是自己實現的),因此編譯 Wasm 的時候就不會再用到 mathutil 這個包。
再次編譯,成功了!
圖 8 編譯成功兼容性問題
編譯出 main.Wasm 按照 Golang 的 Wasm 文檔跑一下,由于目前是直接通過 os.Stdin 讀用戶輸入的 SQL,通過 os.Stdout 輸出結果,所以理論上頁面上會是空白的(我們還沒有操作 dom),但是由于 TiDB 的日志會打向 os.Stdout,所以在瀏覽器的控制臺上應該能看到 TiDB 正常啟動的日志才對。然而很遺憾看到的是異常棧:
圖 9 異常棧可以看到這個錯是運行時沒實現 os.stat 操作,這是因為目前的 Golang 沒有很好的支持 WASI,它僅在 wasm_exec.js 中 mock 了一個 fs:
global.fs = {writeSync(fd, buf) {...},write(fd, buf, offset, length, position, callback) {...},open(path, flags, mode, callback) {...},... }而且這個 mock 的 fs 并沒有實現 stat, lstat, unlink, mkdir 之類的調用,那么解決方案就是我們在啟動之前在全局的 fs 對象上 mock 一下這幾個函數:
function unimplemented(callback) {const err = new Error("not implemented");err.code = "ENOSYS";callback(err); } function unimplemented1(_1, callback) { unimplemented(callback); } function unimplemented2(_1, _2, callback) { unimplemented(callback); }fs.stat = unimplemented1; fs.lstat = unimplemented1; fs.unlink = unimplemented1; fs.rmdir = unimplemented1; fs.mkdir = unimplemented2; go.run(result.instance);然后再刷新頁面,在控制臺上出現了久違的日志:
圖 10 日志信息到目前為止就已經解決了 TiDB 編譯到 Wasm 的所有技術問題,剩下的工作就是找一個合適的能運行在瀏覽器里的 SQL 終端替換掉前面寫的終端,和 TiDB 對接上就能讓用戶在頁面上輸入 SQL 并運行起來了。
用戶接口
通過上面的工作,我們現在有了一個 Exec 函數,它接受 SQL 字符串,輸出 SQL 執行結果,并且它可以在瀏覽器里運行,我們還需要一個瀏覽器版本 SQL 終端和這個函數交互,兩種方案:
對于前端小白的我們來說,第二種方式成本最低,我們很快找到了 jquery.console.js 這個庫,它只需要傳入一個 SQL 處理的 callback 即可運行,而我們的 Exec 簡直就是為這個 callback 量身打造的。
因此我們第一步工作就是把 Exec 掛到瀏覽器的 window 上(暴露到全局給 js 調用):
js.Global().Set("executeSQL", js.FuncOf(func(this js.Value, args []js.Value) interface{} {go func() {// Simplified codesql := args[0].String()args[1].Invoke(k.Exec(sql))}()return nil }))這樣就能在瀏覽器的控制臺運行 SQL 了:
圖 11 在瀏覽器控制臺運行 SQL然后將用 jquery.console.js 搭建一個 SQL 終端,再將 executeSQL 作為 callback 傳入,大功告成:
圖 12 搭建 SQL 終端現在算是有一個能運行的版本了。
本地文件訪問
還有一點點小麻煩要解決,那就是 TiDB 的 load stats 和 load data 功能。load data 語法和功能詳解可以參考 TiDB 官方文檔,其功能簡單的說就是用戶指定一個文件路徑,然后客戶端將這個文件內容傳給 TiDB,TiDB 將其加載到指定的表里。我們的問題在于,瀏覽器中是不能讀取用戶電腦上的文件的,于是我們只好在用戶執行這個語句的時候打開瀏覽器的文件上傳窗口,讓用戶主動選擇一個這樣的文件傳給 TiDB:
js.Global().Get("upload").Invoke(js.FuncOf(func(this js.Value, args []js.Value) interface{} {go func() {fileContent := args[0].String()_, e := doSomething(fileContent)c <- e}()return nil }), js.FuncOf(func(this js.Value, args []js.Value) interface{} {go func() {c <- errors.New(args[0].String())}()return nil }))load stats 的實現也是同理。
此外,我們還使用同樣的原理 “自作主張” 加入了一個新的指令:source,用戶執行這個命令可以上傳一個 SQL 文件,然后我們會執行這個文件里的語句。我們認為這個功能的主要使用場景是:用戶初次接觸 TiDB 時,想驗證其對 MySQL 的兼容性,但是一條一條輸入 SQL 效率太低了,于是可以將所有用戶業務中用到的 SQL 組織到一個 SQL 文件中(使用腳本或其他自動化工具),然后在頁面上執行 source 導入這個文件,驗證結果。
以一個 test.sql 文件為例,展示下 source 命令的效果,test.sql 文件內容如下:
CREATE DATABASE IF NOT EXISTS samp_db;USE samp_db;CREATE TABLE IF NOT EXISTS person (number INT(11),name VARCHAR(255),birthday DATE );CREATE INDEX person_num ON person (number);INSERT INTO person VALUES("1","tom","20170912");UPDATE person SET birthday='20171010' WHERE name='tom';source 命令執行之后彈出文件選擇框:
圖 13 source 命令執行(1/2)選中 SQL 文件上傳后自動執行,可以對數據庫進行相應的修改:
圖 14 source 命令執行(2/2)總結與展望
總的來說,這次 Hackathon 為了移植 TiDB 我們主要解決了幾個問題:
目前而言我們已經將這個項目作為 TiDB Playground (https://play.pingcap.com/) 和 TiDB Tour (https://tour.pingcap.com/) 開放給用戶使用。由于它不需要用戶安裝配置就能讓用戶在閱讀文檔的同時進行嘗試,很大程度上降低了用戶學習使用 TiDB 的成本,社區有小伙伴已經基于這些自己做數據庫教程了,譬如:imiskolee/tidb-wasm-markdown(相關介紹文章)。
圖 15 TiDB Playground由于 Hackathon 時間比較緊張,其實很多想做的東西還沒實現,比如:
歡迎更多感興趣的社區小伙伴們加入進來,一起在這個項目上愉快的玩耍(github.com/pingcap/tidb/projects/27),也可以通過 info@pingcap.com 聯系我們。
閱讀原文:
TiDB-Wasm 原理與實現 | Hackathon 優秀項目介紹 | PingCAP?pingcap.com總結
以上是生活随笔為你收集整理的代码实现sql编译器_TiDB-Wasm 原理与实现 | Hackathon 优秀项目介绍的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: python将csv文件导入mysql-
- 下一篇: 东京战纪月山习角色技能解析及培养指南