EdgeDB 架構簡析

語言: CN / TW / HK

與國外不同,我在中文社群碰到的關於 EdgeDB 最多的問題就是——EdgeDB 與 openGauss、OceanBase、TiDB 有什麼不同嗎?EdgeDB 支援水平伸縮嗎?本文將從 EdgeDB 架構設計的角度嘗試回答以上問題,以及“EdgeDB 是什麼”。

架構

EdgeDB 的整體架構其實非常簡單,說白了就是一個封裝了 PostgreSQL 的伺服器程式:

你的應用程式需要定義一份資料結構/schema,然後根據這份 schema 來向 EdgeDB 傳送 EdgeQL 查詢語句。比如說,這是一份用 EdgeQL SDL 語言編寫的 schema 定義:

type Person {
    property name -> str;
}

type Team {
    property slogan -> str;
    multi link members -> Person {
        property title -> str;
    }
}
複製程式碼

這是你的 EdgeQL 查詢語句:

select Team {
  slogan,
  members: {
    name,
    @title
  } order by @title
}
複製程式碼

關於 EdgeQL 及 SDL 的細節優勢就不展開說了

接著說這張圖,在 EdgeDB 這邊,EdgeQL 的查詢語句會被編譯成 SQL,然後在 PostgreSQL 中執行並將結果原路返回。EdgeDB 本身並不儲存任何資料,包括你的 schema 和資料都直接存在 PostgreSQL 中,同時還有萬八條 EdgeDB 基礎資料,包括內建 schema、資料型別、標準庫、使用者角色、資料庫配置等等。

EdgeDB 對後端的 PostgreSQL 沒有任何魔改,就是普通的 PostgreSQL 資料庫,版本在 13 或以上即可,甚至可以是一些雲平臺自帶的 PostgreSQL。再加上 EdgeDB 把自己的配置資訊都存在 PostgreSQL 裡了,所以相對於 PostgreSQL,EdgeDB 就是一個“無狀態”的服務,因此後面的圖裡面會單獨把 EdgeDB Server 和 PostgreSQL 畫出來,儘管多數情況下 EdgeDB 都是用自帶的 PostgreSQL,並且使用者並無感知。

效能

如果我告訴你,EdgeDB Server 其實是用 Python 寫的,你還敢用嗎?

實際上,EdgeDB 優化到了能媲美 PostgreSQL 原生效能的地步。這聽上去雖然沒什麼,但我說的是整體效率——對比來看現今大部分解決方案,總體效率會受到連線資源分配、SQL 編譯快取、ORM 開銷、SQL 優化等諸多因素的牽連,因此綜合來看 EdgeDB 都是名列前茅的,更不要說 EdgeDB 在開發者工作效率上的提升了。那 EdgeDB 是怎麼做到的呢?

從下往上看,首先就是與 PostgreSQL 通訊的二進位制協議,這裡的 EdgeDB 程式碼脫胎與 asyncpg 專案,也是用 Cython 開過光的,所以速度比 Python 中其他通過 psycopg2 連 PostgreSQL 的方式要快得多,甚至比 Go 語言中的兩種方案都要快。原因除了是二進位制協議和 Cython 的加速外,EdgeDB 大量使用了 pipelining,每個 EdgeQL 查詢(是的,無論多複雜,EdgeDB 都會編譯成一個可以很長但十分高效的 SQL)只會產生一次網路讀寫(邏輯介面),大大降低了反覆往返於網路的時間開銷。

再往上,EdgeDB 會把 PostgreSQL 編譯好的 SQL 控制代碼(prepared statement)快取下來,因為通常的應用程式總共的查詢數量一般都是有限的,比如幾十種不同的查詢,每次執行只是引數不同而已,因此 EdgeDB 可以跳過 PostgreSQL 每次重新編譯 SQL 的步驟,直接進入計劃執行階段。而這在非 EdgeDB 的應用程式或資料庫框架中,需要用到高階技巧(比如 SQLAlchemy 的 baked query 功能)才能實現。

