讓資料庫執行在瀏覽器裡?TiDB-Wasm 原理與實現 | Hackathon 優秀專案介紹

PingCAP發表於2019-11-13

作者: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 上部署客戶端和服務端應用程式。

從上面一段話我們可以得出幾個資訊:

  1. Wasm 是一種可執行的指令格式。
  2. C/C++/Rust 等高階語言寫的程式可以編譯成 Wasm。
  3. Wasm 可以在 web(瀏覽器)環境中執行。

可執行指令格式

看到上面的三個資訊我們可能又有疑問:什麼是指令格式?

我們常見的 ELF 檔案 就是 Unix 系統上最常用的二進位制指令格式,它被 loader 解析識別,載入進記憶體執行。同理,Wasm 也是被某種實現了 Wasm 的 runtime 識別,載入進記憶體執行,目前常見的實現了 Wasm runtime 的工具有各種主流瀏覽器,nodejs,以及一個專門為 Wasm 設計的通用實現:Wasmer,甚至還有人給 Linux 核心提 feature 將 Wasm runtime 整合在核心中,這樣使用者寫的程式可以很方便的跑在核心態。

各種主流瀏覽器對 WebAssembly 的支援程度:

<center>圖 1 主流瀏覽器對 WebAssembly 的支援程度</center>

從高階語言到 Wasm

有了上面的背景就不難理解高階語言是如何編譯成 Wasm 的,看一下高階語言的編譯流程:

<center>圖 2 高階語言編譯流程</center>

我們知道高階程式語言的特性之一就是可移植性,例如 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 world\n")

    ;; _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 執行時實現)執行:

<center>圖 3 Hello World</center>

改造工作

恐懼來自未知,有了背景知識動起手來才無所畏懼,現在可以開啟 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 error
    if 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.RecordSet
        rss, 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 官方文件 編譯:

<center>圖 4 按照 Golang 官方文件編譯(1/2)</center>

果然出師不利,檢視 goleveldb 的程式碼發現,storage 包下面的程式碼針對不同平臺有各自的實現,唯獨沒有 Wasm/js 的:

<center>圖 5 按照 Golang 官方文件編譯(2/2)</center>

所以在 Wasm/js 環境下編譯找不到一些函式。所以這裡的方案就是新增一個 file_storage_js.go,然後給這些函式一個 unimplemented 的實現:

package storage

import (
    "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
}

然後再次編譯:

<center>圖 6 再次編譯的結果</center>

emm… 編譯的時候沒有函式可以說這個函式沒有 Wasm/js 對應的版本,沒有 body 是個什麼情況?好在我們有程式碼可以看,到 arith_decl.go 所在的目錄看一下就知道怎麼回事了:

<center>圖 7 檢視目錄</center>

然後 arith_decl.go 的內容是一些列的函式宣告,但是具體的實現放到了上面的各個平臺相關的彙編檔案中了。

看起來還是和剛剛一樣的情況,我們只需要為 Wasm 實現一套這些函式就可以了。但這裡有個問題是,這是一個程式碼不受我們控制的第三方庫,並且 TiDB 不直接依賴這個庫,而是依賴了一個叫 mathutil 的庫,然後 mathutil 依賴這個 bigfft。悲催的是,這個 mathutil 的程式碼也不受我們控制,因此很直觀的想到了兩種方案:

  1. 給這兩個庫的作者提 PR,讓他們支援 Wasm。
  2. 我們將這兩個庫 clone 過來改掉,然後把 TiDB 依賴改到我們 clone 過來的庫上。

方案一的問題很明顯,整個週期較長,等作者接受 PR 了我們的 Hackathon 都涼涼了(而且還不一定會接受);方案二的問題也不小,這會導致我們和上游脫鉤。那麼有沒有第三種方案呢,即在編譯 Wasm 的時候不依賴這兩個庫,在編譯正常的二進位制檔案的時候又用這兩個庫?經過搜尋發現,我們很多程式碼都用到了 mathutil,但是基本上只用了幾個函式:MinUint64MaxUint64MinInt32MaxInt32 等等,我們想到的方案是:

  1. 新建一個 mathutil 目錄,在這個目錄裡建立 mathutil_linux.gomathutil_js.go
  2. mathutil_linux.go 中 reexport 第三方包的幾個函式。
  3. mathutil_js.go 中自己實現這幾個函式,不依賴第三方包。
  4. 將所有對第三方的依賴改到 mathutil 目錄上。

這樣,mathutil 目錄對外提供了原來 mathutil 包的函式,同時整個專案只有 mathutil 目錄引入了這個不相容 Wasm 的第三方包,並且只在 mathutil_linux.go 中引入(mathutil_js.go 是自己實現的),因此編譯 Wasm 的時候就不會再用到 mathutil 這個包。

