EdgeDB 架构简析

与国外不同,我在中文社区碰到的关于 EdgeDB 最多的问题就是——EdgeDB 与 openGauss、OceanBase、TiDB 有什么不同吗?EdgeDB 支持水平伸缩吗?本文将从 EdgeDB 架构设计的角度尝试回答以上问题,以及“EdgeDB 是什么”。

架构
EdgeDB 的整体架构其实非常简单,说白了就是一个封装了 PostgreSQL 的服务器程序:

EdgeDB 架构简析_第1张图片

你的应用程序需要定义一份数据结构/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 是怎么做到的呢?

EdgeDB 架构简析_第2张图片

从下往上看,首先就是与 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 架构简析_第3张图片

首先,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 架构简析_第4张图片

当你的应用程序调用了 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 架构简析_第5张图片
体验

最后补充一点开发者使用相关的体验。

使用 EdgeDB 进行开发的第一步就是安装 EdgeDB CLI,在 Linux/macOS 下就是一个命令:

$ curl --proto '=https' --tlsv1.2 -sSf https://sh.edgedb.com | sh
复制代码

在 Windows 下也是一个命令(服务器运行时使用 WSL):

PS> iwr https://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学习手册:https://doc.crmeb.com
技术交流论坛:https://q.crmeb.com

你可能感兴趣的:(edge)