MySQLのストレージエンジンを自作してみる

MySQL のストレージエンジン(SE)を自作してみたときのメモ。バージョンは 8.0.13。

アーキテクチャをざっくりと掴むことが目的なので、ストレージエンジンの自作といっても非常に単純な操作しかできないものです。 RDB らしさとも言えるインデックスや行レベルロック、トランザクションなどの高度な処理は実装せず、簡単に入出力の流れを追っていきます。

ゴールは以下の基本的な機能を実現して、「あ、こんなもんなんだ〜」感を覚えることです。

  • CREATE 文でテーブルの作成
  • INSERT 文で行の挿入
  • SELECT 文で行の取得

ちなみに MySQL のコードは C/C++ です。(といっても、テンプレート等の C++ らしい拡張的な機能は使われておらず、ほぼ C で書かれています。クラスは頻繁に使われているので、俗に「クラスのあるC」なんて言われている模様。そのため、C をある程度理解していれば C++ をあまり知らなくてもなんとなーく読めるという印象。)

以降、大雑把に「ストレージエンジンとその実装の概要」「環境構築」「実装」という構成になっています。

ストレージエンジンとその実装の概要

実装に入る前に、まずはざっくり概要を把握しておきます。

前提として MySQL では、オプティマイズやクライアントとの通信を担うサーバの本体機能と、実際のデータアクセスを提供する機能が分離されています。後者がストレージエンジンなわけですが、これがプラガブルになっていることで、アプリケーションのユースケースに合わせてデータアクセスを最適化できるようになっています。例えば、以下の図のデータ部がディスクであってもメモリであっても、はたまたネットワーク越しのデータアクセスであっても、その違いはストレージエンジンレイヤで吸収されることになります。

f:id:norikone:20181228141834p:plain
ストレージエンジンの位置付け

で、このストレージエンジンの実態は、 handler クラスのオブジェクトです。 厳密に言えば、handler クラスを継承したクラスのオブジェクトです。 この継承元の handler クラスはストレージエンジンのベースクラスとして、MySQL サーバがデータアクセス時に使う API を定義します。 handler を継承して、定義された API を実装したクラスがストレージエンジン層そのものということになります。 MySQL サーバはデータ操作時、ハンドラに対して関数(API)を呼び出すだけなので、実際にどこにデータが入っているのか、どのようにデータにアクセスするのかを意識する必要がありません。 (この記事では、下の図での中間層を「ハンドラ」と総称します。ベースクラスとしての handler に焦点を当てる場合には「handler」と書きます。)

f:id:norikone:20181228142706p:plain
ストレージエンジンの実態

上の図の hoge_handler や piyo_handler といったクラスが各ストレージエンジンの実態です。 API の具体例としては例えば、テーブルを作成/開閉するための関数や、行を読み書きするための関数などがあります。API は handler で色々定義されていますが、ストレージエンジンは必要なものだけを実装すれば OK です。今回作るのは超単純にファイルへの読み書きをするだけのストレージエンジンなので、必要最小限の実装をしていきます。

それから、ハンドラとテーブルの関係を簡単に書いておくと、基本的にハンドラはスレッドがテーブルを開く度に生成されます。 ので、ハンドラとテーブルは多対1の関係になります。 複数のクライアントが同時にテーブルにアクセスするようなケースでは、ハンドラはその数だけ用意されるということです。

また、ハンドラの他に handlerton というストレージエンジンの種類に対して1対1になるようなオブジェクトもあります(handler + singleton で handlerton らしい)。 例えば Hoge ストレージエンジンというものがあった時に、hoge_handler クラスのインスタンスは沢山存在することになりますが、hoge_handlerton のインスタンスは常に1つです。 ハンドラがテーブル単位の操作を請け負うのに対して、handlerton はストレージエンジン単位の操作を担います(コミットやロールバックなど)。 テーブルが開かれる時にハンドラオブジェクトを作るのも handlerton の重要な役割です。

