背景
数百万行のデータを保持するMySQLから、定時でSolrのインデックス構築用にMySQLから全行の取得を行っていた。
ただし、サービスインしているためクエリ一発で取得するのは負荷のためNGであり、繰り返し一定行数データを取得する必要があった。
今回、既存クエリでSlow Queryが発生していたため、対応が必要になった。
今回の説明のため、下記の単純なテーブルを例に挙げる。 また、ベンチマークの取得に時間がかかりすぎたのでレコード数は20万に抑えた。
CREATE TABLE `user` ( `id` int(10) NOT NULL AUTO_INCREMENT, `name` varchar(20) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
既存クエリ
LIMIT句でoffsetを指定しながら1000件ずつ取得していた。
SELECT * FROM user ORDER BY id ASC LIMIT 10000,1000
EXPLAINの結果は次の通り。
mysql> EXPLAIN SELECT * FROM user ORDER BY id ASC LIMIT 10000,1000; +----+-------------+-------+-------+---------------+---------+---------+------+-------+-------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------+-------+---------------+---------+---------+------+-------+-------+ | 1 | SIMPLE | user | index | NULL | PRIMARY | 4 | NULL | 11000 | | +----+-------------+-------+-------+---------------+---------+---------+------+-------+-------+
offsetで取ってくる順序を保証しないと、繰り返し実行時に重複してデータを取得する可能性があるので、ORDER句は必須。
上記の通りフルインデックススキャンになっている。
offset位置までのシーケンシャルリード+取得件数分のリードが発生するため、offsetが大きくなればなるほど遅くなる。
改善案1 - BETWEENを使って範囲検索を狙う
BETWEENを使った範囲検索であれば、ソートも不要になりかなり高速になる。
SELECT * FROM user WHERE id BETWEEN 10001 AND 11000
EXPLAIN結果
mysql> EXPLAIN SELECT * FROM user WHERE id BETWEEN 10001 AND 11000; +----+-------------+-------+-------+---------------+---------+---------+------+------+-------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------+-------+---------------+---------+---------+------+------+-------------+ | 1 | SIMPLE | user | range | PRIMARY | PRIMARY | 4 | NULL | 999 | Using where | +----+-------------+-------+-------+---------------+---------+---------+------+------+-------------+ 1 row in set (0.00 sec)
改善案2 - WHERE句で開始位置を限定する
サービス上で削除が頻繁に行われる場合はデータが虫食いになるので、BETWEENだと毎回期待した件数分を取得できるとは限らない。
そのため、繰り返し数が多くなる傾向があり、これを嫌う開発者も存在する。
そういった場合を考慮するのであれば、WHERE句で開始位置を指定してORDER、LIMITを使うと範囲検索になるので、既存よりは高速化できる。
SELECT * FROM user WHERE id > 10000 ORDER BY id ASC LIMIT 1000
EXPLAIN結果
mysql> EXPLAIN SELECT * FROM user WHERE id > 10000 ORDER BY id ASC LIMIT 1000; +----+-------------+-------+-------+---------------+---------+---------+------+--------+-------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------+-------+---------------+---------+---------+------+--------+-------------+ | 1 | SIMPLE | user | range | PRIMARY | PRIMARY | 4 | NULL | 100155 | Using where | +----+-------------+-------+-------+---------------+---------+---------+------+--------+-------------+ 1 row in set (0.00 sec)
ベンチマーク
下記コードで上記3パターンについてベンチマークを行い比較した。
use strict; use warnings; use DBI; use Benchmark qw(:all); my $dbh = DBI->connect('dbi:mysql:sota', 'root', ''); my $sth1 = $dbh->prepare(q{ SELECT * FROM user ORDER BY id ASC LIMIT ?, ? }); my $sth2 = $dbh->prepare(q{ SELECT * FROM user WHERE id BETWEEN ? AND ? }); my $sth3 = $dbh->prepare(q{ SELECT * FROM user WHERE id > ? ORDER BY id ASC LIMIT ? }); cmpthese(100, { before => sub { foreach my $i ( 0 .. 199 ) { my $start = $i * 1000; $sth1->execute($start, 1000); } }, after1 => sub { foreach my $i ( 0 .. 199 ) { my $start = $i * 1000 + 1; my $end = ( $i + 1 ) * 1000; $sth2->execute($start, $end); } }, after2 => sub { foreach my $i ( 0 .. 199 ) { my $start = $i * 1000; $sth3->execute($start, 1000); } }, });
結果を以下に示す。
Rate 1 2 3 before 20.6/s -- -34% -34% after1 31.2/s 51% -- -0% after2 31.2/s 51% 0% --
既存クエリが最も遅く、実行中にSlow Queryが多発していた。
改善案1、改善案2は今回はそれほど差は出なかった。 もっとレコード数が多い場合は改善案1と改善案2に差が見られるかもしれない。