从头开始编写 MySQL 存储引擎






4.91/5 (17投票s)
介绍如何编写 MySQL 存储引擎 - 一个负责持久化 MySQL 表数据的插件。
引言
本文总结了我在编写一个新的 MySQL 存储引擎(“SE”)时学到的东西。MySQL 的存储引擎是负责将数据实际存储在磁盘上并提供数据访问的插件。SE 通常实现为键/值数据库,但并非必须如此。Oracle 的 MySQL 附带了各种 SE,整个 SE 生态系统可能有些令人不知所措。默认的是 InnoDB,一个键/值数据库库。其他 SE 可以读取和写入 CSV 文件(“Tina”)或者专门为归档数据而编写(“archive”)。MariaDB 是一个分支,更加前沿,并且包含用于访问 Cassandra NoSQL 数据库、Percona 的 TokuDB 键/值库以及 xtradb(InnoDB 的改进版本)的 SE。
总共有 13 个 SE 随 MySQL 一起提供,MariaDB 则有大约 20 个。这显然不够。让我们再添加一个!
下载代码和初步印象
您可以从 GitHub 下载 MySQL 代码。我使用的是版本 5.7.12。
git clone https://github.com/mysql/mysql-server.git
cd mysql-server
git checkout mysql-5.7-12
让我们简要看一下源代码结构。文件和目录的数量有些令人望而生畏,但其中大部分对我们并不重要。只有少数几个目录值得仔细研究。
include Stores global include files. Here you will find mysql.h,
which has many important macros and declarations.
sql Contains the actual sql related code: parser, query engine etc.
sql/field.h The Field class describes a MySQL column
sql/item.h The Item class is a node in the abstract syntax tree
which is generated by the parser
sql/sql_*.cc These files implement the actual SQL operations
sql/handler.h The base class for a storage engine
storage This directory has all the storage engines
storage/innobase The InnoDB storage engine
storage/example A storage engine example project
浏览代码后,可以清楚地看到 MySQL 的一部分代码库相对老旧。虽然大部分代码是用 C++ 实现的,但实际上是“带类的 C”。模板、异常或标准库等更高级的 C++ 特性并未被使用。链表是作为包装“void *
”对象的结构实现的(my_list.h)。MySQL 也有自己的 string
实现(string.c),而不是使用 std::string
。RAII 也未被使用。取而代之的是,你会发现许多“goto
”指令,它们负责清理已分配的资源。
函数可以非常长(代码超过 1000 行,并且有许多嵌套的“if
”子句 - mysql_prepare_create_table),并且一些类也很大。大多数方法存储在基类中而不是派生类中,此外,类存储了大量状态。
MySQL 使用多个内存分配器(memroot_allocator.h)。它们不兼容 STL。许多类重写了 new
和 delete
运算符以使用自定义分配器。
代码没有一致的编码风格。缩进各不相同,结构体名称有时大写(“struct THD
”),有时小写加下划线(“st_mem_root
”),或者以大写字母开头后面跟下划线(“Field_long
”)。空格的使用也各不相同(foo= 3; foo = 3; foo=3
)。
我从未与 Oracle 或 MySQL AB 的人交谈过,但根据我们看到的代码,我们可以对他们的企业文化做一些猜测。缺乏编码风格意味着每个工程师都可以选择他最熟悉的风格。编码风格差异很大,即使在同一个文件中,这也可能表明没有“代码所有权”,每个人都可以处理代码的任何部分。我过去曾在类似的环境中处理过类似的代码,那是一个很棒的工作场所。
使用“带类的 C”、拒绝使用 STL 以及陈旧、冗长复杂的函数,如果 MySQL 想摆脱其技术债务并吸引新开发者,将需要大量重构。
编译、安装、运行、调试
让我们继续安装。如果您按照上述步骤操作,那么您已经在“mysql-server”目录中检出了代码。在这里,我们将运行 cmake
,它将生成 Makefiles。我们将创建一个 Debug
版本,它速度较慢但可用于调试。
cd mysql-server
cmake -DCMAKE_BUILD_TYPE=Debug
make -j 5
sudo make install
您的已编译 MySQL 文件现在已安装在 /usr/local/mysql。您可以在子目录中运行 cmake
以获得单独的 Debug 和 Release 版本,并且可以选择不同的安装目录。运行“cmake --help
”可查看选项列表。对于本文,我将保持一切尽可能简单。
现在,在我们成功启动 MySQL 守护进程之前,还有一些事情要做。我们需要创建一个新的数据目录(存储表)并对其进行初始化(请注意,您必须更改以下命令中的路径名)。
mkdir /home/chris/tmp/mysql-data # must be empty, or else...
cd /usr/local/mysql/bin
./mysqld --initialize --datadir=/home/chris/tmp/mysql-data
最后一个命令将生成一个 root 密码。记下它,稍后您会用到。我已将我的开发环境设置为空 root 密码 - 测试时更方便。您可以使用以下命令重置密码(将 <PASSWORD>
替换为上面生成的 root 密码)。
# start the server
./mysqld --datadir=/home/chris/tmp/mysql-data
# in a separate terminal we can now change the password
./mysqladmin password --user=root --password=<password>
现在我们可以创建一个新的数据库。
./mysqladmin create test --user=root --password
客户端也可以启动,然后您就可以创建表、插入数据等。
./mysql --user=root test
引导一个新的存储引擎
存储引擎实现为一个动态库(“.so”文件),并且源文件存储在“mysql-server/storage”目录中。如果您只是想随便玩玩,可以修改现有的“example” SE。我选择创建自己的,名为“upscaledb
”,步骤如下。
cd mysql-server/storage
# copy the “ha_example” directory to “ha_upscaledb”
cp -r ha_example ha_upscaledb
# rename the files
cd ha_upscaledb
mv ha_example.h ha_upscaledb.h
mv ha_example.cc ha_upscaledb.cc
最后一步,使用您喜欢的 IDE(我使用 vim)将“example
”替换为“upscaledb
”,将“EXAMPLE
”替换为“UPSCALEDB
”。别忘了也更改 CMakeLists.txt 文件。然后转到 mysql-server
的根目录,再次运行“cmake
”和“make
”。新的 upscaledb
存储引擎现已构建,文件名为 mysql-server/storage/upscaledb/ha_upscaledb.so。
现在我们必须通知 MySQL 新的存储引擎。首先,我们从安装目录到新的 .so 文件创建一个符号链接。通过此链接,我们的服务器将始终使用最新版本的 .so 文件。每当我们进行更改时,只需编译存储引擎并重新启动 MySQL 服务器。
cd /usr/local/mysql/lib/plugin
sudo ln -s ~/prj/mysql-server/storage/upscaledb/ha_upscaledb.so ha_upscaledb.so
最后一步是更新 MySQL 的内部系统表。我们可以使用 MySQL 客户端来完成此操作。请确保 MySQL 服务器仍在运行!
cd /usr/local/mysql/bin
./mysql --user=root test
mysql> INSTALL PLUGIN upscaledb SONAME ‘ha_upscaledb.so’;
您现在可以开始使用新的 SE 并尝试创建表(CREATE TABLE test (value INTEGER) ENGINE=upscaledb;
)。由于我们的 SE 只是一个没有实现的骨架,因此我们会收到错误。在开始添加逻辑之前,我将向您展示如何调试 MySQL 服务器。以下命令在 gdb
中启动服务器,并让 gdb
捕获所有信号(即,CTRL-C 会中断到调试器)。
cd /usr/local/mysql/bin
gdb --args ./mysqld --gdb --datadir=/home/chris/tmp/mysql-data
尝试在 SE 的“create()
”方法中设置断点,然后再次运行上面的 CREATE TABLE
命令!
添加功能
现在是时候填充我们 Handler
类的各种方法了。详细描述每种方法需要花费太多时间。此外,其中一些方法我仍然不熟悉。但我会提供一个概述,并提供指向实际实现的链接。
重要的是要意识到,可能有一个 Handler
的多个实例在处理同一个表。因此,实际的表数据需要存储在单独的对象(“Share
”)中,然后 Handler
s 获取 Share
。如果您的数据存储在普通文件中,那么文件句柄将是 Share
的成员,而不是您的 Handler
的成员。
您可以在此处查看 ExampleShare
,在此处查看我的 UpscaledbShare
。您可以看到 UpscaledbShare
存储了实际的数据库句柄以及数据库的附加元数据。
创建表
ExampleHandler::create 和 UpscaledbHandler::create 在每次创建表时都会被调用。紧接着,MySQL 将调用该表的 open()
方法。因此,您的 create()
方法可以准备表,但实际上不必打开它。
UpscaledbHandler::create()
的实现非常直接。它创建一个 upscaledb
Environment,然后为每个索引创建一个数据库。如果用户创建了一个没有主键的表,那么就会生成一个索引。数据库配置取决于列的实际类型和一些其他参数(例如,是否唯一)。
打开表
ExampleHandler::open 和 UpscaledbHandler::open 在每次打开表时被调用。如上所述,同一张表可能会(并且确实会)被打开很多次。因此,您必须将实际的表数据存储在您的“Share
”中。
在检索到 Share
对象的指针后,UpscaledbHandler::open()
方法会检查 upscaledb
环境是否已打开。如果已打开,则立即返回。如果未打开,则继续打开文件,并将环境的句柄存储在“Share
”中。
关闭表
ExampleHandler::close
和 UpscaledbHandler::close 方法应关闭表。如果您的表数据存储在 Share
中,那么您可以使用引用计数来确定何时销毁 Share
对象。在我的 UpscaledbHandler
中,我从不销毁 share;毕竟,Share
迟早会再次被需要。
插入行
每当您调用 INSERT SQL
语句时,您的 handler 的 write_row()
方法就会被调用。它唯一的参数是新的行,已序列化为字节数组。这个数组存储的列顺序与您在调用 CREATE TABLE
语句时指定的顺序不同。主键始终位于开头,后面是所有其他索引列,最后是非索引列。
这个字节数组通常以一个(可选的)位图开头,描述当前列中的 null
值。然后是固定长度的列,或者可变长度的列(TINYTEXT
、MEDIUMTEXT
、TEXT
、LONGTEXT
或相应的 BLOB
列之一)。可变长度列以一个或两个字节开始,存储列的大小,然后是数据(可能存储在单独的内存块中;在这种情况下,字节数组包含指向实际数据的编码指针)。如果您将行持久化到文件中,则有意义的是将可变长度行“condense
”为更紧凑的格式,以减小空间。
我的 UpscaledbHandler
缓存了索引的 Field
对象,以便快速提取索引列。然后可以使用以下代码提取索引列的键(“index
”是索引的数值 ID;主索引始终为 0
)。
static inline ups_key_t
key_from_row(TABLE *table, const uchar *buf, int index)
{
KEY_PART_INFO *key_part = table->key_info[index].key_part;
uint16_t key_size = (uint16_t)key_part->length;
uint32_t offset = key_part->offset;
if (key_part->type == HA_KEYTYPE_VARTEXT1
|| key_part->type == HA_KEYTYPE_VARBINARY1) {
key_size = buf[offset];
offset += 1;
}
else if (key_part->type == HA_KEYTYPE_VARTEXT2
|| key_part->type == HA_KEYTYPE_VARBINARY2) {
key_size = *(uint16_t *)&buf[offset];
offset += 2;
}
ups_key_t key = ups_make_key((uchar *)buf + offset, key_size);
return key;
}
提取索引键后,您可以将行的数据存储在文件中。您将不得不处理三种情况:
- 用户未指定任何索引或主键
- 用户指定了主键但没有其他索引
- 用户指定了主键和其他索引
如果这还不够复杂,那么还要记住,索引可以是“virtual
”的,即它组合了多个列(如下面的语句所示)。
CREATE TABLE test (
id INT NOT NULL,
last_name CHAR(30) NOT NULL,
first_name CHAR(30) NOT NULL,
PRIMARY KEY (id),
INDEX name (last_name, first_name) -- creates a virtual index!
);
这是 UpscaledbHandler::write_row 方法的实现。
删除行
DELETE SQL
命令最终会调用您的 handler 的 delete_row()
方法。您必须确保删除所有索引,而不仅仅是主索引。但是,删除主键实际上非常简单,因为 MySQL 会使用数据库游标来定位它。这是 UpscaledbHandler::delete_row 的实现。
更新行
这是最复杂的一个 - 至少如果您想让它快速的话。update_row()
方法接收两个参数:旧行值和新行值。朴素的解决方案是调用 delete_row()
来删除旧行,然后调用 write_row()
来写入新行。这可行,但速度很慢,因为您最终会更新所有列,即使只有一列被修改。只更新已修改的列要快得多。
光标
对于许多任务,MySQL 核心将创建一个数据库游标,定位一个键(在主索引或辅助索引上),然后在使用实际数据时向前移动。有一些方法需要您实现以支持游标。
- index_init(): 为辅助索引创建游标。
- index_end(): 可以关闭游标。
- index_read_map(): 将游标定位到某一行。
- index_next(): 将游标移动到下一个键,检索行。
- index_prev(): 将游标移动到上一个键,检索行。
- index_last(): 将游标移动到最后一个键,检索行。
- index_first(): 将游标移动到第一个键,检索行。
- index_next_same(): 将游标移动到当前键的下一个重复项。
- rnd_init(): 为主索引创建游标。
- rnd_end(): 关闭游标。
- rnd_next(): 将游标移动到下一个键,检索行。
- rnd_pos(): 将游标移动到指定的行。
其他方法
其他一些重要或有趣的方法。
- rename_table(): 重命名表(及其所有文件)。这在您的模式发生更改时使用,例如添加列。MySQL 将所有数据复制到临时表,然后使用
rename_table()
方法将临时表重命名为您的原始表。 - delete_table(): 删除表(及其所有文件)。
- table_flags(): 返回一组描述您的 handler 功能的标志。其中许多没有得到很好的文档记录,而且许多是针对 InnoDB 的。我的猜测是只有 InnoDB 实现所有功能。
- index_flags(): 返回一组描述您的 handler 功能的标志 - 即,是否提供了
index_prev()
和index_next()
方法等。
结论
编写自己的 MySQL 存储引擎听起来是一项复杂的任务,但实际上并非如此。您不必实现我上面描述的所有方法。对于某些方法,MySQL 会处理缺失的实现并提供自己的实现。对于其他方法,它只会用错误中止某些 SQL 命令。ExampleHandler
basically 是空的,并且不提供任何功能。但尽管如此,您可以加载它,在调试器中设置断点,然后开始一点一点地添加功能。如果您遇到困难,可以查看 MySQL 和 MariaDB 的现有 SE。mysql-internals 邮件列表上的开发人员也非常乐于助人。
一些有趣的 SE 的想法
- 一个仅追加数据库,将其数据持久化到 HDFS;然后用户可以使用 Spark 或 Hadoop 的 Map/Reduce 作业进一步处理数据。
- 基于
std::map
或std::multi_map
的内存表。 - 由 XML 文件支持的 SE。
您还有其他什么想法?
历史
- 2016 年 6 月 20 日:初始版本