それが僕には楽しかったんです。

僕と MySQL と時々 MariaDB

Handler と SELECT と時々 WHERE 句

はじめに

どうも、最近どうにか出費を抑えようとしているけんつです。今回は自作ストレージエンジンをやっていて気になった SELECT と WHERE が組み合わさったときの挙動について書こうかなと思います。自作ストレージエンジンを前提にしているので、InnoDB などはこの限りではない可能性が十分にあります。

環境

  • MySQL 8.0.33
  • PopOS 22.04
  • 自作ストレージエンジン

前提

また例によって mtr を使ってクエリを実行しながらデバッグする。mtr に食わせる test, result ファイルは以下の通り。やっていることは単純で2つレコードを追加して、条件にマッチするレコードが1つ返ってくるというもの。

CREATE TABLE t1(id INT)Engine=Toybox;
INSERT INTO t1(id) VALUES(1);
INSERT INTO t1(id) VALUES(2);
SELECT * FROM t1 WHERE id > 1;
DROP TABLE t1;
CREATE TABLE t1(id INT)Engine=Toybox;
INSERT INTO t1(id) VALUES(1);
INSERT INTO t1(id) VALUES(2);
SELECT * FROM t1 WHERE id > 1;
id
2
DROP TABLE t1;

このテストを実行すると無事に PASS するので元気にデバッグする。toybox というのは今作っている自作ストレージエンジンの名前です。

$ ./mtr toybox.select_where
Logging: /home/lrf141/mysqlProject/mysql-server/mysql-test/mysql-test-run.pl  toybox.select_where
MySQL Version 8.0.33
Checking supported features
 - Binaries are debug compiled
Using 'all' suites
Collecting tests
Checking leftover processes
 - found old pid 30513 in 'mysqld.1.pid', killing it...
   ok!
Removing old var directory
Creating var directory '/home/lrf141/mysqlProject/mysql-server/build/mysql-test/var'
Installing system database
Using parallel: 1

==============================================================================
                  TEST NAME                       RESULT  TIME (ms) COMMENT
------------------------------------------------------------------------------
[ 50%] toybox.select_where                       [ pass ]     21
[100%] shutdown_report                           [ pass ]       
------------------------------------------------------------------------------
The servers were restarted 0 times
The servers were reinitialized 0 times
Spent 0.021 of 25 seconds executing testcases

Completed: All 2 tests were successful.

さぁデバッグタイムだ

後は元気にデバッグしていくだけなので読む。

確実に発生している事実

まずは何がどうなっているか事実を確認する。
現段階の実装では rnd_next という(おそらく)テーブルスキャンで各行を読み出すメソッドを通過する。これが一体何回通過するのかというのが重要な事柄となる。

(rr) c
Continuing.

Thread 2 hit Breakpoint 1, ha_toybox::rnd_next (this=0x7f9750482300, buf=0x7f9750464e70 "\377") at /home/lrf141/mysqlProject/mysql-server/storage/toybox/ha_toybox.cc:553
warning: Source file is more recent than executable.
553	  filesort.cc, records.cc, sql_handler.cc, sql_select.cc, sql_table.cc and
(rr) c
Continuing.

Thread 2 hit Breakpoint 1, ha_toybox::rnd_next (this=0x7f9750482300, buf=0x7f9750464e70 "") at /home/lrf141/mysqlProject/mysql-server/storage/toybox/ha_toybox.cc:553
553	  filesort.cc, records.cc, sql_handler.cc, sql_select.cc, sql_table.cc and
(rr) c
Continuing.

Thread 2 hit Breakpoint 1, ha_toybox::rnd_next (this=0x7f9750482300, buf=0x7f9750464e70 "") at /home/lrf141/mysqlProject/mysql-server/storage/toybox/ha_toybox.cc:553
553	  filesort.cc, records.cc, sql_handler.cc, sql_select.cc, sql_table.cc and
(rr) c
Continuing.

通過するのは 3 回となった。これが INSERT した 2 回ではないのは rnd_next の終了条件として HA_ERR_END_OF_FILE を返す必要があるからである。詳しくは以下の記事で紹介している。
rabbitfoot141.hatenablog.com

これによってテーブルスキャンの場合に限り WHERE 句で指定された条件によって handler が一致するレコードを取得するわけではなく、全てのレコードを返しているということがわかる。
ちなみにそのときの backtrace は以下の通り。

(rr) bt
#0  ha_toybox::rnd_next (this=0x7f9750482300, buf=0x7f9750464e70 "")
    at /home/lrf141/mysqlProject/mysql-server/storage/toybox/ha_toybox.cc:553