類似地,EdgeDB 也會把 EdgeQL 到 SQL 的編譯結果快取下來,快取命中就直接執行。只不過,這裡的快取索引不是字串雜湊,而是經過語法語義解析得到的 AST(抽象語法樹)。這樣做的好處是,即使你的 EdgeQL 語句裡有一些字面量,EdgeDB 也可以通過 AST 分析出句子的主幹,不會影響快取的命中。因為每個查詢都要在查快取前做解析,因此 EdgeDB 用 Rust 寫了一個解析器 Python 外掛,解析一條語句的用時大概是 50-70 微秒,也就是 0.05 毫秒,或者 0.00005 秒。

在右邊,就是 EdgeQL 編譯程序池。因為編譯器本身是 Python 寫的,所以執行起來特耗 CPU,於是 EdgeDB 就做了一個程序池(為了繞開 GIL 所以不是執行緒池),專門用來編譯 EdgeQL,然後 pickle 了用 UNIX domain socket 來傳資料。但一般情況下,如果快取充分預熱了,編譯程序池沒什麼太大工作量的。

最後最上面是客戶端連 EdgeDB 的二進位制協議,這個協議特意模仿了 PostgreSQL 的二進位制協議,一方面團隊做過 asyncpg 經驗最豐富,另一方面也是最重要的,就是繼承了 PostgreSQL 協議裡資料的格式。也就是說,從二進位制傳輸層來看,EdgeDB Server 並不需要解包從 PostgreSQL 伺服器傳過來的查詢結果資料,直接換成 EdgeDB 自己的二進位制協議外包裝,傳給客戶端即可。也就是說,對於實際的使用者資料,EdgeDB 幾乎就是一個架在 PostgreSQL 前面的透明代理,但卻使用了完全不同的查詢語言和型別系統。

連線

當 EdgeDB 進入一個真實的高併發環境之後,事情就變得更有意思了:

首先,EdgeDB Server 與客戶端之間的連線是十分輕量級的,完成了鑑權之後就完全無狀態了(除了在資料庫事務中必須繫結同一個後端連線),因為所有的前端連線都共享同一個 EdgeQL 編譯快取和後端 PostgreSQL 連線池,只有當客戶端發起一個請求的時候,EdgeDB 才會給這個客戶端連線分配一個後端 PostgreSQL 資料庫的連線,並且查詢完成了之後就立馬還給連線池,供其他前端連線使用。這裡的原理類似於 pgBouncer,用了 EdgeDB 之後你就不再需要這些中介軟體了,也不需要擔心前端連線會佔用有限的 PostgreSQL 連線數資源。因為有 uvloop 的加持,目前單機併發支援個幾萬、幾十萬前端連線還是沒問題的,只要你的後端 PostgreSQL 能撐得住。

因為特別的輕,所以前端連線在 EdgeDB Server 端並沒有設定“連線池”,只有一個最大連線數的限制,更多的作用是防攻擊而不是因為 PostgreSQL 資源有限。同時,EdgeDB Server 會主動斃掉長時間(預設 30 秒)沒有活動的前端連線——客戶端重新連就好了。

EdgeDB 目前的網路併發 I/O 是由 uvloop 承載的,也就是那個傳說中最快的 Python 非同步網路框架。其實 uvloop 和 asyncpg——甚至一定程度上可以包括 Python 裡的 async/await 語法——都是為了做 EdgeDB 才搞出來的。所以目前 EdgeDB 在 I/O 併發上是 Python 裡目前能做到的最優解,但選 Python 更多還是因為早期 EdgeDB 的迭代次數非常多,需要這種靈活性,接下來穩定之後,會考慮用比如 Rust 來重寫 I/O 層。

其次,你可能注意到了圖中有兩波客戶端,他們用的 schema 不一樣。這對應了 PostgreSQL 裡的一個“邏輯庫”的概念,也就是一個數據庫例項上面可以有多個邏輯子庫。EdgeDB 同樣支援這一功能,並且比 PostgreSQL 的支援更成熟,因為 PostgreSQL 無法幫你平衡不同邏輯庫之間的壓力,總共就那幾百上千個數據庫連線,你給 db1 多分配一個連線就要給 db2 少分配一個,而同一個連線又沒有辦法零成本的換庫(pg 的鍋)。EdgeDB 因為有架構設計上的優勢,所以可以看到前端連線的使用比重,所以我們在 EdgeDB 的後端連線池裡寫了一個複雜的演算法,用來排程不同邏輯庫應分配的資料庫連線資源數,以達到自動平衡出最優的服務質量(QoS),也就是大家不至於旱的旱死澇的澇死,從而徹底解放了“前端”開發人員的腦力,不必再為此事擔心。

