65.9K
CodeProject 正在变化。 阅读更多。
Home

从头开始编写 MySQL 存储引擎

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (17投票s)

2016 年 6 月 20 日

CPOL

12分钟阅读

viewsIcon

73930

downloadIcon

116

介绍如何编写 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。许多类重写了 newdelete 运算符以使用自定义分配器。

代码没有一致的编码风格。缩进各不相同,结构体名称有时大写(“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”)中,然后 Handlers 获取 Share。如果您的数据存储在普通文件中,那么文件句柄将是 Share 的成员,而不是您的 Handler 的成员。

您可以在此处查看 ExampleShare,在此处查看我的 UpscaledbShare。您可以看到 UpscaledbShare 存储了实际的数据库句柄以及数据库的附加元数据。

创建表

ExampleHandler::createUpscaledbHandler::create 在每次创建表时都会被调用。紧接着,MySQL 将调用该表的 open() 方法。因此,您的 create() 方法可以准备表,但实际上不必打开它。

UpscaledbHandler::create() 的实现非常直接。它创建一个 upscaledb Environment,然后为每个索引创建一个数据库。如果用户创建了一个没有主键的表,那么就会生成一个索引。数据库配置取决于列的实际类型和一些其他参数(例如,是否唯一)。

打开表

ExampleHandler::openUpscaledbHandler::open 在每次打开表时被调用。如上所述,同一张表可能会(并且确实会)被打开很多次。因此,您必须将实际的表数据存储在您的“Share”中。

在检索到 Share 对象的指针后,UpscaledbHandler::open() 方法会检查 upscaledb 环境是否已打开。如果已打开,则立即返回。如果未打开,则继续打开文件,并将环境的句柄存储在“Share”中。

关闭表

ExampleHandler::closeUpscaledbHandler::close 方法应关闭表。如果您的表数据存储在 Share 中,那么您可以使用引用计数来确定何时销毁 Share 对象。在我的 UpscaledbHandler 中,我从不销毁 share;毕竟,Share 迟早会再次被需要。

插入行

每当您调用 INSERT SQL 语句时,您的 handler 的 write_row() 方法就会被调用。它唯一的参数是新的行,已序列化为字节数组。这个数组存储的列顺序与您在调用 CREATE TABLE 语句时指定的顺序不同。主键始终位于开头,后面是所有其他索引列,最后是非索引列。

这个字节数组通常以一个(可选的)位图开头,描述当前列中的 null 值。然后是固定长度的列,或者可变长度的列(TINYTEXTMEDIUMTEXTTEXTLONGTEXT 或相应的 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;
}

提取索引键后,您可以将行的数据存储在文件中。您将不得不处理三种情况:

  1. 用户未指定任何索引或主键
  2. 用户指定了主键但没有其他索引
  3. 用户指定了主键和其他索引

如果这还不够复杂,那么还要记住,索引可以是“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 命令。ExampleHandlerbasically 是空的,并且不提供任何功能。但尽管如此,您可以加载它,在调试器中设置断点,然后开始一点一点地添加功能。如果您遇到困难,可以查看 MySQL 和 MariaDB 的现有 SE。mysql-internals 邮件列表上的开发人员也非常乐于助人。

一些有趣的 SE 的想法

  • 一个仅追加数据库,将其数据持久化到 HDFS;然后用户可以使用 Spark 或 Hadoop 的 Map/Reduce 作业进一步处理数据。
  • 基于 std::mapstd::multi_map 的内存表。
  • 由 XML 文件支持的 SE。

您还有其他什么想法?

历史

  • 2016 年 6 月 20 日:初始版本
© . All rights reserved.