#1  0x0000555ae12d5992 in handler::ha_rnd_next (this=0x7f9750482300, buf=0x7f9750464e70 "")
    at /home/lrf141/mysqlProject/mysql-server/sql/handler.cc:2970
#2  0x0000555ae14b000a in TableScanIterator::Read (this=0x7f97504868f8)
    at /home/lrf141/mysqlProject/mysql-server/sql/iterators/basic_row_iterators.cc:219
#3  0x0000555ae16fe8c3 in FilterIterator::Read (this=0x7f9750486940)
    at /home/lrf141/mysqlProject/mysql-server/sql/iterators/composite_iterators.cc:76
#4  0x0000555ae1009aeb in Query_expression::ExecuteIteratorQuery (this=0x7f975039ee00, 
    thd=0x7f97506bc4a0) at /home/lrf141/mysqlProject/mysql-server/sql/sql_union.cc:1770
#5  0x0000555ae1009e87 in Query_expression::execute (this=0x7f975039ee00, thd=0x7f97506bc4a0)
    at /home/lrf141/mysqlProject/mysql-server/sql/sql_union.cc:1823
#6  0x0000555ae0f49104 in Sql_cmd_dml::execute_inner (this=0x7f97504851c8, 
    thd=0x7f97506bc4a0) at /home/lrf141/mysqlProject/mysql-server/sql/sql_select.cc:799
#7  0x0000555ae0f484f5 in Sql_cmd_dml::execute (this=0x7f97504851c8, thd=0x7f97506bc4a0)
    at /home/lrf141/mysqlProject/mysql-server/sql/sql_select.cc:578
#8  0x0000555ae0ebb4d4 in mysql_execute_command (thd=0x7f97506bc4a0, first_level=true)
    at /home/lrf141/mysqlProject/mysql-server/sql/sql_parse.cc:4714
#9  0x0000555ae0ebd91d in dispatch_sql_command (thd=0x7f97506bc4a0, 
    parser_state=0x7f972c5e69f0)
    at /home/lrf141/mysqlProject/mysql-server/sql/sql_parse.cc:5363
#10 0x0000555ae0eb2da3 in dispatch_command (thd=0x7f97506bc4a0, com_data=0x7f972c5e7340, 
    command=COM_QUERY) at /home/lrf141/mysqlProject/mysql-server/sql/sql_parse.cc:2050
#11 0x0000555ae0eb0c0d in do_command (thd=0x7f97506bc4a0)
    at /home/lrf141/mysqlProject/mysql-server/sql/sql_parse.cc:1439
#12 0x0000555ae10fd937 in handle_connection (arg=0x555ae8b3d910)
    at /home/lrf141/mysqlProject/mysql-server/sql/conn_handler/connection_handler_per_thread.cc:302
#13 0x0000555ae33989fe in pfs_spawn_thread (arg=0x555ae9f44d30)
    at /home/lrf141/mysqlProject/mysql-server/storage/perfschema/pfs.cc:3042
#14 0x00007f976b694ac3 in start_thread (arg=<optimized out>) at ./nptl/pthread_create.c:442
#15 0x00007f976b725bf4 in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:100

FilterIterator::Read, Query_expression::ExecuteIteratorQuery とか大変それっぽいやつを通過しているのでそのあたりをこれから見ている。

いざ server レイヤーダイブ

流石に Query Executor はコードパスのうち、どこを見ていけばいいのかわからないので今回も元気に先頭から読んでいくという筋力プレイに走る。
最初は TableScanIterator::Read から読む。

    while ((tmp = table()->file->ha_rnd_next(m_record))) {
      /*
       ha_rnd_next can return RECORD_DELETED for MyISAM when one thread is
       reading and another deleting without locks.
       */
      if (tmp == HA_ERR_RECORD_DELETED && !thd()->killed) continue;
      return HandleError(tmp);
    }
    if (m_examined_rows != nullptr) {
      ++*m_examined_rows;
    }

https://github.com/mysql/mysql-server/blob/mysql-8.0.33/sql/iterators/basic_row_iterators.cc#L219-L229

まずこの部分で handler の rnd_next を行ってテーブルから一行読み出す。これは前述のブログ記事に詳細を書いているので解説は省く。

次にたどり着くのは FilterIterator::Read の以下の実装となる。

    int err = m_source->Read();
    if (err != 0) return err;

    bool matched = m_condition->val_int();

    if (thd()->killed) {
      thd()->send_kill_message();
      return 1;
    }

    /* check for errors evaluating the condition */
    if (thd()->is_error()) return 1;

    if (!matched) {
      m_source->UnlockRow();
      continue;
    }

    // Successful row.
    return 0;