ということで、ストレージエンジン自作の基本は、handler を継承したクラスの実装と、handlerton の実装ということになります。

環境構築

大雑把に書いていきます。

ソースの準備とインストール

まずはソースをダウンロード、解凍して、

cmake -DWITH_DEBUG=1 -DDOWNLOAD_BOOST=1 -DWITH_BOOST=/tmp/boost
make
sudo make install

でインストールし、起動確認とrootパスワード等の初期設定をします。

自作ストレージエンジン用ファイルの準備

ストレージエンジンはソースの storage/ 配下に置かれています。 その中に EXAMPLE ストレージエンジンというチュートリアル用のストレージエンジンが用意されているので、今回はこれをベースにして新しいストレージエンジンを作っていきます。

cp -R storage/example storage/hoge

ここでは新しいストレージエンジンの名前を hoge にしていますが、好きなものでOKです(以下、hoge以外にした場合には適宜読み替えが必要)。

コピーしただけではファイル名やファイル内でストレージエンジン名を使用する箇所が "example" や "EXAMPLE" になっている状態なので、新しいストレージエンジン用に修正していきます。 まずは Makefile を開いて、"example" というワードを "hoge" に置換します。 次に CMakeListsを開いて、"EXAMPLE" → "HOGE"、"example" → "hoge" に置換します。 また、ha_example.cc および ha_example.h というファイルはそれぞれ ha_hoge.cc、ha_hoge.h にリネームします。 この2つのファイルも同様に、ファイル内の "example" → "hoge"、 "EXAMPLE" → "HOGE"、"Example" → "Hoge" と置換します。

最後に、修正を反映させるためにまた make します。

cmake -DWITH_DEBUG=1 -DDOWNLOAD_BOOST=1 -DWITH_BOOST=/tmp/boost 
make

これで hoge ストレージエンジンがとりあえず動くところまで来ました。 とは言ってもコピー元の EXAMPLE ストレージエンジンは何も機能が実装されていないただのテンプレートなので、クエリの処理などはまだできません。

ストレージエンジンの導入

ストレージエンジンは MySQLプラグインとして導入されます。 なのでまず、MySQLプラグインを認識してくれるディレクトリ、つまりストレージエンジンの配置先ディレクトリを見つける必要があります。 MySQL に接続して、以下のコマンドを送ります。

SHOW VARIABLES LIKE '%plugin_dir%';

すると配置先が表示されるので、ここにコンパイルしたストレージエンジンファイルをコピーします。 デフォルトだと配置先は /lib/plugin だと思いますが、plugin ディレクトリは作成しないと無いので作っておきます。 コピー元のストレージエンジンの実態ファイルは /plugin_output_directory/ha_hoge.so です。

で、最後にプラグインのインストールコマンドを打ってストレージエンジンをインストールします。

INSTALL PLUGIN hoge SONAME 'ha_hoge.so';

SHOW ENGINES; でちゃんと導入されたかを確認できます。

ついでに、あとで使うテスト用データベースを作っておきます。

CREATE DATABASE db_test;

これで一通りの準備はできました。 次は実装に入ります。

実装

機能を実装するために、ハンドラである ha_hoge.h、ha_hoge.cc を修正していきます。 今回は、CSV ストレージエンジンを参考にしながら、超単純なファイルの入出力をするストレージエンジンを実装します。

実装する機能としては、ざっくりと以下のような感じです。

  • テーブルの作成
  • テーブルのオープン
  • 行の挿入
  • 行の取得
  • テーブルのクローズ

ハンドラの生成

上の方でも少し書きましたが、ハンドラを動かすためにはまず handlerton がそのハンドラを生成しなければいけません。 コピー元の EXAMPLE ストレージエンジンでは既に最低限の実装はされているのですが、一応その辺の流れを先に見ておきます。

