MySQL 5.0のプロダクトライフサイクルが終了

あけましておめでとうございます。今年もよろしくお願いいたします。
新年最初から注意喚起ですが、2011年12月31日をもってMySQL 5.0のプロダクトライフサイクルが終了しました。今後MySQL 5.0に対して新たなバグ修正やセキュリティ修正は提供されませんので、現在MySQL 5.0を利用している方はバージョンアップを計画されることをおすすめいたします。
ラクル社による買収以降、ソフトウェア製品のサポートポリシーは以下のウェブサイトで確認できるようになっています。
Oracle Lifetime Support Policy for Oracle Software

  • MySQL 5.0:2011年12月まで
  • MySQL 5.1:2013年12月まで
  • MySQL 5.5:2015年12月まで (Extended Supportは2018年12月まで)

MySQL 5.0のプロダクトライフサイクルが終了したということで、一年ぶりにバグ曲線を作成してみました。

MySQL 5.0や5.1と比較すると、MySQL 5.5のバグ曲線は早くも収束し始めているようです。しかしmysqldumpで--single-transactionと--flush-logsを組み合わせると一貫性バックアップにならないsjis環境で意図したインデックスが使われないレプリケーション環境でメモリの二重解放が発生するなど昨年見つかったいくつかの不具合についてその後の進展が見られず、MySQL 5.5が十分に枯れたと判断するにはまだ早いと考えています。

INDEX FULL SCANを狙う - MySQL Casual Advent Calendar 2011

2011年8月のkazeburoさんのエントリに対する解説記事です。結論から言うとkazeburoさんの案に賛成なのですが、本日はどうしてそうなったのかというところを確認していきたいと思います。本記事はMySQL Casual Advent Calendar 2011の17日目のエントリです。16日目はakira1908jpさんでした。
当時の内容を覚えていない方は、先にkazeburoさんのエントリをご一読ください。また、テストケースがGitHubに公開されていますのでカジュアルに再現試験をすることも可能です。

問題のSQLをチューニングするには、MySQLがインデックスに対してどのようにアクセスするかという点についての知識が必要です。まずはインデックスに対するアクセス方式が一つだけではないということを、Oracle DatabaseとMySQLを対比させながら見ていきます。

Bツリーインデックスの構造

以下はentriesテーブルに付与されたセカンダリインデックスを図示したものです。entriesテーブルには主キー(id)に加えてセカンダリインデックス(user_id, status, created_at)が付与されていますが、以降の説明では一部スペースの関係でセカンダリインデックスのstatusカラムを省略しています。このセカンダリインデックスは、複数のカラムが指定された複合インデックス(Composite Index)になっているところがポイントです。リーフブロックの各インデックスエントリには、Oracle Databaseの場合はレコードの物理格納位置を指し示す10バイトの拡張ROWID、MySQL(InnoDB)の場合は主キーの値が格納されています。

インデックスアクセスの方式は大きく分けてOracle Databaseで5種類、MySQLで3種類あります。順に説明していきます。

  1. Bツリーをたどり、特定のリーフブロックにアクセスする
  2. Bツリーをたどり、さらにリーフブロックのリスト構造をたどる
  3. リーフブロックのリスト構造を、最初から最後までたどる
  4. リーフブロックのリスト構造を、スキップしながら最初から最後までたどる (Oracle Databaseのみ)
  5. リーフブロックを、リスト構造を無視してディスク格納順に読み取る (Oracle Databaseのみ)

1. Bツリーをたどり、特定のリーフブロックにアクセスする

WHERE user_id = 2 AND created_at = '2011-09-14'

Bツリーをたどり、リーフブロックに格納されたインデックスエントリを一つ読み取ります。ユニークインデックスに対して等価検索を行ったときのアクセス方式です。Oracle DatabaseではINDEX UNIQUE SCAN、MySQLではtype = constと呼ばれています。もっとも効率的です。

2. Bツリーをたどり、さらにリーフブロックのリスト構造をたどる

WHERE user_id = 2 AND created_at BETWEEN '2011-09-01' AND '2011-09-15'

Bツリーをたどり、リーフブロックに格納されたインデックスエントリを一つあるいは複数読み取ります。必要なインデックスエントリが複数のリーフブロックにまたがっている場合は、リーフブロック同士を連結しているリスト構造をたどって隣のリーフブロックへとアクセスしていきます。非ユニークインデックスに対して等価検索を行ったとき、あるいはインデックスに対して範囲検索を行ったときのアクセス方式です。Oracle DatabaseではINDEX RANGE SCAN、MySQLではtype = refあるいはtype = rangeと呼ばれています。二番目に効率的です。

3. リーフブロックのリスト構造を、最初から最後までたどる

WHERE created_at = '2011-09-10'

リーフブロックのリスト構造を最初から最後までたどり、すべてのインデックスエントリを読み取って検索条件に合致するものを返します。Oracle DatabaseではINDEX FULL SCAN、MySQLではtype = indexと呼ばれるアクセス方式です。図を見ると分かるように、本来アクセス不要なリーフブロックにも大量にアクセスすることになるため非効率であり、性能は良くありません。テーブルをフルスキャンするよりはまだ良いというレベルです。
このアクセス方式はインデックスに格納された順にデータを読み取っていくことで、ソート済みの状態で結果を取得できるところが特長です。Oracle Databaseではインデックスに対する有効な絞り込み条件がなく、かつ、結果をソートされた状態で返す必要があるときにこのアクセス方式が選ばれることがあります。有効な絞り込み条件がないというのは、複合インデックスのいくつかのカラムに対する検索条件が抜けている、NOT条件で検索している、検索条件のカラムに対して関数が適用されている、といったケースが考えられます。実際にこのアクセス方式を選ぶかどうかは統計情報を元にSQLオプティマイザが決めることであり、経験的にはこのアクセス方式が選ばれることはあまり多くありません。
MySQLについては後述します。

4. リーフブロックのリスト構造を、スキップしながら最初から最後までたどる (Oracle Databaseのみ)


※ 図がカジュアル

WHERE created_at = '2011-09-10'

リーフブロックのリスト構造を最初から最後までたどるのですが、このとき不要なブロックをスキップしながらインデックスエントリを読み取っていきます。Oracle Databaseでインデックスの前方カラムに対する有効な絞り込み条件がないものの、後方カラムに対する絞り込み条件がある場合に選ばれることのあるアクセス方式です。これはOracle 9iから導入された機能で、INDEX SKIP SCANと呼ばれています。
このアクセス方式が効率的であるかどうかは、スキップされる前方カラムのカーディナリティに依存します。例えば複合インデックスの第1カラムが性別である場合は、INDEX SKIP SCANの処理量はINDEX RANGE SCANを男性と女性で2回行う程度で済み、十分効率的と言えるでしょう。一方、前方カラムのカーディナリティが大きい場合は、処理量がINDEX FULL SCANに近いレベルにまで悪化することになります。
INDEX SKIP SCANは「FULL」という単語がついていないので問題視されないことが多いのですが、実際にはかなり遅いですので何らかの対策を打つべきです。INDEX SKIP SCANはINDEX FULL SCANを少しでも速くするための救済措置と理解する方が適切です。
MySQLにはこのような機能はありません。

5. リーフブロックを、リスト構造を無視してディスク格納順に読み取る (Oracle Databaseのみ)


※ 図がカジュアル

WHERE created_at = '2011-09-10'

リーフブロックをリスト構造を無視してディスク格納順に最初から最後までたどり、すべてのインデックスエントリを読み取ります。Oracle DatabaseでINDEX FAST FULL SCANと呼ばれるアクセス方式です。
インデックスのリーフブロックはディスク上に順番に並んでいるとは限りませんので、INDEX FULL SCANの場合は通常ディスクに対するランダムアクセスが行われます。一方このINDEX FAST FULL SCANでは、ランダムではなくシーケンシャルアクセスとなります。一般的にランダムアクセスはSSDを使わない限り極めて遅いですので、INDEX FAST FULL SCANの方に性能面での優位性があります。ただし、結果はソートされていない状態で返されます。
経験上INDEX FULL SCANはあまり見たことがないのですが、INDEX FAST FULL SCANは時々見かけます。テーブルをフルスキャンせざるを得ないようなSQLにおいて、いずれかのインデックスがカバーリングインデックスになっていれば、余計なカラムにディスク容量を取られていない分だけテーブルフルスキャンよりもINDEX FAST FULL SCANの方が有利となります。
MySQLにはこのような機能はありません。

