TiDB-Wasm 原理与实现 | Hackathon 优秀项目介绍

做者:Ti-Coolhtml

上周咱们推送了《让数据库运行在浏览器里?TiDB + WebAssembly 告诉你答案》,向你们展现了 TiDB-Wasm 的魅力:TiDB-Wasm 项目是 TiDB Hackathon 2019 中诞生的二等奖项目,实现了将 TiDB 编译成 Wasm 运行在浏览器里,让用户无需安装就可使用 TiDB。前端

本文由 Ti-Cool 队成员主笔,为你们详细介绍 TiDB-Wasm 设计与实现细节。node

10 月 27 日,为期两天的 Hackathon 落下帷幕,咱们用一枚二等奖为这次上海之行画上了圆满的句号,不枉咱们风尘仆仆跑去异地参赛(强烈期待明年杭州能做为赛场,主办方也该鼓励鼓励杭州当地的小伙伴呀 :D )。jquery

咱们几个 PingCAP 的小伙伴找到了 Tony 同窗一块儿组队,组队以后找了一个周末进行了“秘密会晤”——Hackathon kick off。想了 N 个 idea,包括使用 unikernel 技术将 TiDB 直接跑在裸机上,或者将网络协议栈作到用户态以提高 TiDB 集群性能,亦或是使用异步 io 技术提高 TiKV 的读写能力,这些都被一一否决,缘由是这些 idea 不是和 Tony 的工做内容相关,就是和咱们 PingCAP 小伙伴的平常工做相关,作这些至关于咱们在 Hackathon 加了两天班,这一点都不酷。本着「与工做无关」的标准,咱们想了一个 idea:把 TiDB 编译成 Wasm 运行在浏览器里,让用户无需安装就可使用 TiDB。咱们一致认为这很酷,因而给队伍命名为 Ti-Cool(太酷了)。linux

WebAssembly 简介

这里插入一些 WebAssembly 的背景知识,让你们对这个技术有个大体的了解。git

WebAssembly 的 官方介绍 是这样的:WebAssembly(缩写为 Wasm)是一种为基于堆栈的虚拟机设计的指令格式。它被设计为 C/C++/Rust 等高级编程语言的可移植目标,可在 web 上部署客户端和服务端应用程序。程序员

从上面一段话咱们能够得出几个信息:github

  1. Wasm 是一种可执行的指令格式。
  2. C/C++/Rust 等高级语言写的程序能够编译成 Wasm。
  3. Wasm 能够在 web(浏览器)环境中运行。

可执行指令格式

看到上面的三个信息咱们可能又有疑问:什么是指令格式?golang

咱们常见的 ELF 文件 就是 Unix 系统上最经常使用的二进制指令格式,它被 loader 解析识别,加载进内存执行。同理,Wasm 也是被某种实现了 Wasm 的 runtime 识别,加载进内存执行,目前常见的实现了 Wasm runtime 的工具备各类主流浏览器,nodejs,以及一个专门为 Wasm 设计的通用实现:Wasmer,甚至还有人给 Linux 内核提 feature 将 Wasm runtime 集成在内核中,这样用户写的程序能够很方便的跑在内核态。web

各类主流浏览器对 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 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 运行时实现)运行:

图 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 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 官方文档 编译:

图 4 按照 Golang 官方文档编译(1/2)

果真出师不利,查看 goleveldb 的代码发现,storage 包下面的代码针对不一样平台有各自的实现,惟独没有 Wasm/js 的:

图 5 按照 Golang 官方文档编译(2/2)

因此在 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
}
复制代码

而后再次编译:

图 6 再次编译的结果

emm… 编译的时候没有函数能够说这个函数没有 Wasm/js 对应的版本,没有 body 是个什么状况?好在咱们有代码能够看,到 arith_decl.go 所在的目录看一下就知道怎么回事了:

图 7 查看目录

而后 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 这个包。

再次编译,成功了!

图 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 终端和这个函数交互,两种方案:

  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 了:

图 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 咱们主要解决了几个问题:

  1. 浏览器中没法监听端口,咱们给 TiDB 嵌入了一个 SQL 终端。

  2. goleveldb 对 Wasm 的兼容问题。

  3. bigfft 的 Wasm 兼容问题。

  4. Golang 自身对 WASI 支持不完善致使的 fs 相关函数缺失。

  5. TiDB 对本地文件加载转换为浏览器上传文件方式加载。

  6. 支持 source 命令批量执行 SQL。

目前而言咱们已经将这个项目做为 TiDB Playground (play.pingcap.com/) 和 TiDB Tour (tour.pingcap.com/) 开放给用户使用。因为它不须要用户安装配置就能让用户在阅读文档的同时进行尝试,很大程度上下降了用户学习使用 TiDB 的成本,社区有小伙伴已经基于这些本身作数据库教程了,譬如:imiskolee/tidb-wasm-markdown相关介绍文章)。

图 15 TiDB Playground

因为 Hackathon 时间比较紧张,其实不少想作的东西还没实现,好比:

  1. 使用 indexedDB 让数据持久化:须要针对 indexedDB 实现一套 Storage 的 interface。

  2. 使用 P2P 技术(如 webrtc)对其余浏览器提供服务:将来一定会有愈来愈多的应用迁移到 Wasm,而不少应用是须要数据库的,TiDB-Wasm 刚好能够扮演这样的角色。

  3. 给 TiDB 的 Wasm 二进制文件瘦身:目前编译出来的二进制文件有将近 80M,对浏览器不太友好,同时运行时占用内存也比较多。

欢迎更多感兴趣的社区小伙伴们加入进来,一块儿在这个项目上愉快的玩耍(github.com/pingcap/tid…),也能够经过 info@pingcap.com 联系咱们。

原文阅读pingcap.com/blog-cn/tid…

相关文章
相关标签/搜索