通常来说,我们都知道:
- SQL 数据库只限在单机上运行,但它提供了更强的事务管理、schema 与查询功能。
- NoSQL 数据库为了伸缩性与容错性的目的,放弃了事务管理与 schema。
而 FoundationDB 的 SQL 层结合了这两个方面:它首先是一个开源的 SQL 数据库,能够线性地伸缩与提升容错性,并且还具有真正的 ACID 事务功能。曾经互不相容的两种特性,现在已融合在一个统一的系统中。
对于处于以下几种情况的公司来说,这一特性是非常重要的:
- 新的项目要为大规模的伸缩性进行计划。
- 现有的项目遇到了数据库伸缩性的瓶颈。
- 现有的许多项目希望能用一个唯一的、容错性强的数据库抽象层统一工作模式。
在本文中,我将为读者介绍 FoundationDB ,并解释 FoundationDB 的 SQL 层是怎样将 SQL 数据映射到 FoundationDB 中的键 - 值存储后台系统中的。
NoSQL 数据库 ——FoundationDB 的键 - 值存储系统
FoundationDB 是一个分布式的键 - 值存储系统,支持全局 ACID 事务操作,并且性能出众。在安装系统时,可以指定数据分发的级别。数据分发为容错性提供了支持:当某个服务器或网络的某部分产生故障时,数据库仍然可以正常操作,你的应用也不会受到影响。
键- 值与SQL 架构
我们开发的这套架构能够在键- 值存储系统上支持多个层,每个层都能够在FoundationDB 的基础上提供一套不同的数据模型,例如SQL 数据库、文档数据库或图形数据库。许多使用者也自行创建了自定义的层。
下图中列出架构中的了关键部分。处于最底层的是FoundationDB 集群,无论集群的实际大小如何,对它的操作与一个单独的逻辑数据库并没有分别。SQL 层则以一种无状态的中间层方式运行在键- 值存储系统之上。这一层通过SQL 与应用程序进行通信,并使用FoundationDB 的客户端API 与键- 值存储系统进行通信。由于SQL 层是无状态的,因此可以并行地运行任意数据的SQL 层。
SQL 层为键 - 值存储系统带来了如 Google 的 F1 般的能力
SQL 层是对 SQL 与键 - 值存储 API 进行转换的一套逻辑严密的层。首先,SQL 层会从一条 SQL 语句开始,将其转换为最高效地键 - 值操作。这种方式类似于编译器将代码转换为低级别的执行格式。并且,这种转换是完全符合 ANSI SQL 92 标准的。开发者可以将该功能与 ORM、REST API 进行接合,或者直接使用 SQL 层的命令行界面进行调用。从代码的角度来说,SQL 层与键 - 值存储是完全分离的,它是通过 FoundationDB 的 Java 绑定方式与键 - 值存储进行通信的。感兴趣的读者可以查看 FoundationDB 的 SQL 层在 GitHub 上的代码库,其代码是完全开源的。眼下唯一能够和这套系统进行比较的是 Google 的 F1 ,后者是一套基于该公司的 Spanner 技术所创建的 SQL 引擎。
如以下的简单图例所示,SQL 层是由一系列组件所组成的。应用程序通过某种受支持的 SQL 客户端向 SQL 层发送查询语句,在解析之后转换为一棵计划节点树。优化器(Optimizer)会计算最佳的执行计划,并以一棵操作符树的方式表现出来,随后由执行框架(Execution Framework)运行。在执行阶段,对数据的请求将被发送到存储虚拟(Storage Abstraction)层,这一层通过使用 Java 的键 - 值 API 在数据与 FoundationDB 集群之间进行传输。数据库模型将存放在 Information Schema 层中,这一层将被其它多个组件所调用。
将 SQL 数据映射到键 - 值存储系统
SQL 层需要管理两种类型的数据,首先是信息 Schema 的元数据,它负责描述所创建的表与可用的索引。其次,它还需要存储实际的数据,包括表内容、索引及序列。我们首先来描述一下这些数据是如何保存在键 - 值存储系统中的。
本质上讲,每个键都是对应了某张表中的特定行的指针,而值则包含了该行的数据。键的分配是由 Table-Group 所决定的,它是包含了一个或多个表的组。稍后会对这个概念的细节进行更深入的讲解。SQL 层会通过使用键 - 值存储目录层为每个 Table-Group 创建一个目录,存储目录层是为用户管理键空间的一个工具,它为每个独立的目录分配一个简短的字节数组,作为该目录的唯一键。同时,它也维护着其它元数据,以实现通过名称进行查找的功能。
下面这个例子演示了如何创建目录的映射,通过以下语句分配键。
CREATE TABLE schema_a.table1(id INT PRIMARY KEY, c CHAR(10)); CREATE TABLE schema_a.table2(id INT PRIMARY KEY);
在键 - 值存储系统中有一些预定义的目录:
Directory
Tuple
Raw Key
sql/
(9)
\x15\x09
sql/data/
(3)
\x15\x03
sql/data/table/
(31)
\x15\x1F
sql/data/table/schema_a/table1/
(215)
\x15\xD7
sql/data/table/schema_a/table2/
(247)
\x15\xF7
在存储数据时,可以选择使用以下三种格式中的一种:“元组(Tuple)”、“原始数据(Row_Data)”或者是“Protobuf”。如果使用默认的 Tuple 存储格式,那么每一行内容都将保存为一个单独的键 - 值对,键是通过连接以下字符串所生成的元组:目录前缀、该表在 Table-Group 中的位置,以及主键。而值的内容则是由该行中的所有列所组成的一个元组。
举例来说,以下代码对之前创建的表进行操作,产生对应的键与值。
INSERT INTO schema_a.table1 VALUES (1, 'hello'), (2, 'world'); INSERT INTO schema_a.table2 VALUES (5);
Raw Key
Tuple Key
Raw Value
Tuple Value
\x15\xD7\x15\x01\x15\x01
(215, 1, 1)
\x15\x01\x02hello\x00
(1, ‘hello’)
\x15\xD7\x15\x01\x15\x02
(215, 1, 2)
\x15\x02\x02world\x00
(2, ‘world’)
\x15\xF7\x15\x01\x15\x05
(247, 1, 5)
\x15\x05
(5)
了解了键 - 值存储系统中键的结构之后,你就能够从存储系统中直接读取数据了。我们将使用 FoundationDB 的 Python API 来演示这一功能。在 SQL 层中,键与值是通过“.pack()”方法进行编码,并通过“.unpack()”方法进行解码的。下面的示例为你演示如何获取并解码数据。
import fdb fdb.api_version(200) db = fdb.open() directory = fdb.directory.open(db,('sql','data','table','schema_a','table1')) for key, value in db[directory.range()]: print fdb.tuple.unpack(key), ' --> ', fdb.tuple.unpack(value)
以上代码会输出类似下面的结果:
(215, 1, 1) --> (1, u'hello') (215, 1, 2) --> (2, u'world')
现在让我们再来近距离观察一下 Table-Group。每个独立的表都属于一个单独的组,如果某张额外的表能够创建一个对第一张表的“组外键”引用,那么它也能够加入到同一个组中。当我们为某张表创建组外键时,字表将与父表所在的目录进行交互。字表将成为 Table-Group 的一部分,在源表之后进行命名。这两张表的数据在将同一个目录中进行交互,这保证了范围扫描的高速,并且在 Table-Group 之内访问对象及表连接的开销极小。为了演示这一特性,我们将继续之前的示例,这一次的 SQL 语句如下:
CREATE TABLE schema_a.table3(id INT PRIMARY KEY, id_1 INT, GROUPING FOREIGN KEY (id_1) REFERENCES schema_a.table1(id)); INSERT INTO schema_a.table3 VALUES (100, 2), (200, 2), (300, 1);
该语句将返回以下结果:
directory = fdb.directory.open(db,('sql','data','table','schema_a','table1')) for key, value in db[directory.range()]: print fdb.tuple.unpack(key), ' --> ', fdb.tuple.unpack(value) (215, 1, 1) --> (1, u'hello') (215, 1, 1, 2, 300) --> (300, 1) (215, 1, 2) --> (2, u'world') (215, 1, 2, 2, 100) --> (100, 2) (215, 1, 2, 2, 200) --> (200, 2)
由于第三张表的键都处于第一张表中各行的命名空间范围内,因此第三张表中所有插入的行都能够与第一张表的行相关联。键中的两个额外的值分别对应了 Table-Group 中的位置以及第三张表中的主键。对表 1 与表 3 通过引用键进行连接也无需通过标准的连接操作实现,直接通过线性扫描就语句了。这种排序方式比起传统的关系型数据库系统有着极大的优势。
由于键都已经经过排序,因此索引可以直接利用这一点所带来的便利性。所有的表索引只包含一个键值,其中包括两部分内容。每个索引都创建于该表所属的目录之下,一个名为 index 的子目录中,这是该键元组的第一部分内容。第二个部分是一个组合,首先是该索引所对应的各个列的值,之后则是指定这一行所必须的列的值。
举例来说,我们可以为这张表的 c 列创建一个索引。
CREATE INDEX index_on_c ON schema_a.table1(c) STORAGE_FORMAT tuple;
接下来使用 Python 读取这个索引的内容,我们需要在 Python 解释器中加入以下内容:
directory = fdb.directory.open(db, ('sql', 'data', 'table', 'schema_a', 'table1', 'index_on_c')) for key, value in db[directory.range()]: print fdb.tuple.unpack(key), ' --> ', fdb.tuple.unpack(value)
这段代码会输入类似于下图中的内容,显示了键的两个组成部分:即该索引所在的目录的字节值,以及创建索引的 c 列的值加上主键的值。最后一个部分将被索引的值链接到某个特定的行,而该索引键所对应的值为空。
(20127, u'hello', 1) --> () (20127, u'world', 2) --> ()
如果要对 SQL 层的行为进行更多的控制调整,可以使用以下三种存储格式:一是之前描述过的元组格式,一是列键格式,以及 protobuf 格式。列健格式会为某一行的每个列值创建一个独立的键 - 值对。而 protobuf 存储格式为会每一行创建一个 protobuf 消息。
接下来还需要对元数据进行存储与组织。SQL 层使用 protobuf 消息与基于 SQL 的数据的结构进行通信。这个结构是由 schema、组、表、列、索引与外键等对象共同组成的。
SQL 与 NoSQL 的混合模式
如果在应用程序级别使用只读的键 - 值 API,那么 SQL 层就能够在客户端进行直接访问。可以通过键 - 值 API 直接访问数据,但如果增加或改写了 SQL 层所用的关键数据,那就很可能破坏系统的运行。这里例举一些可能会产生的问题:缺乏对索引的维护、缺乏应有的限定,以及忽略了对数据及元数据的版本维护。而这种方式的好处,哪怕是在进行数据读取时也并不明显,因为 SQL 层本身的额外开销就非常小。因此总的来说,性能的开销主要取决于网络延迟。
结论
SQL 与 NoSQL 的结合使用能够相互利用两者的优点。FoundationDB 的键 - 值存储系统为 SQL 层带来的好处包括可伸缩性、容错性及全局 ACID 的事务属性。你的应用程序同样也能从中受益,因此赶紧尝试一下吧!对应那些要执行大量的小批数据读取及写入的应用程序来说,FoundationDB 提供了一个高伸缩并且安全的解决方案,并且可以任意使用 SQL 或 NoSQL。
关于作者
Sytze Harkema从 2014 年 3 月起担任 FoundationDB 的软件工程师,他专注于 SQL 层的开发,致力于使其成为高伸缩 SQL 应用的最佳解决方案。Sytzey 曾经就读于荷兰的 Delft 科技大学及美国的哈佛大学。
评论