INDEX FULL SCANを狙う

entriesテーブルの定義を再度確認します。

CREATE TABLE `entries` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` int(10) unsigned NOT NULL,
  `is_mobile` tinyint(3) unsigned NOT NULL,
  `title` varchar(512) NOT NULL,
  `body` text NOT NULL,
  `created_at` datetime NOT NULL,
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `status` tinyint(3) unsigned NOT NULL,
  PRIMARY KEY (`id`),
  KEY `user_id` (`user_id`,`status`,`created_at`)
) ENGINE=InnoDB AUTO_INCREMENT=100001 DEFAULT CHARSET=utf8mb4

チューニングしたいSQLを以下に示します。

SELECT COUNT(*)
  FROM entries 
 WHERE created_at BETWEEN '2011-12-10 00:00:00' AND '2011-12-10 23:59:59'
   AND is_mobile = 1;

念のためですが、このSQLをチューニングするための最適解は、kazeburoさんも述べていらっしゃるように(is_mobile, created_at)という複合インデックスを追加作成することです。綺麗なINDEX RANGE SCANにするため等価条件で検索されるカラムを前に、範囲条件で検索されるカラムを後ろに定義するところがポイントですが、あとは特に難しいところはないと思います。今回は諸事情によりインデックスの追加作成ができないという制約があって、それでもテーブルフルスキャンより効率的な方式があれば採用したい、という話題です。
先にOracle Databaseの挙動を確認しておきたいと思います。本記事の前半で5種類のインデックスアクセス方式を説明しましたが、Oracle DatabaseのSQLオプティマイザはその中からINDEX SKIP SCANを選択します。

SQL> r
  1  SELECT COUNT(*)
  2    FROM entries
  3   WHERE created_at BETWEEN '2011-12-10 00:00:00' AND '2011-12-10 23:59:59'
  4*    AND is_mobile = 1

  COUNT(*)
----------
       337


Execution Plan
----------------------------------------------------------
Plan hash value: 2621163538

--------------------------------------------------------------------------------------------
| Id  | Operation                    | Name        | Rows  | Bytes | Cost (%CPU)| Time     |
--------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT             |             |     1 |    14 |  3833   (1)| 00:00:46 |
|   1 |  SORT AGGREGATE              |             |     1 |    14 |            |          |
|*  2 |   TABLE ACCESS BY INDEX ROWID| ENTRIES     |  1668 | 23352 |  3833   (1)| 00:00:46 |
|*  3 |    INDEX SKIP SCAN           | ENTRIES_IX1 |  3335 |       |   496   (0)| 00:00:06 |
--------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - filter("IS_MOBILE"=1)
   3 - access("CREATED_AT">=TO_TIMESTAMP('2011-12-10 00:00:00') AND
              "CREATED_AT"<=TO_TIMESTAMP('2011-12-10 23:59:59'))
       filter("CREATED_AT">=TO_TIMESTAMP('2011-12-10 00:00:00') AND
              "CREATED_AT"<=TO_TIMESTAMP('2011-12-10 23:59:59'))


Statistics
----------------------------------------------------------
          0  recursive calls
          0  db block gets
       3861  consistent gets
          0  physical reads
          0  redo size
        527  bytes sent via SQL*Net to client
        519  bytes received via SQL*Net from client
          2  SQL*Net roundtrips to/from client
          0  sorts (memory)
          0  sorts (disk)
          1  rows processed

セカンダリインデックス(user_id, status, created_at)のうち検索条件に指定されているのは第3カラムのcreated_atのみですが、Oracle DatabaseではINDEX SKIP SCANによってこのインデックスを有効に活用することができます。テーブルフルスキャンをした場合と比較すると、アクセスデータブロック数を示すconsistent getsがテーブルフルスキャンの24,135に対しINDEX SKIP SCANでは3,861と減少しており、6倍程度効率的になっていることが分かります。レポーティングなど実行頻度の少ないSQLであれば、これ以上のチューニングは特に必要ないのではないかと思います。

SQL> r
  1  SELECT /*+ FULL(entries) */ COUNT(*)
  2    FROM entries
  3   WHERE created_at BETWEEN '2011-12-10 00:00:00' AND '2011-12-10 23:59:59'
  4*    AND is_mobile = 1
…
Statistics
----------------------------------------------------------
          0  recursive calls
          0  db block gets
      24135  consistent gets

MySQLの場合はどうすれば良いでしょうか。集計処理なのでINDEX UNIQUE SCAN(type = const)では意味がありませんし、INDEX RANGE SCAN(type = range)は複合インデックスの第1カラムから順番に検索条件に含まれていないと使えません。INDEX SKIP SCANやINDEX FAST FULL SCANはそもそも機能がありませんので、消去法でINDEX FULL SCAN(type = index)を狙うということになります。
MySQLでカジュアルにINDEX FULL SCANを狙ってみると、そう簡単には意図したSQL実行計画にならないことが分かります。元エントリのブックマークコメントで私とid:kazuhookuさんがしばらくハマっていました。

■何もしないとテーブルフルスキャンになる
mysql> EXPLAIN
    -> SELECT COUNT(*)
    ->   FROM entries
    ->  WHERE created_at BETWEEN '2011-12-10 00:00:00' AND '2011-12-10 23:59:59'
    ->    AND is_mobile = 1;
+----+-------------+---------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table   | type | possible_keys | key  | key_len | ref  | rows   | Extra       |
+----+-------------+---------+------+---------------+------+---------+------+--------+-------------+
|  1 | SIMPLE      | entries | ALL  | NULL          | NULL | NULL    | NULL | 102106 | Using where |
+----+-------------+---------+------+---------------+------+---------+------+--------+-------------+

■FORCE INDEXは効かない
mysql> EXPLAIN
    -> SELECT COUNT(*)
    ->   FROM entries FORCE INDEX (user_id)
    ->  WHERE created_at BETWEEN '2011-12-10 00:00:00' AND '2011-12-10 23:59:59'
    ->    AND is_mobile = 1;
+----+-------------+---------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table   | type | possible_keys | key  | key_len | ref  | rows   | Extra       |
+----+-------------+---------+------+---------------+------+---------+------+--------+-------------+
|  1 | SIMPLE      | entries | ALL  | NULL          | NULL | NULL    | NULL | 102106 | Using where |
+----+-------------+---------+------+---------------+------+---------+------+--------+-------------+

■サブクエリに追い出してみても効かない
mysql> EXPLAIN
    -> SELECT COUNT(*)
    ->   FROM (
    ->         SELECT is_mobile
    ->         FROM entries FORCE INDEX (user_id)
    ->         WHERE created_at BETWEEN '2011-12-10 00:00:00' AND '2011-12-10 23:59:59'
    ->        ) e
    ->  WHERE is_mobile = 1;
+----+-------------+------------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table      | type | possible_keys | key  | key_len | ref  | rows   | Extra       |
+----+-------------+------------+------+---------------+------+---------+------+--------+-------------+
|  1 | PRIMARY     | <derived2> | ALL  | NULL          | NULL | NULL    | NULL |   3388 | Using where |
|  2 | DERIVED     | entries    | ALL  | NULL          | NULL | NULL    | NULL | 102106 | Using where |
+----+-------------+------------+------+---------------+------+---------+------+--------+-------------+

実はMySQLのINDEX FULL SCANには一つ罠があります。MySQLでINDEX FULL SCANが選択されるには、「SQLがそのインデックスに定義されたカラムにしかアクセスしないこと」という制約条件を満たす必要があるのです。言い方を換えると「カバーリングインデックスになるときにしかINDEX FULL SCANはアクセス方式の候補にならない」ということになります。マニュアルにも明記されているのですが、寡聞にして知りませんでした。

This join type is the same as ALL, except that only the index tree is scanned. This usually is faster than ALL because the index file usually is smaller than the data file. MySQL can use this join type when the query uses only columns that are part of a single index.

実際の挙動を確認してみると、以下のようになります。

■インデックスに定義されたカラムのみにアクセスすれば、INDEX FULL SCANになる
mysql> EXPLAIN
    -> SELECT status
    ->   FROM entries
    ->  WHERE created_at BETWEEN '2011-12-10 00:00:00' AND '2011-12-10 23:59:59';
+----+-------------+---------+-------+---------------+---------+---------+------+--------+--------------------------+
| id | select_type | table   | type  | possible_keys | key     | key_len | ref  | rows   | Extra                    |
+----+-------------+---------+-------+---------------+---------+---------+------+--------+--------------------------+
|  1 | SIMPLE      | entries | index | NULL          | user_id | 13      | NULL | 102106 | Using where; Using index |
+----+-------------+---------+-------+---------------+---------+---------+------+--------+--------------------------+

■is_mobileはこのインデックスに定義されたカラムではないので、テーブルフルスキャンになる
mysql> EXPLAIN
    -> SELECT is_mobile
    ->   FROM entries
    ->  WHERE created_at BETWEEN '2011-12-10 00:00:00' AND '2011-12-10 23:59:59';
+----+-------------+---------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table   | type | possible_keys | key  | key_len | ref  | rows   | Extra       |
+----+-------------+---------+------+---------------+------+---------+------+--------+-------------+
|  1 | SIMPLE      | entries | ALL  | NULL          | NULL | NULL    | NULL | 102106 | Using where |
+----+-------------+---------+------+---------------+------+---------+------+--------+-------------+

■idはこのインデックスに定義されたカラムではないが、INDEX FULL SCANになる
mysql> EXPLAIN
    -> SELECT id
    ->   FROM entries
    ->  WHERE created_at BETWEEN '2011-12-10 00:00:00' AND '2011-12-10 23:59:59';
+----+-------------+---------+-------+---------------+---------+---------+------+--------+--------------------------+
| id | select_type | table   | type  | possible_keys | key     | key_len | ref  | rows   | Extra                    |
+----+-------------+---------+-------+---------------+---------+---------+------+--------+--------------------------+
|  1 | SIMPLE      | entries | index | NULL          | user_id | 13      | NULL | 102106 | Using where; Using index |
+----+-------------+---------+-------+---------------+---------+---------+------+--------+--------------------------+

さらに、上記の3番目の例がポイントです。idカラムはこのインデックスには定義されていないのですが、MySQL(InnoDB)のセカンダリインデックスでは主キーの値がリーフブロックの各インデックスエントリに格納されています。そのため「SQLがそのインデックスに定義されたカラムにしかアクセスしないこと」という制約条件が満たされ、INDEX FULL SCANが選択できるのです。
ここまで理解すれば、問題のSQLをどのように書き換えれば良いのかが分かると思います。

  1. 「created_at BETWEEN '2011-12-10 00:00:00' AND '2011-12-10 23:59:59'」という検索条件をインデックスで解決したい。
  2. しかし、使えそうなインデックスは今のところ(user_id, status, created_at)しかなく、検索条件として利用したいカラムが複合インデックスの後方に定義されてしまっている。
  3. このインデックスを活用できるアクセス方式は、MySQLの場合INDEX FULL SCAN(type = index)のみである。
  4. INDEX FULL SCANが選択されるには、SQLがそのインデックスに定義されたカラムにしかアクセスしないという制約条件を満たす必要がある。
  5. 制約条件を満たすために、is_mobileカラムをSQLから除外する必要がある。しかしis_mobileカラムは最終的には必要である。
  6. これらの条件をすべて満たすには、同じテーブルを2回SELECTして結果をJOINすればよい。JOINに用いるカラムはidである。
  7. idカラムは主キーであり、MySQL(InnoDB)のすべてのインデックスに暗黙的に含まれている。そのためINDEX FULL SCANの制約条件は満たされる。
mysql> EXPLAIN
    -> SELECT COUNT(*)
    ->   FROM entries e1 INNER JOIN entries e2 ON e1.id = e2.id
    ->  WHERE e1.created_at BETWEEN '2011-12-10 00:00:00' AND '2011-12-10 23:59:59'
    ->    AND e2.is_mobile = 1;
+----+-------------+-------+--------+---------------+---------+---------+-------------+--------+--------------------------+
| id | select_type | table | type   | possible_keys | key     | key_len | ref         | rows   | Extra                    |
+----+-------------+-------+--------+---------------+---------+---------+-------------+--------+--------------------------+
|  1 | SIMPLE      | e1    | index  | PRIMARY       | user_id | 13      | NULL        | 102106 | Using where; Using index |
|  1 | SIMPLE      | e2    | eq_ref | PRIMARY       | PRIMARY | 4       | scott.e1.id |      1 | Using where              |
+----+-------------+-------+--------+---------------+---------+---------+-------------+--------+--------------------------+

というわけで、ようやくkazeburoさんと同じ結論にたどり着きました。お疲れさまでした。
余談ですが、MySQL 5.6ではIndex Condition Pushdownという新機能によって、Oracle DatabaseにおけるINDEX SKIP SCANに近いアクセス方式が取れるようになるかもしれません。漢の奥野さんがEnterpriseZine解説記事を寄稿していらっしゃいますので、ご一読ください。
しゅしゅミクモデルはしゅしゅPさま、たこルカモデルMk-IIは6666AAPさまの制作です。この場を借りて御礼申し上げます。MySQL Casual Advent Calendar 2011、18日目はsfujiwaraさんです。それではまた。

SystemTapでMySQL 5.5のDisk I/Oを分析する

2010年1月の記事SystemTapでMySQLのDisk I/Oを分析するの続きです。以前作成したSystemTapスクリプトは、実はMySQL 5.5のDisk I/Oを分析することができませんでした。というのも、MySQL 5.5からInnoDBが非同期I/Oを行うようになったのですが、以前のスクリプトは非同期I/Oに対応していなかったためです。本日はMySQL 5.5におけるInnoDBの非同期I/Oについて、確認していきたいと思います。

非同期I/Oとは

非同期I/Oとは、I/O処理をブロックされることなしに行う方式のことです。通常のI/O処理はそれが完了するまで待たされてしまうのですが、非同期I/Oを用いることでI/O処理の完了を待つことなしに他の処理を進めることができます。以下のウェブサイトでとても詳しく解説されています。

詳解 Linuxカーネル 第3版をお持ちの方は、16.4.1章「Linux 2.6における非同期I/O」も合わせてご覧ください。

MySQL 5.5のInnoDBにおける非同期I/Oの利用

InnoDBは元々Windowsでのみ非同期I/Oを行っており、それ以外のプラットフォームでは「simulated AIO」と呼ばれる擬似的な非同期I/Oを行っていました。これはSQLを実行するコネクションスレッドがI/O要求をキューイングし、I/OヘルパースレッドがI/O要求をキューから取り出して実際のI/Oを行うというものです。コネクションスレッドがI/Oの完了を待たなくて良いという意味では確かに非同期ですが、I/Oヘルパースレッドが行う処理はあくまで同期I/Oであるため、この方式はsimulated AIOと呼ばれていました。I/Oヘルパースレッドの数はinnodb_read_io_threads、innodb_write_io_threadsという二つのパラメータで設定することが可能です。
MySQL 5.5のInnoDBでは、LinuxにおいてOSネイティブの非同期I/Oが利用されるようになりました。このためにはあらかじめlibaioパッケージをインストールしておく必要がありますが、設定パラメータinnodb_use_native_aioはデフォルトで有効になっており、何もしなければ自動的に非同期I/Oが利用されるようになっています。ただし現在のLinuxカーネルでは、Direct I/Oを利用している場合にのみ非同期I/Oが非同期になるという点に注意してください。つまり、非同期I/Oを利用するにはinnodb_flush_method = O_DIRECTの設定が必須となります。
非同期I/Oが有効化されている場合、コネクションスレッドはI/O要求をキューイングするのではなく、直接非同期I/Oを発行するようになります。そしてI/Oヘルパースレッドは自分自身がI/O処理を行うのではなく、コネクションスレッドが行った非同期I/Oの完了を待機して後処理を行うという役割に変化します。具体的にはコネクションスレッドがio_submit(2)を行い、I/Oヘルパースレッドがio_getevents(2)を行うようになります。
これがどのようなメリットをもたらすかというと、I/Oヘルパースレッドの数を増やすことなしに一度に処理できるI/O要求の数を増やすことができるということになります。MySQL 5.1+InnoDB Pluginの構成においてinnodb_read_io_threadsとinnodb_write_io_threadsはそれぞれデフォルトで4であり、読み込みで4つ、書き込みで4つまでのI/O要求を一度に処理することができました。しかし、MySQL 5.5においてはOSとストレージサブシステムが許す限りのI/O要求を一度に処理することができるようになります。例えばSATA Native Command Queuingでは、デバイスあたり最大32個のI/O要求をキューイングすることが可能となっています。

分析例

前回作成したSystemTapスクリプトを、非同期I/Oをプローブできるように改造しました。スクリプトは本エントリの末尾にあります。JdbcRunnerのTiny TPC-Cを用い、多重度16で負荷をかけたときのI/Oの様子を以下に示します。最初はMySQL 5.0.77のデータです。

-------- Synchronous I/O -------- ------- Asynchronous I/O --------
rTH   r/s  rKB/s wTH   w/s  wKB/s rTH   r/s  rKB/s wTH   w/s  wKB/s File
  0     0      0  17   164    629   0     0      0   0     0      0 /u02/mysql/ib_logfile1
  8     8    128   7    15  14496   0     0      0   0     0      0 /u02/mysql/ibdata1
 17   354   6992   1   160   2656   0     0      0   0     0      0 /u02/mysql/tpcc/customer.ibd
  0     0      0   1     5     80   0     0      0   0     0      0 /u02/mysql/tpcc/history.ibd
  2    22    464   0     0      0   0     0      0   0     0      0 /u02/mysql/tpcc/item.ibd
  6     8    128   1    12    224   0     0      0   0     0      0 /u02/mysql/tpcc/new_orders.ibd
 17    58    928   1    56   1008   0     0      0   0     0      0 /u02/mysql/tpcc/order_line.ibd
 10    20    320   1    14    240   0     0      0   0     0      0 /u02/mysql/tpcc/orders.ibd
 17  1057  20656   1   513   9264   0     0      0   0     0      0 /u02/mysql/tpcc/stock.ibd

rTHはそのファイルを読み込んだスレッドの数、r/sは1秒あたりの読み込み回数、rKB/sは1秒あたりに読み込んだ容量(KB)を表しています。非同期I/Oは行われておらず、全て同期I/Oであることが分かります。テーブルスペースファイルに対して読み込みを行うスレッドは最大でコネクションスレッド数+innodb_read_io_threadsの17個、書き込みを行うスレッドはinnodb_write_io_threadsの1つだけとなっています。
ここでお詫びですが、2009年の記事SSDの真の性能を引き出す MySQL 5.1.38 InnoDB Plugin - SH2の日記において、アーキテクチャの図が間違っていました。innodb_read_io_threadsは先読みだけを行うスレッドであり、通常のページ読み込みはコネクションスレッド自身が行います。コメント欄にあるid:mogwaingさんのご指摘が正しいです。どうもありがとうございました。描き直した図を以下に示します。

先読みというのは、ある程度連続したページの読み込みが要求された際に、フルスキャンだと判断して次に読まれるであろう先の方のデータをあらかじめ読み込んでおく仕組みのことです。詳しくはマニュアルの7.7. Changes in the Read-Ahead Algorithmをご覧ください。
次はMySQL 5.1.60+InnoDB Pluginのデータです。

-------- Synchronous I/O -------- ------- Asynchronous I/O --------
rTH   r/s  rKB/s wTH   w/s  wKB/s rTH   r/s  rKB/s wTH   w/s  wKB/s File
  0     0      0  17   222    886   0     0      0   0     0      0 /u02/mysql/ib_logfile1
  6     6     96   4    12  11440   0     0      0   0     0      0 /u02/mysql/ibdata1
 16   458   7328   4   157   2528   0     0      0   0     0      0 /u02/mysql/tpcc/customer.ibd
  5    10    160   4    24    480   0     0      0   0     0      0 /u02/mysql/tpcc/history.ibd
  6     9    144   0     0      0   0     0      0   0     0      0 /u02/mysql/tpcc/item.ibd
  4     6     96   0     0      0   0     0      0   0     0      0 /u02/mysql/tpcc/new_orders.ibd
 17    70   1120   4    46    976   0     0      0   0     0      0 /u02/mysql/tpcc/order_line.ibd
 14    29    464   3    21    352   0     0      0   0     0      0 /u02/mysql/tpcc/orders.ibd
 16  1089  17424   4   385   7104   0     0      0   0     0      0 /u02/mysql/tpcc/stock.ibd

MySQL 5.0.77と比べると、テーブルスペースファイルに対して書き込みを行っているスレッドの数が増えていることが分かります。非同期I/Oは行われていません。

最後に、MySQL 5.5.18のデータです。

-------- Synchronous I/O -------- ------- Asynchronous I/O --------
rTH   r/s  rKB/s wTH   w/s  wKB/s rTH   r/s  rKB/s wTH   w/s  wKB/s File
  0     0      0  17   156    651   0     0      0   0     0      0 /u02/mysql/ib_logfile0
 16    40    640   7    18  17072   0     0      0   4    62    992 /u02/mysql/ibdata1
 16   306   4896   0     0      0   0     0      0   7   145   2320 /u02/mysql/tpcc/customer.ibd
  1     1     16   0     0      0   3     5     80   1     1     16 /u02/mysql/tpcc/history.ibd
 13    22    352   0     0      0   0     0      0   0     0      0 /u02/mysql/tpcc/item.ibd
  9    22    352   0     0      0   0     0      0   2    36    576 /u02/mysql/tpcc/new_orders.ibd
 16    80   1280   0     0      0   6    11    176   6   110   1760 /u02/mysql/tpcc/order_line.ibd
 11   109   1744   0     0      0   0     0      0   2    12    192 /u02/mysql/tpcc/orders.ibd
 16  1178  18848   0     0      0   0     0      0   7   701  11216 /u02/mysql/tpcc/stock.ibd

テーブルスペースファイルに対する書き込みが非同期I/Oになっていることが分かります。また先読みも非同期I/Oになったため、historyテーブルとorder_lineテーブルに対して先読みが行われていることが判別できます。

このように、MySQL 5.5でI/Oの仕組みが変わりました。ただ性能面ではMySQL 5.1+InnoDB Pluginの時点で既にかなり良くなっているため、そこからの改善幅はあまり大きくはないと思います。おそらく、よほど高性能なストレージを持ってこない限りI/O性能の違いは分からないでしょう。

SystemTapスクリプト

今回作成したSystemTapスクリプトを以下に示します。前回と同様Perlスクリプトとのセットになっているため、以下のようにパイプでつなげて実行してください。

# stap io2.stp | perl io2.pl

SystemTapスクリプトです。

global PROCNAME = "mysqld"
global fds

probe begin {
    printf("start\n")
}

probe syscall.read, syscall.pread {
    if (execname() == PROCNAME) {
        fds[pid(), tid()] = fd
    }
}

probe syscall.read.return, syscall.pread.return {
    if (execname() == PROCNAME && $return != -1) {
        printf("read\t%d\t%d\t%d\t%d\n", pid(), tid(), fds[pid(), tid()], $return)
    }
}

probe syscall.write, syscall.pwrite {
    if (execname() == PROCNAME) {
        fds[pid(), tid()] = fd
    }
}

probe syscall.write.return, syscall.pwrite.return {
    if (execname() == PROCNAME && $return != -1) {
        printf("write\t%d\t%d\t%d\t%d\n", pid(), tid(), fds[pid(), tid()], $return)
    }
}

probe syscall.io_submit {
    if (execname() == PROCNAME) {
        for (i = 0; i < nr; i++) {
            if ($iocbpp[i]->aio_lio_opcode == 0) {
                // IOCB_CMD_PREAD
                printf("aio_read\t%d\t%d\t%d\t%d\n",
                       pid(), tid(), $iocbpp[i]->aio_fildes, $iocbpp[i]->aio_nbytes)
            } else if ($iocbpp[i]->aio_lio_opcode == 1) {
                // IOCB_CMD_PWRITE
                printf("aio_write\t%d\t%d\t%d\t%d\n",
                       pid(), tid(), $iocbpp[i]->aio_fildes, $iocbpp[i]->aio_nbytes)
            }
        }
    }
}

probe syscall.close {
    if (execname() == PROCNAME) {
        printf("close\t%d\t%d\t%d\n", pid(), tid(), fd)
    }
}

probe timer.s(1) {
    printf("print\n")
}

集計用のPerlスクリプトです。

#!/usr/bin/perl

use strict;
use warnings;

my (%file_map, $do_print, %has_io, %rthreads, %rtimes, %rbytes, %wthreads, %wtimes, %wbytes,
    %aio_rthreads, %aio_rtimes, %aio_rbytes, %aio_wthreads, %aio_wtimes, %aio_wbytes);

$do_print = 0;

while (my $line = <>) {
    chomp($line);
    my ($call, $pid, $tid, $fd, $size) = split(/\t/, $line);
  
    if (($call eq 'read') or ($call eq 'write')
        or ($call eq 'aio_read') or ($call eq 'aio_write')) {
        
        if (!defined($file_map{"$pid,$fd"})) {
            $file_map{"$pid,$fd"} = readlink("/proc/$pid/fd/$fd") or next;
        }
        
        my $file = $file_map{"$pid,$fd"};
        
        if (substr($file, 0, 1) eq '/') {
            $do_print = 1;
            $has_io{$file} = 1;
            
            if ($call eq 'read') {
                $rthreads{$file}->{$tid} = 1;
                $rtimes{$file}++;
                $rbytes{$file} += $size;
            } elsif ($call eq 'write') {
                $wthreads{$file}->{$tid} = 1;
                $wtimes{$file}++;
                $wbytes{$file} += $size;
            } elsif ($call eq 'aio_read') {
                $aio_rthreads{$file}->{$tid} = 1;
                $aio_rtimes{$file}++;
                $aio_rbytes{$file} += $size;
            } elsif ($call eq 'aio_write') {
                $aio_wthreads{$file}->{$tid} = 1;
                $aio_wtimes{$file}++;
                $aio_wbytes{$file} += $size;
            }
        }
    
    } elsif ($call eq 'close') {
        if (defined($file_map{"$pid,$fd"})) {
            delete($file_map{"$pid,$fd"});
        }
    
    } elsif ($call eq 'print') {
        my ($sec, $min, $hour, $day, $mon, $year) = localtime();
        
        printf "%04d-%02d-%02d %02d:%02d:%02d\n",
               $year + 1900, $mon + 1, $day, $hour, $min, $sec;
        
        if ($do_print == 1) {
            print "-------- Synchronous I/O -------- ------- Asynchronous I/O --------\n";
            print "rTH   r/s  rKB/s wTH   w/s  wKB/s rTH   r/s  rKB/s wTH   w/s  wKB/s File\n";
            
            foreach my $file (sort keys %has_io) {
                printf "%3d %5d %6d %3d %5d %6d %3d %5d %6d %3d %5d %6d %s\n",
                       scalar(keys(%{$rthreads{$file}})),
                       defined($rtimes{$file})     ? $rtimes{$file}            : 0,
                       defined($rbytes{$file})     ? $rbytes{$file}     / 1024 : 0,
                       scalar(keys(%{$wthreads{$file}})),
                       defined($wtimes{$file})     ? $wtimes{$file}            : 0,
                       defined($wbytes{$file})     ? $wbytes{$file}     / 1024 : 0,
                       scalar(keys(%{$aio_rthreads{$file}})),
                       defined($aio_rtimes{$file}) ? $aio_rtimes{$file}        : 0,
                       defined($aio_rbytes{$file}) ? $aio_rbytes{$file} / 1024 : 0,
                       scalar(keys(%{$aio_wthreads{$file}})),
                       defined($aio_wtimes{$file}) ? $aio_wtimes{$file}        : 0,
                       defined($aio_wbytes{$file}) ? $aio_wbytes{$file} / 1024 : 0,
                       $file;
            }
            
            print "\n";
            
            $do_print = 0;
            %has_io = %rthreads = %rtimes = %rbytes = %wthreads = %wtimes = %wbytes = ();
            %aio_rthreads = %aio_rtimes = %aio_rbytes = %aio_wthreads = %aio_wtimes = %aio_wbytes = ();
        }
    
    } else {
        print "$call\n";
    }
}

Scientific Linux/CentOS 6.0でMySQL InnoDB Pluginを利用する

先日CentOS 6.0がリリースされたので、色々試している方も多いと思います。MySQLについて、Scientific Linux/CentOS 6.0では派生元のRed Hat Enterprise Linux 6.0と同様、バージョン5.1.52が採用されています。このディストリビューション付属版のMySQL 5.1.52ですが、残念なことにInnoDB Pluginが無効化されてしまっています。

* Fri Jan  8 2010 Tom Lane <tgl@redhat.com> 5.1.42-4
- Sync with current Fedora build, including:
- Update to MySQL 5.1.42, for various fixes described at
  http://dev.mysql.com/doc/refman/5.1/en/news-5-1-42.html
- Disable symbolic links by default in /etc/my.cnf
Resolves: #553653
- Remove static libraries (.a files) from package, per packaging guidelines
- Change %%define to %%global, per packaging guidelines
- Disable building the innodb plugin; it tickles assorted gcc bugs and
  doesn't seem entirely ready for prime time anyway.
Resolves: #553632
- Start mysqld_safe with --basedir=/usr, to avoid unwanted SELinux messages
  (see 547485)
- Stop waiting during "service mysqld start" if mysqld_safe exits
Resolves: #544095

バージョン5.1.42のInnoDB PluginはまだRC版だったので無効化するのは正しいのですが、バージョン5.1.52を採用するにあたっても再度有効化はしてくれなかったようです。もったいないですね。
一方、最新版のMySQL 5.5を入れようとすると、今度はRPMの依存関係でmysql-libsをアンインストールする必要がでてきてしまい、

# yum erase mysql-libs

Dependencies Resolved

====================================================================================================
 Package                              Arch        Version                   Repository         Size
====================================================================================================
Removing:
 mysql-libs                           x86_64      5.1.52-1.el6_0.1          @sl-security      4.0 M
Removing for dependencies:
 cronie                               x86_64      1.4.4-2.el6               @sl               166 k
 cronie-anacron                       x86_64      1.4.4-2.el6               @sl                41 k
 crontabs                             noarch      1.10-32.1.el6             @sl               2.4 k
 openoffice.org-brand                 x86_64      1:3.2.1-19.6.el6_0.5      @sl-security      852 k
 openoffice.org-calc                  x86_64      1:3.2.1-19.6.el6_0.5      @sl-security       28 k
 openoffice.org-calc-core             x86_64      1:3.2.1-19.6.el6_0.5      @sl-security       23 M
 openoffice.org-core                  x86_64      1:3.2.1-19.6.el6_0.5      @sl-security      237 M
 openoffice.org-draw                  x86_64      1:3.2.1-19.6.el6_0.5      @sl-security       25 k
 openoffice.org-draw-core             x86_64      1:3.2.1-19.6.el6_0.5      @sl-security      2.1 M
 openoffice.org-graphicfilter         x86_64      1:3.2.1-19.6.el6_0.5      @sl-security      663 k
 openoffice.org-impress               x86_64      1:3.2.1-19.6.el6_0.5      @sl-security       29 k
 openoffice.org-impress-core          x86_64      1:3.2.1-19.6.el6_0.5      @sl-security      3.0 M
 openoffice.org-langpack-en           x86_64      1:3.2.1-19.6.el6_0.5      @sl-security      0.0
 openoffice.org-langpack-ja_JP        x86_64      1:3.2.1-19.6.el6_0.5      @sl-security       36 M
 openoffice.org-math                  x86_64      1:3.2.1-19.6.el6_0.5      @sl-security       15 k
 openoffice.org-math-core             x86_64      1:3.2.1-19.6.el6_0.5      @sl-security      3.3 M
 openoffice.org-pdfimport             x86_64      1:3.2.1-19.6.el6_0.5      @sl-security      3.8 M
 openoffice.org-presenter-screen      x86_64      1:3.2.1-19.6.el6_0.5      @sl-security      4.6 M
 openoffice.org-writer                x86_64      1:3.2.1-19.6.el6_0.5      @sl-security       31 k
 openoffice.org-writer-core           x86_64      1:3.2.1-19.6.el6_0.5      @sl-security       15 M
 openoffice.org-xsltfilter            x86_64      1:3.2.1-19.6.el6_0.5      @sl-security      1.9 M
 postfix                              x86_64      2:2.6.6-2.2.el6_1         @sl-security      9.7 M
 redhat-lsb                           x86_64      4.0-2.1.el6               @sl                22 k
 redland                              x86_64      1.0.7-11.el6              @sl               602 k
 sysstat                              x86_64      9.0.4-12.el6_0.1          @sl-fastbugs      753 k

Transaction Summary
====================================================================================================
Remove       26 Package(s)
Reinstall     0 Package(s)
Downgrade     0 Package(s)

Is this ok [y/N]:

と、Pythonほどではありませんがなかなか面倒なことになります。ううむ…
Scientific Linux/CentOS 6.0でMySQL 5.5を使いたい場合は、今のところRemiを使うのがよさそうです。依存関係を綺麗に解決してくれます。しかし今回はこうした外部リポジトリには頼らず、出たばかりのディストリビューションにあまり大きく手を加えない方向でInnoDB Pluginだけ有効化してみたいと思います。

RPMのビルド環境を作る

ここからはMySQLのSource RPMを元に、InnoDB Pluginを有効化したRPMを再作成していきます。ビルド環境の準備についてはrpmbuildコマンドを使おう - よくきたWikiなどを参考にしてください。.rpmmacrosには_topdir以外に、vendorとdistも定義しておくと便利です。サンプルを以下に示します。

%_topdir /home/taira/rpmbuild
%vendor  HIRATSUKA Sadao
%dist    .el6_0

Source RPMhttp://ftp.jaist.ac.jp/pub/Linux/scientific/6/SRPMS/vendor/などから入手し、インストールします。

$ wget http://ftp.jaist.ac.jp/pub/Linux/scientific/6/SRPMS/vendor/mysql-5.1.52-1.el6_0.1.src.rpm
--2011-07-20 00:31:15--  http://ftp.jaist.ac.jp/pub/Linux/scientific/6/SRPMS/vendor/mysql-5.1.52-1.el6_0.1.src.rpm
ftp.jaist.ac.jp をDNSに問いあわせています... 150.65.7.130, 2001:200:141:feed::feed
ftp.jaist.ac.jp|150.65.7.130|:80 に接続しています... 接続しました。
HTTP による接続要求を送信しました、応答を待っています... 200 OK
長さ: 20854090 (20M) [application/x-wais-source]
`mysql-5.1.52-1.el6_0.1.src.rpm' に保存中

