SQL Lab
第二部 パフォーマンス編 · 第 0 章 · 約 15

1 年後のショップと、この計器盤

3,200 万行の注文明細。4 つの計器と 4 つのタブ、実行計画という実物、キャッシュの温度。

この章で答える問い
  1. 1 年後のテーブルは、第一部と何が同じで何が違うか
  2. この計器盤の 4 つの数字と 4 つのタブは、何を見せているか
  3. 同じクエリの 2 回目が速いのはなぜか

実物: 1 年後のテーブル

テーブルの大きさ
見る場所: order_items の data_mb(約 2,000MB)と table_rows

第一部 0 章で数十 KB だった表が、order_items だけで 2GB になっている。table_rows は推定値で、正確な行数はこのあと数える。表の名前と大きさを見たので、次は列の並びを見る。

customersidINT PKnameVARCHARtierVARCHARcreated_atDATETIME50 万行ordersidBIGINT PKcustomer_id複製元INTstatus複製元VARCHARordered_at複製元DATETIME800 万行order_itemsidBIGINT PKorder_idBIGINTcustomer_id複製INTproduct_idINTquantityTINYINTunit_priceINTstatus複製VARCHARordered_at複製DATETIME3,200 万行(主役)参照参照帯 = orders から意図的に複製した列。同じ値を 2 か所に持つ。破線 = 参照する列。外部キー制約は付けていない。products(2 万行)・stocks(2 万行)と一部の列は省略。
第一部と同じ 3 つの表だが、order_items に orders から複製した列(帯の 3 行)が増え、外部キー制約は付けていない。

第一部との違いが 2 つある。どちらも、行数が増えたときに現場でよく行われる判断だ。

1 つ目は、order_items に customer_id、status、ordered_at が複製されていること。第一部 4 章の正規化では「同じ事実を 2 か所に書かない」と言った。 それを破っている。理由は、このショップで最も多く実行されるクエリが「この顧客の直近の購入明細」で、正規化したままだと毎回 orders と JOIN する必要があるからだ。 複製すれば order_items だけで答えられる。代償として、注文の status を変えるときは orders と order_items の両方を更新する必要がある(第一部 7 章のトランザクションで)。 これを非正規化と呼び、「読みが多く、更新の経路を限定できる」列に対して行う。

2 つ目は、外部キー制約を付けていないこと。第一部 4 章で、存在しない顧客の注文を DB が拒否するのを見た。 1 秒に数千行を書き込むテーブルでは、書き込みごとに親の行を確認するコストが無視できなくなり、大規模なサービスでは制約を外してアプリ側で整合性を担保することがある。 この教材の第二部もそうしている。「制約を外すと何が守られなくなるか」を第一部で理解した上で外す、という順序が大事だ。

実物: 計器盤

第一部 8 章で一度見た 4 つのタブが、この第二部の主役になる。まず何も考えずに実行して、4 つの計器を見る。

顧客 123456 の購入明細
見る場所: 上段の 4 つの計器。特に「読んだ行数」と「アクセス方法」

数秒かかったはずだ。返した行数は 1、読んだ行数は 3,200 万、アクセス方法は Table scan。 これがマイページのアラートの正体で、第 1 章で直す。ここでは計器の意味を押さえる。

  • 実行時間 — クエリ本体にかかった時間。1 秒を超えると赤、20ms 未満で青。
  • 読んだ行数 — 結果を作るために InnoDB が触った行の数(Innodb_rows_read)。返した行数との差が「読んだのに捨てた量」。100 万を超えると赤。
  • 返した行数 — 結果の行数。表示は先頭 500 行まで。
  • アクセス方法 — 実行計画の葉ノード。Table scan なら全行を読み、Index lookup なら索引で直行した。

実物: 実行計画

「実測プラン」タブを開き、右上の「生テキスト」を押すと、DB が返した文字列そのものが出る。こういう形をしている。

-> Aggregate: count(0), avg(order_items.unit_price)  (cost=3.6e+6 rows=1) (actual time=4500..4500 rows=1 loops=1)
    -> Filter: (order_items.customer_id = 123456)  (cost=3.3e+6 rows=3.2e+6) (actual time=190..4500 rows=64 loops=1)
        -> Table scan on order_items  (cost=3.3e+6 rows=32e+6) (actual time=0.5..3900 rows=32e+6 loops=1)