ハンドラ生成の大雑把な流れとして、まず mysqld の起動時に各ストレージエンジンの handlerton が生成されます。 すべからく hoge ストレージエンジンの handlerton もこのタイミングで生成・初期化されることになります。 で、hoge_handlerton の初期化は ha_hoge.cc の hoge_init_func() という関数が行います。 そこでは handlerton 構造体の色々なメンバを設定したりしますが、特に大事なのは以下のハンドラ生成関数の登録です。

hoge_hton->create = hoge_create_handler;

hoge_hton が handlerton 構造体で、hoge_create_handler が実際にハンドラを生成する関数です。 ハンドラの生成が必要になった時には、そのテーブル(≒ストレージエンジン)の handlerton の create が呼ばれることになっています。 つまり上のコードは、hoge ストレージエンジンがハンドラを生成する際には hoge_create_handler を使ってくださいという指定になります。 で、hoge_create_handler の中身を見てみると、以下のようにただコンストラクタを呼んでオブジェクトを生成しているだけだとわかります。

static handler *hoge_create_handler(handlerton *hton, TABLE_SHARE *table,
        bool, MEM_ROOT *mem_root) {
  return new (mem_root) ha_hoge(hton, table);
}

ということで、ハンドラは handlerton に登録した関数によって生成されるので、handlerton の初期化関数内でちゃんとその関数を登録してあげましょうということです。

テーブルの作成(CREATE)

ようやくコーディング。 まずは CREATE TABLE 文を処理する機能を作ります。 handler の create() がテーブルの作成時に呼び出される関数です(上に書いた handlerton の create とは別物です)。 今回作るのはファイルへの入出力をするストレージエンジンなので、データの保存先はファイルです。 なので、このストレージエンジンの create() でやるべきことは、テーブルデータ用のファイルを作成することです。

int ha_hoge::create(const char *name, TABLE *, HA_CREATE_INFO *,
                       dd::Table *) {
  DBUG_ENTER("ha_hoge::create");

  File table_file;
  if((table_file = my_create(name, 0, O_RDWR, MYF(0))) < 0)  // テーブルデータファイルの作成
    DBUG_RETURN(-1);
  if((my_close(table_file, MYF(0))) < 0)
    DBUG_RETURN(-1);

  DBUG_RETURN(0);
}

my_create() や my_close() という関数がありますが、これはシステムコールのラップ関数です(ファイル作成のシステムコールは creat() ですが MySQL ではしっかりと my_create() になっていますね)。 恐らく移植性のためでしょうが、MySQL の開発では直接素のシステムコールは呼ばずにラッパーを使うようです。 通常のシステムコールに比べて一つ引数が増えていますが、これは処理に失敗した場合の動作などを指定するものです。

ちなみに、「my_○○系」を更にラップした「mysql_file_○○」という関数(マクロ)もあります。 これらは多分 PSI(Performance Schema Instrumentation) というデータベース動作の計測用に用意されたものです。 試していませんが、MySQL で計測オプションが有効になっているとこれらの計測系プログラムがシステムコールレベルの動作まで計測してくれるっぽいですね(勿論パフォーマンスに影響ありでしょうが)。

少し脱線したので本筋に戻って、動作確認のために上のコードをまた make して反映させてから、以下のコマンドを送ってみます。

CREATE TABLE ta_test (col1 CHAR(100), col2 CHAR(100)) ENGINE=hoge;

そうすると、データディレクトリの db_test 配下に、ta_test というファイルができているはずです。 ta_test.sdi というファイルも一緒に作られますが、こちらにはカラム定義や使用するストレージエンジンなどのメタデータ系の情報が書かれています(MySQL8 より前の frm ファイルに近い?)。

ちなみに上の実装は、テーブル作成時のオプション等はガン無視する仕様になっています。 今回はそこまで作り込みませんが、しっかりとしたストレージエンジンを作るのであれば、そういったオプションへの対応もこの関数内で必要になってくるでしょう。 その場合は、テーブル作成に関する情報が入っている第三引数をよしなに捌く感じになりそうです。

テーブルのオープン