100%[==========================================================>] 20,854,090  2.93M/s 時間 8.0s

2011-07-20 00:31:23 (2.48 MB/s) - `mysql-5.1.52-1.el6_0.1.src.rpm' へ保存完了 [20854090/20854090]

$ rpm -ivh mysql-5.1.52-1.el6_0.1.src.rpm
   1:mysql                  警告: ユーザ mockbuild は存在しません - root を使用します
警告: グループ mockbuild は存在しません - root を使用します
…

SPECファイルを修正する

SPECS/mysql.specをテキストエディタで開き、241行目、--without-plugin-innodb_pluginと書いてあるところを--with-plugins-innodb_pluginへと書き換えます。with-pluginではなくwith-pluginsとsが付きます。

%configure \
        --with-readline \
        --with-ssl=/usr \
        --without-debug \
        --enable-shared \
        --with-embedded-server \
        --localstatedir=/var/lib/mysql \
        --with-unix-socket-path=/var/lib/mysql/mysql.sock \
        --with-mysqld-user="mysql" \
        --with-extra-charsets=all \
        --with-big-tables \
        --with-pic \
        --with-plugin-innobase \
        --with-plugins-innodb_plugin \
        --with-plugin-partition \
        --enable-local-infile \
        --enable-largefile \
        --enable-thread-safe-client \
        --disable-dependency-tracking

RPMをビルドする

rpmbuildコマンドでRPMをビルドします。この時点でビルドに必要なパッケージが不足している場合は、インストールするように促されます。defineオプションでrunselftestを0に設定すると、ビルド後のテストをスキップすることができます。

$ rpmbuild -bb --define='runselftest 0' mysql.spec
実行中(%prep): /bin/sh -e /var/tmp/rpm-tmp.pDzWkP
+ umask 022
+ cd /home/taira/rpmbuild/BUILD
+ cd /home/taira/rpmbuild/BUILD
+ rm -rf mysql-5.1.52
+ /usr/bin/gzip -dc /home/taira/rpmbuild/SOURCES/mysql-5.1.52-nodocs
+ /bin/tar -xf -
…
+ umask 022
+ cd /home/taira/rpmbuild/BUILD
+ cd mysql-5.1.52
+ rm -rf /home/taira/rpmbuild/BUILDROOT/mysql-5.1.52-1.el6_0.1.x86_64
+ exit 0

いまどきのPCでは5分程度でビルドが完了します。

インストール、設定

作成されたRPMはRPMS/x86_64に保存されています。インストールします。

$ ls -lh
合計 43M
-rw-r--r-- 1 taira taira 1.2M  7月 20 00:51 2011 mysql-5.1.52-1.el6_0.1.x86_64.rpm
-rw-r--r-- 1 taira taira 516K  7月 20 00:51 2011 mysql-bench-5.1.52-1.el6_0.1.x86_64.rpm
-rw-r--r-- 1 taira taira 135K  7月 20 00:51 2011 mysql-devel-5.1.52-1.el6_0.1.x86_64.rpm
-rw-r--r-- 1 taira taira  12M  7月 20 00:51 2011 mysql-embedded-5.1.52-1.el6_0.1.x86_64.rpm
-rw-r--r-- 1 taira taira 6.4M  7月 20 00:51 2011 mysql-embedded-devel-5.1.52-1.el6_0.1.x86_64.rpm
-rw-r--r-- 1 taira taira 2.5M  7月 20 00:51 2011 mysql-libs-5.1.52-1.el6_0.1.x86_64.rpm
-rw-r--r-- 1 taira taira  14M  7月 20 00:51 2011 mysql-server-5.1.52-1.el6_0.1.x86_64.rpm
-rw-r--r-- 1 taira taira 7.2M  7月 20 00:51 2011 mysql-test-5.1.52-1.el6_0.1.x86_64.rpm

# rpm -Uvh mysql-server-5.1.52-1.el6_0.1.x86_64.rpm mysql-5.1.52-1.el6_0.1.x86_64.rpm
準備中...                ########################################### [100%]
   1:mysql                  ########################################### [ 50%]
   2:mysql-server           ########################################### [100%]

小規模サーバ向けmy.cnfのサンプルです。さくらのVPS 512/1Gを想定しています。

[mysqld]
## character set
character_set_server = utf8
collation_server = utf8_general_ci

## isolation level
transaction_isolation = READ-COMMITTED

## connection
max_connections = 151
thread_cache_size = 8

## per connection buffer
sort_buffer_size = 4M

## file descriptor
table_open_cache = 4096

## network
max_allowed_packet = 16M

## storage engine
default_storage_engine = InnoDB

## innodb plugin
ignore-builtin-innodb
plugin-load = innodb=ha_innodb_plugin.so;innodb_trx=ha_innodb_plugin.so;innodb_locks=ha_innodb_plugin.so;innodb_lock_waits=ha_innodb_plugin.so;innodb_cmp=ha_innodb_plugin.so;innodb_cmp_reset=ha_innodb_plugin.so;innodb_cmpmem=ha_innodb_plugin.so;innodb_cmpmem_reset=ha_innodb_plugin.so
innodb_file_format = Barracuda
innodb_file_per_table = 1
innodb_flush_log_at_trx_commit = 1
innodb_flush_method = O_DIRECT
innodb_buffer_pool_size = 64M
innodb_log_buffer_size = 8M
innodb_log_file_size = 128M

## myisam
key_buffer_size = 1M

## query cache
query_cache_type = 1
query_cache_size = 16M

## binary log
#server_id = 1
#log_bin = mysql-bin
#binlog_format = MIXED
#binlog_cache_size = 1M

## slow query log
slow_query_log = 1
long_query_time = 1.0

show pluginsコマンドで、InnoDB Pluginが有効化されていることが確認できます。

mysql> show plugins;
+---------------------+--------+--------------------+---------------------+---------+
| Name                | Status | Type               | Library             | License |
+---------------------+--------+--------------------+---------------------+---------+
| binlog              | ACTIVE | STORAGE ENGINE     | NULL                | GPL     |
| partition           | ACTIVE | STORAGE ENGINE     | NULL                | GPL     |
| CSV                 | ACTIVE | STORAGE ENGINE     | NULL                | GPL     |
| MEMORY              | ACTIVE | STORAGE ENGINE     | NULL                | GPL     |
| MyISAM              | ACTIVE | STORAGE ENGINE     | NULL                | GPL     |
| MRG_MYISAM          | ACTIVE | STORAGE ENGINE     | NULL                | GPL     |
| InnoDB              | ACTIVE | STORAGE ENGINE     | ha_innodb_plugin.so | GPL     |
| INNODB_TRX          | ACTIVE | INFORMATION SCHEMA | ha_innodb_plugin.so | GPL     |
| INNODB_LOCKS        | ACTIVE | INFORMATION SCHEMA | ha_innodb_plugin.so | GPL     |
| INNODB_LOCK_WAITS   | ACTIVE | INFORMATION SCHEMA | ha_innodb_plugin.so | GPL     |
| INNODB_CMP          | ACTIVE | INFORMATION SCHEMA | ha_innodb_plugin.so | GPL     |
| INNODB_CMP_RESET    | ACTIVE | INFORMATION SCHEMA | ha_innodb_plugin.so | GPL     |
| INNODB_CMPMEM       | ACTIVE | INFORMATION SCHEMA | ha_innodb_plugin.so | GPL     |
| INNODB_CMPMEM_RESET | ACTIVE | INFORMATION SCHEMA | ha_innodb_plugin.so | GPL     |
+---------------------+--------+--------------------+---------------------+---------+
14 rows in set (0.00 sec)

ご利用ください。

MySQLでALTER TABLE文の進捗状況を確認する

MySQLでテーブルへのカラム追加やテーブルの再編成を行うには、ALTER TABLE文を使用します。MySQLのALTER TABLE文は、変更後の定義にもとづく作業用テーブルを作成し、変更前のテーブルから作業用テーブルへデータをコピーして、最後に二つのテーブルを入れ替えるという仕組みになっています。テーブルへのインデックス追加についても、現在のところ大半のケースで内部的にALTER TABLE文が実行されています。
ALTER TABLE文の怖いところは、処理がもうすぐ終わるのかどうかが分からないところです。テーブルサイズが1GBを超えるあたりから分単位の時間がかかるようになり、100GBともなると本当に終わるのか?と見ていて不安になります。メンテナンス時間が限られている場合は、作業を中断すべきかどうか難しい判断を迫られることもあります。
実は、というほどではありませんが、ALTER TABLE文の進み具合を確認する方法があります。

mysql> SHOW GLOBAL STATUS LIKE 'Handler_write';
+---------------+----------+
| Variable_name | Value    |
+---------------+----------+
| Handler_write | 22472949 |
+---------------+----------+
1 row in set (0.00 sec)

ALTER TABLE文は内部的に作業用テーブルへのINSERT処理を実行するので、ステータス変数Handler_writeにINSERTされたレコード件数が記録されていきます。

mysql> ALTER TABLE order_line ENGINE = InnoDB;
Query OK, 4796697 rows affected (1 min 4.78 sec)
Records: 4796697  Duplicates: 0  Warnings: 0

mysql> SHOW GLOBAL STATUS LIKE 'Handler_write';
+---------------+----------+
| Variable_name | Value    |
+---------------+----------+
| Handler_write | 27269647 |
+---------------+----------+
1 row in set (0.00 sec)

ですから、作業開始前にHandler_writeの値と対象テーブルのレコード件数を控えておけば、どこまで処理が進んだのかを確認することができるのです。InnoDBの場合はInnodb_rows_insertedでも同じ値が取れます。

#!/bin/bash

while true
do
  cat <<_EOF_
SHOW GLOBAL STATUS LIKE 'Handler_write';
_EOF_
  sleep 10
done | mysql -u root -p -N
$ ./watch.sh
Enter password:
Handler_write   27270904
Handler_write   27270916
Handler_write   27270917
Handler_write   27270918
Handler_write   27981431
Handler_write   28791103
Handler_write   29546564
Handler_write   30319521
Handler_write   30980129
Handler_write   31629177
Handler_write   32067622
Handler_write   32067623
Handler_write   32067624
Handler_write   32067625
Handler_write   32067626

ただしテーブルサイズが大きくなるとINSERT処理がだんだん遅くなるため、最初の10分間で1億レコード処理できたとしても、次の10分間でまた1億レコード処理できるとは限らないという点には注意が必要です。

MySQL Info形式のマニュアル

本番環境のトラブルシューティングで、サーバルームに缶詰になって作業することがあります。インターネットには繋がらない、ノートパソコンは持ち込み禁止、場合によっては携帯電話も持ち込み禁止、突然呼ばれたのであの本この本も持ってきていないという状況で、MySQLのコマンドをど忘れしたらどうすればよいでしょうか。
実はMySQLには公式サイトに掲載されているものと同じマニュアルが付属しており、infoコマンドで閲覧することができます。

$ info mysql


Red Hat Enterprise Linux/CentOSのバンドル版ではmysqlパッケージ、公式バイナリではMySQL-serverパッケージにマニュアルが入っています。Info形式のマニュアルは、GNUのソフトウェアで標準的に使われているものだそうです。
操作方法をメモしておきます。

キー 内容
Enter リンクをたどる
Space、BackSpace 次のページに進む/前のページに戻る
n、p 次の章節に進む/前の章節に戻る (深さを変えない。1.2→1.3)
]、[ 次の章節に進む/前の章節に戻る (深さを変える。1.2→1.2.1)
u 上の章節に戻る
q 終了する

サーバルームでinfoコマンドの操作方法を思い出せない場合は、こうしています。

$ gunzip -c /usr/share/info/mysql.info.gz | less

今日はクエリキャッシュをクリアしようとして「FLUSH QUERY CACHE」を打っても何も起こらず困っていたのですが、このマニュアルのおかげで作業を進めることができました。「RESET QUERY CACHE」でした…。

SSDに対するBlock Discard/TRIMをSystemTapで可視化する

パソコンを買いました。

今回はOLIOSPECさんのMicro Monster H67 Super Silentをカスタマイズして注文しました。デスクトップ機をリプレイスするのは実に9年ぶりなのですが、最近のパソコンはすごく良いですね。速くて静かで低消費電力です。本日はこのパソコンを使って、近年のLinuxにおけるSSD事情を確認していきたいと思います。

TRIMとは

近頃では定番SSDの座をすっかりCrucial RealSSD C300に奪われてしまった感のあるIntel X25-M G2ですが、4KiBアライメントが不要という使い勝手の良さ、およびWindows XPでもTRIMを利用可能というところを評価してこちらを選びました。どちらの製品も間もなくモデルチェンジを控えており、今が買いどきかと言われると少し微妙な情勢です。でも欲しかったので仕方がないです。

TRIMとは、OSがSSDに対し不要になった領域を通知するATA規格のコマンドです。SSDはNANDフラッシュの特性上、空き領域が少なくなると性能が劣化するという弱点があります。OS上でファイルを削除すれば空き領域は増えたように見えますが、SSDにとってはそれが「書いた」のか「消した」のかの区別がつかないため、TRIMコマンドによって実際に「消した」ことを伝える必要があります。

ただし、古いOSではTRIMがサポートされていません。TRIMを利用するには最新のOSとファイルシステムを使用する必要があります。現在のところ、以下のOSとファイルシステムの組み合わせでTRIMを利用することが可能となっています。

例外的にIntel X25-M G2については、Intel SSD Toolboxを用いることでWindows XP/VistaでもTRIMを利用することができます。

LinuxにおけるTRIMの利用とモニタリング

本当はCentOS 6.0がリリースされるのを首を長くして待っていたのですが、待ちきれなかったのでとりあえずFedora 14をインストールしました。LinuxでTRIMを利用するには、ext4ファイルシステムをマウントする際にdiscardオプションを指定します。

# mount -t ext4 -o discard /dev/sda5 /mnt

Linuxでは、ブロックデバイスに不要領域を通知する機能のことをBlock Discardと呼んでいます。Block DiscardによってATAバイスにはTRIMコマンドが、SCSIバイスにはUNMAPというコマンドが発行されます。
さて、設定はこれだけなのですが、これで実際にTRIMが機能しているのかどうか少し心配になります。というのも、TRIMについてはログなどが何も出力されないためです。
そこで、SystemTapを用いてBlock Discardの様子をモニタリングするスクリプトを作ってみました。SystemTapについては昨年のエントリでも取り扱っていますので、そちらも是非ご覧ください。

以下のスクリプトをdiscard.stpという名前で保存します。

probe kernel.function("blkdev_issue_discard").return {
  printf("%s, sector=%d, nr_sects=%d, return=%d (%d - %d KiB)\n",
    execname(), $sector, $nr_sects, $return,
    $sector / 2, ($sector + $nr_sects) / 2 - 1);
}

stapコマンドでSystemTapを起動します。

# stap discard.stp

別のターミナルで、大きなファイルを作ってから削除してみます。

# dd if=/dev/zero of=temp.dat bs=8388608 count=128 oflag=direct
128+0 records in
128+0 records out
1073741824 bytes (1.1 GB) copied, 9.1373 s, 118 MB/s

# rm temp.dat 
rm: remove 通常ファイル `temp.dat'? y

