tom__bo’s Blog

MySQL!! MySQL!! @tom__bo

MySQLでredis storage engineを作った

MySQLのストレージエンジンはplugableになっていて、APIを実装すれば自作のストレージエンジンを組み込むことができる。 ということで、試しにRedisをストレージエンジンとして使うRedis Storage Engineを作りました。

github.com

途中で飽きてしまった ちまちま実装するよりC++の勉強とInnoDB読んだほうが良さそうと思ったので、お蔵入りするつもりでしたが、Yahoo! Japanでストレージエンジンを研究開発しているという話で個人的に盛り上がったので、改めて作ったところまでを見直して、整理しておこうという趣旨です。

実装したものはCREATE TABLEDMLがある程度カバーされたおもちゃですが、自作ストレージエンジン開発のためのドキュメントはなくなっていく一方なので(MySQL internal documentを含む既存のドキュメント・ブログ・本は古く、更新予定のものがない)これから自作しようという人の参考程度にはなるかと思います。

また、MySQL内部でストレージエンジンが処理している部分とMySQL自体(ストレージエンジンより手前(?)のexecuterまで)が処理しているあたりの境界の理解が進んで良いと思います。

ストレージエンジンを自作するためのヒントの節で参考にした資料を書いていますが、基本的にはnorikoneさんのMySQLのストレージエンジンを自作してみるの記事を読むと、どこから始めればよいかわかるので、その点は省略します。

Redis Storage Engineの概要

アーキテクチャ

  • イテレータから逐次呼び出されることを考慮しつつ、都度127.0.0.1:6379(hard coded)にリクエストを飛ばしてデータ操作する。
  • redis側では1行のデータをすべてcsvの文字列に変換して、テーブル名をkeyにしたリスト型で保持する
  • つまり全てtable scanを前提に実装。
  • indexはset型を使いつつ帰ってきた結果を自前でfilter, sortする予定(だった)
  • フラグによって適切な型を選択しない場合、Storage Engine(以降、SE)側ではすべてstringとして値を受け渡しできるので、redis SEではすべてstringとして扱っている。(WHERE句の条件などはSEで処理する必要はない(ICPなど最適化するなら対応したほうが良さそう))

f:id:tom__bo:20200531193728p:plain
`select * from r1;`と実行した場合にExecuterがtable scanの過程で`ha_redis::rnd_next`を読んでredis SEがredisにアクセスする図

handlerクラスのメソッドを継承したha_redisクラスで以下のメソッドで実装

ha_redisで実装したメソッド MySQL側での意味 redisへの操作
open() tableのopen処理 redisへの接続のみ試して失敗したらエラー
write_row() insert insertする行のデータをcsvの文字列に連結して、RPUSH
update_row() update 更新する行を特定して、LSETで上書き
delete_row() delete CSV SEの挙動を参考に削除対象の行を"."に更新し、最後のrnd_end()でこれらを一括削除
rnd_init() テーブルスキャンの初期化 捜査するための情報を初期化
rnd_end() テーブルスキャンの終了処理 delete_row()によって削除対象になっているレコードをすべて削除
rnd_next() テーブルスキャンの1スキャン操作 current_rowを頼りにLINDEXで1行に相当するレコードを取得
rnd_pos() position指定での読み込み LINDEXで取得
delete_table() tableの削除, TRUNCATE tableもtruncate()
delete_all_rows()ではなくこちらが呼ばれる
DEL key(table)nameでkeyごと削 除

ストレージエンジンを自作するためのヒント

ここではMySQLのストレージエンジンを自作してみようと思った人への参考資料を紹介します。 (具体例や詳細はメモが見つかり次第追記予定です...)

冒頭で紹介したMySQLのストレージエンジンを自作してみるが非常によく説明されているので、まずはこれを読んで、Storage Engineの立ち位置やとりあえず手を付けて見るにはどうしたらよいかを把握すると良いと思います。

norikone.hatenablog.com