テーブルデータを操作する機能を実装する前に、それらの機能の前処理として必要になるテーブルのオープンを実装します。 handler クラスの open() がテーブルのオープンを担当する関数です。 今回は create() で作成されたデータファイルを開いて、ファイルディスクリプタを取得することをオープン処理とします。 ので、handler にそれを保持する変数を用意しておきます。 こいつは後々 SELECT 文での行読み取りなんかで使います。

class ha_hoge : public handler {
  ~~
  File data_file;  // データファイルのディスクリプタ
  ~~

以下 open() の実装です。

int ha_hoge::open(const char *name, int, uint, const dd::Table *) {
  DBUG_ENTER("ha_hoge::open");

  if (!(share = get_share(name))) DBUG_RETURN(1);  // Hoge_share の取得
  thr_lock_data_init(&share->lock, &lock, NULL);  // ロックオブジェクトの初期化

  if ((data_file = my_open(share->data_file_name, O_RDONLY, MYF(0))) == -1){  // ファイルのオープン
    close();
    DBUG_RETURN(-1);
  };

  DBUG_RETURN(0);
}

第一引数の name にはオープンするテーブルの名前が入ってきます。 上のコードではまず、get_share() を呼び出して自インスタンスの share に代入しています。 share は Hoge_share クラスの変数で、Hoge_share は Handler_share を継承したクラスです。 この Hoge_share はテーブル操作についての共通情報や関数などを持っているオブジェクトで、すべての hoge ハンドラインスタンスが共有します。 こいつに何を持たせるかはストレージエンジン次第ですが、ここでは後で使うためにデータファイル名を持たせておきます。 以下 Hoge_share の実装です。

class Hoge_share : public Handler_share {
 public:
    THR_LOCK lock;
    Hoge_share();
    ~Hoge_share() { thr_lock_delete(&lock); }