質量

提到服務質量(QoS),就不得不說一下 EdgeDB 在官方客戶端裡為 QoS 所做的優化。

當你的應用程式呼叫了 EdgeDB 官方客戶端(簡稱 client 吧,因為物件名一般就是 client)的 query() 方法之後,client 並不是單純的把請求轉發出去了事,而是做了一系列通常是由應用開發者完成的、能夠提高應用服務質量的事情。

每個 client 都封裝了一個連線池,初始為空,僅在需要的時候才會建立連線,所以 client 是妥妥的懶載入模式。建立連線時,也許是斷網了,也許 EdgeDB Server 所在的 Docker 容器還沒啟動起來,也許是雲服務正在重啟或者故障轉移,反正就是一下沒連上。怎麼辦?client 報錯給應用開發者之前,會先自己嘗試重連,萬一連上了就繼續執行唄,反正這個重試時間是可以配置的。

拿到連線之後,client 會先檢視本地查詢快取,如果已經有了這個查詢的型別資訊,就會直接使用該資訊對輸入引數資料進行編碼,然後直接用一次 optimistic_execute 伺服器互動來完成查詢;否則,就要先 prepare 拿到引數型別等相關資訊,再進行 execute 呼叫,需要兩次往返伺服器。

再往後,伺服器如果成功返回結果固然是好,但有些情況下就是會出錯。再一次地,當 client 把問題抱怨給應用開發者之前,會先嚐試自行解決。如果到 EdgeDB 資料庫的連線尚在,問題僅限於比如說隔離級別導致的資料衝突,或者後端 PostgreSQL 暫時掉線了等“可重試”的問題,那麼 client 會直接嘗試重新發送執行已經拼裝好的請求資料。但如果連 EdgeDB 資料庫連線都沒了,那麼在重試規則允許的情況下,client 會直接嘗試重連,除非這條 EdgeQL 查詢語句不是隻讀的(因為網路不穩定這事兒誰也說不準,也許已經執行成功了呢,所以還是隻重試只讀查詢最為穩妥。你問我 client 怎麼知道語句是不是隻讀的?EdgeDB Server 知道呀, prepare result 裡面就有隻讀資訊。要是這個資料都沒來得及傳回來,那還是直接報錯好了)。

對於使用資料庫事務的程式碼,這個過程依然是一樣的,只不過對應用開發者更為透明瞭而已。比如下面這段 Python 程式碼:

async for tx in client.transaction():
    async with tx:
        await tx.execute("insert ...")
複製程式碼

或是下面這段 JavaScript 程式碼:

await client.transaction(async tx => {
    await tx.execute(`insert ...`);
});
複製程式碼

EdgeDB 的官方客戶端從介面上強制要求,應用開發者必須考慮到整段事務程式碼如果發生重試應該怎麼辦。這其實才是正確的資料庫事務寫法(因為 SERIALIZABLE 隔離級別下,處理 SerializationError 的最佳實踐就是有意識地重新執行整段事務程式碼),不能因為其他資料庫驅動沒提供這種寫法,你就可以讓使用者看大白頁然後成為“重試”的一環。有了強制的重試事務介面,你才不會把一些本不應放在事務中的程式碼誤寫進去,比如操作一個 Redis 裡的計數器。

有了各種保障 QoS 的機制,當比如 client 連線池裡的連線放太久被 Server 給斃了的時候,應用開發者完全不需要擔心因此而導致出錯,同時 EdgeDB 也可以減輕一些併發的壓力,已達到整體服務質量的提升。

周邊

從另一個角度來看 EdgeDB 的話,它不僅僅只是一個數據庫伺服器:

體驗

最後補充一點開發者使用相關的體驗。

使用 EdgeDB 進行開發的第一步就是安裝 EdgeDB CLI,在 Linux/macOS 下就是一個命令:

$ curl --proto '=https' --tlsv1.2 -sSf http://sh.edgedb.com | sh
複製程式碼

在 Windows 下也是一個命令(伺服器執行時使用 WSL):

PS> iwr http://ps1.edgedb.com -useb | iex
複製程式碼

完成之後,你需要在你的專案資料夾下,初始化 EdgeDB 的專案(如果你是新專案,可以用空資料夾):