https://github.com/mysql/mysql-server/blob/mysql-8.0.33/sql/iterators/composite_iterators.cc#L76-L95

ここで大変重要になってくるのは、 m_condition->val_int() である。
何故かというと一行目と二行目で今回のテストケースでは二行目のみが結果としてクライアントに返却されるが、この戻り値の bool が各行の読み出しで結果が異なるためである。

# 一行目の呼び出しと matched の値
(rr) c
Continuing.

Thread 41 hit Breakpoint 4, TableScanIterator::Read (this=0x7f97504868f8) at /home/lrf141/mysqlProject/mysql-server/sql/iterators/basic_row_iterators.cc:219
219	    while ((tmp = table()->file->ha_rnd_next(m_record))) {
(rr) c
Continuing.

Thread 41 hit Breakpoint 1, ha_toybox::rnd_next (this=0x7f9750482300, buf=0x7f9750464e70 "\377") at /home/lrf141/mysqlProject/mysql-server/storage/toybox/ha_toybox.cc:553
warning: Source file is more recent than executable.
553	  filesort.cc, records.cc, sql_handler.cc, sql_select.cc, sql_table.cc and
(rr) c
Continuing.

Thread 41 hit Breakpoint 3, FilterIterator::Read (this=0x7f9750486940) at /home/lrf141/mysqlProject/mysql-server/sql/iterators/composite_iterators.cc:81
81	    if (thd()->killed) {
(rr) p matched
$15 = false


# 二行目の呼び出しと matched の値
(rr) c
Continuing.

Thread 41 hit Breakpoint 4, TableScanIterator::Read (this=0x7f97504868f8) at /home/lrf141/mysqlProject/mysql-server/sql/iterators/basic_row_iterators.cc:219
219	    while ((tmp = table()->file->ha_rnd_next(m_record))) {
(rr) c
Continuing.

Thread 41 hit Breakpoint 1, ha_toybox::rnd_next (this=0x7f9750482300, buf=0x7f9750464e70 "") at /home/lrf141/mysqlProject/mysql-server/storage/toybox/ha_toybox.cc:553
553	  filesort.cc, records.cc, sql_handler.cc, sql_select.cc, sql_table.cc and
(rr) c
Continuing.

Thread 41 hit Breakpoint 3, FilterIterator::Read (this=0x7f9750486940) at /home/lrf141/mysqlProject/mysql-server/sql/iterators/composite_iterators.cc:81
81	    if (thd()->killed) {
(rr) p matched
$16 = true

確かに一行目と二行目で matched の値がそれぞれ false, true になっている。matched が false になると continue に入り、次の行を読み出すようにする。

そしてその処理が何に影響を与えるかというと、Query_expression::ExecuteIteratorQuery にある以下の実装を呼び出すかどうかに影響を与える。

    for (;;) {
      int error = m_root_iterator->Read();
      DBUG_EXECUTE_IF("bug13822652_1", thd->killed = THD::KILL_QUERY;);

      if (error > 0 || thd->is_error())  // Fatal error
        return true;
      else if (error < 0)
        break;
      else if (thd->killed)  // Aborted by user
      {
        thd->send_kill_message();
        return true;
      }

      ++*send_records_ptr;

      if (query_result->send_data(thd, *fields)) {
        return true;
      }
      thd->get_stmt_da()->inc_current_row_for_condition();
    }

https://github.com/mysql/mysql-server/blob/mysql-8.0.33/sql/sql_union.cc#L1769-L1789

この段階では m_root_iterator->Read() は rnd_next の戻り値となる 0 を引っ張ってくるので、 query_result->send_data の呼び出しに到達する。

まとめ

というわけでテーブルスキャンを伴う SELECT と WHERE 句の扱いは以下のようになっている。

  • handler はテーブルに含まれる全ての行を読み出す
  • server layer でそれが条件に合うかどうかを判定する
  • 条件に一致する場合はレコードの情報をクライアントに返却する

おわりに

結構雑にはなったが、大まかな処理としてテーブルスキャンが伴う WHERE 句の動きについて理解することができた。
今回知り得た情報以上の内容を知りたくなった場合は条件をどのように構造体として表現しているか、Iterator の選択はどのように行っているかを見ていく必要がありそうだが、今回はここまでとする。

テーブルスキャン時に呼ばれる rnd_next については分かってきたが、これと似たようなインターフェースを持つものに UPDATE, DELETE が存在するのでその場合の WHERE 句はどのように制御されるのかをまた今度調べてみようと思う。