amamanamam

データベースと仲良くなりたいです

大テーブルと小テーブルのJOINのコスト計算の話

先日MySQLアンカンファレンスhogeさんの以下の発表を聴かせていただいた。

www.docswell.com

100万行×3行のJOINが全検索でhash joinになり、後者の行数を増やして100万行×10行にするとeq_refになる現象を紹介いただいた。 自分の環境でも同様の現象を再現できて、オプティマイザトレースの内容やコスト計算の過程が非常に興味深かったので、色々調べてみたことを垂れ流してみる。

環境

mysql> select version();
+--------------+
| version()    |
+--------------+
| 8.0.28-debug |
+--------------+
1 row in set (0.00 sec)

mysql> desc mytable;
+---------+--------------+------+-----+---------+----------------+
| Field   | Type         | Null | Key | Default | Extra          |
+---------+--------------+------+-----+---------+----------------+
| id      | int          | NO   | PRI | NULL    | auto_increment |
| name    | varchar(255) | YES  |     | NULL    |                |
| type_id | int          | NO   | MUL | NULL    |                |
| value   | varchar(255) | YES  |     | NULL    |                |
+---------+--------------+------+-----+---------+----------------+
4 rows in set (0.02 sec)

mysql> desc types;
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int          | NO   | PRI | NULL    | auto_increment |
| name  | varchar(255) | YES  |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+
2 rows in set (0.01 sec)

mysql> table types;
+----+-------+
| id | name  |
+----+-------+
|  1 | typeA |
|  2 | typeB |
|  3 | typeC |
+----+-------+
3 rows in set (0.00 sec)

mysql> select count(*) from mytable;
+----------+
| count(*) |
+----------+
|   524288 |
+----------+
1 row in set (0.60 sec)

事象

 select * 
 from mytable 
 left join types on mytable.type_id = types.id;

前節の環境の元で 上記クエリのEXPLAINを眺めると以下のような結果となる

mysql> explain select * from mytable left join types on mytable.type_id = types.id;
+----+-------------+---------+------------+------+---------------+------+---------+------+--------+----------+--------------------------------------------+
| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra                                      |
+----+-------------+---------+------------+------+---------------+------+---------+------+--------+----------+--------------------------------------------+
|  1 | SIMPLE      | mytable | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 522428 |   100.00 | NULL                                       |
|  1 | SIMPLE      | types   | NULL       | ALL  | PRIMARY       | NULL | NULL    | NULL |      2 |   100.00 | Using where; Using join buffer (hash join) |
+----+-------------+---------+------------+------+---------------+------+---------+------+--------+----------+--------------------------------------------+
2 rows in set, 1 warning (0.01 sec)

このようにtypesが全検索でhash joinでの結合が選択される。 しかし、次のようにレコード数を少し増やすと結果はeq_refに変わる。

insert into types values (4,'typeD'),(5,'typeE'),(6,'typeF'),(7,'typeG'),(8,'typeH'),(9,'typeI'),(10,'typeJ');

mysql> table types;
+----+-------+
| id | name  |
+----+-------+
|  1 | typeA |
|  2 | typeB |
|  3 | typeC |
|  4 | typeD |
|  5 | typeE |
|  6 | typeF |
|  7 | typeG |
|  8 | typeH |
|  9 | typeI |
| 10 | typeJ |
+----+-------+
10 rows in set (0.00 sec)

mysql> explain select * from mytable left join types on mytable.type_id = types.id;
+----+-------------+---------+------------+--------+---------------+---------+---------+----------------------+--------+----------+-------+
| id | select_type | table   | partitions | type   | possible_keys | key     | key_len | ref                  | rows   | filtered | Extra |
+----+-------------+---------+------------+--------+---------------+---------+---------+----------------------+--------+----------+-------+
|  1 | SIMPLE      | mytable | NULL       | ALL    | NULL          | NULL    | NULL    | NULL                 | 522428 |   100.00 | NULL  |
|  1 | SIMPLE      | types   | NULL       | eq_ref | PRIMARY       | PRIMARY | 4       | kubo.mytable.type_id |      1 |   100.00 | NULL  |
+----+-------------+---------+------------+--------+---------------+---------+---------+----------------------+--------+----------+-------+
2 rows in set, 1 warning (0.00 sec)

では、typesテーブルのレコード数が3件のときのオプティマイザの判断をオプティマイザトレースを通して観察してみることにする。

オプティマイザトレース

mysql> SELECT * FROM INFORMATION_SCHEMA.OPTIMIZER_TRACE\G
*************************** 1. row ***************************
                            QUERY: explain select * from mytable left join types on mytable.type_id = types.id
                            TRACE: {
  "steps": [
    {
      "join_preparation": {
        "select#": 1,
        "steps": [
          {
            "expanded_query": "/* select#1 */ select `mytable`.`id` AS `id`,`mytable`.`name` AS `name`,`mytable`.`type_id` AS `type_id`,`mytable`.`value` AS `value`,`types`.`id` AS `id`,`types`.`name` AS `name` from (`mytable` left join `types` on((`mytable`.`type_id` = `types`.`id`)))"
          },
          {
            "transformations_to_nested_joins": {
              "transformations": [
                "parenthesis_removal"
              ],
              "expanded_query": "/* select#1 */ select `mytable`.`id` AS `id`,`mytable`.`name` AS `name`,`mytable`.`type_id` AS `type_id`,`mytable`.`value` AS `value`,`types`.`id` AS `id`,`types`.`name` AS `name` from `mytable` left join `types` on((`mytable`.`type_id` = `types`.`id`))"
            }
          }
        ]
      }
    },
    {
      "join_optimization": {
        "select#": 1,
        "steps": [
          {
            "condition_processing": {
              "condition": "WHERE",
              "original_condition": null,
              "steps": [
                {
                  "transformation": "equality_propagation",
                  "resulting_condition": null
                }
              ]
            }
          },
          {
            "table_dependencies": [
              {
                "table": "`mytable`",
                "row_may_be_null": false,
                "map_bit": 0,
                "depends_on_map_bits": [
                ]
              },
              {
                "table": "`types`",
                "row_may_be_null": true,
                "map_bit": 1,
                "depends_on_map_bits": [
                  0
                ]
              }
            ]
          },
          {
            "ref_optimizer_key_uses": [
              {
                "table": "`types`",
                "field": "id",
                "equals": "`mytable`.`type_id`",
                "null_rejecting": true
              }
            ]
          },
          {
            "rows_estimation": [
              {
                "table": "`mytable`",
                "table_scan": {
                  "rows": 522423,
                  "cost": 505
                }
              },
              {
                "table": "`types`",
                "table_scan": {
                  "rows": 3,
                  "cost": 0.25
                }
              }
            ]
          },
          {
            "considered_execution_plans": [
              {
                "plan_prefix": [
                ],
                "table": "`mytable`",
                "best_access_path": {
                  "considered_access_paths": [
                    {
                      "rows_to_scan": 522423,
                      "access_type": "scan",
                      "resulting_rows": 522423,
                      "cost": 52747.3,
                      "chosen": true
                    }
                  ]
                },
                "condition_filtering_pct": 100,
                "rows_for_plan": 522423,
                "cost_for_plan": 52747.3,
                "rest_of_plan": [
                  {
                    "plan_prefix": [
                      "`mytable`"
                    ],
                    "table": "`types`",
                    "best_access_path": {
                      "considered_access_paths": [
                        {
                          "access_type": "eq_ref",
                          "index": "PRIMARY",
                          "rows": 1,
                          "cost": 182848,
                          "chosen": true,
                          "cause": "clustered_pk_chosen_by_heuristics"
                        },
                        {
                          "rows_to_scan": 3,
                          "access_type": "scan",
                          "using_join_cache": true,
                          "buffers_needed": 4092,
                          "resulting_rows": 3,
                          "cost": 157750,
                          "chosen": true
                        }
                      ]
                    },
                    "condition_filtering_pct": 100,
                    "rows_for_plan": 1.56727e+06,
                    "cost_for_plan": 210497,
                    "chosen": true
                  }
                ]
              }
            ]
          },
          {
            "condition_on_constant_tables": "true",
            "condition_value": true
          },
          {
            "attaching_conditions_to_tables": {
              "original_condition": "true",
              "attached_conditions_computation": [
                {
                  "table": "`types`",
                  "rechecking_index_usage": {
                    "recheck_reason": "not_first_table",
                    "range_analysis": {
                      "table_scan": {
                        "rows": 3,
                        "cost": 2.65
                      },
                      "potential_range_indexes": [
                        {
                          "index": "PRIMARY",
                          "usable": true,
                          "key_parts": [
                            "id"
                          ]
                        }
                      ],
                      "setup_range_conditions": [
                      ],
                      "group_index_range": {
                        "chosen": false,
                        "cause": "not_single_table"
                      },
                      "skip_scan_range": {
                        "chosen": false,
                        "cause": "not_single_table"
                      },
                      "analyzing_range_alternatives": {
                        "range_scan_alternatives": [
                          {
                            "index": "PRIMARY",
                            "chosen": false,
                            "cause": "depends_on_unread_values"
                          }
                        ],
                        "analyzing_roworder_intersect": {
                          "usable": false,
                          "cause": "too_few_roworder_scans"
                        }
                      }
                    }
                  }
                }
              ],
              "attached_conditions_summary": [
                {
                  "table": "`mytable`",
                  "attached": null
                },
                {
                  "table": "`types`",
                  "attached": "<if>(is_not_null_compl(types), (`types`.`id` = `mytable`.`type_id`), true)"
                }
              ]
            }
          },
          {
            "finalizing_table_conditions": [
              {
                "table": "`types`",
                "original_table_condition": "<if>(is_not_null_compl(types), (`types`.`id` = `mytable`.`type_id`), true)",
                "final_table_condition   ": "<if>(is_not_null_compl(types), (`types`.`id` = `mytable`.`type_id`), true)"
              }
            ]
          },
          {
            "refine_plan": [
              {
                "table": "`mytable`"
              },
              {
                "table": "`types`"
              }
            ]
          }
        ]
      }
    },
    {
      "join_explain": {
        "select#": 1,
        "steps": [
        ]
      }
    }
  ]
}
MISSING_BYTES_BEYOND_MAX_MEM_SIZE: 0
          INSUFFICIENT_PRIVILEGES: 0
1 row in set (0.02 sec)

トレース内容の前半を見ると、eq_refのコストとscanのコストが表示されており、scanのコストの方が安い様子が伺える。ここで、scanとはテーブルスキャンのことを指す。値も均衡しているので、typesのレコードを7行増やすことでこれらのコストの大小が逆転して、eq_refが選ばれるようになったのだろうと予想できる。要するに「JOIN先のテーブルはレコード少ないし、PRIMARYキーで検索するよりもテーブル全部見た方が早くねー?」と判断したと予想できる。これは一般にレコード数が少数の場合に起こりうることである。この辺りの判断過程は後のソースの節で詳しく確認する。

一方でトレース後半に、"recheck_reason": "not_first_table"という文字列が表示されている。どうやらnot_first_tableという理由で再チェックが走っている様子。何だかよくわからない。ここについては次回の記事で(多分)触れることにする。

では、トレース内容の前半の判断過程のソースを眺めていく。

ソース

コスト比較の過程

まずはJOIN::optimaizeでオプティマイズを開始。この関数のコメントにもあるが、この関数がオプティマイズフェーズの開始点となる。