    const char *data_file_name;  // データファイル名
};

で、この Hoge_share を取得するための get_share() 関数の実装が以下。

Hoge_share *ha_hoge::get_share(const char *table_name) {
  Hoge_share *tmp_share;

  DBUG_ENTER("ha_hoge::get_share()");

  lock_shared_ha_data();  // Hoge_share のロック
  if (!(tmp_share = static_cast<Hoge_share *>(get_ha_share_ptr()))) {  // 既に Hoge_share インスタンスが存在すればそれを取得
    tmp_share = new Hoge_share;  // インスタンスがなければ新規生成
    tmp_share->data_file_name = table_name;
    tmp_share->write_opened = false;
    set_ha_share_ptr(static_cast<Handler_share *>(tmp_share));  // 生成したインスタンスをシングルトンとして登録
  }
  unlock_shared_ha_data();  // Hoge_share へのロックの解放
  
  DBUG_RETURN(tmp_share);
}

lock_shared_ha_data() は、Hoge_share の競合をプロテクトするためにロックをかける関数です。 Hoge_share はシングルトンである必要があるので、複数のハンドラが同時にこれを生成したりしないようにしています。 get_ha_share_ptr() は既に存在する Hoge_share が存在すればそれを返す関数で、まだ生成されていなければ if 文に入って生成・初期化します。 このタイミングで引数で取ったテーブル名を、Hoge_share->data_file_name に入れておきます。 生成後、set_ha_share_ptr() でシングルトンインスタンスとして登録することで、次回以降の get_share() ではそれが返るようになります。

これでオープン処理の実装は終わりです。

データの挿入(INSERT)

次は、INSERT 文で指定された文字列をデータファイルに追記する処理を実装します。 INSERT 文に対応する API は write_row() です。 例えば以下の文を実行した時に、データファイルに「"piyo","hugahuga"\n」と書き込まれるのがゴールです(このファイル内でのフォーマットは何でもいいのですが、わかりやすそうな CSV 形式にします)。

INSERT INTO ta_test VALUES("piyo", "hugahuga");

まず準備として、ファイル書き込み用のファイルディスクリプタを生成する関数を作っておきます。 書き込み用ファイルディスクリプタはストレージエンジンに1つだけあればいいので、ハンドラ間で共有できるように Hoge_share に持たせます。

class Hoge_share : public Handler_share {
 public:
    ~~
    File write_filedes;  // データファイルの書き込み用ディスクリプタ
    bool write_opened;
    ~~
};

で、以下の関数で share->write_filedes をセットします。

int ha_hoge::init_writer() {
  DBUG_ENTER("ha_hoge::init_writer");

  if ((share->write_filedes =
          my_open(share->data_file_name, O_RDWR | O_APPEND, MYF(0))) == -1) {  // 書き込み用ファイルディスクリプタのセット
    DBUG_RETURN(-1);
  }
  share->write_opened = true;

  DBUG_RETURN(0);
}

それから、文字列書き込みの際に使うバッファをハンドラに用意しておきます(実際にはこのバッファは読み込み処理でも使う汎用バッファです)。以下の String クラスは、string.h の String ではなく、include/sql_string.h の String クラスです。

class ha_hoge : public handler {
  ~~
  String buffer;
  ~~

で、write_row() の実装が以下です。

int ha_hoge::write_row(uchar *) {
  DBUG_ENTER("ha_hoge::write_row");

  int size;

  ha_statistic_increment(&System_status_var::ha_write_count);

  if (!share->write_opened)
    if (init_writer()) DBUG_RETURN(-1);

  size = encode_quote();  // バッファへのクエリ文字列格納とバッファサイズの取得
  if ((my_write(share->write_filedes, (uchar *) buffer.ptr(), size, MYF(0))) < 0)  // データファイルへの書き込み
    DBUG_RETURN(-1);

  stats.records++;
  DBUG_RETURN(0);
}

統計情報更新等の処理も一応書いてみましたが、本筋としてやっていることは単純で、クエリで指定された文字列を整形して、データファイルに書き込んでいるだけです。 my_write() では、init_writer() で用意したディスクリプタに対して、buffer が格納している文字列を、size 分だけ書き込みます。

書き込む内容である buffer の中身をいつセットしているんだという話になりますが、これは encode_quote() 内でやっています。 encode_quote() はクエリで受け取った文字列を CSV 形式で buffer にまとめて、そのサイズを返します。 上に書いた INSERT 文の例では、buffer に「"piyo","hugahuga"\n」を格納し、その終端を表すためにサイズを返すのがこの関数のゴールです。 以下その実装です。

int ha_hoge::encode_quote() {
  char attribute_buffer[1024];
  String attribute(attribute_buffer, sizeof(attribute_buffer), &my_charset_bin);

  my_bitmap_map *org_bitmap = tmp_use_all_columns(table, table->read_set);  // カラム情報の読み取りフラグを立てる
  buffer.length(0);  // buffer を初期化

  for (Field **field = table->field; *field; field++) {
    const char *p;
    const char *end;

    (*field)->val_str(&attribute, &attribute);  // クエリ文字列の実際の長さを attribute に格納
    p = attribute.ptr();  // 書き込む文字列の先頭にポインタをセット
    end = attribute.length() + p;  // 書き込む文字列の終端にポインタをセット

    buffer.append('"');
    for (; p < end; p++)
      buffer.append(*p);
    buffer.append('"');
    buffer.append(',');
  }

  buffer.length(buffer.length() - 1);
  buffer.append('\n');

  tmp_restore_column_map(table->read_set, org_bitmap);  // 読み取りフラグを寝かせる
  return (buffer.length());
}

まず、tmp_use_all_columns() でカラム情報を読み取るためのフラグを立てています。 これを立てておかないと、後で field 変数から情報を取得する際にアサーションで落ちてしまいます。 で、クエリで受け取った文字列は、table->field の配列で管理されていて、例えば2カラムへの挿入の場合には field[0] と field[1] にそれぞれの値が格納されてきます。 つまり上の INSERT クエリの場合には field[0] に "piyo"、field[1] に "hugahuga" が入ってくるイメージです。 なので、for ループを回してそれぞれのカラムを処理していきます。 ここで厄介なのは、各 field に格納されている値は末尾に大量のスペースが入っていることです。 つまり、ポインタを進めながら field[0] の値を1文字ずつ buffer に入れていく際に、どこまでが field[0] の実際の値なのか判断する必要があります。 で、この辺の判断は (*field)->val_str() がやっていて、引数で渡している attribute に、挿入すべき文字列の長さ(=文字列の終端)を格納しています。 この長さは attribute.length() + p で end 変数に格納され、次の for ループの終了条件になります。 その for ループでは、ポインタを進めながらループで buffer を埋めていき、最終的に buffer には「"piyo","hugahuga"\n」という文字列が格納されます。

この実装だと TABLE 構造体 と Field クラスのインクルードが必要なので、ha_hoge.cc に追記します。

#include "sql/table.h"
#include "sql/field.h"

変更を反映させてから先程の INSERT 文を実行すると、ta_test に文字列が追記されているはずです。 これでとりあえずの INSERT 処理は実装できました。

テーブルスキャン

テーブルの作成やオープンはそれぞれに対応する関数を一つ実装するだけでしたが、テーブルスキャンでは複数の関数の実装が必要になります。 以下は CSV ストレージエンジンが5行取得する際の関数コールの流れですが、眺めてみるとなんとなくテーブルスキャンの流れを掴めると思います。

ha_tina::store_lock
ha_tina::external_lock
ha_tina::info
ha_tina::rnd_init
ha_tina::extra - ENUM HA_EXTRA_CACHE   Cache record in HA_rrnd()
ha_tina::rnd_next
ha_tina::rnd_next
ha_tina::rnd_next
ha_tina::rnd_next
ha_tina::rnd_next
ha_tina::extra - ENUM HA_EXTRA_NO_CACHE   End caching of records (def)
ha_tina::external_lock
ha_tina::extra - ENUM HA_EXTRA_RESET   Reset database to after open

つまり実装が必要なのは、