再次編譯,成功了!

<center>圖 8 編譯成功</center>

相容性問題

編譯出 main.Wasm 按照 Golang 的 Wasm 文件跑一下,由於目前是直接通過 os.Stdin 讀使用者輸入的 SQL,通過 os.Stdout 輸出結果,所以理論上頁面上會是空白的(我們還沒有操作 dom),但是由於 TiDB 的日誌會打向 os.Stdout,所以在瀏覽器的控制檯上應該能看到 TiDB 正常啟動的日誌才對。然而很遺憾看到的是異常棧:

<center>圖 9 異常棧</center>

可以看到這個錯是執行時沒實現 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);

然後再重新整理頁面,在控制檯上出現了久違的日誌:

<center>圖 10 日誌資訊</center>

到目前為止就已經解決了 TiDB 編譯到 Wasm 的所有技術問題,剩下的工作就是找一個合適的能執行在瀏覽器裡的 SQL 終端替換掉前面寫的終端,和 TiDB 對接上就能讓使用者在頁面上輸入 SQL 並執行起來了。

使用者介面

通過上面的工作,我們現在有了一個 Exec 函式,它接受 SQL 字串,輸出 SQL 執行結果,並且它可以在瀏覽器裡執行,我們還需要一個瀏覽器版本 SQL 終端和這個函式互動,兩種方案:

  1. 使用 Golang 直接操作 dom 來實現這個終端。
  2. 在 Golang 中把 Exec 暴露到全域性,然後找一個現成的 js 版本的終端和這個全域性的 Exec 對接。

對於前端小白的我們來說,第二種方式成本最低,我們很快找到了 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 code
        sql := args[0].String()
        args[1].Invoke(k.Exec(sql))
    }()
    return nil
}))

這樣就能在瀏覽器的控制檯執行 SQL 了:

<center>圖 11 在瀏覽器控制檯執行 SQL</center>

然後將用 jquery.console.js 搭建一個 SQL 終端,再將 executeSQL 作為 callback 傳入,大功告成:

<center>圖 12 搭建 SQL 終端</center>

現在算是有一個能執行的版本了。

本地檔案訪問

還有一點點小麻煩要解決,那就是 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 命令執行之後彈出檔案選擇框:

<center>圖 13 source 命令執行(1/2)</center>

選中 SQL 檔案上傳後自動執行,可以對資料庫進行相應的修改:

<center>圖 14 source 命令執行(2/2)</center>

總結與展望

總的來說,這次 Hackathon 為了移植 TiDB 我們主要解決了幾個問題:

  1. 瀏覽器中無法監聽埠,我們給 TiDB 嵌入了一個 SQL 終端。
  2. goleveldb 對 Wasm 的相容問題。
  3. bigfft 的 Wasm 相容問題。
  4. Golang 自身對 WASI 支援不完善導致的 fs 相關函式缺失。
  5. TiDB 對本地檔案載入轉換為瀏覽器上傳檔案方式載入。
  6. 支援 source 命令批量執行 SQL。

目前而言我們已經將這個專案作為 TiDB Playground (https://play.pingcap.com/) 和 TiDB Tour (https://tour.pingcap.com/) 開放給使用者使用。由於它不需要使用者安裝配置就能讓使用者在閱讀文件的同時進行嘗試,很大程度上降低了使用者學習使用 TiDB 的成本,社群有小夥伴已經基於這些自己做資料庫教程了,譬如:imiskolee/tidb-wasm-markdown相關介紹文章)。

<center>圖 15 TiDB Playground</center>

由於 Hackathon 時間比較緊張,其實很多想做的東西還沒實現,比如:

  1. 使用 indexedDB 讓資料持久化:需要針對 indexedDB 實現一套 Storage 的 interface。
  2. 使用 P2P 技術(如 webrtc)對其他瀏覽器提供服務:未來必定會有越來越多的應用遷移到 Wasm,而很多應用是需要資料庫的,TiDB-Wasm 恰好可以扮演這樣的角色。
  3. 給 TiDB 的 Wasm 二進位制檔案瘦身:目前編譯出來的二進位制檔案有將近 80M,對瀏覽器不太友好,同時執行時佔用記憶體也比較多。

歡迎更多感興趣的社群小夥伴們加入進來,一起在這個專案上愉快的玩耍(github.com/pingcap/tidb/projects/27),也可以通過 info@pingcap.com 聯絡我們。

原文閱讀https://pingcap.com/blog-cn/tidb-wasm-introduction/

相關文章