$ edgedb project init
No `edgedb.toml` found in this repo or above.
Do you want to initialize a new project? [Y/n]
> Y
How would you like to run EdgeDB for this project?
1. Local (native package)
2. Docker
> 1
Checking EdgeDB versions...
Specify the version of EdgeDB to use with this project [1-rc3]:
> # left blank for default
Specify the name of EdgeDB instance to use with this project:
> my_instance
Initializing EdgeDB instance...
Bootstrap complete. Server is up and running now.
Project initialialized.
複製程式碼

此時,CLI 程式會下載 EdgeDB Server 並建立一個數據庫例項(內帶 PostgreSQL 例項),然後在當前資料夾下建立以下檔案(夾):

  • edgedb.toml - EdgeDB 專案檔案,包含版本號什麼的;
  • dbschema/default.esdl - 一個空的 schema 定義,供你後續編輯 schema 用;
  • dbschema/migrations/*.edgeql - 自動生成的資料庫 migration,不要手動編輯,由 CLI 命令管理。

這些檔案都應該新增到版本控制如 Git 中,接下來你就可以正式進入開發了。跟 EdgeDB 有關的行為大概有:

  • 連到資料庫裡,手動執行一些查詢:直接 edgedb + 回車;
  • 修改 schema:直接修改 esdl 檔案,完成之後先執行 edgedb migration create 建立 migration 指令碼,然後執行 edgedb migrate 完成 migration;
  • 程式碼連資料庫:import edgedb + edgedb.create_client(),不同語言或環境略有不同,但只要在有 edgedb.toml 的(子)資料夾中執行程式碼,就不需要額外的連線引數;生產環境用環境變數來設定連線引數。

能看出來,EdgeDB 在日常開發中的使用體驗十分簡單暴力,因為客戶端庫和命令列工具都是自家產的,所以我們把能省的都幫開發者省了,並且一致性極高。比如我不說你甚至都不會意識到,開發環境也是啟用了 TLS 的,因為 EdgeDB Server 會自動建立開發證書,CLI 記住信任的證書,客戶端庫就能暢通無阻的連伺服器了,不需要開發者的任何干預。將來的託管 EdgeDB 雲端例項也會做到一樣的體驗。

總結

通過系統架構可以看出,目前 EdgeDB 的關注點在於:

  • EdgeQL,理論上有可能成為 SQL 的“接班人”
  • 單機資料庫效率,作為基礎資料庫,先服務好大部分中小規模的應用場景
  • 開發體驗與工作效率,為用起來“爽”做了大量工作
  • 雲生態適配,有作為 serverless 資料庫的潛力

然而,EdgeDB 與 NewSQL 並沒有什麼關係,目前在水平伸縮方面也並沒有提供額外的支援,就是定位為一款新型基礎通用 OLTP 資料庫。誠然,你可以提供自己的可伸縮魔改 PostgreSQL 後端,EdgeDB 也內建支援一定程度的高可用,也可以改出來只讀副本什麼的,但那並不是當前 EdgeDB 的關注重點。如果 EdgeDB Server 本身變成了系統瓶頸,那麼就在同一個 PostgreSQL 後端上多加一個 EdgeDB Server 例項,一個不行,就兩個(之後 I/O 改進了,直接增加 I/O 線/程序數即可)。

但是,EdgeDB 的定位更偏向於類似 PostgreSQL 這樣的基礎資料庫,大神們可以在基礎資料庫之上玩出花來,但通用資料庫本身則會傾向於先把基礎打牢。EdgeDB 以 EdgeQL 為招牌,在兼顧效能的同時將開發者體驗和效率列在第一位,並一舉打破了許多現有技術棧——比如 ORM——的束縛,為現代應用開發帶來了宣告式 schema、包含 migration 的工作流、transaction 重試等最佳實踐,可以說是一種全新的資料庫物種。

最後

如果你覺得此文對你有一丁點幫助,點個贊。或者可以加入我的開發交流群:1025263163相互學習,我們會有專業的技術答疑解惑

如果你覺得這篇文章對你有點用的話,麻煩請給我們的開源專案點點star:http://github.crmeb.net/u/defu不勝感激 !

PHP學習手冊:http://doc.crmeb.com
技術交流論壇:http://q.crmeb.com