  • store_lock()
    • 内部ロック
  • external_lock()
    • 外部ロックなど
  • info()
    • 最適化情報の設定
  • rnd_init()
    • テーブルスキャンの準備
  • extra()
    • ヒントの設定
  • rnd_next()
    • 行の読み取り

の6つです。

store_lock()

レコードの読み書きにあたって、MySQL サーバがロックを取得しようとした時に呼び出される関数です。 MySQL サーバ側のロックロジックでは、取得時にこの store_lock() を呼び出すことで、ストレージエンジン側にもロックの裁量を与えているみたいです。 store_lock() の中で、MySQL サーバ側で判断したロック種別をストレージエンジンに合わせたものに変更できるようにしているみたいですが、今回は細かいロック機構を実装するつもりはありませんし、ぶっちゃけどんな状況でこのロックの変更が必要になるのか不明なので変更しないでおきます(一切ロックを提供しないストレージエンジンではここに何も書かないとかになりそう)。

THR_LOCK_DATA **ha_hoge::store_lock(THD *, THR_LOCK_DATA **to,
                                       enum thr_lock_type lock_type) {
  if (lock_type != TL_IGNORE && lock.type == TL_UNLOCK) lock.type = lock_type;
  *to++ = &lock;
  return to;
}

external_lock()

外部ロックのためのフックです。 外部ロックは簡単に言えば、テーブルデータファイルへのファイルロックのことです。 複数の MySQL サーバが単一のデータファイルを弄る場合なんかで競合を回避するために外部ロックが必要になります。 トランザクショナルなストレージエンジンでは、ロールバック等のためにステートメントの開始と終了をこの関数で検出するような使われ方もするみたいですが、この用法についてはコメントで "abused" と表現されています。 とりあえず今回は関係ない関数なので、デフォルトの 0 を返すだけの実装でいきます。

int ha_hoge::external_lock(THD *, int) {
  DBUG_ENTER("ha_hoge::external_lock");
  DBUG_RETURN(0);
}

rnd_init()

テーブルスキャンのための準備をする関数です。 データファイル内をトレースするためのポジション情報やレコード数情報を初期化しておきます。

int ha_hoge::rnd_init(bool) {
  DBUG_ENTER("ha_hoge::rnd_init");

  current_position = 0;
  stats.records = 0;

  DBUG_RETURN(0);
}

ポジション情報はハンドラに持たせておきます。

class ha_hoge : public handler {
  ~~
  off_t current_position;
  ~~

info()

最適化のためにオプティマイザが使う統計情報などを設定する関数です。 ちなみに SHOW TABLES STATUS の結果は、ここで設定された情報が基になっているようです。 例によって細かい実装はしませんが、以下のコードだけ入れておきます。

int ha_hoge::info(uint) {
  DBUG_ENTER("ha_hoge::info");
  if (stats.records < 2)
    stats.records= 2;
  DBUG_RETURN(0);
}

コメントによればレコード数が0もしくは1の場合と、2以上の場合とで、オプティマイザの処理が変わるみたいで、前者だとテーブルスキャンの時に全部のレコードが読まれない可能性があるっぽいです(詳しいことはわかりません)。 ので、とりあえず2以上の値を返しておく感じで。

extra()

MySQL サーバ側からストレージエンジンの動作に対してヒントを送るための関数らしいですが、これも拡張的というか応用的な機能なのでスルーします。 操作するファイルの種類に応じてスキャン動作を変えたりする時なんかに必要なのかもしれません。

int ha_hoge::extra(enum ha_extra_function) {
  DBUG_ENTER("ha_hoge::extra");
  DBUG_RETURN(0);
}

rnd_next()

やっとですが、この rnd_next() がデータファイルから実際にレコードを読み取る関数です。 テーブルスキャンは、終了条件を満たすまでこいつが呼ばれ続ける形で行われます。 イメージとしては、1行読み取る度に1回この関数が呼ばれる感じです。 基本的には、ファイルカーソルが EOF に達した時に HA_ERR_END_OF_FILE を返して処理を終了してあげるのが正常な動作です。 終了条件は自由に設定できますが、今回はとりあえず EOF まで読んでいくだけの実装をしてみます。

以下、rnd_next() の実装です。

int ha_hoge::rnd_next(uchar *buf) {
  DBUG_ENTER("ha_hoge::rnd_next");

  ha_statistic_increment(&System_status_var::ha_read_rnd_next_count);

  int error = find_current_row(buf);  // レコードを buf に格納
  if(!error)
    stats.records++;

  DBUG_RETURN(error);
}

引数の buf がファイルから読み取ったレコードを格納する先です。 MySQL サーバの視点で見ると、ストレージエンジンの rnd_next() を繰り返してレコードを buf に格納してもらい、結果をクライアントに返す形になっています。 で、上のコードでは実際の行読み取りは find_current_row() に任せていて、rnd_next() 内では大枠の処理だけ書いています。

やや長めですが、以下が その find_current_row() の実装です。

int ha_hoge::find_current_row(uchar *buf) {
  DBUG_ENTER("ha_hoge::find_current_row");

  my_bitmap_map *org_bitmap;
  uchar read_buf[IO_SIZE];
  bool is_end_quote;
  uchar *p;
  uchar current_char;
  uint bytes_read;

  memset(buf, 0, table->s->null_bytes);  // NULL指定ビットマップの初期化
  org_bitmap = tmp_use_all_columns(table, table->write_set);  // 書き込み用ビットを立てる

  for (Field **field = table->field; *field; field++) {
    bytes_read = my_pread(data_file, read_buf, sizeof(read_buf), current_position, MYF(0));  // read_bufにファイルの行を読み込む
    if (!bytes_read){  // ファイルを読み終わったら
      tmp_restore_column_map(table->write_set, org_bitmap);
      DBUG_RETURN(HA_ERR_END_OF_FILE);
    }

    p = read_buf;
    current_char = *p;
    buffer.length(0);
    is_end_quote = false;

    for(;;){  // buffer に読み取った行を詰める
      if (current_char == '"') {
        if (is_end_quote) {
          current_position += 2;
          break;
        }
        is_end_quote = true;
      } else {
        buffer.append(current_char);
      }
      current_char = *++p;
      current_position++;
    }

    (*field)->store(buffer.ptr(), buffer.length(), buffer.charset());  // buffer の内容を buf に格納する
  }

  tmp_restore_column_map(table->write_set, org_bitmap);  // 書き込みビットを寝かせる
  DBUG_RETURN(0);  // まだ行が存在するので0を返して処理を継続させる
}

memset() では buf の先頭を0で埋めています。 これは、MySQL レコードの内部フォーマットの先頭がカラム数分の NULL 指示ビットマップだからです(カラムの内容はその後に続きます)。 NULL 指示ビットマップは、それに対応するレコードの内容が NULL であることを明示するための仕組みです。 今回はカラムに NULL が入ることを想定しないので、memset() ではこのビットマップ部分をすべて0に設定しているわけです。 ビットマップ部分の大きさ(バイト単位なので、今回のように2カラムの場合には1バイト)は table->s->null_bytes に入っているので、memset() で0を埋めるサイズの指定にはこれを使います。 また、固定長カラムの場合にはビットマップの先頭に NULL 指示とは関係のない開始ビットが付加されます。 つまり、今回のように CHAR のカラムを2つ用意した場合の挙動は以下のようになります。

memset(buf, 0x01, table->s->null_bytes);  // 0x01 は開始ビットなので変更しない
memset(buf, 0x02, table->s->null_bytes);  // 右から2番目のビットを立てる = col1 が NULL として結果が返される
memset(buf, 0x04, table->s->null_bytes);  // 右から3番目のビットを立てる = col2 が NULL として結果が返される

カラム値に NULL を許可するストレージエンジンでは、この辺りをうまく調整して NULL を表現できるように実装する必要があります。

tmp_use_all_columns() でやっていることは、table->write_set に書き込み用のフラグを立てることです。 これを立てておかないと後々 buf に書き込むときにアサーションエラーになります。

次に、その次の行の for ループを table->field で回します(カラム数文回ります)。 Field は各カラム情報を表現するクラスで、実際のデータを保持するのは ptr というメンバです(カラムデータのインメモリコピーと言えます)。 で、読み取ったカラムデータを buf に入れてあげるのが rnd_next() の役割な訳ですが、実はループ内での field->ptr は buf と同じ場所を指しています。 したがって、後半の (*field)->store() で field->ptr にデータを格納してあげることで、それが buf にも入ることになります。 find_current_row() 内にレコードデータを buf に格納しているコードが存在しないのはこのためです。 要するに、field から間接的に buf を扱っている形になります。

ファイルからのレコード読み取り処理ロジックは、pread() で read_buf に読み込んだ行を1文字ずつ buffer に格納しているだけの単純なものです。 ただ、find_current_row() は各行に対して呼ばれるので、複数行を読み取る場合には「現在どこの行まで読んだか」を記録しておく必要があります。 これは current_position で管理していて、文字を読む度にカウントしています。

以上が読み取り処理の実装になります。 コンパイルして以下のクエリを送ると、INSERT で格納したデータがしっかりと返っくるはずです。

SELECT * FROM ta_test;

おわり

ということで、ストレージエンジンの基本的な入出力機能を一通り実装してみました。 本格的にやろうとすると、ここから更にインデクシングやロッキングなどの高度な機能が必要になりますが、とりあえずこれだけでもストレージエンジンの概要はざっくりと掴めた気がします。 謎のビルドエラーでハマったりして面倒臭さが強烈でしたが、気が向いたらその辺の機能もまた実装してみようかなあと思います。 おわり。