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

僕と MySQL と時々 MariaDB

MySQL ハンドラーレイヤー探索記 ~無限 Empty Set 編~

はじめに

どうも InnoDB に詳しくなろうと思って色々調べていたら何故か mysql-server レイヤーに詳しくなっていっているけんつです。
最近 InnoDB と最もシンプルな実装であろう tina を見比べつつ、適当に実装を変えて遊んでいたら無限 Empty Set に悩まされたのでその挙動について書く。

ハンドラーの挙動

SELECT が開始されると以下の順番で handler のメソッドを呼び出す。

  • handler::store_lock
  • handler::external_lock
  • hander::info
  • handler::rnd_init
  • handler::extra
  • handler::rnd_next ×レコード数
  • handler::rnd_next ←これがめちゃくちゃ大事だった
  • handler::extra
  • handler::external_lock
  • handler::extra

rnd_next メソッドがレコード数分呼ばれて、都度引数として受け取った uchar * のバッファーにデータを詰めて結果が返ってくるという風になっている。
ここでみんな大好き example ストレージエンジンを見てみると、以下のような実装が存在する。

int ha_example::rnd_next(uchar *) {
  int rc;
  DBUG_TRACE;
  rc = HA_ERR_END_OF_FILE;
  return rc;
}

https://github.com/mysql/mysql-server/blob/trunk/storage/example/ha_example.cc#L454-L459

HA_ERR_END_OF_FILE が返された時点でサーバーレイヤーは rnd_next を繰り返し呼び出すことをやめるようになっている。


最低限必要なのは適切なタイミングで HA_ERR_END_OF_FILE を返しスキャンを行うことを停止させることと、バッファに適切なデータを詰めるということ。

波乱の幕開け

SELECT 以前にいつも不思議だった write_row の引数にある uchar * の値は一体何が詰まってくるのか、rnd_next のバッファには何を詰めれば良いのかということを調べていた。
色々調べていると write_row はインサートされた行を以下のドキュメントに記載のフォーマットで詰めてくるということが分かった。
MySQL: Row format

ここで一行 INSERT してバッファを保持しつつ SELECT 時に全く同じバッファを詰めて返せるのかということを検証していたがこれが完全に波乱の幕開けであった。

tina を改造するのは面倒だったので example ストレージの rnd_next をいじって次のようにしてみた。

int ha_example::rnd_next(uchar *buf) {
  int rc;
  DBUG_TRACE;
  memcpy(buf, insertBuf, 1024);
  rc = HA_ERR_END_OF_FILE;
  return rc;
}

これで SELECT * FROM をやれば、追加した行と同じ行が返ってくるはずだと思っていたが返ってきたのは Empty Set であった。
この検証に数時間費やし、tina と同様の実装を改造して一行だけ返すようにしても何故かだめだった。

なので、ここから地獄のデバッグ作業の幕開けとなる。

なんで Empty Set なのか

一つずつ順にコードパスを遡っていき、怪しそうな場所を見つけた。
どうやらここがハンドラーの rnd_next を呼び出して、結果をみつつごにょごにょやっているところらしい。
https://github.com/mysql/mysql-server/blob/trunk/sql/iterators/basic_row_iterators.cc#L219

実装を見るに HA_ERR_RECORD_DELETED を返してスレッドが閉じていない場合はスキップするという処理らしい。

で、その TableScanIterator の Read メソッドはこの部分が呼び出している。
https://github.com/mysql/mysql-server/blob/trunk/sql/sql_union.cc#L1770

その後は以下の処理に入り break となる。
https://github.com/mysql/mysql-server/blob/trunk/sql/sql_union.cc#L1775-L1776

ここまで分かったところでそのすぐ下に send_record_ptr と thd->current_found_rows なるめちゃくちゃ怪しそうな変数の存在が重要になってくる。というかどう見てもここである。

更に詳細なことは調べていないがこれが何かというと、break に入った段階で send_record_ptr は更新されない上に明らかにそれっぽい send_data も呼ばれないのでバッファに何を詰めても恐らく無視している。
というわけで一回 rnd_next が呼び出された状態では 0 を返し、もう一度 rnd_next が呼ばれた時に HA_ERR_END_OF_FILE を返してやると無事に一行結果が返ってきた。

つまり rnd_next はレコード数分 + HA_ERR_END_OF_FILE を返す分が必要になるのでレコード数 + 1 回呼ばれるのが正常らしい。

余談

write_row のバッファをそのまま使って rnd_next のバッファに詰めてやると insert した結果をそのまま表示できたので rnd_next のバッファも Row Format に従ったバイナリ形式であれば mysql-server が解釈してくれるというので間違いないらしい。なんなら Row Format のドキュメントにも書いていた。

おわりに

本当は InnoDB の諸々の実装と最もシンプルなストレージエンジンを見比べるつもりだったが思わぬところで Server レイヤーの仕組みに少し詳しくなった。