これが実行計画で、「DB がこの SQL をどういう手順で処理したか」の記録だ。3 行あり、下から上へ読む。 一番下で order_items を全部読み(Table scan)、その上で customer_id が 123456 の行だけ残し(Filter)、一番上で数えて平均した(Aggregate)。 手順を決めた DB 内部の部品をオプティマイザと呼ぶ。読み方の詳細は第 2 章でやる。

4 つのタブの役割はこうなる。

  • 結果 — 結果セット。
  • 推定プラン — 実行する前にオプティマイザが立てた計画(EXPLAIN FORMAT=TREE)。rows= はすべて見積もり。
  • 実測プラン — 実行して測った計画(EXPLAIN ANALYZE)。各行に見積もりと実測が並び、ラボはそれを 2 本のバーで表示する。ズレが 10 倍を超えると赤い注意書きが出る。行をクリックすると演算子の解説が開く。
  • 統計 — 実行前後のサーバー統計の差分。ディスクから読んだページ数、一時テーブル、ソートの回数など。

4 つの計器は、このタブのどこかの値を抜き出して上段に並べたものだ。実行時間だけはタブになく、ラボが測っている。

c0-first の 4 つの計器を、4 つのタブに結ぶとこうなる。

実行時間数秒読んだ行数3,200 万返した行数1アクセス方法Table scan(タブなし)ラボが計測した時間統計サーバー統計の差分結果結果セット実測プランEXPLAIN ANALYZE推定プランEXPLAIN FORMAT=TREE実行前の見積もり。rows= はすべて見積もりで、計器には出ないInnodb_rows_read結果の行数一番下の行: Table scan on order_items読んだ行数 − 返した行数 = 読んだのに捨てた量(3,200 万 − 1)
計器は 4 つのタブの要約で、読んだ行数は統計タブの Innodb_rows_read、アクセス方法は実測プランの一番下の行から来る。

推定と実測の 2 つがある理由は、オプティマイザが実行前の見積もりで計画を選ぶからだ。上の計画では「customer_id = 123456 は 320 万行残るだろう」と見積もっている(rows=3.2e+6)。 実際は 64 行。見積もりが外れれば、選ばれる計画も外れることがある。第 3 章の障害はそれで起きる。

いきなり本番の罠: COUNT(*)

第一部 3 章で一瞬だった COUNT(*) を、3,200 万行で叩く。かかる時間を予測してから実行する。

注文明細は何行あるか
見る場所: 実行時間
実行する前に予測する

数秒かかったはずだ。InnoDB は行数をどこにも記録していないので、COUNT(*) は一番小さい索引(今は主キーしかないので本体)を端から端まで数える。 管理画面に「総注文数」を素朴に出すと、ページを開くたびにこの数秒が発生する。正確な数が要らないなら information_schema の推定値、要るなら集計済みの表を別に持つ。

温まったキャッシュ、冷えたキャッシュ

同じクエリを続けて 2 回実行し、実行時間と、統計タブの Innodb_buffer_pool_reads を比べる。

同じクエリを 2 回
見る場所: 履歴の #1 と #2 の実行時間。統計タブの Innodb_buffer_pool_reads(ディスクから読んだページ数)

2 回目のほうが速く、Innodb_buffer_pool_reads が 0 に近づいたはずだ。1 回目はディスクからページを読み、2 回目はメモリ(バッファプール)にあるものを使った。 つまり実行時間には「クエリの良し悪し」と「キャッシュの温度」が混ざっている。 この教材でクエリ同士を比べるときは、必ず 2 回ずつ実行して 2 回目同士で比べること。

この画面の約束

  • 1 回に実行できる文は 1 つ。
  • SELECT は 60 秒でタイムアウトする。「終わらなかった」こと自体が結果になる章がある。
  • 索引の作成(CREATE INDEX)にはタイムアウトがない。3,200 万行への索引作成は 1〜2 分かかる。
  • 各章は前の章で作った索引を前提にすることがある。本文冒頭の「前提」に書く。

1 年後の DB は、同じ表が 3,200 万行になり、非正規化と制約の省略という現場の判断が加わっている。計器盤は「何行読んだか」と「どう探したか」を見せる。

次の章へ

マイページのアラートを止める。顧客 123456 の 64 行のために 3,200 万行を読んでいる理由と、索引の中身を開ける。