bool JOIN::optimize(bool finalize_access_paths) {
  DBUG_TRACE;

  uint no_jbuf_after = UINT_MAX;
...
// Set up join order and initial access paths
  THD_STAGE_INFO(thd, stage_statistics);
  if (make_join_plan()) {
    if (thd->killed) thd->send_kill_message();
    DBUG_PRINT("error", ("Error: JOIN::make_join_plan() failed"));
    return true;
  }

上記JOIN::optimaizeがmake_join_plan()を呼び出す。この関数ではベストなJOIN順序を決定するとのこと。

bool JOIN::make_join_plan() {
...
// Choose the table order based on analysis done so far.
  if (Optimize_table_order(thd, this, nullptr).choose_table_order())
    return true;

そして、JOIN順を決定する過程で以下のbest_access_path関数を呼び出す。この関数ではその名の通り、1つ1つのテーブルに対してベストなアクセス方法を見つけ出す。オプティマイザトレースのbest_access_pathの表記の部分に関連する。

void Optimize_table_order::best_access_path(JOIN_TAB *tab,
                                            const table_map remaining_tables,
                                            const uint idx, bool disable_jbuf,
                                            const double prefix_rowcount,
                                            POSITION *pos) {
                                            
...

// The 'ref' access method with lowest cost as found by find_best_ref()
  Key_use *best_ref = nullptr;
  
// Look for the best ref access if the storage engine supports index access.
  if (tab->keyuse() != nullptr &&
      (table->file->ha_table_flags() & HA_NO_INDEX_ACCESS) == 0)
    best_ref =
        find_best_ref(tab, remaining_tables, idx, prefix_rowcount,
                      &found_condition, &ref_depend_map, &used_key_parts);
                      
  double rows_fetched = best_ref ? best_ref->fanout : DBL_MAX;
  /*
    Cost of executing the best access method prefix_rowcount
    number of times
  */
  double best_read_cost = best_ref ? best_ref->read_cost : DBL_MAX;                  

上記のfind_best_ref関数によってどのキーを使ったrefアクセスが最もコスト安かを計算する。typesテーブルの場合はPRIMARYでのみJOINしているので、このキーでのeq_refが最も安いと計算される。つまりこの時点でオプティマイザの中ではeq_refで行こかという空気感になっている。

しかし、その後best_access_path関数は以下のように続く。 ここではrefアクセスが高コストかどうかを(1a)~(4)のヒューリスティックな条件でチェックし、テーブルスキャンやindexスキャンやrangeアクセスの可能性を探っている。これらの条件をすべて満たさない場合はテーブルスキャンやindexスキャンやrangeアクセスが候補となる。実際、mytableの場合もtypesの場合もこれらの条件は満たさないのでelse以下のソースに続く。

※ただし、全検索が選択されない場合(100万行×10行のJOIN)の場合も(1a)~(4)の条件を満たさないので、ココが今回の事象の分岐点というわけではない

/*
    Don't test table scan if it can't be better.
    Prefer key lookup if we would use the same key for scanning.

    Don't do a table scan on InnoDB tables, if we can read the used
    parts of the row from any of the used index.
    This is because table scans uses index and we would not win
    anything by using a table scan. The only exception is INDEX_MERGE
    quick select. We can not say for sure that INDEX_MERGE quick select
    is always faster than ref access. So it's necessary to check if
    ref access is more expensive.

    We do not consider index/table scan or range access if:

    1a) The best 'ref' access produces fewer records than a table scan
        (or index scan, or range acces), and
    1b) The best 'ref' executed for all partial row combinations, is
        cheaper than a single scan. The rationale for comparing

        COST(ref_per_partial_row) * E(#partial_rows)
           vs
        COST(single_scan)

        is that if join buffering is used for the scan, then scan will
        not be performed E(#partial_rows) times, but
        E(#partial_rows)/E(#partial_rows_fit_in_buffer). At this point
        in best_access_path() we don't know this ratio, but it is
        somewhere between 1 and E(#partial_rows). To avoid
        overestimating the total cost of scanning, the heuristic used
        here has to assume that the ratio is 1. A more fine-grained
        cost comparison will be done later in this function.
    (2) The best way to perform table or index scan is to use 'range' access
        using index IDX. If it is a 'tight range' scan (i.e not a loose index
        scan' or 'index merge'), then ref access on the same index will
        perform equal or better if ref access can use the same or more number
        of key parts.
    (3) See above note about InnoDB.
    (4) NOT ("FORCE INDEX(...)" is used for table and there is 'ref' access
             path, but there is no quick select)
        If the condition in the above brackets holds, then the only possible
        "table scan" access method is ALL/index (there is no quick select).
        Since we have a 'ref' access path, and FORCE INDEX instructs us to
        choose it over ALL/index, there is no need to consider a full table
        scan.
  */
if (rows_fetched < tab->found_records &&  // (1a)
      best_read_cost <= tab->read_time)     // (1b)
  {
    // "scan" means (full) index scan or (full) table scan.
    if (tab->range_scan()) {
      trace_access_scan.add_alnum("access_type", "range");
      trace_quick_description(tab->range_scan(), &thd->opt_trace);
    } else
      trace_access_scan.add_alnum("access_type", "scan");

    trace_access_scan
        .add("cost",
             tab->read_time + cost_model->row_evaluate_cost(
                                  static_cast<double>(tab->found_records)))
        .add("rows", tab->found_records)
        .add("chosen", false)
        .add_alnum("cause", "cost");
  } else if (tab->range_scan() && best_ref &&                            // (2)
             used_index(tab->range_scan()) == best_ref->key &&           // (2)
             used_key_parts >= table->quick_key_parts[best_ref->key] &&  // (2)
             tab->range_scan()->type != AccessPath::GROUP_INDEX_SKIP_SCAN &&
             tab->range_scan()->type != AccessPath::INDEX_SKIP_SCAN)  // (2)
  {
    trace_access_scan.add_alnum("access_type", "range");
    trace_quick_description(tab->range_scan(), &thd->opt_trace);
    trace_access_scan.add("chosen", false)
        .add_alnum("cause", "heuristic_index_cheaper");
  } else if ((table->file->ha_table_flags() & HA_TABLE_SCAN_ON_INDEX) &&  //(3)
             !table->covering_keys.is_clear_all() && best_ref &&          //(3)
             (!tab->range_scan() ||                                       //(3)
              (tab->range_scan()->type ==
                   AccessPath::ROWID_INTERSECTION &&             //(3)
               best_ref->read_cost < tab->range_scan()->cost)))  //(3)
  {
    if (tab->range_scan()) {
      trace_access_scan.add_alnum("access_type", "range");
      trace_quick_description(tab->range_scan(), &thd->opt_trace);
    } else
      trace_access_scan.add_alnum("access_type", "scan");

    trace_access_scan.add("chosen", false)
        .add_alnum("cause", "covering_index_better_than_full_scan");
  } else if ((table->force_index && best_ref && !tab->range_scan()))  // (4)
  {
    trace_access_scan.add_alnum("access_type", "scan")
        .add("chosen", false)
        .add_alnum("cause", "force_index");
  } else {

さてelse以降は以下のように続く。 改めてだが、else以降の分岐に来たということは「refアクセスよりもテーブルスキャンやindexスキャンやrangeスキャンのコストの方が安い可能性が出た」ということである。そのため、ここではスキャンのコストを計算して、先ほどのベストなrefアクセスのコストと比較している。

ココが今回の事象の分岐点となる。具体的には以下の最後のscan_total_cost < best_read_cost + cost_model->row_evaluate_cost(prefix_rowcount * rows_fetchedのコスト比較である。この条件を満たすとscanの方がコスト安いねという判断になる。

     double scan_read_cost = calculate_scan_cost(
        tab, idx, best_ref, prefix_rowcount, found_condition, disable_jbuf,
        &rows_after_filtering, &trace_access_scan);

    /*
      We estimate the cost of evaluating WHERE clause for found
      records as row_evaluate_cost(prefix_rowcount * rows_after_filtering).
      This cost plus scan_cost gives us total cost of using
      TABLE/INDEX/RANGE SCAN.
    */
    const double scan_total_cost =
        scan_read_cost +
        cost_model->row_evaluate_cost(prefix_rowcount * rows_after_filtering);

    trace_access_scan.add("resulting_rows", rows_after_filtering);
    trace_access_scan.add("cost", scan_total_cost);

    if (best_ref == nullptr ||
        (scan_total_cost <
         best_read_cost +
             cost_model->row_evaluate_cost(prefix_rowcount * rows_fetched))) {

実際、typesレコードが3件の場合の各コストをprintすると以下のようになる(右辺と左辺がオプティマイザトレースで表示されたコストとほぼ一致することがわかる)

  • scan_total_cost=157749.99852886199
  • best_read_cost=130605.75
  • cost_model->row_evaluate_cost(prefix_rowcount * rows_fetched) = 522423 * 1 * 0.1

ここで、row_evaluate_cost関数は引数の値にrow_evaluate_costというサーバーコストを乗算する関数である(MySQL8.0では0.1がデフォルト。この値が高ければ高いほど行フェッチの大小によるコスト差が大きくなる)

mysql> SELECT * FROM mysql.server_cost where cost_name="row_evaluate_cost";
+-------------------+------------+---------------------+---------+---------------+
| cost_name         | cost_value | last_update         | comment | default_value |
+-------------------+------------+---------------------+---------+---------------+
| row_evaluate_cost |       NULL | 2022-08-08 07:12:45 | NULL    |           0.1 |
+-------------------+------------+---------------------+---------+---------------+
1 row in set (0.02 sec)

確かに左辺のscan_total_costの方がコストが低い。一方でtypesのレコードを10件に増やすと、右辺のbest_read_cost、prefix_rowcount、rows_fetchedは全く変わらず、scan_total_costの方がグンと高くなり、右辺を上回る様子を確認することができる。つまり、「PRIMARYキーで検索するよりもテーブル全部見た方が早くねー?」の判断から「テーブル件数多いからPRIMARYキーで検索した方が宜しくね?」に変わるのだろう。

この分岐を満たす(scanのコストの方が安いと分かる)と以下のようにbest_refがnullptrになる。つまり頑張って見つけたベストなrefアクセスが無かったことにされる。また、best_uses_jbufがtrueとなる。そしてその値がPOSITIONに詰められる。

これ以降はrecheckの項に入っていくがそれは次回(多分)

      /*
        If the table has a range (tab->quick is set) make_join_query_block()
        will ensure that this will be used
      */
      best_read_cost = scan_read_cost;
      rows_fetched = rows_after_filtering;

      if (tab->found_records) {
        /*
          Although join buffering may be used for this table, this
          filter calculation is not done to calculate the cost of join
          buffering itself (that is done inside
          calculate_scan_cost()). The is_join_buffering parameter is
          therefore 'false'.
        */
        const float full_filter = calculate_condition_filter(
            tab, nullptr, ~remaining_tables & ~excluded_tables,
            static_cast<double>(tab->found_records), false, false,
            trace_access_scan);
        filter_effect = static_cast<float>(std::min(
            1.0, tab->found_records * full_filter / rows_after_filtering));
      }
      best_ref = nullptr;
      best_uses_jbuf = !disable_jbuf;
      ref_depend_map = 0;
    }

    trace_access_scan.add("chosen", best_ref == nullptr);
    
    ...
    
  pos->filter_effect = filter_effect;
  pos->rows_fetched = rows_fetched;
  pos->read_cost = best_read_cost;
  pos->key = best_ref;
  pos->table = tab;
  pos->ref_depend_map = ref_depend_map;
  pos->loosescan_key = MAX_KEY;
  pos->use_join_buffer = best_uses_jbuf;

コスト計算

ところでscan_total_costやbest_read_cost自体はどのように計算されているのだろうか。

まずはbest_read_cost(refアクセスによる読み取りコスト)から見ていこう。振り返ってみるとbest_read_costの実態はbest_ref->read_costであり、best_refは以下のfind_best_refで構成される。この関数内のcur_read_costという値がそれである。

どうやらcur_read_costはprev_record_reads(join, idx, table_deps)table->cost_model()->page_read_cost(1.0)の乗算で計算されるようだ。

Key_use *Optimize_table_order::find_best_ref(
    const JOIN_TAB *tab, const table_map remaining_tables, const uint idx,
    const double prefix_rowcount, bool *found_condition,
    table_map *ref_depend_map, uint *used_key_parts) {
    
    ...
    
// Check if we found full key
      if (all_key_parts_covered && !ref_or_null_part) /* use eq key */
      {
        cur_used_keyparts = (uint)~0;
        if (keyinfo->flags & HA_NOSAME &&
            ((keyinfo->flags & HA_NULL_PART_KEY) == 0 ||
             all_key_parts_non_null)) {
          cur_read_cost = prev_record_reads(join, idx, table_deps) *
                          table->cost_model()->page_read_cost(1.0);

prev_record_readsは以下のようになっており、返り値は結合先のテーブルに結合される行数を表す。すなわちJOIN元のテーブルであるmytableのフェッチした行数にfileteredの値を乗算したものである。

static double prev_record_reads(JOIN *join, uint idx, table_map found_ref) {
  double found = 1.0;
  POSITION *pos_end = join->positions - 1;
  for (POSITION *pos = join->positions + idx - 1; pos != pos_end; pos--) {
    const double fanout = pos->rows_fetched * pos->filter_effect;
    if (pos->table->table_ref->map() & found_ref) {
      found_ref |= pos->ref_depend_map;
      /*
        For the case of "t1 LEFT JOIN t2 ON ..." where t2 is a const table
        with no matching row we will get position[t2].rows_fetched==0.
        Actually the size of output is one null-complemented row, therefore
        we will use value of 1 whenever we get rows_fetched==0.

        Note
        - the above case can't occur if inner part of outer join has more
          than one table: table with no matches will not be marked as const.

        - Ideally we should add 1 to rows_fetched for every possible null-
          complemented row. We're not doing it because: 1. it will require
          non-trivial code and add overhead. 2. The value of rows_fetched
          is an inprecise estimate and adding 1 (or, in the worst case,
          #max_nested_outer_joins=64-1) will not make it any more precise.
      */
      if (pos->rows_fetched > DBL_EPSILON) found *= fanout;
    } else if (fanout < 1.0) {
      /*
        With condition filtering it is possible that a table has a
        lower fanout than 1.0. If so, calculate the fanout of this
        table into the found rows estimate so the produced number is
        not too pessimistic. Otherwise, the expected number of row
        combinations returned by this function may be higher than the
        prefix_rowcount for the table. See BUG#18352936
      */
      found *= fanout;
    }
  }
  return found;
}

page_read_costは以下のようになっており、buffer_block_read_cost(pages_in_mem)とio_block_read_cost(pages_on_disk)の和が返り値となっている。

ここでin_memはメモリ内のページ数と総ページ数の比率の推定値を表す。 よってpages_in_memはメモリ内のページ数の推定値、pages_on_diskはディスク内のページ数の推定値を表すことになる。

また、buffer_block_read_cost関数とio_block_read_cost関数はそれぞれbuffer_block_read_costio_block_read_costという名のエンジンコストを乗算する関数である。それぞれメモリやディスクからインデックスやデータページを読み取る定数コストを表す。

つまり、page_read_costの返り値は「(メモリ内のページ数×メモリから読み出す定数コスト)+(ディスク内ページ数×ディスクから読み出す定数コスト)」を表すことが分かる。

mysql> SELECT * FROM mysql.engine_cost;
+-------------+-------------+------------------------+------------+---------------------+---------+---------------+
| engine_name | device_type | cost_name              | cost_value | last_update         | comment | default_value |
+-------------+-------------+------------------------+------------+---------------------+---------+---------------+
| default     |           0 | io_block_read_cost     |       NULL | 2022-08-08 07:12:45 | NULL    |             1 |
| default     |           0 | memory_block_read_cost |       NULL | 2022-08-08 07:12:45 | NULL    |          0.25 |
+-------------+-------------+------------------------+------------+---------------------+---------+---------------+
2 rows in set (0.00 sec)
double Cost_model_table::page_read_cost(double pages) const {
  assert(m_initialized);
  assert(pages >= 0.0);

  const double in_mem = m_table->file->table_in_memory_estimate();

  const double pages_in_mem = pages * in_mem;
  const double pages_on_disk = pages - pages_in_mem;
  assert(pages_on_disk >= 0.0);

  const double cost =
      buffer_block_read_cost(pages_in_mem) + io_block_read_cost(pages_on_disk);

  return cost;
}

よってbest_read_costは「JOIN先に結合する行数×{(メモリ内のページ数×メモリから読み出す定数コスト)+(ディスク内ページ数×ディスクから読み出す定数コスト)}」で表されることが分かる。

次にscan_total_cost(scanのコスト)を見て行こう。振り返ってみるとscan_total_costはscan_total_cost = scan_read_cost + cost_model->row_evaluate_cost(prefix_rowcount * rows_after_filtering)で表される。後者のrow_evaluate_costは先ほども見たようにサーバーコストで0.1の定数である。prefix_rowcountはJOIN先に結合する行数(今回で言うとmytableの全行数)、rows_after_filteringはJOIN先のテーブルのフィルタリング後の行数(今回で言うとtypesの全行数)である。typesの行数を3から10に増やせば確かにscan_total_costは格段に増えそうである。

なお、scan_read_costは下記の関数の返り値であるscan_and_filter_costが該当する。このscan_and_filter_costはscan_and_filter_cost = buffer_count *(single_scan_read_cost + cost_model->row_evaluate_cost( tab->records() - *rows_after_filtering))という式で構成されている。

一つ一つの値を見ていこうと思ったが、なかなか構成要素が多くて意味合いを読み取るのに苦しんでしまった。そのためソースコメントから察することにした。どうやら「ストレージエンジンから行を取得するコストに加えて、クエリ条件によってフィルタリングされると見積もられる行のCPU実行コスト」ということらしい。

double Optimize_table_order::calculate_scan_cost(
    const JOIN_TAB *tab, const uint idx, const Key_use *best_ref,
    const double prefix_rowcount, const bool found_condition,
    const bool disable_jbuf, double *rows_after_filtering,
    Opt_trace_object *trace_access_scan) {
    
    ...
    
    else
      scan_cost = table->file->table_scan_cost();  // table scan
    const double single_scan_read_cost = scan_cost.total_cost();
    
    ...
    
      const double buffer_count =
          1.0 + ((double)cache_record_length(join, idx) * prefix_rowcount /
                 (double)thd->variables.join_buff_size);

      scan_and_filter_cost =
          buffer_count *
          (single_scan_read_cost + cost_model->row_evaluate_cost(
                                       tab->records() - *rows_after_filtering));

次回(多分)はこれ以降のソースを見てみることにする。

Nested Loopとクエリチューニングの考え方

アンドパッドでDBREをしている久保です。この記事は ANDPAD Advent Calendar 2023 の 12日目の記事です。

初めに

この記事ではMySQLの結合方法の一つであるNested Loop Joinについて紹介し、具体的なクエリの処理順について例を持って紹介します。また、それを踏まえてクエリチューニングの根幹の考え方について触れていきます。ここでは、クエリチューニングそのものの具体的な方法については詳しくは紹介せず、あくまで考え方の紹介になりますのでご注意ください。

Nested Loopとは

Nested Loop Join(NLJ)とは結合アルゴリズムの一種です。 これはテーブル1行1行に対して結合先のテーブルをスキャンする手法、大雑把に言えばforループのようなものになります。例えば、t1,t2,t3というテーブルに対し、結合条件以外のwhere条件なしでJOINすると、以下のような疑似コード形式で処理されます。

for each row in t1 {
  for each row in t2 matching join conditions {
    for each row in t3 matching join conditions {
      send to client
    }
  }
}

つまりこれは、t1の1行1行に対して結合条件にマッチするt2の行をフェッチして、更にその1行に対して結合条件にマッチするt3の行をフェッチする...といった処理ということです。よって、t1,t2,t3から取得する行数をそれぞれX,Y,Zとすると、X × Y × Zが合計取得行数になります。

ここで、ループの一番外側のテーブルを駆動表もしくは外部表、内側のテーブルを内部表と言います。上記の例でいえば、t1が駆動表、t2,t3が内部表となります。

では、適当なクエリのEXPLAINを見ながら、NestedLoopの挙動を具体的に確認していきます。

具体的な処理の流れ

あるクエリのEXPLAIN結果を見て、前節のNestedLoopの概要を踏まえて、処理の順番を理解していきます。ここではMySQL公式のテストデータベースであるworldデータベースを使って、以下のようなクエリを用意しました。

select country.Name,city.Name 
from country 
join city on country.code = city.CountryCode 
where country.Continent = 'Asia' 
and city.Population > 5000000;

なお、country.codeはcountryのPKであり、country.Continentにはインデックスが付与されていません。また、city.CountryCodeにはインデックスが付与されていますが、city.Populationにはインデックスが付与されていません。

この時、explain結果は以下のようになります。

mysql> Explain select country.Name,city.Name from country join city on country.code = city.CountryCode where country.Continent = 'Asia' and city.Population > 5000000\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: country
   partitions: NULL
         type: ALL
possible_keys: PRIMARY
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 239
     filtered: 14.29
        Extra: Using where
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: city
   partitions: NULL
         type: ref
possible_keys: CountryCode
          key: CountryCode
      key_len: 12
          ref: world.country.Code
         rows: 17
     filtered: 33.33
        Extra: Using where
2 rows in set, 1 warning (0.00 sec)

では、このEXPLAIN結果と前節のNested Loopの挙動を踏まえて、どのような処理順となるのかを解説していきます。

  1. countryテーブルの行の絞り込みから始まります。ここで絞り込まれた結果が次のテーブルと結合されます。

    • countryの全行をフェッチ(type:ALL)&where条件のフィルタリング(Extra:Using where)により結果的に34行ほど(rows*filtered)が絞り込まれます。
  2. この34行のcountryレコード1件1件に対して、cityテーブルの絞り込みがなされます。

    • まず、34行のうちのループの初めの1件のCodeカラム値をNと置くと、city.CountryCodeがNである行がユニークでないインデックスを用いて(type:ref)想定17行(rows)取得されます。
    • 次に、その17行に対してwhere条件のフィルタリング(Extra:Using where)がなされて、結果的に想定6行ほど(rows*filtered)が絞り込まれます。
    • 以上のステップを34件繰り返して結合をします。
  3. 結合結果をクライアントに返却します。

以上が具体的なNested Loopの流れとなります。これはJOIN先のテーブルが増えてもネストが増えるだけで処理順は大きく変わりません。

余談ですが、このようにクエリの処理順を書き出してみると、意外とチューニングの糸口が見つかることがよくあるのでオススメです。

どのようにすればクエリが早くなるか

では、先ほどの具体例のクエリを用いて、どのようにすればクエリが早くなるのかを考えていきましょう。 なお、先ほどの具体例のクエリは規模の小さいテーブルを相手にしていた事もあり、パフォーマンスに全く難がないクエリですが、便宜上先ほどのクエリを使って説明します。

前節の処理の流れを再度振り返ってみると、countryから取得される行数は34行で、それぞれの行に対してcityから取得される行は約6件でした。つまり、合計で34回のループ×6行取得したことになります。よって、前者の34回の「ループ回数」もしくは後者の内部表の6行の「取得行数」を減らすことができれば、よりスピードが出ることが分かります。

また、前者の34行は全行239件をフェッチした結果をフィルタリングしたものであり、後者の6行はインデックスを辿って取得した結果を whereフィルタリングしたものでした。よって、それぞれの行数取得をインデックスを使って解決できれば、よりスピードが出ることが分かります。

考え方が少し分かってきたので、次の章でここまでを一般化してまとめます。

クエリチューニングの考え方

では、クエリチューニングの考え方を紹介します。 駆動表t_1と内部表t_2が存在するとして、t_1,t_2取得する行数をそれぞれX,Yとします。 この時、ループがX回行われて、それぞれの行に対して内部表のレコードがY行取得されます。

取得行数を減らす

前節を踏まえてチューニングの観点の1つはとにかくXもしくはYの数を減らすことです。

ループ数Xを減らすには基本的には駆動表のwhere条件の絞り込みを増やすことになります。何か他の条件で絞り込めないか要件を振り返ってみましょう。また、Yの数は1であること、すなわちユニークインデックスでの内部表の捜査(type:eq_ref)であることが最も理想的です。 JOIN順を組み替えてeq_refにならないか思案してみましょう。

場合によってはオプティマイザの見積もり違いにより、内部表の方が絞り込み精度が良かったりすることもあります。その時はより絞り込めるテーブルをfrom句に持ってきてSTRAIGHT_JOIN句でJOIN順序を固定してしまっても良いかもしれません。ただし、オプティマイザの判断を無視することになるので慎重に扱いましょう。

インデックスを使う

そしてもう一つはXとYの取得をインデックスを使って絞り込むようにすることになります。 XとYの数がこれ以上絞り込めなければ、XとY自身の取得の仕方を思案する必要があります。より絞り込めるような新規でインデックスを作成したり、既存のインデックスで使えるものがないか思案しましょう。force indexでインデックスを強制する方法もありますが、これも同様にオプティマイザの判断を無視することになるので慎重に扱いましょう。

最後に

Nested Loopの概要とクエリ処理順、チューニングの考え方を紹介しました。 普段クエリチューニングに慣れていない方は是非今回紹介した考え方をもとにチューニングを是非検討してみてください。クエリチューニングには銀の弾丸はありませんが、どこに注目してチューニングを検討していくべきかを体系的に理解すれば、糸口や余地が見つかりやすくなると思います。この記事がその一助になれば幸いです。

アンドパッドでは、「幸せを築く人を、幸せに。」というミッションの実現のため、一緒に働く仲間を大募集しています。 会社や事業、開発チームにご興味を持たれた方は、下記のサイトをぜひご覧ください。DBREも絶賛募集中です!

engineer.andpad.co.jp

EXPLAINフォーマットを作ってみるぞの話

この記事は MySQL Advent Calendar 2023 3日目の記事です。

導入

EXPLAINにはtraditional(いわゆる通常のEXPLAIN)の他にtreeやjsonのフォーマットが以下のように存在する。

--traditional
mysql> explain SELECT     employees.employee_id,     employees.first_name,     employees.last_name,     employees.hire_date,     employees.salary,     departments.department_name,     departments.location FROM     employees JOIN     departments ON employees.department_id = departments.department_id WHERE     employees.salary > 80000     AND departments.location = 'San Francisco';
+----+-------------+-------------+------------+------+---------------+---------------+---------+--------------------------------+------+----------+-------------+
| id | select_type | table       | partitions | type | possible_keys | key           | key_len | ref                            | rows | filtered | Extra       |
+----+-------------+-------------+------------+------+---------------+---------------+---------+--------------------------------+------+----------+-------------+
|  1 | SIMPLE      | departments | NULL       | ALL  | PRIMARY       | NULL          | NULL    | NULL                           |   10 |    10.00 | Using where |
|  1 | SIMPLE      | employees   | NULL       | ref  | department_id | department_id | 5       | test.departments.department_id |    1 |    33.33 | Using where |
+----+-------------+-------------+------------+------+---------------+---------------+---------+--------------------------------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)

--tree
mysql> explain format=tree SELECT     employees.employee_id,     employees.first_name,     employees.last_name,     employees   departments ON employees.department_id = departments.department_id WHERE     employees.salary > 80000     AND departments.location = 'San Francisco';
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                                                                                                                                                                                                      |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Nested loop inner join  (cost=1.60 rows=0)
    -> Filter: (departments.location = 'San Francisco')  (cost=1.25 rows=1)
        -> Table scan on departments  (cost=1.25 rows=10)
    -> Filter: (employees.salary > 80000.00)  (cost=0.28 rows=0)
        -> Index lookup on employees using department_id (department_id=departments.department_id)  (cost=0.28 rows=1)
 |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.01 sec)

--json

mysql> explain format=json SELECT     employees.employee_id,     employees.first_name,     employees.last_name,     employees   departments ON employees.department_id = departments.department_id WHERE     employees.salary > 80000     AND departments.location = 'San Francisco';
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| {
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "1.60"
    },
    "nested_loop": [
      {
        "table": {
          "table_name": "departments",
          "access_type": "ALL",
          "possible_keys": [
            "PRIMARY"
          ],
          "rows_examined_per_scan": 10,
          "rows_produced_per_join": 1,
          "filtered": "10.00",
          "cost_info": {
            "read_cost": "1.15",
            "eval_cost": "0.10",
            "prefix_cost": "1.25",
            "data_read_per_join": "2K"
          },
          "used_columns": [
            "department_id",
            "department_name",
            "location"
          ],
          "attached_condition": "(`test`.`departments`.`location` = 'San Francisco')"
        }
      },
      {
        "table": {
          "table_name": "employees",
          "access_type": "ref",
          "possible_keys": [
            "department_id"
          ],
          "key": "department_id",
          "used_key_parts": [
            "department_id"
          ],
          "key_length": "5",
          "ref": [
            "test.departments.department_id"
          ],
          "rows_examined_per_scan": 1,
          "rows_produced_per_join": 0,
          "filtered": "33.33",
          "cost_info": {
            "read_cost": "0.25",
            "eval_cost": "0.03",
            "prefix_cost": "1.60",
            "data_read_per_join": "687"
          },
          "used_columns": [
            "employee_id",
            "first_name",
            "last_name",
            "hire_date",
            "salary",
            "department_id"
          ],
          "attached_condition": "(`test`.`employees`.`salary` > 80000.00)"
        }
      }
    ]
  }
} |
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set, 1 warning (0.00 sec)

これらフォーマットとは別にkuboフォーマットを試しに作ってみたぞいというのが本記事の概要である。とはいっても、traditionalの出力フォーマットに有益でないカラムを1つ追加したものをkuboフォーマットと名付けただけである。そのため、そこまで大掛かりなことはしていない。

具体的には以下のようにEXTRAの横にkuboカラムが追加されている状態で出力される。 どんな行に対しても'kubo'と表示されるなんとも末恐ろしいカラムである。

mysql> explain format=kubo SELECT     employees.employee_id,     employees.first_name,     employees.last_name,     employees   departments ON employees.department_id = departments.department_id WHERE     employees.salary > 80000     AND departments.location = 'San Francisco';
+----+-------------+-------------+------------+------+---------------+---------------+---------+--------------------------------+------+----------+-------------+------+
| id | select_type | table       | partitions | type | possible_keys | key           | key_len | ref                            | rows | filtered | Extra       | kubo |
+----+-------------+-------------+------------+------+---------------+---------------+---------+--------------------------------+------+----------+-------------+------+
|  1 | SIMPLE      | departments | NULL       | ALL  | PRIMARY       | NULL          | NULL    | NULL                           |   10 |    10.00 | Using where | kubo |
|  1 | SIMPLE      | employees   | NULL       | ref  | department_id | department_id | 5       | test.departments.department_id |    1 |    33.33 | Using where | kubo |
+----+-------------+-------------+------------+------+---------------+---------------+---------+--------------------------------+------+----------+-------------+------+

さて、以降ではソースのどこにどのような編集を加えたかを順に説明する。

作るぞ

sql_yacc

まずはsql_yacc.yyに与えられているフォーマット指定部分の文法を編集する。以下のようにkuboフォーマット指定の場合はExplain_format_type::KUBOを指定する分岐を追加する。これによって後にlex->explain_formatに格納されるフォーマットクラスが決まる。

opt_explain_format_type:
          /* empty */
          {
            $$= Explain_format_type::DEFAULT;
          }
        | FORMAT_SYM EQ ident_or_text
          {
            if (is_identifier($3, "JSON"))
              $$= Explain_format_type::JSON;
            else if (is_identifier($3, "TRADITIONAL"))
              $$= Explain_format_type::TRADITIONAL;
            else if (is_identifier($3, "TREE"))
              $$= Explain_format_type::TREE;
            else if (is_identifier($3, "KUBO"))
              $$= Explain_format_type::KUBO;
            else
            {
              my_error(ER_UNKNOWN_EXPLAIN_FORMAT, MYF(0), $3.str);
              MYSQL_YYABORT;
            }
          }

そしてsql_yacc.ccでは以下のような分岐が与えられる。 なお、Explain_format_type::KUBOは次に定義する。

#line 14012 "sql_yacc.yy" /* yacc.c:1646  */
    {
            if (is_identifier((yyvsp[0].lexer.lex_str), "JSON"))
              (yyval.explain_format_type)= Explain_format_type::JSON;
            else if (is_identifier((yyvsp[0].lexer.lex_str), "TRADITIONAL"))
              (yyval.explain_format_type)= Explain_format_type::TRADITIONAL;
            else if (is_identifier((yyvsp[0].lexer.lex_str), "TREE"))
              (yyval.explain_format_type)= Explain_format_type::TREE;
            else if (is_identifier((yyvsp[0].lexer.lex_str), "KUBO"))
              (yyval.explain_format_type)= Explain_format_type::KUBO;
            else
            {
              my_error(ER_UNKNOWN_EXPLAIN_FORMAT, MYF(0), (yyvsp[0].lexer.lex_str).str);
              MYSQL_YYABORT;
            }
          }

parser_yystype.hのExplain_format_typeクラスの定義にKUBOを追加する。

enum class Explain_format_type {
  // DEFAULT will be changed during parsing to TRADITIONAL
  // for regular EXPLAIN, or TREE for EXPLAIN ANALYZE.
  DEFAULT,
  TRADITIONAL,
  JSON,
  TREE,
  TREE_WITH_EXECUTE,
  KUBO
};

parse_tree_nodes

次に以下のようにparse_tree_nodes.ccのmake_cmd関数にkuboフォーマットの分岐を追加する。この関数はParse_tree_rootのmake_cmdを実装したものである。大雑把に言えば、ここでは前節で決定したExplain_format_typeに対応するフォーマットインスタンスを準備している。

※Explain_format_kuboクラスは後に用意する

...
#include "sql/opt_explain_json.h"         // Explain_format_JSON
#include "sql/opt_explain_traditional.h"  // Explain_format_traditional
#include "sql/opt_explain_kubo.h"  // Explain_format_kubo
...
Sql_cmd *PT_explain::make_cmd(THD *thd) {
  LEX *const lex = thd->lex;
  switch (m_format) {
    case Explain_format_type::TRADITIONAL:
      lex->explain_format = new (thd->mem_root) Explain_format_traditional;
      break;
    case Explain_format_type::JSON:
      lex->explain_format = new (thd->mem_root) Explain_format_JSON;
      break;
    case Explain_format_type::TREE:
      lex->explain_format = new (thd->mem_root) Explain_format_tree;
      break;
    case Explain_format_type::TREE_WITH_EXECUTE:
      lex->explain_format = new (thd->mem_root) Explain_format_tree;
      lex->is_explain_analyze = true;
      break;
    //kuboフォーマットの分岐
    case Explain_format_type::KUBO:
      lex->explain_format = new (thd->mem_root) Explain_format_kubo;
      break;
    default:
      assert(false);
      lex->explain_format = new (thd->mem_root) Explain_format_traditional;
  }
  if (lex->explain_format == nullptr) return nullptr;  // OOM

sql_class

次にsql_class.ccのsend_explain_fieldsにkuboフォーマットの場合の分岐を用意する。

このメソッドはsend_headersというtraditionalなEXPLAINのカラム名に関する情報を送信するメソッドから呼び出されている。このメソッドではmem_root_dequeの末尾に各種カラム名をpushしている。

今回は EXTRAの隣に新規のカラムを追加するので、kuboフォーマットの場合にkuboカラムをpushするように分岐を加える。

※is_kubo()はExplain_format_kuboクラスを後に用意する際に実装する。

int THD::send_explain_fields(Query_result *result) {
  mem_root_deque<Item *> field_list(current_thd->mem_root);
  Item *item;
  CHARSET_INFO *cs = system_charset_info;
  field_list.push_back(new Item_return_int("id", 3, MYSQL_TYPE_LONGLONG));
  field_list.push_back(new Item_empty_string("select_type", 19, cs));
  field_list.push_back(item =
                           new Item_empty_string("table", NAME_CHAR_LEN, cs));
  item->set_nullable(true);
  /* Maximum length of string that make_used_partitions_str() can produce */
  item = new Item_empty_string("partitions", MAX_PARTITIONS * (1 + FN_LEN), cs);
  field_list.push_back(item);
  item->set_nullable(true);
  field_list.push_back(item = new Item_empty_string("type", 10, cs));
  item->set_nullable(true);
  field_list.push_back(item = new Item_empty_string(
                           "possible_keys", NAME_CHAR_LEN * MAX_KEY, cs));
  item->set_nullable(true);
  field_list.push_back(item = new Item_empty_string("key", NAME_CHAR_LEN, cs));
  item->set_nullable(true);
  field_list.push_back(
      item = new Item_empty_string("key_len", NAME_CHAR_LEN * MAX_KEY));
  item->set_nullable(true);
  field_list.push_back(
      item = new Item_empty_string("ref", NAME_CHAR_LEN * MAX_REF_PARTS, cs));
  item->set_nullable(true);
  field_list.push_back(
      item = new Item_return_int("rows", 10, MYSQL_TYPE_LONGLONG));
  item->set_nullable(true);
  field_list.push_back(
      item = new Item_float(NAME_STRING("filtered"), 0.1234, 2, 4));
  item->set_nullable(true);
  field_list.push_back(new Item_empty_string("Extra", 255, cs));
  item->set_nullable(true);
  
  //kuboフォーマットの分岐
  if (current_thd->lex->explain_format->is_kubo()) {
    field_list.push_back(new Item_empty_string("kubo", 255, cs));
    item->set_nullable(true);
  }
  return (result->send_result_set_metadata(
      this, field_list, Protocol::SEND_NUM_ROWS | Protocol::SEND_EOF));
}

opt_expalin_kubo

最後にExplain_format_kuboクラスを実装する。実装するとは言っても、Explain_format_traditionalとほぼ同じファイルを追加するだけである。そのため、新規ファイルを作らずともsql/opt_expalin_traditional.ccに書いても良いと思う。

定義ファイルはExplain_format_traditionalの'traditional'の部分を'kubo'に置き換えて、is_kubo()メソッドを追加するだけで良い。

#include <assert.h>
// assert
#include "sql/opt_explain_format.h"
#include "sql/parse_tree_node_base.h"

class Item;
class Query_result;
class Query_expression;
template <class T>
class mem_root_deque;

class Explain_format_kubo : public Explain_format {
  class Item_null *nil;
  qep_row column_buffer;  ///< buffer for the current output row
  
 public:
  Explain_format_kubo() = default;

  bool is_hierarchical() const override { return false; }
  bool send_headers(Query_result *result) override;
  bool begin_context(enum_parsing_context, Query_expression *,
                     const Explain_format_flags *) override {
    return false;
  }
  bool end_context(enum_parsing_context) override { return false; }
  bool flush_entry() override;
  qep_row *entry() override { return &column_buffer; }
  bool is_kubo() const override { return true; }

 private:
  bool push_select_type(mem_root_deque<Item *> *items);
};

実装は同様にExplain_format_traditionalと同じ内容のものを用意して、'traditional'の部分を'kubo'に置き換えて、flush_entry()にkuboカラムへ'kubo'を追加するよう付け加えるだけで良い。

なお、この関数では先ほどと同様にmem_root_dequeにカラム値を順番に入れていっている。よって、EXTRAへのpushが終わった後にkuboカラムへのpushをする必要があることに注意したい。

bool Explain_format_kubo::flush_entry() {
  /*
    Buffer_cleanup will empty column_buffer upon exit. So column values start
    clear for the next row.
  */
  Buffer_cleanup bc(&column_buffer);
  mem_root_deque<Item *> items(current_thd->mem_root);
  if (push(&items, column_buffer.col_id, nil) || push_select_type(&items) ||
      push(&items, column_buffer.col_table_name, nil) ||
      push(&items, column_buffer.col_partitions, nil) ||
      push(&items, column_buffer.col_join_type, nil) ||
      push(&items, column_buffer.col_possible_keys, nil) ||
      push(&items, column_buffer.col_key, nil) ||
      push(&items, column_buffer.col_key_len, nil) ||
      push(&items, column_buffer.col_ref, nil) ||
      push(&items, column_buffer.col_rows, nil) ||
      push(&items, column_buffer.col_filtered, nil))
    return true;

  if (column_buffer.col_message.is_empty() &&
      column_buffer.col_extra.is_empty()) {
    items.push_back(nil);
  } else if (!column_buffer.col_extra.is_empty()) {
    StringBuffer<64> buff(system_charset_info);
    List_iterator<qep_row::extra> it(column_buffer.col_extra);
    qep_row::extra *e;
    while ((e = it++)) {
      assert(traditional_extra_tags[e->tag] != nullptr);
      if (buff.append(traditional_extra_tags[e->tag])) return true;
      if (e->data) {
        bool brackets = false;
        switch (e->tag) {
          case ET_RANGE_CHECKED_FOR_EACH_RECORD:
          case ET_USING_INDEX_FOR_GROUP_BY:
          case ET_USING_JOIN_BUFFER:
          case ET_FIRST_MATCH:
          case ET_REMATERIALIZE:
            brackets = true;  // for backward compatibility
            break;
          default:
            break;
        }
        if (e->tag != ET_FIRST_MATCH &&  // for backward compatibility
            e->tag != ET_PUSHED_JOIN && buff.append(" "))
          return true;
        if (brackets && buff.append("(")) return true;
        if (buff.append(e->data)) return true;
        if (brackets && buff.append(")")) return true;
      }
      if (buff.append("; ")) return true;
    }
    if (!buff.is_empty()) buff.length(buff.length() - 2);  // remove last "; "
    if (push(&items, buff.dup(current_thd->mem_root), buff.length()))
      return true;
  } else {
    if (push(&items, column_buffer.col_message, nil)) return true;
  }
  
  // kuboカラムへ'kubo'を追加
  const char* kubo = "kubo";
  size_t length = strlen(kubo);
  if (push(&items, kubo, length)) return true;

  if (output->send_data(current_thd, items)) return true;
  return false;

以上の編集を以てビルドすれば目的のフォーマットが使えるようになる!

もっとマシなカラムにしたいぞ

「もっとマシなカラムにしたいぞ」ともう一人の僕が宣っているので、format=jsonでも出力されるprefix_costを表示させることにした。column_bufferにすでにcol_prefix_costが格納されているので、それを表示させれば良い。そのため、ここまでの実装ができていれば修正ポイントは少ない。

具体的にはsql_class.ccの分岐修正、opt_explain_kubo.ccpush関数にpush関数追加と分岐修正をすれば良い(col_prefix_costはdouble型であり、double型を引数に取るpush関数が存在しないので、自分で追加する必要がある)

if (current_thd->lex->explain_format->is_kubo()) {
    field_list.push_back(new Item_float(NAME_STRING("prefix_cost"), 0.1234, 2, 4));
    item->set_nullable(true);
  }
static bool push(mem_root_deque<Item *> *items, const qep_row::column<double> &c,
                 Item_null *nil) {
  if (c.is_empty()) {
    items->push_back(nil);
    return false;
  }
  Item_float *item = new Item_float(c.get(), 4);
  if (item == nullptr) {
    return true;
  }
  items->push_back(item);
  return false;
}

....

if (push(&items, column_buffer.col_prefix_cost, nil)) return true;

これを経てビルドすると、以下のようにEXTRAの横にprefix_costが表示されるようになる。これによって、総コストと特にどの段階でコストがかかっているかを把握できる(ここでいうコストとはread_costとeval_costの和を指す)

mysql> explain format=kubo SELECT     employees.employee_id,     employees.first_name,     employees.last_name,     employees.hire_date,     employees.salary,     departments.department_name,     departments.location FROM     employees JOIN
   departments ON employees.department_id = departments.department_id WHERE     employees.salary > 80000     AND departments.location = 'San Francisco';
+----+-------------+-------------+------------+------+---------------+---------------+---------+--------------------------------+------+----------+-------------+-------------+
| id | select_type | table       | partitions | type | possible_keys | key           | key_len | ref                            | rows | filtered | Extra       | prefix_cost |
+----+-------------+-------------+------------+------+---------------+---------------+---------+--------------------------------+------+----------+-------------+-------------+
|  1 | SIMPLE      | departments | NULL       | ALL  | PRIMARY       | NULL          | NULL    | NULL                           |   10 |    10.00 | Using where |      2.0000 |
|  1 | SIMPLE      | employees   | NULL       | ref  | department_id | department_id | 5       | test.departments.department_id |    1 |    33.33 | Using where |      3.2222 |
+----+-------------+-------------+------------+------+---------------+---------------+---------+--------------------------------+------+----------+-------------+-------------+
2 rows in set, 1 warning (0.01 sec)

もっとこんなカラムがあったらいいよなぁ!?みたいな話題や案がございましたら是非教えてください!

明日のイルカのお友達は

明日は@nippondanjiさんから「GIPKことはじめ」です!お楽しみに!

caching_sha2_passwordの仕様の話

MySQLの認証プラグインの1つにcaching_sha2_passwordがある。 MySQL5.7ではmysql_native_passwordがデフォルトではあったが、MySQL8.0ではこやつがデフォルトとなっている(参考:リリースノート)。実際、手元で以下のように確認ができる

mysql> show variables like 'default_authentication_plugin';
+-------------------------------+-----------------------+
| Variable_name                 | Value                 |
+-------------------------------+-----------------------+
| default_authentication_plugin | caching_sha2_password |
+-------------------------------+-----------------------+
1 row in set (0.42 sec)

さてこのデフォルトに躍り出たcaching_sha2_passwordの仕様を他のプラグインと比較しながら理解していく

ざっくり認証までの過程

amamanamam.hatenablog.com 以前にも記載したように、クライアントがサーバーへ接続するときにLoginRequestというフェーズでユーザ情報を送信する。そして、サーバー側では送られたユーザがmysql.userテーブルにあるか検索し、どの認証プラグインを使用するか決定する。認証プラグインが決定した後に、サーバーはそのプラグインを呼び出してユーザー認証を開始し、その結果をクライアント側に送信する。このようにMySQLでは認証がプラガブル(様々なタイプのプラグインを付け外しできる)になっている。選べる認証プラグイン公式ページに記載されている。

因みに、使用する認証プラグインはもちろんサーバーが勝手に決めるわけではない。クライアント側と幾ばくかのやりとりをする。LoginRequest以前のServerGreetingの段階でサーバー側はクライアント側にdefault_authentication_pluginで設定されている認証プラグインを送信する。それに対してLoginRequestでクライアント側は使用する認証プラグイン(サーバー側から送られてきたものと必ずしも一致しない)をサーバー側に送信する。そして、サーバー側でユーザで設定されている認証プラグインと期待されている認証プラグインの一致を確かめてから認証を行う。なお、不一致があればAuthSwitchRequestをクライアント側に送信して別の認証方法を選択するように依頼する。

※ServerGreetingの段階でパケット内を除くと後述するNonce(サーバーからクライアント側に送るランダムデータ)のような要素が見つかる。おそらく、サーバークライアント間のやりとりの節約のために、使用する認証プラグインの予測をつけて予め認証に必要な情報を交換していると思われる。

mysql_native_passwordの認証過程

まずはmysql_native_passwordについて。Source Code Documentを適宜参照しながら解説する。

mysql_native_passwordはパスワードハッシュ方式に基づいた認証を実装している。ハッシュ関数SHA1が用いられている。以下のようにユーザ作成を行うと、mysql.userのauthentication_stringカラムにSHA1(SHA1(passwors))の結果が格納される。よって同じパスワードであれば同じauthentication_stringになる。

mysql> create user kubo identified with 'mysql_native_password' by 'password';
Query OK, 0 rows affected (0.27 sec)

mysql>  select Host,User,plugin,authentication_string from mysql.user where User='kubo';
+------+------+-----------------------+-------------------------------------------+
| Host | User | plugin                | authentication_string                     |
+------+------+-----------------------+-------------------------------------------+
| %    | kubo | mysql_native_password | *2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19 |
+------+------+-----------------------+-------------------------------------------+
1 row in set (0.03 sec)

クライアント・サーバー間ではチャレンジ&レスポンス認証が行われる。 サーバ側からクライアント側に20バイトのランダムデータ(Nonce)が送信され、クライアント側で以下の計算をしてサーバーに送信する。

SHA1( password ) XOR SHA1( "20-bytes random data from server" <concat> SHA1( SHA1( password ) ) )

サーバーはauthentication_stringの値、すなわちSHA1( SHA1( password )とランダムデータの値を知っているので、それらとクライアントから送られてきた計算結果にXORとSHA1を作用させることで、パスワードハッシュを照合することができる。言い換えれば、ユーザが入力したパスワードに2回SHA1を作用させた値(クライアント側)とauthentication_stringの値(サーバー側)の照合をすることができる。

sha256_passwordの認証過程

sha256_passwordはSHA-256 ハッシングを実装する認証プラグインである。こちらを適宜参照して解説する。

今度はsalt付きでハッシュ化されるため、以下のように同じパスワードのユーザ作成でも異なるauthentication_stringの値となる。

mysql> create user kubo1 identified with 'sha256_password' by 'password';
Query OK, 0 rows affected (0.03 sec)

mysql> create user kubo2 identified with 'sha256_password' by 'password';
Query OK, 0 rows affected (0.03 sec)

mysql> select Host,User,plugin,SUBSTR(HEX(authentication_string), -10) from mysql.user where User in ('kubo1','kubo2');
+------+-------+-----------------+-----------------------------------------+
| Host | User  | plugin          | SUBSTR(HEX(authentication_string), -10) |
+------+-------+-----------------+-----------------------------------------+
| %    | kubo1 | sha256_password | 736D4A6132                              |
| %    | kubo2 | sha256_password | 7631786841                              |
+------+-------+-----------------+-----------------------------------------+

クライアント・サーバー間ではmysql_native_passwordと同様のやりとりをすることはできない。それはサーバー側ではsalt付きのパスワードハッシュ値を知っているが、クライアント側はそのsaltの値を知らないことにある。そのため、クライアント側で先ほどの計算式のような計算結果をサーバー側に送ったとしても、salt無しでハッシュ化されたパスワードが渡るのみで、サーバー側ではauthentication_stringとの照合ができない。

とはいえ、そのまま生パスワードの送信には問題がある。そこでSSL/TLS接続もしくはRSA暗号化通信がsha256_passwordでは必須となる。このように安全な通信経路や暗号化通信が確立された上で、プレーンテキストのパスワードがレスポンス時に送信され、無事に照合ができるようになる。

caching_sha2_passwordの認証過程

caching_sha2_passwordもまたSHA-256 ハッシングを実装する認証プラグインである。こちらもSource Code Document公式ブログを適宜参照しながら解説する。

クライアント・サーバー間のやり取りには以下の2つのフェーズがある。

  • Fast authentication
  • Complete authentication

Fast authenticationでは、mysql_native_passwordのようにまずサーバーからクライアントへランダムデータ(Nonce)を送る。その後クライアントでは以下の計算結果をサーバーに送る

XOR(SHA256(password), SHA256(SHA256(SHA256(password)), Nonce))

ここでサーバー側では該当ユーザのパスワードハッシュの値がキャッシュ内にあるか確認する。そこでもし見つかれば、クライアントから送られてきた計算結果にその値とランダムデータの値をXORとSHA1を作用させることで、パスワードハッシュの照合ができる。言い換えれば、ユーザが入力したパスワードに2回SHA2を作用させた値(クライアント側)とキャッシュ内の値(サーバー側)の照合をすることができる。

もしもキャッシュ内に何も見つからなければComplete authenticationのフェーズに入る。 Complete authenticationでは、sha256_passwordのようにSSL/TLS接続もしくはRSA暗号化通信がなされた状況下でパスワードをそのまま受け取って照合を行う。

このようにcaching_sha2_passwordはmysql_native_passwordとsha256_passwordを組み合わせたような認証方法となっている。

「テーブルをフラッシュする」とは何ぞやの話

今回はFLUSH TABLEについて取り上げる。これは一体どんな操作なのだろうか

FLUSH TABLEしてみる

まず適当にtestテーブルからSELECTをしてshow open tablesをするとtestテーブルがオープンされていることが分かる。

mysql> select * from test limit 5;
+----+-------------+
| id | name        |
+----+-------------+
|  0 | kubo1048580 |
|  1 | kubo        |
|  2 | kubo2       |
|  3 | kubo3       |
|  4 | kubo4       |
+----+-------------+
5 rows in set (3.24 sec)

mysql> show open tables;
+----------+-------------------+--------+-------------+
| Database | Table             | In_use | Name_locked |
+----------+-------------------+--------+-------------+
| kubo     | test              |      0 |           0 |
| mysql    | column_statistics |      0 |           0 |
+----------+-------------------+--------+-------------+
2 rows in set (3.53 sec)

ここでflush table testを実行すると、以下のようにshow open tablesの結果からtestテーブルが消え去る。

mysql> flush table test;
Query OK, 0 rows affected (0.01 sec)

mysql> show open tables;
+----------+-------------------+--------+-------------+
| Database | Table             | In_use | Name_locked |
+----------+-------------------+--------+-------------+
| mysql    | column_statistics |      0 |           0 |
+----------+-------------------+--------+-------------+
1 row in set (0.00 sec)

同様にflush table を実行すると、以下のようにshow open tablesの結果からシステムテーブルを含めて全て消え去る

mysql> flush table;
Query OK, 0 rows affected (0.01 sec)

mysql> show open tables;
Empty set (0.00 sec)

この挙動と以前取り上げたopen tableの内容から踏まえると、flush tableはopenしたテーブルを閉じる操作、つまりテーブルキャッシュにあるテーブルディスクリプタをクリアする操作と理解できる。

amamanamam.hatenablog.com

実際、flush table (test)を実行した時にtdc_remove_tableというTABLEとTABLE_SHAREをテーブル(定義)キャッシュから削除するメソッドを通る。

結論が出たのでこれで終わり...というのも寂しいので、ソースを追ってみる。 因みに以下がflush table testを実行した時のtdc_remove_tableまでのバックトレースである。 このバックトレースの上から順にmysql_execute_commandまでソースをざっくり眺めていくことにする。

tdc_remove_table(THD * thd, enum_tdc_remove_table_type remove_type, const char * db, const char * table_name, bool has_lock) (/mysql-8.0.28/sql/sql_base.cc:10223)
close_cached_tables(THD * thd, TABLE_LIST * tables, bool wait_for_refresh, ulong timeout) (/mysql-8.0.28/sql/sql_base.cc:1184)
handle_reload_request(THD * thd, unsigned long options, TABLE_LIST * tables, int * write_to_binlog) (/mysql-8.0.28/sql/sql_reload.cc:330)
mysql_execute_command(THD * thd, bool first_level) (/mysql-8.0.28/sql/sql_parse.cc:4044)
dispatch_sql_command(THD * thd, Parser_state * parser_state) (/mysql-8.0.28/sql/sql_parse.cc:5174)
dispatch_command(THD * thd, const COM_DATA * com_data, enum_server_command command) (/mysql-8.0.28/sql/sql_parse.cc:1938)
do_command(THD * thd) (/mysql-8.0.28/sql/sql_parse.cc:1352)
handle_connection(void * arg) (/mysql-8.0.28/sql/conn_handler/connection_handler_per_thread.cc:302)
pfs_spawn_thread(void * arg) (/mysql-8.0.28/storage/perfschema/pfs.cc:2947)
libpthread.so.0!start_thread(void * arg) (/build/glibc-SzIz7B/glibc-2.31/nptl/pthread_create.c:477)
libc.so.6!clone() (/build/glibc-SzIz7B/glibc-2.31/sysdeps/unix/sysv/linux/x86_64/clone.S:95)

tdc_remove_table

ここでは前述の通り、テーブルディスクリプタをキャッシュから削除する処理を行なっている。

まず、table_def_cacheからcreate_table_def_keyで作成したキーを使用してイテレータを取得する。つまりテーブル定義キャッシュから指定されたテーブルの情報を探索している。

ちなみに、table_def_cacheはmalloc_unordered_mapという標準のハッシュマップstd::unordered_mapを拡張したテンプレートクラスの型である。malloc_unordered_mapはメモリ割り当てにおいて my_malloc を使用しているらしい。

次にヘルパー関数remove_tableで指定されたテーブル情報を削除する。 具体的にはTABLE_SHAREオブジェクトを取得してそれがopenされていることが確認されれば、free_tableで関連するTABLEオブジェクトをテーブルキャッシュから削除する。

void tdc_remove_table(THD *thd, enum_tdc_remove_table_type remove_type,
                      const char *db, const char *table_name, bool has_lock) {
  char key[MAX_DBKEY_LENGTH];
  size_t key_length;
  ...
  
  key_length = create_table_def_key(db, table_name, key);
  
  auto it = table_def_cache->find(string(key, key_length));

  ....
  // Helper function that evicts the TABLE_SHARE pointed to by an iterator.
  auto remove_table = [&](Table_definition_cache::iterator my_it) {
    if (my_it == table_def_cache->end()) return;
    TABLE_SHARE *share = my_it->second.get();
    /*
      Since share->ref_count is incremented when a table share is opened
      in get_table_share(), before LOCK_open is temporarily released, it
      is sufficient to check this condition alone and ignore the
      share->m_open_in_progress flag.

      Note that it is safe to call table_cache_manager.free_table() for
      shares with m_open_in_progress == true, since such shares don't
      have any TABLE objects associated.
    */
    if (share->ref_count() > 0) {
      /*
        Set share's version to zero in order to ensure that it gets
        automatically deleted once it is no longer referenced.

        Note that code in TABLE_SHARE::wait_for_old_version() assumes
        that marking share as old and removal of its unused tables
        and of the share itself from TDC happens atomically under
        protection of LOCK_open, or, putting it another way, that
        TDC does not contain old shares which don't have any tables
        used.
      */
      if (remove_type != TDC_RT_REMOVE_NOT_OWN_KEEP_SHARE &&
          remove_type != TDC_RT_MARK_FOR_REOPEN)
        share->clear_version();
      table_cache_manager.free_table(thd, remove_type, share);
    } else if (remove_type != TDC_RT_MARK_FOR_REOPEN) {
      // There are no TABLE objects associated, so just remove the
      // share immediately. (Assert: When called with
      // TDC_RT_REMOVE_NOT_OWN_KEEP_SHARE, there should always be a
      // TABLE object associated with the primary TABLE_SHARE.)
      assert(remove_type != TDC_RT_REMOVE_NOT_OWN_KEEP_SHARE ||
             share->is_secondary_engine());
      table_def_cache->erase(to_string(share->table_cache_key));
    }
  };

  remove_table(it);

close_cached_tables

 ではそんなtdc_remove_tableを呼び出しているメソッドに注目する。

初めに if (!tables)の分岐がある。これはFLUSH TABLEの対象が指定されているか否かの判定をしている。今回はFLUSH TABLE testというように特定のテーブルを指定しているので結果はfalseとなる。もしtrueであれば、テーブルキャッシュとテーブル定義キャッシュの両方がクリアされる。

さて今回の分岐はfalseなのでelse以下のロジックに移る。 ここではget_cached_table_shareでテーブル定義キャッシュからTABLE_SHAREオブジェクトを取得する。そして、もし取得が無事できればtdc_remove_tableを呼び出してテーブルキャッシからTABLEオブジェクトが削除される。

bool close_cached_tables(THD *thd, TABLE_LIST *tables, bool wait_for_refresh,
                         ulong timeout) {
  bool result = false;
  bool found = true;
  struct timespec abstime;
  DBUG_TRACE;
  assert(thd || (!wait_for_refresh && !tables));

  table_cache_manager.lock_all_and_tdc();
  if (!tables) {
    /*
      Force close of all open tables.

      Note that code in TABLE_SHARE::wait_for_old_version() assumes that
      incrementing of refresh_version and removal of unused tables and
      shares from TDC happens atomically under protection of LOCK_open,
      or putting it another way that TDC does not contain old shares
      which don't have any tables used.
    */
    refresh_version++;
    DBUG_PRINT("tcache",
               ("incremented global refresh_version to: %lu", refresh_version));

    /*
      Get rid of all unused TABLE and TABLE_SHARE instances. By doing
      this we automatically close all tables which were marked as "old".
    */
    table_cache_manager.free_all_unused_tables();
    /* Free table shares which were not freed implicitly by loop above. */
    while (oldest_unused_share->next)
      table_def_cache->erase(to_string(oldest_unused_share->table_cache_key));
  } else {
    bool share_found = false;
    for (TABLE_LIST *table = tables; table; table = table->next_local) {
      TABLE_SHARE *share = get_cached_table_share(table->db, table->table_name);

      if (share) {
        /*
          tdc_remove_table() also sets TABLE_SHARE::version to 0. Note that
          it will work correctly even if m_open_in_progress flag is true.
        */
        tdc_remove_table(thd, TDC_RT_REMOVE_UNUSED, table->db,
                         table->table_name, true);
        share_found = true;
      }
    }
    if (!share_found) wait_for_refresh = false;  // Nothing to wait for
  }

  table_cache_manager.unlock_all_and_tdc();

  ...

  /* Wait until all threads have closed all the tables we are flushing. */
  DBUG_PRINT("info", ("Waiting for other threads to close their open tables"));

  while (found && !thd->killed) {
    TABLE_SHARE *share = nullptr;
    found = false;
    /*
      To a self-deadlock or deadlocks with other FLUSH threads
      waiting on our open HANDLERs, we have to flush them.
    */
    mysql_ha_flush(thd);
    DEBUG_SYNC(thd, "after_flush_unlock");

    mysql_mutex_lock(&LOCK_open);

    if (!tables) {
      for (const auto &key_and_value : *table_def_cache) {
        share = key_and_value.second.get();
        if (share->has_old_version()) {
          found = true;
          break;
        }
      }
    } else {
      for (TABLE_LIST *table = tables; table; table = table->next_local) {
        share = get_cached_table_share(table->db, table->table_name);
        if (share && share->has_old_version()) {
          found = true;
          break;
        }
      }
    }

    if (found) {
      /*
        The method below temporarily unlocks LOCK_open and frees
        share's memory. Note that it works correctly even for
        shares with m_open_in_progress flag set.
      */
      if (share->wait_for_old_version(
              thd, &abstime, MDL_wait_for_subgraph::DEADLOCK_WEIGHT_DDL)) {
        mysql_mutex_unlock(&LOCK_open);
        result = true;
        goto err_with_reopen;
      }
    }

    mysql_mutex_unlock(&LOCK_open);
  }

handle_reload_request

次にclose_cached_tablesを呼び出している箇所に注目する。

このメソッドではoptionsすなわち「どのFLUSHステートメントか 」を表すフラグで分岐するif文が多くある(参考:COM_REFRESH Flags)。

今回のFLUSH TABLE testステートメント(options & (REFRESH_TABLES | REFRESH_READ_LOCK))の分岐に合致する。ただし、REFRESH_READ_LOCKには該当せずREFRESH_TABLESに該当する。REFRESH_READ_LOCKはFLUSH TABLES WITH READ LOCステートメントに該当する。この分岐内でclose_cached_tablesを呼び出す。

if (thd && thd->locked_tables_mode)の分岐があるが、今回はここを通らない。通らないのであまり気にする必要はないのだが、locked_tables_modeが何者か全く分からずハマった。現時点でわかってることをメモがてら残す。

locked_tables_modeはここのコメントを見るにロックテーブルモードと呼ばれる「一度に多くのテーブルを開いてロックする必要がある場合」に使用されるロックモードらしい。例えばストアドやトリガを使用する場合はプリロックモードとも呼ばれ、呼び出された時に関連するテーブルを一度に開いてロックをかけるようになっている。これによって呼び出される度にテーブルを何度も開いてロックをかけてまた閉じるといった操作の必要がなくなる。らしい。なるほど分かるようでわからん。

bool handle_reload_request(THD *thd, unsigned long options, TABLE_LIST *tables,
                           int *write_to_binlog) {
  bool result = false;
  select_errors = 0; /* Write if more errors */
  int tmp_write_to_binlog = *write_to_binlog = 1;

...

  /*
    Note that if REFRESH_READ_LOCK bit is set then REFRESH_TABLES is set too
    (see sql_yacc.yy)
  */
  if (options & (REFRESH_TABLES | REFRESH_READ_LOCK)) {
    if ((options & REFRESH_READ_LOCK) && thd) {
      
      ...
      
    } else {
      if (thd && thd->locked_tables_mode) {
        /*
          If we are under LOCK TABLES we should have a write
          lock on tables which we are going to flush.
        */
        if (tables) {
          for (TABLE_LIST *t = tables; t; t = t->next_local)
            if (!find_table_for_mdl_upgrade(thd, t->db, t->table_name, false))
              return true;
        } else {
          /*
            It is not safe to upgrade the metadata lock without GLOBAL IX lock.
            This can happen with FLUSH TABLES <list> WITH READ LOCK as we in
            these cases don't take a GLOBAL IX lock in order to be compatible
            with global read lock.
          */
          if (thd->open_tables &&
              !thd->mdl_context.owns_equal_or_stronger_lock(
                  MDL_key::GLOBAL, "", "", MDL_INTENTION_EXCLUSIVE)) {
            my_error(ER_TABLE_NOT_LOCKED_FOR_WRITE, MYF(0),
                     thd->open_tables->s->table_name.str);
            return true;
          }

          for (TABLE *tab = thd->open_tables; tab; tab = tab->next) {
            if (!tab->mdl_ticket->is_upgradable_or_exclusive()) {
              my_error(ER_TABLE_NOT_LOCKED_FOR_WRITE, MYF(0),
                       tab->s->table_name.str);
              return true;
            }
          }
        }
      }

      if (close_cached_tables(
              thd, tables, ((options & REFRESH_FAST) ? false : true),
              (thd ? thd->variables.lock_wait_timeout : LONG_TIMEOUT))) {
        /*
          NOTE: my_error() has been already called by reopen_tables() within
          close_cached_tables().
        */
        result = true;
      }
    }
  }
  
  ...
  
  return result || (thd ? thd->killed : 0);
}

mysql_execute_command

最後にhandle_reload_requestを呼び出しているのは以下になる。

SQLCOM_FLUSHのクライアントコマンドを受けるのでcase SQLCOM_FLUSH以下のソースを通り、handle_reload_requestが呼び出される。なお、REFRESH_READ_LOCK・REFRESH_FOR_EXPORT(つまりFLUSH TABLES WITH READ LOCK・FLUSH TABLES ... FOR EXPORTステートメント実行時)の場合は別メソッドが呼び出される。

int mysql_execute_command(THD *thd, bool first_level) {
  int res = false;
  LEX *const lex = thd->lex;
  
  ...
  ...
  ...
  
  case SQLCOM_FLUSH: {
      ...

      if (first_table && lex->type & REFRESH_READ_LOCK) {
        /* Check table-level privileges. */
        if (check_table_access(thd, LOCK_TABLES_ACL | SELECT_ACL, all_tables,
                               false, UINT_MAX, false))
          goto error;
        if (flush_tables_with_read_lock(thd, all_tables)) goto error;
        my_ok(thd);
        break;
      } else if (first_table && lex->type & REFRESH_FOR_EXPORT) {
        /* Check table-level privileges. */
        if (check_table_access(thd, LOCK_TABLES_ACL | SELECT_ACL, all_tables,
                               false, UINT_MAX, false))
          goto error;
        if (flush_tables_for_export(thd, all_tables)) goto error;
        my_ok(thd);
        break;
      }

      /*
        handle_reload_request() will tell us if we are allowed to write to the
        binlog or not.
      */
      if (!handle_reload_request(thd, lex->type, first_table,
                                 &write_to_binlog)) {
        /*
          We WANT to write and we CAN write.
          ! we write after unlocking the table.
        */
        /*
          Presumably, RESET and binlog writing doesn't require synchronization
        */

        if (write_to_binlog > 0)  // we should write
        {
          if (!lex->no_write_to_binlog)
            res = write_bin_log(thd, false, thd->query().str,
                                thd->query().length);
        } else if (write_to_binlog < 0) {
          /*
             We should not write, but rather report error because
             handle_reload_request binlog interactions failed
           */
          res = 1;
        }

        if (!res) my_ok(thd);
      }

      break;
    }

insert intention lockについての話

環境

mysql> select version();
+--------------+
| version()    |
+--------------+
| 8.0.28-debug |
+--------------+
1 row in set (0.00 sec)

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

インテンションロックとは

まずはそもそもインテンションロックってなんだっけの話をする。 MySQL公式の説明は以下となる

InnoDB では、行ロックとテーブルロックの共存を許可する複数粒度ロックがサポートされています。 たとえば、LOCK TABLES ... WRITE などのステートメントは、指定されたテーブルに対して排他ロック (X ロック) を取得します。 複数の粒度レベルでロックするには、InnoDB で intention locks を使用します。 インテントロックは、トランザクションが後でテーブルの行に必要とするロックのタイプ (共有または排他) を示すテーブルレベルのロックです。 インテントロックには、次の 2 種類があります: ・ intention shared lock (IS) は、トランザクションがテーブルの個々の行に shared ロックを設定することを示します。 ・intention exclusive lock (IX) は、トランザクションがテーブル内の個々の行に排他ロックを設定することを示します。 たとえば、SELECT ... FOR SHARE は IS ロックを設定し、SELECT ... FOR UPDATE は IX ロックを設定します。

記載の通り、SELECT ... FOR SHARE/UPDATE等々で行ロックを取得する前にインテンションロック(IX/IS)というテーブルロックが獲得される。

このロックは後続の行ロックを意図するためのロックである。例えばSELECT ... FOR UPDATEで行の排他ロックを取る前に、このテーブルレベルのインテンションロックが付与されて「これからこのテーブルの行の排他ロックが行われるぞ」という事を指し示してくれるのである。 なお、このロックはインテンションロック同士は互換性がある。テーブルレベルのロックタイプの互換性は以下のようになっている

X IX S IS
X 競合 競合 競合 競合
IX 競合 互換 競合 互換
S 競合 競合 互換 互換
IS 競合 互換 互換 互換

つまり、行レベルロックと通常のテーブルロック(X/S)の競合をテーブルレベルのロック同士の競合の話に置き換えることができるということである。インテンションロックが無ければ行レベルロックとテーブルレベルロックの異なるレベルでの競合比較が必要となる。異なるレベル同士の比較を許可するとややこしそう。

ギャップロック

次にギャップロックてそもそもなんだっけの話をする。 MySQL公式の説明は以下となる

ギャップロックは、インデックスレコード間のギャップのロック、または最初のインデックスレコードの前または最後のインデックスレコードの後のギャップのロックです。 たとえば、SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE;では、範囲内の既存のすべての値間のギャップがロックされているため、カラムにそのような値がすでに存在するかどうかにかかわらず、他のトランザクションが 15 の値をカラム t.c1 に挿入できなくなります。

他のブログでもよく取り上げれられている話題なのでサラッと説明する。

公式の説明の通り、ギャップロックとはインデックスレコードの論理的なギャップに関するロックである。これによりファントムリード、つまり「あるトランザクションのINSERTにより別トランザクションで再度読み取りした結果が変わってしまうこと」を防ぐことができる。

ちなみに以下のように通常のギャップロック同士は競合しない

mysql1> CREATE TABLE child (id int(11) NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
Query OK, 0 rows affected, 1 warning (0.06 sec)

mysql1> INSERT INTO child (id) values (90),(102);
Query OK, 2 rows affected (0.02 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql1> begin;
Query OK, 0 rows affected (0.00 sec)

mysql1>select * from child where id between 99 and 100 for update;
Empty set (0.00 sec)

--別トランザクション
mysql2>begin;
Query OK, 0 rows affected (0.01 sec)

mysql2>select * from child where id between 99 and 100 for update;
Empty set (0.00 sec)

--gapが同じ範囲でgrantされている
mysql1>SELECT * FROM performance_schema.data_locks\G
*************************** 1. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140023569830888:1093:140023450130672
ENGINE_TRANSACTION_ID: 26680
            THREAD_ID: 56
             EVENT_ID: 22
        OBJECT_SCHEMA: kubo
          OBJECT_NAME: child
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 140023450130672
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 2. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140023569830888:32:4:3:140023450127696
ENGINE_TRANSACTION_ID: 26680
            THREAD_ID: 56
             EVENT_ID: 22
        OBJECT_SCHEMA: kubo
          OBJECT_NAME: child
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140023450127696
            LOCK_TYPE: RECORD
            LOCK_MODE: X,GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 102
*************************** 3. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140023569829880:1093:140023450124352
ENGINE_TRANSACTION_ID: 26679
            THREAD_ID: 55
             EVENT_ID: 38
        OBJECT_SCHEMA: kubo
          OBJECT_NAME: child
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 140023450124352
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 4. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140023569829880:32:4:3:140023450121296
ENGINE_TRANSACTION_ID: 26679
            THREAD_ID: 55
             EVENT_ID: 38
        OBJECT_SCHEMA: kubo
          OBJECT_NAME: child
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140023450121296
            LOCK_TYPE: RECORD
            LOCK_MODE: X,GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 102
4 rows in set (0.00 sec)

挿入インテンションロック

では挿入インテンションロックについて触れていく MySQL公式の説明は以下である

挿入意図ロックは、行の挿入前に INSERT 操作によって設定されるギャップロックのタイプです。 このロックは、同じインデックスギャップに挿入する複数のトランザクションは、そのギャップ内の同じ場所に挿入しなければ相互に待機する必要がないように、意図的に挿入することを示しています。 値が 4 と 7 のインデックスレコードが存在すると仮定します。 5 と 6 の値をそれぞれ挿入しようとする個別のトランザクションでは、挿入された行の排他ロックを取得する前に、挿入意図ロックを使用して 4 と 7 のギャップがロックされますが、行が競合していないため相互にブロックされません。

INSERTで行の挿入前に挿入インテンションロックというギャップロックが獲得される。 前述のインテンションロックはテーブルレベルのロックであることに注意。

このロックは後続の挿入を意図するロックである。INSERTで行の挿入を行う前にこのギャップロックが取得されて「これからこのギャップに行を挿入するぞ」ということを指し示してくれる。 なお、挿入インテンションロック同士は互換性があり、挿入インテンションロックと通常のギャップロックは競合する(後の例で確認する)

つまり、ギャップロックでINSERTを防ぐ話をギャップロック同士の競合の話に置き換えることができるということである。

挿入インテンションロックの例でも見てみる

公式説明にある例示を実際にやってみることにする。

クライアント A は、2 つのインデックスレコード (90 および 102) を含むテーブルを作成し、100 を超える ID を持つインデックスレコードに排他ロックを設定するトランザクションを開始します。 排他ロックには、レコード 102 の前にギャップロックが含まれます:

mysql1> CREATE TABLE child (id int(11) NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
Query OK, 0 rows affected, 1 warning (0.06 sec)

mysql1> INSERT INTO child (id) values (90),(102);
Query OK, 2 rows affected (0.02 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql1> begin;
Query OK, 0 rows affected (0.00 sec)

mysql1> SELECT * FROM child WHERE id > 100 FOR UPDATE;
+-----+
| id  |
+-----+
| 102 |
+-----+
1 row in set (0.00 sec)

mysql1>SELECT * FROM performance_schema.data_locks\G
*************************** 1. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140023569829880:1093:140023450124352
ENGINE_TRANSACTION_ID: 26665
            THREAD_ID: 48
             EVENT_ID: 30
        OBJECT_SCHEMA: kubo
          OBJECT_NAME: child
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 140023450124352
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 2. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140023569829880:32:4:1:140023450121296
ENGINE_TRANSACTION_ID: 26665
            THREAD_ID: 48
             EVENT_ID: 30
        OBJECT_SCHEMA: kubo
          OBJECT_NAME: child
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140023450121296
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: supremum pseudo-record
*************************** 3. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140023569829880:32:4:3:140023450121296
ENGINE_TRANSACTION_ID: 26665
            THREAD_ID: 48
             EVENT_ID: 30
        OBJECT_SCHEMA: kubo
          OBJECT_NAME: child
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140023450121296
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: 102
3 rows in set (0.02 sec)

↑data_locksの結果からも分かるように、開区間(90, +∞)の範囲でロックを取得している。

クライアント B はトランザクションを開始して、ギャップにレコードを挿入します。 トランザクションは、排他ロックの取得を待機している間、挿入意図ロックを取得します。

mysql2>begin;
Query OK, 0 rows affected (0.00 sec)

mysql2>INSERT INTO child (id) VALUES (101);
--待ちが発生

mysql1>SELECT * FROM  sys.innodb_lock_waits\G
*************************** 1. row ***************************
                wait_started: 2023-10-18 09:20:47
                    wait_age: 00:00:03
               wait_age_secs: 3
                locked_table: `kubo`.`child`
         locked_table_schema: kubo
           locked_table_name: child
      locked_table_partition: NULL
   locked_table_subpartition: NULL
                locked_index: PRIMARY
                 locked_type: RECORD
              waiting_trx_id: 26666
         waiting_trx_started: 2023-10-18 09:20:47
             waiting_trx_age: 00:00:03
     waiting_trx_rows_locked: 1
   waiting_trx_rows_modified: 0
                 waiting_pid: 9
               waiting_query: INSERT INTO child (id) VALUES (101)
             waiting_lock_id: 140023569830888:32:4:3:140023450127696
           waiting_lock_mode: X,GAP,INSERT_INTENTION
             blocking_trx_id: 26665
                blocking_pid: 8
              blocking_query: SELECT * FROM  sys.innodb_lock_waits
            blocking_lock_id: 140023569829880:32:4:3:140023450121296
          blocking_lock_mode: X
        blocking_trx_started: 2023-10-18 09:19:41
            blocking_trx_age: 00:01:09
    blocking_trx_rows_locked: 2
  blocking_trx_rows_modified: 0
     sql_kill_blocking_query: KILL QUERY 8
sql_kill_blocking_connection: KILL 8
1 row in set (0.08 sec)

mysql1>SELECT * FROM performance_schema.data_locks\G
*************************** 1. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140023569830888:1093:140023450130672
ENGINE_TRANSACTION_ID: 26666
            THREAD_ID: 49
             EVENT_ID: 19
        OBJECT_SCHEMA: kubo
          OBJECT_NAME: child
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 140023450130672
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 2. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140023569830888:32:4:3:140023450127696
ENGINE_TRANSACTION_ID: 26666
            THREAD_ID: 49
             EVENT_ID: 19
        OBJECT_SCHEMA: kubo
          OBJECT_NAME: child
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140023450127696
            LOCK_TYPE: RECORD
            LOCK_MODE: X,GAP,INSERT_INTENTION
          LOCK_STATUS: WAITING
            LOCK_DATA: 102
*************************** 3. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140023569829880:1093:140023450124352
ENGINE_TRANSACTION_ID: 26665
            THREAD_ID: 48
             EVENT_ID: 30
        OBJECT_SCHEMA: kubo
          OBJECT_NAME: child
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 140023450124352
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 4. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140023569829880:32:4:1:140023450121296
ENGINE_TRANSACTION_ID: 26665
            THREAD_ID: 48
             EVENT_ID: 30
        OBJECT_SCHEMA: kubo
          OBJECT_NAME: child
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140023450121296
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: supremum pseudo-record
*************************** 5. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140023569829880:32:4:3:140023450121296
ENGINE_TRANSACTION_ID: 26665
            THREAD_ID: 48
             EVENT_ID: 30
        OBJECT_SCHEMA: kubo
          OBJECT_NAME: child
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140023450121296
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: 102
5 rows in set (0.01 sec)

innodb_lock_waitsとdata_locksからも分かるように、2番目のトランザクションのINSERTの際に挿入意図ロックの待機をしている事が確認できる。

挿入インテンションロックのデッドロックの例でも見てみる

select .. for updateで拾ってレコードが無ければinsertするみたいなロジックが同時に走れば起こり得る。

mysql1>select * from child;
+-----+
| id  |
+-----+
|  90 |
| 102 |
+-----+
2 rows in set (0.01 sec)

mysql1>begin;
Query OK, 0 rows affected (0.00 sec)

mysql1>select * from child where id =98 for update;
Empty set (0.00 sec)

mysql2>begin;
Query OK, 0 rows affected (0.00 sec)

mysql2>select * from child where id =99 for update;
Empty set (0.00 sec)

mysql1>insert into child(id) value(98);
--待機

mysql2>insert into child(id) value(99);
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

mysql> show engine innodb status

...
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-10-25 09:24:10 140022342727424
*** (1) TRANSACTION:
TRANSACTION 26687, ACTIVE 45 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1192, 2 row lock(s)
MySQL thread id 11, OS thread handle 140023115962112, query id 204 localhost root update
insert into child(id) value(98)

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 32 page no 4 n bits 72 index PRIMARY of table `kubo`.`child` trx id 26687 lock_mode X locks gap before rec
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 80000066; asc    f;;
 1: len 6; hex 000000006822; asc     h";;
 2: len 7; hex 810000010e011d; asc        ;;


*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 32 page no 4 n bits 72 index PRIMARY of table `kubo`.`child` trx id 26687 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 80000066; asc    f;;
 1: len 6; hex 000000006822; asc     h";;
 2: len 7; hex 810000010e011d; asc        ;;


*** (2) TRANSACTION:
TRANSACTION 26688, ACTIVE 32 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1192, 2 row lock(s)
MySQL thread id 12, OS thread handle 140023518537472, query id 205 localhost root update
insert into child(id) value(99)

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 32 page no 4 n bits 72 index PRIMARY of table `kubo`.`child` trx id 26688 lock_mode X locks gap before rec
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 80000066; asc    f;;
 1: len 6; hex 000000006822; asc     h";;
 2: len 7; hex 810000010e011d; asc        ;;


*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 32 page no 4 n bits 72 index PRIMARY of table `kubo`.`child` trx id 26688 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 80000066; asc    f;;
 1: len 6; hex 000000006822; asc     h";;
 2: len 7; hex 810000010e011d; asc        ;;

*** WE ROLL BACK TRANSACTION (2)

これはid=98,id=99のselect..for updateでギャップロックを取得し、その後互いのinsertによる挿入インテンションロックと競合してデッドロックが発生しているということである。

おまけ

※以下の話はまだ根拠が不十分なのでテキトーに見て欲しい。

あるトランザクションのinsertが他トランザクションによってブロックされている際に、data_locksテーブルを参照することで挿入インテンションロックの存在を確認できた。 一方で、1トランザクションでinsertをする際(特に競合していない時)にはdata_locksテーブルを参照しても挿入インテンションロックを確認できない。

これは、insertの前に一瞬だけ挿入インテンションロックが獲得されるためだと想像できる。すなわち、data_locksテーブルを参照するタイミングでは既にリリースされていると思われる。

では、ある適当なタイミングでブレークポイントを置いて、insert実施時にそのタイミングでブレークさせた時、data_locksにINSERT_INTENTIONの行が現れるのだろうか。言い換えれば、INSERT_INTENTIONの文字面が拝める良いブレークポイント はあるのだろうか。

結論から言うと、INSERT_INTENTIONの文字面は拝めなかった。 「insertの前に一瞬だけ挿入インテンションロックが獲得される」という前提がそもそも怪しいかもしれない。つまり必ずしも挿入インテンションロックが獲得される訳では無いかもしれない。

insertの過程でlock_rec_insert_check_and_lockという挿入を妨げるロックをチェックするメソッドがある。 もしもそのようなロックが存在すれば、以下のようにtype_mode = LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTIONのロックを待機させる。つまり前章で見た例はこの場合に該当する(実際例で見た通りdata_locksにはLOCK_MODE=X,GAP,INSERT_INTENTIONの行が存在していた)

一方で、そのようなロックが存在しない場合は特に何もしない。そしてそれ以降特にINSERT_INTENTION獲得らしきフェーズは現れない(無いことの証明は難しいが恐らく)。またこの時点でのdata_locksにはLOCK_MODE=IXの行しか存在しない。

そのため、あくまで「先に競合するロックが存在しなければ挿入インテンションロックを取らない」と思われる。確かにそのようなロックが存在しなければ挿入の意図を表す必要もないかもしれない。

引き続き調べてみる。

{
    locksys::Shard_latch_guard guard{UT_LOCATION_HERE, block->get_page_id()};

    /* When inserting a record into an index, the table must be at
    least IX-locked. When we are building an index, we would pass
    BTR_NO_LOCKING_FLAG and skip the locking altogether. */
    ut_ad(lock_table_has(trx, index->table, LOCK_IX));

    /* Spatial index does not use GAP lock protection. It uses
    "predicate lock" to protect the "range" */
    ut_ad(!dict_index_is_spatial(index));

    lock = lock_rec_get_first(lock_sys->rec_hash, block, heap_no);

    if (lock == nullptr) {
      *inherit = false;
    } else {
      *inherit = true;

      /* If another transaction has an explicit lock request which locks
      the gap, waiting or granted, on the successor, the insert has to wait.

      An exception is the case where the lock by the another transaction
      is a gap type lock which it placed to wait for its turn to insert. We
      do not consider that kind of a lock conflicting with our insert. This
      eliminates an unnecessary deadlock which resulted when 2 transactions
      had to wait for their insert. Both had waiting gap type lock requests
      on the successor, which produced an unnecessary deadlock. */

      const ulint type_mode = LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION;

      const lock_t *wait_for =
          lock_rec_other_has_conflicting(type_mode, block, heap_no, trx);

      if (wait_for != nullptr) {
        RecLock rec_lock(thr, index, block, heap_no, type_mode);

        trx_mutex_enter(trx);

        err = rec_lock.add_to_waitq(wait_for);

        trx_mutex_exit(trx);
      }
    }
  } /* Shard_latch_guard */

※lock_rec_insert_check_and_lockまでのバックトレース

lock_rec_insert_check_and_lock(ulint flags, const rec_t * rec, buf_block_t * block, dict_index_t * index, que_thr_t * thr, mtr_t * mtr, ulint * inherit) (/mysql-8.0.28/storage/innobase/lock/lock0lock.cc:5206)
btr_cur_ins_lock_and_undo(ulint flags, btr_cur_t * cursor, dtuple_t * entry, que_thr_t * thr, mtr_t * mtr, ulint * inherit) (/mysql-8.0.28/storage/innobase/btr/btr0cur.cc:2591)
btr_cur_optimistic_insert(ulint flags, btr_cur_t * cursor, ulint ** offsets, mem_heap_t ** heap, dtuple_t * entry, rec_t ** rec, big_rec_t ** big_rec, que_thr_t * thr, mtr_t * mtr) (/mysql-8.0.28/storage/innobase/btr/btr0cur.cc:2810)
row_ins_clust_index_entry_low(uint32_t flags, ulint mode, dict_index_t * index, ulint n_uniq, dtuple_t * entry, que_thr_t * thr, bool dup_chk_only) (/mysql-8.0.28/storage/innobase/row/row0ins.cc:2554)
row_ins_clust_index_entry(dict_index_t * index, dtuple_t * entry, que_thr_t * thr, bool dup_chk_only) (/mysql-8.0.28/storage/innobase/row/row0ins.cc:3139)
row_ins_index_entry(dict_index_t * index, dtuple_t * entry, uint32_t & multi_val_pos, que_thr_t * thr) (/mysql-8.0.28/storage/innobase/row/row0ins.cc:3331)
row_ins_index_entry_step(ins_node_t * node, que_thr_t * thr) (/mysql-8.0.28/storage/innobase/row/row0ins.cc:3467)
row_ins(ins_node_t * node, que_thr_t * thr) (/mysql-8.0.28/storage/innobase/row/row0ins.cc:3586)
row_ins_step(que_thr_t * thr) (/mysql-8.0.28/storage/innobase/row/row0ins.cc:3710)
row_insert_for_mysql_using_ins_graph(const byte * mysql_rec, row_prebuilt_t * prebuilt) (/mysql-8.0.28/storage/innobase/row/row0mysql.cc:1583)
row_insert_for_mysql(const byte * mysql_rec, row_prebuilt_t * prebuilt) (/mysql-8.0.28/storage/innobase/row/row0mysql.cc:1713)
ha_innobase::write_row(ha_innobase * const this, uchar * record) (/mysql-8.0.28/storage/innobase/handler/ha_innodb.cc:8920)
handler::ha_write_row(handler * const this, uchar * buf) (/mysql-8.0.28/sql/handler.cc:7937)
write_record(THD * thd, TABLE * table, COPY_INFO * info, COPY_INFO * update) (/mysql-8.0.28/sql/sql_insert.cc:2165)
Sql_cmd_insert_values::execute_inner(Sql_cmd_insert_values * const this, THD * thd) (/mysql-8.0.28/sql/sql_insert.cc:635)
Sql_cmd_dml::execute(Sql_cmd_dml * const this, THD * thd) (/mysql-8.0.28/sql/sql_select.cc:581)
mysql_execute_command(THD * thd, bool first_level) (/mysql-8.0.28/sql/sql_parse.cc:3553)
dispatch_sql_command(THD * thd, Parser_state * parser_state) (/mysql-8.0.28/sql/sql_parse.cc:5174)
dispatch_command(THD * thd, const COM_DATA * com_data, enum_server_command command) (/mysql-8.0.28/sql/sql_parse.cc:1938)
do_command(THD * thd) (/mysql-8.0.28/sql/sql_parse.cc:1352)

トランザクションの同時実行(動的アプローチ)

この記事は「リレーショナルデータベース入門 第3版」のトランザクションの同時実行の章の一部のサーベイです。

こちらの記事の続きです

amamanamam.hatenablog.com

ロック法

ここでは時間の経過とともに次々と到着するトランザクションステップをあるプロトコルに従って実行させると、その結果が直列可能性を保証されているという「動的」な同時実行制御について紹介する。この目的のためのプロトコルは以下のようなものがある

・ロッキングプロトコル

・時刻印順序プロトコル

・楽観的制御法

・多版同時実行制御法

ロッキングプロトコルによるロック法から順に紹介する

ロッキングプロトコル

定義(ロッキングプロトコル)

単にこのプロトコルに従っても一般には相反直列可能性を保証しない。 つまり必ずしもDBの一貫性を保証するとは限らない。なぜならデータ項目を読んだ直後にその項目をアンロックしていることにある。 例えば、以下はロッキングプロトコルに従っているが、遺失更新異状を起こす相反直列不可能なスケジュールである。

2相ロッキングプロトコル

定義(2相ロッキングプロトコル)

以降、2相ロッキングプロトコルを2PLと略す

先ほどの例はreadの後に writeが控えてるにも関わらずunlockしているので2相ロッキングプロトコルに従っていない。 以下が2PLに従った例となる。トランザクションが必要なデータ項目をロックしていく過程を成長相、アンロックしていく過程を縮退相という。

さて、いくつか言葉の定義をする。

スケジュールが順法であるとは、すでに他のトランザクションがロックしているデータ項目を別のトランザクションがロックすることはない場合をいう。

また、ロック法では静的なスケジュール法とは異なり動的である。従って、予めトランザクション群の同時実行のためのスケジュールを立案できないので、次々と到着するステップが2PLに従って実行されるように制御するアルゴリズムが必要になり、それをスケジューラという。

スケジューラは同時実行しているトランザクション群の最後のステップが到着・実行終了時点で、全てのステップの実行履歴を出力できるが、それを静的なスケジュール法でのスケジュールとみなすことができる。2PL関係の記述でスケジュールと述べるときは、この意味で指す。

2PLに関して次が知られている。

定理

また、2PLかつ順法であれば相反直列化可能であることが知られている。

2PLに従えばとは言っても、各トランザクションがいつの時点から縮退相に入るのかを予め知るのは難しい。そこで、コミットかアボートに達しCOMMIT処理かROLLBACK処理を行なった直後に、ロックしていたデータ項目をアンロックする方法が考えられる。これを厳格な2PL という。多くのDBMSがこれを採用している。

デッドロック

2PLに従った順法なスケジュールは相反直列化可能であることがわかったが、実はデッドロックという大きな欠点がある。

例えば以下のような場合、次のread処理のためにお互いが相手のアンロック待ちになってしまう。このようなデッドロックという状況が発生すると、それ以降のトランザクションの実行ができなくなる。

このようなデットロック発生時はどちらかのトランザクションを生贄としてアボートさせるような対応が存在するが、一方でデッドロックフリーというデッドロックの発生を予防するという考え方もある。さまざまなアプローチが考案されてきたが、ここでは時刻印を使う方法を述べる、

この方法では、トランザクションTi開始時点の時刻TS(Ti)がトランザクションの時刻印として付与される。従って、トランザクションTiがトランザクションTjより先に始まれば、TS(Ti)<TS(Tj)である。このとき、TiはTjより年長、TjはTiより年下という。時刻印を用いたデッドロック防止法には次の二つの方式がある

待つ死ぬ方式

 もしTiがデータ項目xをロックしようとしたとき、それをTjがロックしていた場合

 TiがTjより年上ならばTiはTjを待ち、年下ならばTiは死ぬ

 アボートされたTiはアボートされる前の時刻印を付与されて再スタートする

傷つけつ待つ方式

 もしTiがデータ項目xをロックしようとしたとき、それをTjがロックしていた場合

 TiがTjより年上ならば年下のTjを傷付ける、年下ならばTiはTjを待つ

 アボートされたTjはアボートされる前の時刻印を付与されて再スタートする

いずれの方式もデッドロックフリーな方法である。しかしいずれも、何らかのトランザクションをアボートすることにより波及ロールバックが発生してしまい、再スタートを余儀なくされてしまう問題がある。

ロッキングプロトコルの効率改善

2つのトランザクションがデータ項目を読むとき、前述のロッキングプロトコルでは片方の実行のためにロックするともう片方がそれを読めないという考え方であった。 しかし、読むだけなら許してあげようという考え方があり、この結果TPSを向上させる。このため次の2種類のロックを導入することが考えられた

・共有ロック

・専有ロック(排他ロック)

つまりトランザクションがデータ項目を読むためにロックする場合は共有ロックをかけ、一方書くためにロックする場合は専有ロックをかける。2PLの概念はこの場合にも素直に拡張でき、今までの定理もそのまま成立する。

多版同時実行制御(MVCC)

多版同時実行制御(MVCC)とは、2PLに従った場合のように競合しているトランザクションの実行終了を待つのではなく、同じデータ項目の異なる版(version)を養牛することによってトランザクションの隔離性を向上させつつ同時実行性を高める手法である。静的なスケジュール法や2PLに従った同時実行制御では保証されないスケジュールも、MVCCでは正しい結果を保証することがありうる。

MVCCの元でトランザクションの同時実行を司るスケジューラを多版スケジューラという。まず多版スケジューラがどのようにステップ制御していくのかを見る

今、トランザクション集合{T1,...,Tn}を同時実行するためのスケジュールSが与えられたとする。 Sに従いトランザクションTiが実行を開始した時、その時の時刻をTiに時刻印TS(Ti)として付与する。 なお、トランザクションは並列でなく同時実行なので、お互いの開始時間は異なる。 つまり、時刻印を見れば先発か後発なのかが分かる。

次にデータ項目xを考える。多版スケジューラはxの版(version)を保持する。これを例えば、xi,xj,xkという具合に書く。なお初期値はx0と書く。 版はトランザクションがデータ項目に書きを実行したときに生成され、時刻印が付与される。

ではMVCCのアルゴリズムを説明する。 MVCCアルゴリズムは次の2つの規則に則り、トランザクションの読み書きを版への読み書きに変換する

このアルゴリズムでは、つまりTi:read(x)やTi:write(x)の操作を版への読み書きの形に変換しているのである。 その上でreadの変換は最新の版のデータ項目に対して行うこと、writeの変換は「自分より後発のトランザクションが自分より先発のトランザクションの書き出し結果を呼んでいるとき」は行わないことを制約としている。 このアルゴリズムの沿ったスケジュールを多版スケジュールという。

なお、MVCCに従う多版スケジュールは正当であることが知られている。