タグ: MySQL

  • 実行計画キャッシュ の再利用 多段階JOINを含むSQL 後編

    実行計画キャッシュ の再利用 多段階JOINを含むSQL 後編

    はじめに

    この記事は「 実行計画キャッシュ の再利用 多段階JOINを含むSQL」シリーズの後編です。前編では5段階JOINと複数集計を含むクエリを対象にしたテーブル設計とデータ投入を、そして中編では4つの異なるクエリ構文パターン(リテラル固定、リテラル可変、PREPARE固定、SET + PREPARE)におけるEXPLAIN ANALYZEの出力を比較しました。
    後編となる本記事では、それぞれのクエリを1000回ずつ実行した際のパフォーマンスデータを元に、実行計画キャッシュの再利用や構文の違いによる実行速度への影響を検証・考察します。MySQLは8.4.5を利用しています。


    パターンごとの1000回実行結果

    各パターンを1000回ずつ実行し、平均実行時間・合計実行時間を測定しました。結果は以下の通りです。

    パターン 内容 total_time_sec avg_time_sec
    リテラル固定(user_id = 1234 599.4615 秒 0.5995 秒
    リテラル可変(user_id = [変更あり] 598.7534 秒 0.5988 秒
    PREPARE + EXECUTE(user_id固定) 615.8934 秒 0.6159 秒
    SET @user_id + PREPARE + EXECUTE(変更)※SET含む 615.7727 秒 0.6158 秒

    実行結果の比較と解釈

    パターン1が他よりも速かった理由

    リテラル固定はキャッシュの再利用が難しいとされますが、今回の測定では結果的に最も高速でした。MySQLのパーサやオプティマイザが十分に効率化されている可能性があります。単純な構文であるがゆえにオーバーヘッドが最小限に抑えられているのかもしれません。

    パターン2もほぼ同等の速さ

    user_idを変更しても構文の大枠が変わらないため、MySQLの内部で解析や最適化が効率化されている可能性はあります。ただし、今回の検証ではステートメントキャッシュの明確な影響は確認できておらず、「キャッシュが効いている」と断言する根拠はありません。

    PREPARE系がやや遅い理由

    パターン3とパターン4はやや遅くなっています。これはPREPARE + EXECUTEの構造がストアド・ステートメント的な扱いとなり、毎回の EXECUTE 呼び出しのたびに多少の内部処理コストが発生するためと考えられます。また、今回の計測結果ではリテラル構文とPREPARE構文の間に約2〜3%の差が見られました。SQLインジェクション対策としてPREPAREを採用することは正当であり、セキュリティの代償としてこの程度のパフォーマンス差は許容範囲と考えるべきです。

    SET変数のオーバーヘッドについて

    パターン4ではSETを1000回実行しており、その合計時間が0.1320秒と画像から確認できました。つまり、SET文1回あたりの平均は0.0001秒程度であり、今回の合計実行時間に大きな影響は与えていません。SETのオーバーヘッドは無視できるほど小さいと考えてよいでしょう。


    MySQL まとめ

    • リテラル固定でもパフォーマンスは高かった(キャッシュ以外の要因の可能性)
    • PREPARE構文は安全で再利用性が高いが、やや遅延が見られることもある
    • SET変数のオーバーヘッドは非常に小さい(0.132秒/1000回)
    • 今回の検証でもステートメントキャッシュの影響は明確に確認できなかった
    • リテラル構文とPREPARE構文では約2%の実行時間差があり、これはセキュリティ対策のコストとして妥当な範囲

    次回のテーマ

    SQLインジェクション

  • 実行計画キャッシュ の再利用 多段階JOINを含むSQL 中編

    実行計画キャッシュ の再利用 多段階JOINを含むSQL 中編

    はじめに

    この記事は「 実行計画キャッシュ の再利用 多段階JOINを含むSQL」シリーズの中編です。前編では、10万件以上のデータを投入するためのテーブル設計とINSERT文を整備しました。中編となる本記事では、実際に使用するクエリのパターンを4つ紹介し、それぞれのEXPLAIN ANALYZEの出力を掲載しながら、MySQLがどのようにクエリを最適化・実行しているかを紐解いていきます。MySQLは8.4.5を利用しています。


    比較する4つのクエリパターン

    今回比較するのは以下の4つのパターンです。

    1. パターン1:リテラル固定埋め込み
      user_id = 1234 をSQL文に直接書き込む。
    2. パターン2:リテラル可変埋め込み
      1234 の部分を別のidに差し替えて何度も実行。
    3. パターン3:PREPARE + EXECUTE(値固定)
      変数は使わず、クエリ文字列内に固定の = 1234 を埋め込んでPREPARE。
    4. パターン4:SET + PREPARE + EXECUTE(値可変)
      SET @user_id = 1234; で変数を定義し、クエリ内では = @user_id を使用。

    クエリはいずれも以下のようなJOIN構造と集計を含みます:

    SELECT
        COUNT(DISTINCT o.id) AS order_count,
        COUNT(DISTINCT p.id) AS product_count,
        SUM(p.price * oi.quantity) AS total_amount
    FROM
        order_items AS oi
    INNER JOIN orders AS o
        ON oi.order_id = o.id
        AND o.user_id = 1234
    INNER JOIN products AS p
        ON oi.product_id = p.id
    INNER JOIN categories AS c
        ON p.category_id = c.id
    INNER JOIN suppliers AS s
        ON p.supplier_id = s.id;
    

    パターンごとの EXPLAIN ANALYZE

    以下に、それぞれのクエリに対する EXPLAIN ANALYZE の抜粋結果を掲載します。なお、これらの EXPLAIN ANALYZE はすべてクエリの初回実行時に行ったものであるため、MySQLによる実行計画キャッシュの再利用は行われていません。したがって、実行時間は構文の差や初回解析・最適化コストの影響を受けている点に注意が必要です。EXPLAIN ANALYZEより各クエリの実行計画に大きな差がないことが確認できますね。

    パターン1:リテラル固定

    -> Group aggregate: count(distinct o.id), ... (actual time=1958..1958 rows=1 loops=1)
        -> Nested loop inner join (actual time=388..1956 rows=42 loops=1)
            -> Filter: (o.user_id = 1234) (actual time=0.00317..0.00317 rows=0 loops=300000)
    

    最も単純なパターンであり、実行時間はやや長め。

    パターン2:リテラル可変

    -> Group aggregate: count(distinct o.id), ... (actual time=728..728 rows=1 loops=1)
        -> Nested loop inner join (actual time=8.86..727 rows=42 loops=1)
            -> Filter: (o.user_id = 1234) (actual time=0.00194..0.00194 rows=0 loops=300000)
    

    リテラルが変更されても構文が類似しているため、パースや最適化の結果にあまり差が見られない。

    パターン3:PREPARE + EXECUTE

    -> Group aggregate: count(distinct o.id), ... (actual time=714..714 rows=1 loops=1)
        -> Nested loop inner join (actual time=3.82..714 rows=42 loops=1)
            -> Filter: (o.user_id = 1234) (actual time=0.00192..0.00192 rows=0 loops=300000)
    

    PREPAREによってパースのコストは削減されるが、EXECUTE時に若干のオーバーヘッドが発生している可能性があります。

    パターン4:SET + PREPARE + EXECUTE

    -> Group aggregate: count(distinct o.id), ... (actual time=717..717 rows=1 loops=1)
        -> Nested loop inner join (actual time=3.21..717 rows=42 loops=1)
            -> Filter: (o.user_id = 1234) (actual time=0.00193..0.00193 rows=0 loops=300000)
    

    SETによって変数に値を設定し、PREPAREされたクエリでそれを参照。パターン3とほぼ同等の実行計画と時間。


    次回予告

    後編ではこれらのパターンを用いて 1000回ずつ実行したパフォーマンステスト の結果と、その考察をまとめていきます。

  • 実行計画キャッシュ の再利用 多段階JOINを含むSQL 前編

    実行計画キャッシュ の再利用 多段階JOINを含むSQL 前編

    はじめに

    本記事では、MySQLにおけるSQLパフォーマンスの違い.実行計画キャッシュ を比較するための準備として、大規模なデータセットを用意する方法を解説します。最終的には、user_idを用いた複数のクエリパターンに対し、PREPARE + EXECUTEやリテラル埋め込み方式などを比較し、それぞれの実行時間にどのような違いが現れるかを検証します。

    この記事は3部構成の前編にあたります。ここでは主にテストデータの構造を設計し、MySQL上にデータを投入するところまでを扱います。MySQLは8.4.5を利用しています。


    テストデータの準備

    今回の検証に使用するテーブルは以下のとおりです。

    テーブル名 件数目安 補足
    users 10,000件 ユーザーごとに複数注文を発生させる
    orders 100,000件 1人あたり10件程度(user_idはランダム or 連番)
    order_items 300,000件 1注文あたり3品程度
    products 10,000件 重複利用を前提とする(order_items.product_id
    categories 10件 1000商品に1カテゴリくらいの粒度
    suppliers 100件 商品ごとにランダムで紐付け

    テーブル作成スクリプト

    CREATE TABLE users (
        id INT PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(100)
    );
    
    CREATE TABLE orders (
        id INT PRIMARY KEY AUTO_INCREMENT,
        user_id INT,
        created_at DATETIME,
        FOREIGN KEY (user_id) REFERENCES users(id)
    );
    
    CREATE TABLE products (
        id INT PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(100),
        price INT,
        category_id INT,
        supplier_id INT
    );
    
    CREATE TABLE order_items (
        id INT PRIMARY KEY AUTO_INCREMENT,
        order_id INT,
        product_id INT,
        quantity INT
    );
    
    CREATE TABLE categories (
        id INT PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(100)
    );
    
    CREATE TABLE suppliers (
        id INT PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(100)
    );
    

    データ投入スクリプト

    -- users(10,000件)
    INSERT INTO users (name)
    SELECT CONCAT('user_', LPAD(@i:=@i+1, 5, '0'))
    FROM information_schema.columns, (SELECT @i:=0) vars
    LIMIT 10000;
    
    -- categories(10件)
    INSERT INTO categories (name)
    SELECT CONCAT('category_', LPAD(@i:=@i+1, 2, '0'))
    FROM information_schema.columns, (SELECT @i:=0) vars
    LIMIT 10;
    
    -- suppliers(100件)
    INSERT INTO suppliers (name)
    SELECT CONCAT('supplier_', LPAD(@i:=@i+1, 3, '0'))
    FROM information_schema.columns, (SELECT @i:=0) vars
    LIMIT 100;
    
    -- products(10,000件)
    INSERT INTO products (name, price, category_id, supplier_id)
    SELECT 
      CONCAT('product_', LPAD(@i:=@i+1, 5, '0')),
      FLOOR(100 + RAND() * 900),
      FLOOR(1 + RAND() * 10),
      FLOOR(1 + RAND() * 100)
    FROM information_schema.columns, (SELECT @i:=0) vars
    LIMIT 10000;
    
    -- orders(100,000件)
    INSERT INTO orders (user_id, created_at)
    SELECT 
      FLOOR(1 + RAND() * 10000),
      NOW() - INTERVAL FLOOR(RAND() * 365) DAY
    FROM information_schema.columns, (SELECT @i:=0) vars
    LIMIT 100000;
    
    -- order_items(300,000件)
    INSERT INTO order_items (order_id, product_id, quantity)
    SELECT 
      FLOOR(1 + RAND() * 100000),
      FLOOR(1 + RAND() * 10000),
      FLOOR(1 + RAND() * 5 + 1)
    FROM information_schema.columns, (SELECT @i:=0) vars
    LIMIT 300000;
    

    次回予告

    次回の中編では、上記データを用いたクエリの実行計画の比較、および各方式のEXPLAIN ANALYZE結果を読み解く準備について取り上げます。

  • MySQL 実行計画キャッシュとSQL再利用の検証(単一テーブル編)

    MySQL 実行計画キャッシュとSQL再利用の検証(単一テーブル編)

    はじめに

    SQLを処理する際、 MySQL はまずクエリの構文解析や最適化を行い、その結果として「実行計画」を生成します。この実行計画には、どのインデックスを使うか、テーブルをどの順で結合するかといった情報が含まれます。通常、同じクエリが繰り返し実行される場合、都度この計画を作り直すのは非効率です。そこで登場するのが「実行計画のキャッシュ」です。特にPREPARE文を用いたプレースホルダ形式のSQLでは、 MySQL が生成した実行計画を再利用できるため、パフォーマンスが安定しやすくなります。

    本記事では、実行計画のキャッシュが MySQL のパフォーマンスにどのような影響を与えるかを、単純な SELECT * FROM orders WHERE user_id = ? を用いて検証しました。

    MySQL は8.4.5を利用しています。


    検証の目的

    • リテラルSQLとプレースホルダSQL(PREPARE + EXECUTE)のパフォーマンス差を測定
    • performance_schema.events_statements_summary_by_digest による実行統計を比較

    テストデータの準備

    検証用に orders テーブルを作成し、10万件のダミーデータを挿入しました。user_id は 1〜10000 をローテーションして割り当てています。

    -- 既存テーブルがあれば削除
    DROP TABLE IF EXISTS orders;
    
    -- orders テーブルの作成
    CREATE TABLE orders (
        id INT AUTO_INCREMENT PRIMARY KEY,
        user_id INT NOT NULL,
        product_name VARCHAR(100),
        order_date DATETIME DEFAULT CURRENT_TIMESTAMP
    );
    
    -- インデックスの追加(必須)
    CREATE INDEX idx_user_id ON orders(user_id);
    
    -- データ挿入(10万件)
    SET @uid = 0;
    INSERT INTO orders (user_id, product_name)
    SELECT
        (@uid := @uid + 1) % 10000 + 1 AS user_id,
        CONCAT('Product-', FLOOR(1 + RAND() * 100))
    FROM
        information_schema.columns AS a,
        information_schema.columns AS b
    LIMIT 100000;
    
    -- 統計情報の更新
    ANALYZE TABLE orders;
    

    検証環境と方法

    1. orders テーブルを TRUNCATE し、10万件のデータを挿入
    2. ANALYZE TABLE orders を実行し統計情報を更新
    3. リテラル形式のSELECTを1000回実行
    4. digest統計を確認・記録
    5. events_statements_summary_by_digest を TRUNCATE
    6. PREPARE + SET + EXECUTE形式で同様に1000回実行
    7. digest統計を再度記録

    検証に使用したSQL

    以下はそれぞれ1000回ずつ実行したSQLのサンプルです。

    リテラルSQL(1000回)

    
    -- 例:user_id を毎回変えて1000回実行
    SELECT * FROM orders WHERE user_id = 1;
    SELECT * FROM orders WHERE user_id = 2;
    SELECT * FROM orders WHERE user_id = 3;
    ...
    SELECT * FROM orders WHERE user_id = 1000;
    

    プレースホルダSQL(1000回)

    
    -- 実行前にPREPARE
    PREPARE stmt FROM 'SELECT * FROM orders WHERE user_id = ?';
    
    -- 変数を代入してEXECUTE
    SET @uid = 1;
    EXECUTE stmt USING @uid;
    
    SET @uid = 2;
    EXECUTE stmt USING @uid;
    
    SET @uid = 3;
    EXECUTE stmt USING @uid;
    ...
    SET @uid = 1000;
    EXECUTE stmt USING @uid;
    

    ※PREPAREは1回だけでOKですが、SETとEXECUTEは毎回繰り返します。

    プレースホルダSQL(PREPARE文+EXECUTE文)は、実行計画のキャッシュ対象となります。
    一度PREPAREされたSQLは、異なるバインド変数(@uid)に対しても同じ実行計画が再利用され、
    SQL解析・最適化のコストを軽減できます。詳細は

    MySQL公式ドキュメント: SQL Syntax for Prepared Statements
    を参照してください。


    実行結果

    クエリ形式 構成要素 COUNT total_time_sec avg_time_sec
    リテラルSQL SELECT * FROM orders WHERE user_id = … 1000 0.3740 0.0004
    プレースホルダSQL SET @uid = ? 1000 0.1259 0.0001
    EXECUTE stmt USING @uid 1000 0.3448 0.0003
    合計(プレースホルダ) SET + EXECUTE 2000 0.4707

    考察

    • EXECUTE単体ではプレースホルダの方が速い(avg 0.0003秒 vs 0.0004秒)
    • ただし、SETのコストを含めると、プレースホルダ全体の処理時間は約26%遅くなった
    • 実行計画キャッシュ自体は効いており、EXECUTEが高速だったのはその証拠
    • 今回のような単純なクエリでは、プレースホルダの恩恵が薄い可能性もある

    MySQL まとめ

    MySQLにおける実行計画キャッシュの効果は、SQLの再利用性に依存します。今回はプレースホルダ形式によって実行計画が再利用されていることが確認できましたが、SETのオーバーヘッドにより、単純なクエリではリテラルSQLの方が速くなる場合もあります。

    今後はより複数テーブルの結合クエリでのキャッシュ効果を検証することで、実運用に近いケースでの有用性を確認していきます。


    次回のテーマ

    多段階JOINを含むSQLでのキャッシュ再利用

  • MySQL ANALYZE TABLE の効果と使いどき

    MySQL ANALYZE TABLE の効果と使いどき

    統計情報と実行計画の関係を理解する

    MySQL のパフォーマンスチューニングの中で見落とされがちなのが、 MySQL ANALYZE TABLE の役割です。

    パフォーマンスが出ないクエリに対してインデックスやSQLの書き方だけに注目しがちです。実は、統計情報の鮮度が見落とされることも少なくありません。MySQLのオプティマイザは、テーブルの中身を直接見るのではなく、統計的な情報を使って実行プランを決定しています。そのため、古くなった統計情報に基づいて選ばれたプランは、現実と大きくズレた非効率なものになりがちです。

    ANALYZE TABLE コマンドは、テーブルの統計情報(インデックスのカーディナリティやデータ分布)を更新し、クエリオプティマイザがより正確な実行計画を立てられるようにするためのものです。

    この記事では、「推定と実測のズレ」がクエリパフォーマンスに与える影響や、有効な使いどきを具体的な事例とともに紹介していきます。

    MySQLバージョンは8.4.5を利用しています。


    ANALYZE TABLE とは?

    ANALYZE TABLE は、テーブルやインデックスに関する統計情報を収集し直すコマンドです。

    ANALYZE TABLE テーブル名;
    

    この統計情報は、クエリ実行時に使われる EXPLAINEXPLAIN ANALYZErows=cost= の推定に反映されます。

    • 統計が古いと → 推定値がズレてしまう
    • 推定がズレると → 最適でない実行計画が選ばれてしまう

    どんなときに実行すべき?

    大量のデータ更新があったとき

    • INSERTで10万件以上追加
    • DELETEでテーブルの大部分を削除
    • UPDATEでインデックス対象カラムの値を大幅に変更

    こういった場合は、統計情報が実態を反映していない可能性が高くなります。

    推定と実測にズレがあるとき(EXPLAIN ANALYZE)

    以下のようなケースでは、ANALYZE TABLEの実行が有効です:

    -> Index lookup on orders using idx_user_id (user_id=50000)
       (cost=0.35 rows=1)
       (actual time=0.012..0.012 rows=0 loops=1)
    

    このように推定rows=1に対し実測rows=0の場合、オプティマイザは誤った判断を下す可能性が高くなります。

    CREATE TABLEやストアドプロシージャ作成時は不要

    テーブルを作ったばかりの段階では統計情報が意味を持たないため、ANALYZEは不要です。ストアドプロシージャも同様で、クエリの中身が統計に依存するものでなければ関係ありません。


    効果を検証:統計更新前後での比較

    次のようにテストデータを大量に追加してから ANALYZE TABLE を実行し、その前後で EXPLAIN ANALYZE を比較してみます。

    -- 統計更新
    ANALYZE TABLE orders;
    
    -- クエリの実行計画を確認
    EXPLAIN ANALYZE
    SELECT *
    FROM orders
    WHERE user_id = 50000;
    

    この比較により、rows=(推定値)が実測に近づくケースが多く、JOIN順序の選択やインデックスの使用可否に良い影響を与えることが分かります。


    ANALYZE TABLE の効果と使いどきまとめ

    タイミング ANALYZE TABLE の必要性 理由
    テーブル作成直後 不要 まだデータがなく、統計も意味をなさない
    大量のINSERT/DELETE後 実行すべき 統計情報が現状と乖離している可能性が高い
    EXPLAINで推定と実測にズレがある 実行すべき 誤った実行計画が選ばれるのを防ぐ
    ストアドプロシージャ作成時 不要 ストプロ自体に統計の概念は関係しない

    パフォーマンスが思ったより出ていないと感じたとき、ANALYZE TABLE を使って統計情報を最新に保つことで、オプティマイザの判断精度が大幅に改善されることがあります。

    これは派手なテクニックではありません。しかし、パフォーマンスチューニングにおける地味で強力な一手です。

    今後の検証でもデータを投入後のテーブルに実行していこうと思います。


    次回のテーマ

    実行計画のキャッシュとSQL再利用の仕組みを見てみようと思います。

  • MySQL EXPLAIN ANALYZE を使った実行計画の実測検証

    MySQL EXPLAIN ANALYZE を使った実行計画の実測検証

    MySQL EXPLAIN ANALYZE を使った実行計画の実測検証

    MySQL 8.0.18 以降では、EXPLAIN ANALYZE という新機能が利用可能になりました。これは従来の EXPLAIN が出力する理論上の実行計画に加え、実際にクエリを実行して実測値(実行時間・行数・ループ数など)を表示してくれる強力な分析ツールです。

    この記事では、10万件のテストデータを使い、EXPLAIN ANALYZE の基本的な使い方と3種類のクエリによる実行計画の違いを実演形式で紹介します。

    MySQLは8.4.5を利用しています。


    テストデータの作成

    検証には以下の2テーブルを使用します。

    CREATE TABLE users (
        id INT PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(100),
        email VARCHAR(255)
    );
    
    CREATE TABLE orders (
        id INT PRIMARY KEY AUTO_INCREMENT,
        user_id INT,
        amount DECIMAL(10,2),
        created_at DATETIME,
        INDEX idx_user_id (user_id)
    );
    

    その後、10万件のテストデータを users および orders テーブルに挿入します(詳細SQLは記事末尾に記載)。


    クエリ①:インデックスを使った高速検索

    EXPLAIN ANALYZE
    SELECT *
    FROM orders
    WHERE user_id = 50000;
    

    実行計画出力(要約)

    -> Index lookup on orders using idx_user_id (user_id=50000)
       (cost=0.35 rows=1)
       (actual time=0.0119..0.0119 rows=0 loops=1)
    
    • インデックス使用:idx_user_id
    • 実行時間:0.012 ms(高速)
    • 実測件数:0(データなし)
    • 推定 vs 実測:rows=1(予測)とrows=0(実測)でズレ

    クエリ②:JOIN+GROUP BY の集計処理

    EXPLAIN ANALYZE
    SELECT u.id, u.name, COUNT(o.id) AS total_orders
    FROM users u
    INNER JOIN orders o
        ON u.id = o.user_id
    WHERE u.id BETWEEN 1000 AND 2000
    GROUP BY u.id;
    

    実行計画出力(要約)

    -> Group aggregate: count(o.id)  (actual time=0.0707..2.68 rows=647)
        -> Nested loop inner join  (actual time=0.0626..2.5 rows=1091)
            -> Index range scan on u using PRIMARY (actual time=0.0445..0.363 rows=1001)
            -> Covering index lookup on o using idx_user_id (actual time=0.0016..0.00188 rows=1.09 loops=1001)
    
    • インデックス使用:両テーブルで有効
    • 実行時間:約2.6ms
    • JOIN対象:1001人 → 注文あり:647人
    • 高速化要因:Covering Index 使用

    クエリ③:CASTによるインデックス無効化

    EXPLAIN ANALYZE
    SELECT *
    FROM orders
    WHERE CAST(user_id AS CHAR) = '50000';
    

    実行計画出力(要約)

    -> Filter: (cast(user_id as char) = '50000')  (actual time=36.7..36.7 rows=0)
        -> Table scan on orders  (actual time=1.33..25.6 rows=100000)
    
    • インデックス使用:無効化
    • 実行時間:約36ms(遅い)
    • 原因:CAST により全件スキャンが発生

    MySQL まとめ

    EXPLAIN ANALYZE を使うことで、従来の EXPLAIN では見えなかった実際の実行時間や取得行数、ループ回数を可視化でき、クエリのパフォーマンス改善に大きく貢献します。

    今回の3つのクエリを比較すると、以下のような違いが明確に表れました。

    クエリ インデックス 実行時間 実測件数 推定との乖離 主な原因
    クエリ①
    user_id = 50000
    使用 0.012ms 0 あり(rows=1 → 0) 統計情報の不一致
    クエリ②
    JOIN + GROUP BY
    使用 2.6ms 647件(JOIN後) なし(推定精度良好) インデックス+カバリング最適
    クエリ③
    CAST(user_id AS CHAR)
    無効 36ms 0 なし(全件スキャンで当然) 関数適用によるインデックス無効化

    学びと今後の指針

    • **インデックスは適切に設計されても、クエリの書き方次第で簡単に無効化される**ことを常に意識する必要があります。
    • **統計情報が古いと推定件数と実測件数にギャップが生まれ、クエリプランが最適でなくなる**ことがあります。ANALYZE TABLE による更新も重要です。
    • EXPLAIN ANALYZE を用いることで、パフォーマンス改善のボトルネックを論理ではなく実測で判断できるようになります。
    • 特に 関数(CAST・DATE・SUBSTRING など)をWHERE句で使うとインデックスが無効化されるため、列 = 値の形式を守ることがベストです。

    開発や運用の現場では「インデックスがあるはずなのに遅い」という場面に何度も出くわします。そのたびに EXPLAIN ANALYZE を使って「本当にインデックスが使われているのか」「何件読んでいるのか」「処理が重くなっている箇所はどこか」を検証できると、データベースの見え方が変わってきます。

    MySQL 8.0以降の開発では、EXPLAIN ANALYZEを積極的に活用することをおすすめします。


    次回のテーマ

    EXPLAIN ANALYSISの効果を発揮させるためにANALYSIS TABLEを紹介しましたが、次回はこれをいじってみたいと思います。

  • MySQL EXPLAIN出力の各カラムを解説(type, key, Extraなど)

    MySQL EXPLAIN出力の各カラムを解説(type, key, Extraなど)

    はじめに

    前回の記事では、EXPLAINとEXPLAIN ANALYZEの基本的な使い方を紹介しました。今回は、EXPLAINの出力結果に含まれる各カラムの意味を丁寧に解説していきます。

    これらのカラムを正しく読み解くことで、クエリの実行効率やボトルネックの特定が格段にしやすくなります。

    MySQLは8.4.5を利用しています。


    EXPLAINの基本構成

    EXPLAINを実行すると、以下のようなテーブル形式の出力が返されます。

    EXPLAIN
    SELECT
        U.name,
        O.total
    FROM
        users AS U
    INNER JOIN
        orders AS O
        ON U.id = O.user_id;
    
    id select_type table partitions type possible_keys key key_len ref rows filtered Extra
    1 SIMPLE O NULL ALL NULL NULL NULL NULL 29448 100.00 Using where
    1 SIMPLE U NULL eq_ref PRIMARY PRIMARY 4 testdb.O.user_id 1 100.00 NULL

    重要なカラムの意味と読み方

    type:結合タイプ(アクセス方法)

    このカラムは、テーブルへのアクセス方法を示します。パフォーマンスに最も影響する重要な指標です。

    type 意味
    ALL フルテーブルスキャン(最も遅い)
    index インデックスの全走査
    range インデックスの範囲検索
    ref インデックスに基づく複数行の検索
    eq_ref インデックスを使った単一行の結合(最も効率的)
    const / system 1件の行を直接取得(最も高速)

    基本的に、ALL → const の順で効率が良くなります。


    key:実際に使用されたインデックス

    このカラムには、クエリで使用されたインデックスの名前が表示されます。NULLであれば、インデックスが使用されていないことを意味します。

    • インデックスを貼ってもここがNULLであれば意味がない
    • 適切なWHERE句やJOIN条件が指定されていない可能性あり

    possible_keys:利用可能なインデックス候補

    このカラムには、クエリ実行時に使える可能性があるインデックスが表示されます。

    • keyがNULLでも、ここに候補が出ていれば最適化の余地あり
    • ここもNULLなら、そもそも使えるインデックスがない可能性が高い

    rows:読み込まれる行数の見積もり

    MySQL が「このテーブルから何件読み取ることになるか」と予測した行数です。

    • ここが多ければ多いほど、負荷がかかる可能性がある
    • 実際の件数と大きくずれる場合は EXPLAIN ANALYZE の活用がおすすめ

    filtered:WHERE句などで残る行の割合

    クエリの条件で「何%のレコードが残るか」を示します。100.00なら、全件が対象ということです。

    • rows × filtered ÷ 100 ≒ 実際に処理される行数

    Extra:その他の補足情報

    最も重要なカラムの一つです。ここにはクエリの詳細な処理内容が表示されます。

    Extra 意味
    Using where WHERE句でフィルタ処理が行われている
    Using index カバリングインデックスが使用されている
    Using temporary 一時テーブルを使っている(遅い可能性あり)
    Using filesort ファイルソート(ORDER BYなどで遅くなりがち)
    NULL 特に補足なし(シンプルな処理)

    ここに Using temporaryUsing filesort が出ている場合は、注意が必要です。


    MySQL まとめ

    • typeは最も重要。ALLを避けることが基本
    • keyがNULLの場合、インデックスが使われていない可能性あり
    • rowsfilteredの組み合わせで負荷を予測できる
    • ExtraUsing filesortUsing temporaryが出たら要注意

    次回のテーマ

    今回はEXPLAINを確認したので次回はMySQL8.0から実装されたEXPLAIN ANALYSISをいじってみたいと思います。

  • MySQL EXPLAIN,EXPLAIN ANALYSISの使い方を解説

    MySQL EXPLAIN,EXPLAIN ANALYSISの使い方を解説

    SQLのパフォーマンスチューニングにおいて、EXPLAINは欠かせない分析ツールです。クエリの実行計画を確認することで、インデックスが効いていない箇所や結合順序の無駄など、パフォーマンス改善の糸口を見つけることができます。

    この記事では、EXPLAINの基本的な使い方から、EXPLAIN ANALYZEを用いた実行時間の把握まで、10万件のテストデータを使って実演形式で紹介していきます。

    MySQLは8.4.5を利用しています。


    テストテーブルの作成とデータ準備

    今回は users テーブルと orders テーブルの2つを用意し、それぞれに大量のダミーデータを挿入します。外部キーやインデックスは付けずに、実行計画をわかりやすくすることを目的としています。

    テーブル作成SQL

    CREATE TABLE users (
        id INT PRIMARY KEY AUTO_INCREMENT,
        name VARCHAR(100),
        age INT
    );
    
    CREATE TABLE orders (
        id INT PRIMARY KEY AUTO_INCREMENT,
        user_id INT,
        total DECIMAL(10,2),
        created_at DATETIME
    );
    

    ダミーデータ挿入SQL(10万件以上対応)

    -- users に10万件
    INSERT INTO users (name, age)
    SELECT
        CONCAT('User_', num), FLOOR(20 + (RAND() * 40))
    FROM (
        SELECT @rownum := @rownum + 1 AS num
        FROM
            information_schema.columns c1,
            information_schema.columns c2,
            (SELECT @rownum := 0) AS init
        LIMIT 100000
    ) AS numbers;
    
    -- orders に3万件
    INSERT INTO orders (user_id, total, created_at)
    SELECT
        FLOOR(1 + (RAND() * 10000)),
        ROUND(RAND() * 1000, 2),
        NOW() - INTERVAL FLOOR(RAND() * 365) DAY
    FROM
        information_schema.columns c1,
        information_schema.columns c2
    LIMIT 30000;
    

    EXPLAINの基本的な使い方

    EXPLAIN
    SELECT
        *
    FROM
        users
    WHERE
        age > 30;
    

    このように、対象のクエリの前に EXPLAIN をつけるだけで実行計画が表示されます。

    実行例:users テーブルの全件検索

    id select_type table partitions type possible_keys key key_len ref rows filtered Extra
    1 SIMPLE users NULL ALL NULL NULL NULL NULL 99960 33.33 Using where

    結合クエリに対するEXPLAINの使用

    EXPLAIN
    SELECT
        U.id,
        U.name,
        O.total
    FROM
        users AS U
    INNER JOIN
        orders AS O
        ON U.id = O.user_id;
    
    id select_type table partitions type possible_keys key key_len ref rows filtered Extra
    1 SIMPLE O NULL ALL NULL NULL NULL NULL 29448 100.00 Using where
    1 SIMPLE U NULL eq_ref PRIMARY PRIMARY 4 testdb.O.user_id 1 100.00 NULL

    ポイント

    • ordersテーブルはインデックスなしの全件スキャン(7〜9ms)
    • usersテーブルは主キーインデックスによる高速検索(1件あたり0.0016ms)

    EXPLAIN ANALYZEによる詳細な実行時間の確認

    MySQL 8.0以降で利用できる EXPLAIN ANALYZE は、従来の EXPLAIN に比べて、より実行時に近い情報を得ることができる強力なツールです。

    通常の EXPLAIN は「このクエリはこういう風に実行されるかもしれない」という推定計画を返します。一方で EXPLAIN ANALYZE はクエリを実際に実行した上で、「どれくらいの時間がかかり」「何行が処理されたか」「何回ループされたか」などの実測結果を返してくれます。

    EXPLAIN ANALYZE
    SELECT
        U.name,
        O.total
    FROM
        users AS U
    INNER JOIN
        orders AS O
        ON U.id = O.user_id;
    
    -> Nested loop inner join  (cost=13276 rows=29448) (actual time=0.469..61.2 rows=30000 loops=1)
        -> Filter: (O.user_id is not null)  (cost=2969 rows=29448) (actual time=0.454..9.35 rows=30000 loops=1)
            -> Table scan on O  (cost=2969 rows=29448) (actual time=0.453..7.42 rows=30000 loops=1)
        -> Single-row index lookup on U using PRIMARY (id=O.user_id)  (cost=0.25 rows=1) (actual time=0.00157..0.00159 rows=1 loops=30000)
    

    EXPLAINとの違い

    • EXPLAIN: 実行計画の「予測値」を表示(rows, cost, typeなど)
    • EXPLAIN ANALYZE: 実際にクエリを実行し、「実測値(actual time, loops, 実際のrows)」を返す

    この違いにより、例えば EXPLAIN では “rows=10000” と推定されていたのに、実際は “rows=2” だったといったケースもあります。そのような差異を把握することで、MySQLオプティマイザの挙動と実際の処理のズレを確認できます。

    各出力項目の意味

    • actual time=X..Y: クエリステップの開始から終了までにかかった時間(ミリ秒)
    • rows=N: 実際に処理されたレコード数
    • loops=M: そのステップが何回繰り返されたか

    例えば、上記出力の末尾には:

    • loops=30000:ordersの各行に対して、usersの1件をルックアップしている(ネストループ構造)
    • actual time=0.00157..0.00159:usersへの主キー検索は1件あたり約0.0016ミリ秒と非常に高速

    なぜ便利なのか?

    以下のような状況では EXPLAIN ANALYZE が特に効果を発揮します:

    • 実行時間が長いが原因がわからない
    • インデックスを貼ったのに効いているか不明
    • サブクエリやJOINのパフォーマンスに差がある
    • 実行計画の読みと、実際の実行結果に差があると感じたとき

    従来の EXPLAIN では判断が難しかった「どの部分に時間がかかっているのか?」という疑問に、EXPLAIN ANALYZEは明確な数値で答えてくれます。

    注意点

    EXPLAIN ANALYZEは実際にクエリを実行します。 そのため、大量データの更新・削除・挿入クエリなどでは使用に注意が必要です。誤ってUPDATEやDELETEで実行すると、実データに影響を及ぼします(SELECTでのみ使うようにしてください)。

    開発・検証環境での強力な武器

    たとえばパフォーマンスチューニング中に、クエリの候補が2つあるとき:

    1. どちらが早いか?
    2. どちらがテーブル全体をスキャンしているのか?
    3. インデックスは実際に使われているのか?

    こういった判断をするときに、EXPLAIN ANALYZEが実行結果を伴った形でガイドしてくれます。

    今回の例のように、usersテーブルに対して主キー検索が高速に行われているという確認ができるのも、実際の実行時間が明示されているからこそわかるポイントです。


    MySQL まとめ

    • EXPLAINはクエリの実行計画を視覚化するのに非常に有用
    • インデックスの使用状況やスキャンタイプを確認できる
    • EXPLAIN ANALYZEを使えば実行時間の詳細まで取得可能

    今回は使い方に焦点を当てましたが、次回は各出力カラムの意味について詳しく解説する予定です。


    次回のテーマ

    EXPLAIN出力の各カラム(type, key, rows, Extraなど)を解説

  • MySQL INNER JOIN と EXISTS のパフォーマンス差2

    MySQL INNER JOIN と EXISTS のパフォーマンス差2

    はじめに

    SQLを書くとき、INNER JOINEXISTSのどちらを使うべきか迷うことがあります。特に、DISTINCTが不要なケースでは、出力結果が同じでもパフォーマンスに差が出ることがあります。

    この記事では、重複排除が不要な1対1のテーブル構造において、INNER JOIN と EXISTS のパフォーマンス差を検証し、実行結果・実行時間・実行計画の違いを詳しく比較します。

    MySQLは8.4.5を利用しています。


    テストテーブルの作成とデータ挿入

    テスト用に以下の2テーブルを用意しました。

    • users: ユーザー情報(10万件)
    • orders: 各ユーザーの注文情報(1対1、インデックスなし)

    データは以下のように生成しました(高速連番生成)。

    CREATE TABLE users (
        id INT PRIMARY KEY,
        name VARCHAR(100)
    );
    
    CREATE TABLE orders (
        id INT PRIMARY KEY,
        user_id INT,
        product VARCHAR(100)
    );
    
    -- users データ挿入
    INSERT INTO users (id, name)
    SELECT
        @row := @row + 1 AS id,
        CONCAT('user_', @row)
    FROM
        (SELECT 0 FROM information_schema.columns LIMIT 1000) AS t1,
        (SELECT 0 FROM information_schema.columns LIMIT 100) AS t2,
        (SELECT @row := 0) AS init;
    
    -- orders データ挿入
    INSERT INTO orders (id, user_id, product)
    SELECT
        @row2 := @row2 + 1 AS id,
        @row2 AS user_id,
        CONCAT('product_', @row2)
    FROM
        (SELECT 0 FROM information_schema.columns LIMIT 1000) AS t3,
        (SELECT 0 FROM information_schema.columns LIMIT 100) AS t4,
        (SELECT @row2 := 0) AS init;
    

    INNER JOIN と EXISTS のクエリ比較

    INNER JOIN クエリ

    SELECT
        U.id,
        U.name
    FROM
        users AS U
    INNER JOIN
        orders AS O
        ON U.id = O.user_id;
    

    実行結果(上位10件):

    id name
    1 user_1
    2 user_2
    3 user_3
    4 user_4
    5 user_5
    6 user_6
    7 user_7
    8 user_8
    9 user_9
    10 user_10

    EXISTS クエリ

    SELECT
        U.id,
        U.name
    FROM
        users AS U
    WHERE
        EXISTS (
            SELECT
                1
            FROM
                orders AS O
            WHERE
                O.user_id = U.id
        );
    

    実行結果(上位10件):

    id name
    1 user_1
    2 user_2
    3 user_3
    4 user_4
    5 user_5
    6 user_6
    7 user_7
    8 user_8
    9 user_9
    10 user_10

    パフォーマンス比較

    実行時間(10回の平均)

    クエリ種別 COUNT_STAR total_time_sec avg_time_sec
    INNER JOIN 10 1.9675 0.1967
    EXISTS 10 1.3704 0.1370

    EXPLAIN結果(INNER JOIN)

    id select_type table type possible_keys key key_len ref rows filtered Extra
    1 SIMPLE O ALL NULL NULL NULL NULL 100040 100.00 Using where
    1 SIMPLE U eq_ref PRIMARY PRIMARY 4 testdb.O.user_id 1 100.00 NULL

    EXPLAIN結果(EXISTS)

    id select_type table type possible_keys key key_len ref rows filtered Extra
    1 SIMPLE U ALL PRIMARY NULL NULL NULL 97689 100.00 NULL
    1 SIMPLE <subquery2> eq_ref <auto_distinct_key> <auto_distinct_key> 5 testdb.U.id 1 100.00 NULL
    2 MATERIALIZED O ALL NULL NULL NULL NULL 100040 100.00 NULL

    考察:INNER JOINとEXISTSの使い分け

    今回の検証では、出力結果は同一ながら、EXISTSの方が平均30%ほど高速でした。これはサブクエリが「マッチする1行が見つかれば終了」する特性を活かせたためです。

    一方、INNER JOINは結合先を全スキャンしてからフィルタするため、特にインデックスがない場合はコストが高くなりがちです。

    結論として、次のような基準で使い分けると良いでしょう:

    • 結合先テーブルのデータを取得する必要がある場合 → INNER JOIN
    • 存在チェックだけで十分な場合 → EXISTS

    MySQL まとめ

    INNER JOIN と EXISTS のパフォーマンス差は、構文上は同じ意味でも、テーブル構造・インデックス有無・スキャン行数によって変わります。今回のようなDISTINCT不要なケースでは、特にEXISTSが有利になるシーンも多いことがわかりました。

    実行計画を確認し、適切に使い分けることが高速化への第一歩です。


    次回のテーマ

    EXPLAINの使い方をやってみたいと思います。

  • MySQL LEFT JOINとNOT EXISTSの使い分けとパフォーマンス差

    MySQL LEFT JOINとNOT EXISTSの使い分けとパフォーマンス差

    はじめに

    SQLの世界では、同じ目的を達成する複数の方法が存在します。その代表的な例が「LEFT JOIN を使った除外」と「NOT EXISTS を使った除外」です。一見似たような動きをするこれらの手法ですが、実際のパフォーマンスには大きな違いが出る場合があります。この記事では、両者の動作の違いを解説しつつ、実際にMySQL上で検証を行い、どちらがより高速なのかを検討していきます。

    MySQLは8.4.5を利用しています。


    テストテーブルの作成

    まずはパフォーマンス検証用に、ユーザーと注文履歴の2つのテーブルを用意します。意図的にインデックスを張らないことで、フルスキャンの影響も観察しやすくしています。

    CREATE TABLE users (
        id INT PRIMARY KEY,
        name VARCHAR(50)
    );
    
    CREATE TABLE orders (
        id INT PRIMARY KEY,
        user_id INT,
        item VARCHAR(50),
        FOREIGN KEY (user_id) REFERENCES users(id)
    );
    

    次に、それぞれのテーブルに10万件のデータを挿入します。ユーザーは10万件、注文はそのうち5万件のユーザーにだけ発行します。

    -- ユーザーを10万件挿入
    INSERT INTO users (id, name)
    SELECT
        seq,
        CONCAT('user_', seq)
    FROM (
        SELECT
            @row := @row + 1 AS seq
        FROM
            (SELECT 0 FROM information_schema.columns LIMIT 1000) AS t1,
            (SELECT 0 FROM information_schema.columns LIMIT 100) AS t2,
            (SELECT @row := 0) AS init
    ) AS numbers;
    
    -- 5万件分の注文データを挿入(偶数ユーザーのみ)
    INSERT INTO orders (id, user_id, item)
    SELECT
        seq,
        seq * 2,
        CONCAT('item_', seq)
    FROM (
        SELECT
            @row := @row + 1 AS seq
        FROM
            (SELECT 0 FROM information_schema.columns LIMIT 1000) AS t1,
            (SELECT 0 FROM information_schema.columns LIMIT 50) AS t2,
            (SELECT @row := 0) AS init
    ) AS numbers;
    
    id user_id item
    1 2 item_1
    2 4 item_2
    3 6 item_3
    4 8 item_4
    5 10 item_5
    6 12 item_6
    7 14 item_7
    8 16 item_8
    9 18 item_9
    10 20 item_10

    偶数のユーザーだけ注文するデータですね。


    LEFT JOIN を使った未注文ユーザーの抽出

    注文していないユーザーを抽出するために、LEFT JOIN と IS NULL を使うパターンが以下です。

    SELECT
        u.id,
        u.name
    FROM
        users AS u
    LEFT JOIN
        orders AS o
            ON
                u.id = o.user_id
    WHERE
        o.id IS NULL;
    

    このクエリは一見わかりやすく、多くの人に好まれますが、MySQLの内部的にはフルスキャンになりがちで、場合によってはパフォーマンスが低下します。

    id name
    1 user_1
    3 user_3
    5 user_5
    7 user_7
    9 user_9
    11 user_11
    13 user_13
    15 user_15
    17 user_17
    19 user_19

    NOT EXISTS を使った未注文ユーザーの抽出

    次に、同じ目的を NOT EXISTS で実現してみましょう。

    SELECT
        u.id,
        u.name
    FROM
        users AS u
    WHERE 
        NOT EXISTS (
            SELECT
                1
            FROM
                orders AS o
            WHERE
                o.user_id = u.id
        );
    

    サブクエリがユーザーごとに評価されるため、効率が悪いように思われがちですが、実際にはインデックスの有無やオプティマイザの判断によって高速になるケースが少なくありません。

    id name
    1 user_1
    3 user_3
    5 user_5
    7 user_7
    9 user_9
    11 user_11
    13 user_13
    15 user_15
    17 user_17
    19 user_19

    結果は同じです。


    実行計画とパフォーマンスの比較

    それぞれのクエリに対して、EXPLAINを使って実行計画を確認しました。

    LEFT JOIN

    id select_type table partitions type possible_keys key key_len ref rows filtered Extra
    1 SIMPLE u NULL ALL NULL NULL NULL NULL 100175 100.00 NULL
    1 SIMPLE o NULL ref user_id user_id 5 testdb.u.id 1 10.00 Using where; Not exists; Using index

    NOT EXISTS

    id select_type table partitions type possible_keys key key_len ref rows filtered Extra
    1 SIMPLE u NULL ALL NULL NULL NULL NULL 100175 100.00 NULL
    1 SIMPLE <subquery2> NULL eq_ref <auto_distinct_key> <auto_distinct_key> 5 testdb.u.id 1 100.00 Using where; Not exists
    2 MATERIALIZED o NULL index user_id user_id 5 NULL 50317 100.00 Using index
    • LEFT JOIN パターンでは、orders テーブルの reference が確認されました。
    • NOT EXISTS パターンでは、usersテーブルはallで共通ですが、ordersテーブルをindex scan&equal referenceでより効率的な検索になっていました。

    さらに、両者のクエリに対して SELECT SQL_NO_CACHE を使って測定したところ、以下のような実行時間の差がありました(あくまで一例)。

    DIGEST_TEXT COUNT_STAR total_time_sec avg_time_sec
    LEFT JOIN 20 3.5257 0.1763
    NOT EXISTS 20 1.8674 0.0934

    このように、テーブルサイズが大きい場合やインデックスが不足している状況では、NOT EXISTS の方が有利な結果になることが多いです。


    MySQL まとめ

    「LEFT JOIN + IS NULL」と「NOT EXISTS」は、どちらも同じ論理的結果を返すことができますが、MySQLのオプティマイザが選ぶ実行計画によって、実行速度に差が生まれる可能性があります。

    • 可読性重視なら LEFT JOIN も選択肢に入りますが、
    • パフォーマンス重視であれば NOT EXISTS を検討すべきです。
    • 特に大規模なテーブルでの除外クエリには注意が必要です。

    実際の案件では、 EXPLAIN 使ってみましょう。


    次回のテーマ

    EXISTSでもDISTINCTを使わない形式でも早いのか次回で比べてみたいと思います。