すると、数秒経ってから以下のようなログが出力されました。

jbd2/sda5-8, sector=5767168, nr_sects=16392, return=0 (2883584 - 2891779 KiB)
jbd2/sda5-8, sector=4718592, nr_sects=262144, return=0 (2359296 - 2490367 KiB)
jbd2/sda5-8, sector=4980736, nr_sects=262144, return=0 (2490368 - 2621439 KiB)
jbd2/sda5-8, sector=5242880, nr_sects=262144, return=0 (2621440 - 2752511 KiB)
jbd2/sda5-8, sector=5505024, nr_sects=262144, return=0 (2752512 - 2883583 KiB)
jbd2/sda5-8, sector=6029312, nr_sects=262144, return=0 (3014656 - 3145727 KiB)
jbd2/sda5-8, sector=5799936, nr_sects=229376, return=0 (2899968 - 3014655 KiB)
jbd2/sda5-8, sector=6291456, nr_sects=262144, return=0 (3145728 - 3276799 KiB)
jbd2/sda5-8, sector=6815744, nr_sects=32768, return=0 (3407872 - 3424255 KiB)
jbd2/sda5-8, sector=6569984, nr_sects=245760, return=0 (3284992 - 3407871 KiB)

左から、プロセス名、TRIM開始セクタ、セクタ数、戻り値、括弧内はTRIM対象範囲をKiB単位に換算した値です。jbd2というのはファイルシステムジャーナリング機能を付加するJournaling Block Deviceモジュールのext4対応版です。見ると、確かに合計1GiBの領域がTRIMされていることや、一度にTRIMされる領域が最大128MiBであることなどが分かります。
また、Fedora 14ではファイルシステム構築時にもTRIMが発行されます。