このblogとMySQLのinternals manualにあるCustom engineの説明を読むと、実はCREATE TABLE, テーブルのOPEN, SELECT, INSERTくらいまでの説明はあってもそれ以上のものがないことがわかります。

ここからは既存のストレージエンジンを読んだり、handlerの呼ばれ方を把握するためにGDBでstep実行しながら理解を勧めていくことしかないと思います。 実際に読むならExample SE, CSV SEから始めるとと良いと思います。 前述したMySQLのストレージエンジンを自作してみるCSVストレージエンジンを読んで書かれたもののようで、実装もCSV SEの一部を説明したものなのでわかりやすいと思います。 UPDATE, DELETEについてもCSV SEを読んでみると、CSV SEの実装はめちゃくちゃ雑でパフォーマンスも最悪なことがわかりつつ、実装が単純なので何を実装すればDMLCRUD操作が実現できるかが把握できます。

Storage Engineについて書いている本だと,Expert MySQLChapter 10: Building Your Own Storage Engineが詳しく、Spartan Storage EngineというSEを紹介しつつCREATE TABLE, 基本的なDMLの実装と1カラムのみのIndexを実装するサンプルまでがあります。

Understanding MySQL Internals(邦訳版: 詳解MySQL)もありますが、古いので(5.0,5.1時代)、あまり参考にならないように思います。メソッド名やenumとして残っているものもありますが、8.0でstep実行すると使われてないものも多いです。

Build & 実行

準備として以下が必要 - hiredisのインストール - pkg-configでhiredisを見つけられるようにしておく(redis SEをbuildするcmakeでpkg-configを使ったため) - 127.0.0.1:6379で動くredis (接続先はハードコードw)

install/build手順はREADME参照

実行サンプル

Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 8
Server version: 8.0.20-debug Source distribution

Copyright (c) 2000, 2020, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> install plugin redis soname 'ha_redis.so';
Query OK, 0 rows affected (0.01 sec)

mysql> show engines;
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
| Engine             | Support | Comment                                                        | Transactions | XA   | Savepoints |
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
| REDIS              | YES     | Redis storage engine                                           | NO           | NO   | NO         |
| MEMORY             | YES     | Hash based, stored in memory, useful for temporary tables      | NO           | NO   | NO         |
| InnoDB             | DEFAULT | Supports transactions, row-level locking, and foreign keys     | YES          | YES  | YES        |
| PERFORMANCE_SCHEMA | YES     | Performance Schema                                             | NO           | NO   | NO         |
| MyISAM             | YES     | MyISAM storage engine                                          | NO           | NO   | NO         |
| FEDERATED          | NO      | Federated MySQL storage engine                                 | NULL         | NULL | NULL       |
| MRG_MYISAM         | YES     | Collection of identical MyISAM tables                          | NO           | NO   | NO         |
| BLACKHOLE          | YES     | /dev/null storage engine (anything you write to it disappears) | NO           | NO   | NO         |
| CSV                | YES     | CSV storage engine                                             | NO           | NO   | NO         |
| ARCHIVE            | YES     | Archive storage engine                                         | NO           | NO   | NO         |
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
10 rows in set (0.00 sec)

mysql> create database redis_db;
Query OK, 1 row affected (0.02 sec)

mysql> use redis_db
Database changed


mysql> CREATE TABLE r1 (
    -> id INT NOT NULL,
    -> c1 VARCHAR(50)
    -> ) ENGINE = redis;
Query OK, 0 rows affected (0.02 sec)

mysql> show create table r1\G
*************************** 1. row ***************************
       Table: r1
Create Table: CREATE TABLE `r1` (
  `id` int NOT NULL,
  `c1` varchar(50) DEFAULT NULL
) ENGINE=REDIS DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.01 sec)

mysql> insert into r1(id, c1) values (0, "need to set SBL");
ERROR 1662 (HY000): Cannot execute statement: impossible to write to binary log since BINLOG_FORMAT = ROW and at least one table uses a storage engine limited to statement-based logging.

