クエリキャッシュ周りの実装を読む

クエリキャッシュを有効にしたときのペナルティ、主にキャッシュ内のルックアップ処理がどうなっているのか気になったのでソースを読む。
この機能が含まれるソースはlibmysqld/sql_cache.cc とその周辺。
ソースの先頭にクエリキャッシュで使うデータ構造やその使い方、今後の課題などが書いてあって面白い。キャッシュのデータはステートメントと結果セットそれぞれ別のブロックに格納されて、互いに二重リンクリストで繋がれており、行き来が出来るようになっている。
ルックアップの為の関数があるのかと思って探したが、ルックアップ専用の関数は無いようで、いきなり結果を返すためのQuery_cache::send_result_to_client()が検索を担っている。幾つかの状態チェックを経たあと、SQL自体がキャッシュ対象であるSELECT文かを判別するコードがあるのだけど

    if ((my_toupper(system_charset_info, sql[i])     != 'S' ||
         my_toupper(system_charset_info, sql[i + 1]) != 'E' ||
         my_toupper(system_charset_info, sql[i + 2]) != 'L') &&
        sql[i] != '/')
    {

という感じで書かれている。なんて、大胆な。ここに限らず読み易さよりも効率を優先しているのか、inlineで長い処理を延々やっている場所があったり、読む人に優しくないコードが多い。
主題であるルックアップ処理は共通のルックアップ関数を使っていて、本質的な処理はhash_first@mysys/hash.cにある。関数の名前の通り、hashをリンクリストにした構造を手繰っていきながら最初のマッチを返す処理をしている。ルックアップは以下のような単純な線形探査だった。

    do
    {
      pos= dynamic_element(&hash->array,idx,HASH_LINK*);
      if (!hashcmp(hash,pos,key,length))
      {
        DBUG_PRINT("exit",("found key at %d",idx));
        *current_record= idx;
        DBUG_RETURN (pos->data);
      }
      if (flag)
      {
        flag=0;                                 /* Reset flag */
        if (hash_rec_mask(hash,pos,hash->blength,hash->records) != idx)
          break;                                /* Wrong link */
      }
    }
    while ((idx=pos->next) != NO_RECORD);

ハッシュ表にヒットしたときには、その先に結果セットが入っているかなどのいくつかのチェックを経て、実際にクライアントに結果を返し0でreturnする。キャッシュのルックアップから実際の結果の書き出しまで一つの関数で完結しているというのは、ヒットしたときには非常に速そうではある。実際のクライアントへの出力処理は以下のような感じ。

  do
  {
    DBUG_PRINT("qcache", ("Results  (len %lu, used %lu, headers %lu)",
                          result_block->length, result_block->used,
                          result_block->headers_len()+
                          ALIGN_SIZE(sizeof(Query_cache_result))));
    
    Query_cache_result *result = result_block->result();
    if (net_real_write(&thd->net, result->data(),
                       result_block->used -
                       result_block->headers_len() -
                       ALIGN_SIZE(sizeof(Query_cache_result))))
      break;                                    // Client aborted
    result_block = result_block->next;
    thd->net.pkt_nr= query->last_pkt_nr; // Keep packet number updated
  } while (result_block != first_result_block);

どーにも、hashの衝突を考慮していないような気がしたりするが、杞憂に違いないので見なかったことにする。
クエリキャッシュは構文解析の前に効いているのではないかと考えていたが、処理はその通りで、先のキャッシュ検索関数はmysql_parse@libmysqld/sql_parse.ccから呼ばれていた。

void mysql_parse(THD *thd, char *inBuf, uint length)
{
  DBUG_ENTER("mysql_parse");

  DBUG_EXECUTE_IF("parser_debug", turn_parser_debug_on(););

  mysql_init_query(thd, (uchar*) inBuf, length);
  if (query_cache_send_result_to_client(thd, inBuf, length) <= 0)
  {

このように関数の先頭でいきなり呼ばれているので、構文解析より前段階でキャッシュが効く動作になる。hashを使ってキャッシュ内のクエリを管理しているので、構文上同じ意味を持つSQLであっても*1完全一致がでないとキャッシュヒットしないのはこのあたりが理由。
キャッシュを入れるとオーバーヘッドの他に懸念すべきは、古い情報を保持してしまう事だが、クエリキャッシュに関しては気にしなくて良いようだ。キャッシュ情報がどのテーブルに関連する情報を保持しているかを持っていて、INSERT文などの処理の最終段で関連するテーブルの情報について無効化する処理が入っていた。これならば、キャッシュがかなり早期に捨てられてしまうことになるにせよ、古い情報が保持されることにはならないはずだ。
実装を追いかけておいて言うのもなんだけど、クエリキャッシュはかなり有効性が疑問な機能ではある。prepared statementと同時には使えないし、テーブルへの書き込みがあると関連するキャッシュが全て無効にされてしまうので、かなり読み出しに偏重した環境でかつ全く同一のクエリが多数リクエストされることが想定される環境でないと使いどころがない。大きなテーブルサイズで読み出しと書き込みが並行して行われるような環境ではほとんど無意味だろう。memcachedのページにもあんなもん使いもんにならねーよ、みたないな説明がある。
MySQLの実装はほとんど初めて読んだのだけど実際読んでみると、効率化のためか結構変な書き方をされているところがあって追うのが大変。デバッグ用のコードは多数埋め込まれているので、今回のように特定の機能を追うだけならコードを読むよりこっちを有効にして、動かしながら流れを追ったほうが早かったかもしれない。

*1:SELECT * FROM user;とselect * from user;みたいな