# mkfs -t ext4 /dev/sda5
mke2fs 1.41.12 (17-May-2010)
Filesystem label=
OS type: Linux
Block size=4096 (log=2)
Fragment size=4096 (log=2)
Stride=0 blocks, Stripe width=0 blocks
524288 inodes, 2097152 blocks
104857 blocks (5.00%) reserved for the super user
First data block=0
Maximum filesystem blocks=2147483648
64 block groups
32768 blocks per group, 32768 fragments per group
8192 inodes per group
Superblock backups stored on blocks: 
	32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632

Writing inode tables: done                            
Creating journal (32768 blocks): done
Writing superblocks and filesystem accounting information: done

This filesystem will be automatically checked every 25 mounts or
180 days, whichever comes first.  Use tune2fs -c or -i to override.

このときSystemTapの出力は以下のようになり、一度にデバイスの全領域をTRIMしていることが見てとれます。

mkfs.ext4, sector=0, nr_sects=16777216, return=0 (0 - 8388607 KiB)

一方、HDDなどTRIMに対応していないデバイスでは、戻り値として-95が返されます。これはEOPNOTSUPP、「Operation not supported on transport endpoint」というエラーを表しています。

jbd2/sdb5-8, sector=1312776, nr_sects=8, return=-95 (656388 - 656391 KiB)
jbd2/sdb5-8, sector=270336, nr_sects=245760, return=-95 (135168 - 258047 KiB)
jbd2/sdb5-8, sector=524288, nr_sects=262144, return=-95 (262144 - 393215 KiB)
jbd2/sdb5-8, sector=1048576, nr_sects=262144, return=-95 (524288 - 655359 KiB)
jbd2/sdb5-8, sector=802816, nr_sects=245760, return=-95 (401408 - 524287 KiB)
jbd2/sdb5-8, sector=1572864, nr_sects=262144, return=-95 (786432 - 917503 KiB)
jbd2/sdb5-8, sector=1327104, nr_sects=245760, return=-95 (663552 - 786431 KiB)
jbd2/sdb5-8, sector=2097152, nr_sects=262144, return=-95 (1048576 - 1179647 KiB)
jbd2/sdb5-8, sector=1851392, nr_sects=245760, return=-95 (925696 - 1048575 KiB)
jbd2/sdb5-8, sector=2375680, nr_sects=65536, return=-95 (1187840 - 1220607 KiB)

おわりに

メジャーなLinuxディストリビューションにおけるSSDのTRIM対応はWindowsに比べて少し遅れていたため、これまでSSDの導入に躊躇していたのですが、ようやく条件が整ってきました。ちょうどIntelから新しいCPUも発売されたことですし、Sandy BridgeRHEL 6.0/Ubuntu 10.10+最新SSDへと環境を刷新する絶好のタイミングかもしれません。
LinuxでのSSD利用についてはもう少し調べているので、情報がまとまったら再度レポートしたいと思います。