mysql> set session binlog_format = statement;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into r1(id, c1) values (1, "Hello_redis_storage_engine!!");
Query OK, 1 row affected (0.00 sec)

mysql> insert into r1(id, c1) values (2, "Yeeey!!");
Query OK, 1 row affected (0.00 sec)

mysql> select * from r1;
+----+------------------------------+
| id | c1                           |
+----+------------------------------+
|  1 | Hello_redis_storage_engine!! |
|  2 | Yeeey!!                      |
+----+------------------------------+
2 rows in set (0.00 sec)

ここまでのデータをredisで見てみる

127.0.0.1:6379> keys *
1) "r1"
2) "test1"
127.0.0.1:6379> lrange r1 0 -1
1) "1,Hello_redis_storage_engine!!,"
2) "2,Yeeey!!,"

redis側でデータを消せばMySQLでselectしても消えるw

この他、WHERE句の絞りこみがされる(SEで実装する必要がない), update, delete, InnoDBのテーブルとのJOINの様子

mysql> insert into r1(id, c1) values (3, "Test3"), (4, "Test3"), (5, "Test3");
Query OK, 1 row affected (0.01 sec)

mysql> select * from r1 where id > 3;
+----+-------+
| id | c1    |
+----+-------+
|  4 | Test3 |
|  5 | Test3 |
+----+-------+
2 rows in set (0.00 sec)

mysql> update r1 set c1 = "Test4!" where id = 4;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> delete from r1 where id = 5;
Query OK, 1 row affected (0.01 sec)

mysql> select * from r1;
+----+------------------------------+
| id | c1                           |
+----+------------------------------+
|  1 | Hello_redis_storage_engine!! |
|  2 | Yeeey!!                      |
|  3 | Test3                        |
|  4 | Test4!                       |
+----+------------------------------+
4 rows in set (0.00 sec)

--------------------------
-- InnoDBのテーブルとのJOIN
--------------------------

mysql> create table inno (
    -> id int not null auto_increment,
    -> c1 int not null,
    -> primary key(id));
Query OK, 0 rows affected (0.04 sec)

mysql> show create table inno\G
*************************** 1. row ***************************
       Table: inno
Create Table: CREATE TABLE `inno` (
  `id` int NOT NULL AUTO_INCREMENT,
  `c1` int NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.01 sec)

mysql> insert into inno (c1) values (1),(3);
Query OK, 2 rows affected (0.01 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql> select * from inno;
+----+----+
| id | c1 |
+----+----+
|  1 |  1 |
|  2 |  3 |
+----+----+
2 rows in set (0.00 sec)

mysql> select * from inno inner join r1 on inno.id = r1.id;
+----+----+----+------------------------------+
| id | c1 | id | c1                           |
+----+----+----+------------------------------+
|  1 |  1 |  1 | Hello_redis_storage_engine!! |
|  2 |  3 |  2 | Yeeey!!                      |
+----+----+----+------------------------------+
2 rows in set (0.01 sec)

MTRを使ったテスト

こんなの(ステートマシンとも言えるpluginのコンポーネントの)テストどうするの? と思っていた頃にmita2さんのブログでMySQL Test Run(MTR)というSQLクエリベースでE2E(?)テストをする方法を紹介したブログを見つけて喜んでいた。

mita2db.hateblo.jp

ちゃんと公式ドキュメントが充実していて、それをもとに書いた簡単なテストが以下です。大したものではないので、説明は省略。

redis-storage-engine/test/redis_se at master · tom--bo/redis-storage-engine · GitHub

感想

とりあえずtable単位でロックをとって排他制御すればめちゃくちゃ単純なストレージエンジンが作れることはわかった。 実行計画を作る上でOptimizerとどう連携するのか, 自作のストレージエンジンでbinlog_format = rowをどうやって対応するのかなど、気になるところがめちゃくちゃあるので、やはりInnoDBを読めるC++力をつけたほうが良さそう。勉強します。