カテゴリー: 検証

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

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

    はじめに

    SQLで「関連するデータを持つ行」を取得する際、INNER JOINEXISTSのどちらを使うべきか迷ったことはありませんか?見た目の違いはあっても、結果は同じに見える場面が多いため、なんとなく使い分けている人も多いかもしれません。

    本記事では、INNER JOINとEXISTSの構文と挙動、そしてパフォーマンス差について、10万件のユーザー15万件の関連データを用いて実験・検証してみます。


    MySQL テーブル構成とデータ準備

    CREATE TABLE users (
        id          INT PRIMARY KEY,
        name        VARCHAR(50)
    );
    
    CREATE TABLE purchases (
        id          INT PRIMARY KEY,
        user_id     INT,
        product     VARCHAR(100)
    );
    

    それぞれに以下のような大量データを挿入します:

    -- ユーザー10万件
    INSERT INTO users (id, name)
    SELECT
        seq,
        CONCAT('user_', seq)
    FROM (
        SELECT
            @row := @row + 1 AS seq
        FROM
            information_schema.columns,
            information_schema.columns,
            (SELECT @row := 0) AS init
        LIMIT 100000
    ) AS seed;
    
    -- 購入履歴15万件(ユーザーIDに偏りあり)
    INSERT INTO purchases (id, user_id, product)
    SELECT
        seq,
        FLOOR(1 + RAND() * 100000),
        CONCAT('item_', FLOOR(RAND() * 100))
    FROM (
        SELECT
            @row := @row + 1 AS seq
        FROM
            information_schema.columns,
            information_schema.columns,
            (SELECT @row := 0) AS init
        LIMIT 150000
    ) AS seed;
    

    これにより、特定のユーザーには複数の購入履歴が紐付きます。

    
    SELECT
        *
    FROM
        purchases
    ORDER BY 
        user_id desc
    limit 10;
    

    などでuser_idがmax100000であること、1つのIDに複数のproductが紐づいていることを確認してください。
    ※ランダム生成なのでmaxが99999とかになることもあります。


    INNER JOINを使った「購入履歴のあるユーザー」抽出

    SELECT DISTINCT 
        u.id
    FROM
        users AS u
    INNER JOIN
        purchases AS p
            ON
                u.id = p.user_id;
    

    INNER JOINは、両テーブルで一致する行を返します。この場合、購入履歴のあるユーザーのみが取得されます。ただし、同じユーザーが複数購入している場合は重複するため、DISTINCTでの除外が必要です。


    EXISTSを使った同様の取得方法

    SELECT
        u.id
    FROM
        users AS u
    WHERE
        EXISTS (
            SELECT
                1
            FROM
                purchases AS p
            WHERE
                p.user_id = u.id
        );
    

    この構文では、ユーザーごとにサブクエリで購入履歴が存在するかを調べています。重複行が存在しないため、DISTINCTも不要です。


    パフォーマンス比較(10万×15万)

    インデックスは張らずに、MySQLでクエリ時間を計測してみました。

    INNER JOIN と EXISTS のパフォーマンス比較

    クエリ 実行回数
    (COUNT_STAR)
    合計実行時間
    (total_time_sec)
    平均実行時間
    (avg_time_sec)
    SELECT DISTINCTROW `u`.`id` FROM `users` AS `u`
    INNER JOIN `purcha…`
    10 3.8297 0.3830
    SELECT `u`.`id` FROM `users` AS `u`
    WHERE EXISTS (SELECT ? FROM …)
    10 1.6099 0.1610
    • INNER JOIN + DISTINCT:約3.8秒
    • EXISTS:約1.6秒

    INNER JOINは中間結果が膨大になり、DISTINCTでの重複排除にも時間がかかります。一方、EXISTSは条件を満たす1件さえ見つかればサブクエリを終了するため、効率が良いです。


    EXPLAINによる実行計画の違い

    INNER JOINの実行計画では:

    id select_type table partitions type possible_keys key key_len ref rows filtered Extra
    1 SIMPLE p NULL ALL NULL NULL NULL NULL 150080 100.00 Using where
    1 SIMPLE u NULL eq_ref PRIMARY PRIMARY 4 testdb.p.u_id 1 100.00 Using index
    • users: equal reference
    • purchases: full scan
    • 重複行をJOINで大量に生成 → DISTINCTでフィルタリング

    EXISTSの場合:

    id select_type table partitions type possible_keys key key_len ref rows filtered Extra
    1 SIMPLE u NULL index PRIMARY PRIMARY 4 NULL 97689 100.00 Using index
    1 SIMPLE <subquery2> NULL eq_ref <auto_distinct_key> <auto_distinct_key> 5 testdb.u.id 1 100.00 NULL
    2 MATERIALIZED p NULL ALL NULL NULL NULL NULL 150080 100.00 NULL
    • users: index full scan
    • purchases: full scan
    • サブクエリ: equal reference
    • 不要なデータの生成が少ない

    ”結合結果が大きくなるほど、EXISTSの効率が際立ちます。”

    ↑ってチャッピー(ChatGPT)のコメントなんだけど、DISTINCTが効率が悪いって考えの方が近いんじゃないかな。


    MySQL 使い分けの指針

    • INNER JOINは結果を結合して「複数の列を同時に取得」したいときに有効。つまりDISTINCTしないなら早い
    • EXISTSは「存在するかどうか」だけを調べる用途に最適
    • EXISTSは重複を持たない構造になっており、パフォーマンスに優れることが多い
    • データ量が多くなるほど、JOINよりEXISTSの利点が顕著になる

    次回のテーマ

    LEFT JOINとNOT EXISTSの違いとパフォーマンス比較 をやってみたいと思います。

  • MySQL JOINの順序やON句 vs WHERE句の違いと挙動の変化

    MySQL JOINの順序やON句 vs WHERE句の違いと挙動の変化

    はじめに

    SQLでテーブルを結合するときに使うJOIN句ですが、JOINの順序や、ON句とWHERE句の使い分けによって、意図しない絞り込みが発生することがあります。私自身、LEFT JOINを使っているつもりが、WHERE句のせいでINNER JOINのような挙動になっていたという経験があります。

    今回は、小さなテストテーブルを使って、JOINの順序とON句・WHERE句の違いがどのように影響するのかを丁寧に検証していきます。


    MySQL テスト用テーブルの作成

    CREATE TABLE users (
        id          INT PRIMARY KEY,
        name        VARCHAR(50)
    );
    
    CREATE TABLE orders (
        id          INT PRIMARY KEY,
        user_id     INT,
        item        VARCHAR(50)
    );
    
    CREATE TABLE payments (
        id          INT PRIMARY KEY,
        order_id    INT,
        amount      INT
    );
    

    初期データの挿入:

    INSERT INTO users VALUES
        (1, 'Alice'),
        (2, 'Bob'),
        (3, 'Charlie');
    
    INSERT INTO orders VALUES
        (1, 1, 'Book'),
        (2, 2, 'Pen');
    
    INSERT INTO payments VALUES
        (1, 1, 100);
    

    この構成では、Aliceは注文と支払いあり、Bobは注文のみ、Charlieはどちらもなしです。


    WHERE句で絞るとLEFT JOINが台無しに

    
    SELECT
        u.name,
        o.item,
        p.amount
    FROM
        users AS u
    LEFT JOIN
        orders AS o
            ON
                u.id = o.user_id
    LEFT JOIN
        payments AS p
            ON
                o.id = p.order_id
    WHERE
        p.amount > 0;
    
    name item amount
    Alice Book 100

    LEFT JOINを使っているように見えますが、WHERE p.amount > 0 によって p.amount がNULLの行が除外されてしまい、INNER JOINと同じような結果になります。これではLEFT JOINの意味がなくなります。


    ON句に条件を書くとLEFT JOINの意味が保たれる

    
    SELECT
        u.name,
        o.item,
        p.amount
    FROM
        users AS u
    LEFT JOIN
        orders AS o
            ON
                u.id = o.user_id
    LEFT JOIN
        payments AS p
            ON
                o.id = p.order_id
            AND p.amount > 0;
    
    name item amount
    Alice Book 100
    Bob Pen NULL
    Charlie NULL NULL

    このように、p.amount > 0 をON句に移すことで、LEFT JOINの「合致しなければNULL」が維持され、支払いのないBobや注文のないCharlieも結果に含まれるようになります。


    JOINの順序による結果の違い

    users → orders → payments(LEFT JOIN)

    
    SELECT
        u.name,
        o.item,
        p.amount
    FROM
        users AS u
    LEFT JOIN
        orders AS o
            ON
                u.id = o.user_id
    LEFT JOIN
        payments AS p
            ON
                o.id = p.order_id;
    
    name item amount
    Alice Book 100
    Bob Pen NULL
    Charlie NULL NULL

    usersを起点にしているため、Charlieのように何も関連がないユーザーも結果に含まれます。

    payments → orders → users(INNER JOIN)

    
    SELECT
        p.amount,
        o.item,
        u.name
    FROM
        payments AS p
    INNER JOIN
        orders AS o
            ON
                p.order_id = o.id
    INNER JOIN
        users AS u
            ON
                o.user_id = u.id;
    
    name item amount
    Alice Book 100

    この場合、paymentsを起点にしているため、支払いがあるAliceだけが結果に含まれます。


    副問い合わせにしても同じ罠がある

    
    SELECT
        *
    FROM (
        SELECT
            u.name,
            o.item,
            p.amount
        FROM
            users AS u
        LEFT JOIN
            orders AS o
                ON
                    u.id = o.user_id
        LEFT JOIN
            payments AS p
                ON
                    o.id = p.order_id
    ) AS result
    WHERE
        result.amount > 0;
    
    name item amount
    Alice Book 100

    LEFT JOINしていても、外側のWHERE句でNULLが除外されれば、INNER JOINのような挙動になります。やはりON句に条件を入れる方が安全です。


    MySQL JOIN 今日のまとめ

    • LEFT JOINにWHERE句で条件を書くと、INNER JOINのように絞り込まれてしまう
    • ON句に絞り込み条件を入れることで、NULLを保持した結合が可能
    • JOINの起点テーブルが結果に含まれる範囲を決める
    • 副問い合わせでもWHEREで絞るとLEFT JOINの意味が消える

    次回はINNER JOIN と EXISTS の使い分けとパフォーマンス差の検証をしてみたいと思います。

  • MySQL 入れ子JOINや副問い合わせを活用した柔軟な絞り込み手法

    MySQL 入れ子JOINや副問い合わせを活用した柔軟な絞り込み手法

    MySQL 入れ子JOINや副問い合わせを活用した柔軟な絞り込み手法を紹介します

    本記事では、ECサイトなどでよく見られる「ユーザー」「注文」「支払い」の3テーブル構成を使いながら、JOINと副問い合わせを活用した柔軟な絞り込み手法を紹介します。INNER JOIN や LEFT JOIN、NOT EXISTS、入れ子JOIN などを用いて、実務でも役立つデータの抽出方法を確認していきます。

    今回の用意したMySQLのデータ構造(テストデータ)

    -- users テーブル
    INSERT INTO users (id, name) 
    VALUES
    (1, 'Alice'), (2, 'Bob'), (3, 'Charlie'), (4, 'Diana'), (5, 'Ethan'),
    (6, 'Fiona'), (7, 'George'), (8, 'Hannah'), (9, 'Ian'), (10, 'Julia');
    
    -- orders テーブル(全15件)
    INSERT INTO orders (id, user_id, item) 
    VALUES
    (1, 1, 'Book'), (2, 2, 'Pen'), (3, 3, 'Notebook'), (4, 1, 'Bag'),
    (5, 4, 'Tablet'), (6, 6, 'Headphones'), (7, 7, 'Camera'), (8, 8, 'Charger'),
    (9, 9, 'Mouse'), (10, 10, 'Keyboard'), (11, 2, 'Laptop'),
    (12, 3, 'Monitor'), (13, 5, 'Desk'), (14, 6, 'Phone Case'), (15, 10, 'Speaker');
    
    -- payments テーブル(全12件)
    INSERT INTO payments (id, order_id, amount) 
    VALUES
    (1, 1, 1200), (2, 2, 300), (3, 3, 500), (4, 4, 2500), (5, 5, 15000),
    (6, 6, 9800), (7, 7, 42000), (8, 8, 1600), (9, 9, 2400),
    (10, 10, 5600), (11, 11, 89000), (12, 13, 12000);
    

    1. INNER JOIN による絞り込み

    支払いが完了している注文だけを対象に、ユーザー名・商品・金額を取得します。

    SELECT 
        u.name, 
        o.item, 
        p.amount
    FROM 
        users AS u
    INNER JOIN 
        orders AS o 
            ON 
                u.id = o.user_id
    INNER JOIN 
        payments AS p 
            ON 
            o.id = p.order_id;
    

    INNER JOIN 出力例

    name item amount
    Alice Book 1200
    Alice Bag 2500
    Bob Pen 300
    Bob Laptop 89000
    Charlie Notebook 500
    Diana Tablet 15000
    Fiona Headphones 9800
    George Camera 42000
    Hannah Charger 1600
    Ian Mouse 2400
    Julia Keyboard 5600
    Ethan Desk 12000

    2. LEFT JOIN + NULL 判定で未払い注文を抽出

    支払いが存在しない注文(未払い)を取り出します。LEFT JOIN を使い、NULL を条件にします。

    SELECT 
        o.*
    FROM 
        orders AS o
    LEFT JOIN 
        payments AS p 
            ON 
                o.id = p.order_id
    WHERE 
        p.id IS NULL;
    

    未払い注文の抽出結果

    id user_id item
    12 3 Monitor
    14 6 Phone Case
    15 10 Speaker

    3. サブクエリで支払い総額が多いユーザーを抽出

    支払い合計額が 20,000円以上のユーザー名を取得します。

    SELECT 
        u.name
    FROM 
        users AS u
    WHERE 
        u.id IN (
            SELECT 
                o.user_id
            FROM 
                orders AS o
            INNER JOIN 
                payments AS p 
                    ON 
                        o.id = p.order_id
            GROUP BY 
                o.user_id
            HAVING 
                SUM(p.amount) >= 20000
        );
    

    支払い総額が2万円以上のユーザー

    name
    Alice
    Bob
    Diana
    George
    Ethan

    4. 入れ子JOINとWHERE句を使った絞り込み

    支払い金額が 10,000円以上の注文だけを対象に、ユーザー名・商品・金額を取得します。
    ※1つのクエリに同じ一時テーブルを使いまわせないので

    payments2

    を作っています。

    
    CREATE TEMPORARY TABLE payments2 (
        id INT PRIMARY KEY,
        order_id INT,
        amount INT
    );
    
    INSERT INTO payments2
    SELECT 
        *
    FROM 
        payments 
    ;
    
    SELECT
        u.name,
        o.item,
        p2.amount 
    FROM
        users AS u 
    INNER JOIN 
        ( SELECT
            o.* 
        FROM
            orders AS o 
        INNER JOIN
            payments AS p
                ON
    	        o.id = p.order_id 
        WHERE
            p.amount >= 10000 ) AS o
    	    ON
    	        u.id = o.user_id 
    INNER JOIN
        payments2 AS p2
            ON
                o.id = p2.order_id;
    
    DROP TABLE IF EXISTS payments2;
    
    name item amount
    Diana Tablet 15000
    George Camera 42000
    Bob Laptop 89000
    Ethan Desk 12000

    5. NOT EXISTS を使って未払い注文を抽出

    サブクエリと NOT EXISTS を使って、支払いが存在しない注文を抽出します。

    SELECT 
        *
    FROM 
        orders AS o
    WHERE 
        NOT EXISTS (
            SELECT 
                1 
            FROM 
                payments AS p 
            WHERE 
                p.order_id = o.id
       );
    
    id user_id item
    12 3 Monitor
    14 6 Phone Case
    15 10 Speaker

    MySQL まとめ

    このように、JOINや副問い合わせを柔軟に組み合わせることで、「支払い済みかつ高額」「未払いだけを除外」「ユーザーごとの条件付き集計」など、業務で必要とされる複雑な絞り込みもSQLだけで実現できます。
    次回は、JOINの順序やON句 vs WHERE句の違いによる絞り込み挙動の変化について解説する予定です。

  • MySQL 注意!JOINの組み合わせ例:JOINの順序による絞り込みの罠

    MySQL 注意!JOINの組み合わせ例:JOINの順序による絞り込みの罠

    SQLで複雑な結合を行う際、JOINの種類や順序によって結果が意図しない形になることがあります。本記事では、LEFT JOINの後にINNER JOINを続けたときに、思わぬ絞り込みが発生するケースについて解説します。


    テーブル構造とサンプルデータ

    -- 一時テーブル削除
    DROP TEMPORARY TABLE IF EXISTS payments;
    DROP TEMPORARY TABLE IF EXISTS orders;
    DROP TEMPORARY TABLE IF EXISTS users;
    
    -- users(ユーザー)
    CREATE TEMPORARY TABLE users (
      id INT PRIMARY KEY,
      name VARCHAR(50)
    );
    
    -- orders(注文)
    CREATE TEMPORARY TABLE orders (
      id INT PRIMARY KEY,
      user_id INT,
      item VARCHAR(100)
    );
    
    -- payments(支払い)
    CREATE TEMPORARY TABLE payments (
      id INT PRIMARY KEY,
      order_id INT,
      amount INT
    );
    
    -- データ挿入(users)
    INSERT INTO users VALUES
    (1, 'Alice'),
    (2, 'Bob'),
    (3, 'Charlie');
    
    -- データ挿入(orders)
    INSERT INTO orders VALUES
    (101, 1, 'Book'),
    (102, 2, 'Laptop'),
    (103, NULL, 'Camera'), -- user_idがNULL
    (104, 99, 'Chair');     -- 存在しないuser_id(99)
    
    -- データ挿入(payments)
    INSERT INTO payments VALUES
    (201, 101, 3000),
    (202, 102, 80000);
    -- 注文103と104には支払い情報なし
    

    この状態で、ユーザー別の注文履歴とその支払い情報を取得したいとします。

    JOINの順序に注意

    次のようにJOINを書くと、意図しない結果になります。

    -- 想定外の絞り込みが起こる例
    SELECT u.name, o.item, p.amount
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
    INNER JOIN payments p ON o.id = p.order_id;
    

    実行結果:

    +-------+--------+--------+
    | name  | item   | amount |
    +-------+--------+--------+
    | Alice | Book   | 3000   |
    | Bob   | Laptop | 80000  |
    +-------+--------+--------+
    

    今回欲しい情報としてはユーザー別の注文履歴なのでCharlieの情報も欲しいです。しかし表の方の実行結果ではCharlieやその他の注文が表示されていませでした。これは、INNER JOIN paymentsが全体にかかっており、ordersにマッチしなかった行が除外されてしまうためです。

    意図通りにするには

    JOINの順序を制御するには、LEFT JOIN全体を括弧で囲み、サブクエリ風に扱うことで対応できます。

    -- 正しく全データを保持する形
    SELECT u.name, o.item, p.amount
    FROM (
      users u
      LEFT JOIN orders o ON u.id = o.user_id
    )
    LEFT JOIN payments p ON o.id = p.order_id;
    

    または、以下のようにJOINの入れ子で構成しても良いでしょう:

    SELECT u.name, o.item, p.amount
    FROM users u
    LEFT JOIN (
      orders o
      LEFT JOIN payments p ON o.id = p.order_id
    ) ON u.id = o.user_id;
    
    MySQLの期待実行結果
    期待した実行結果

    補足: 実務では、JOINの順序や種類を明確にするために、括弧を使って結合範囲を明示するのが一般的です。これは人間の可読性だけでなく、SQL解析ツールやBIツール、ORMなどのソフトウェアでも意図を正確に伝えるために重要です。

    補足2: パフォーマンスの面では、INNER JOIN を中心に構成されたクエリの方が MySQL の最適化が効きやすい傾向があります。LEFT JOIN を多用すると、結合の順序やNULLの扱いでオプティマイザが制限を受ける場合があるため、大規模データではEXPLAINや実行時間の比較も検討しましょう。

    補足3: 実務上、JOINを入れ子にして記述すると結合の範囲が明示されるため、結果の予測がしやすく、バグの防止にもつながります。構文として括弧を使うことで、JOINの優先順位を人間にもソフトにも分かりやすく示せます。なお、入れ子のJOINはINNER JOINが中心となるため、パフォーマンスの面でも有利な場合があります。(By ChatGPT) 私自身はこれまで実務で明示的な入れ子JOINを使う機会がありませんでした。可動性やRDBMSの種類によっては読み取り内容が異なるようなので積極的に使っていこうと思います。


    まとめ

    • JOINの種類だけでなく、結合の順序も結果に大きく影響する
    • LEFT JOIN → INNER JOIN は予期せぬ絞り込みを引き起こすことがある
    • サブクエリや括弧を使って結合範囲を明確にすることで防げる
    • 特に実務では明示的に書く方がバグや可読性の観点からも推奨される

    次回は、JOINの入れ子や副問い合わせを活用した柔軟な絞り込み手法を紹介します。

  • MySQL JOINのパフォーマンス(実行計画と実行時間)

    MySQL JOINのパフォーマンス(実行計画と実行時間)

    SQLでよく使われるJOINは、複数テーブルを結合して、共通の情報を一つの結果にまとめるための機能です。

    この節では、JOINのパフォーマンスについて検証してみます。


    検証用のテーブル構築

    本記事ではパフォーマンス検証のため、100万件のユーザーと60万件の注文データを持つ実テーブルを使用します。

    -- 既存のテーブル削除
    DROP TABLE IF EXISTS orders_large;
    DROP TABLE IF EXISTS users_large;
    
    -- ユーザーテーブル(100万件)
    CREATE TABLE users_large (
        id INT PRIMARY KEY,
        name VARCHAR(50)
    );
    
    -- 注文テーブル(ランダムに60万件)
    CREATE TABLE orders_large (
        id INT PRIMARY KEY AUTO_INCREMENT,
        user_id INT,
        product VARCHAR(100),
        INDEX(user_id)
    );
    
    -- ユーザー 100万件挿入
    INSERT INTO users_large (id, name)
    SELECT seq, CONCAT('User_', seq)
    FROM (
      SELECT @row := @row + 1 AS seq
      FROM (SELECT 0 FROM information_schema.columns LIMIT 100) t1,
           (SELECT 0 FROM information_schema.columns LIMIT 100) t2,
           (SELECT 0 FROM information_schema.columns LIMIT 100) t3,
           (SELECT 0 FROM information_schema.columns LIMIT 100) t4,
           (SELECT 0 FROM information_schema.columns LIMIT 100) t5,
           (SELECT @row := 0) t0
    ) AS numbers
    LIMIT 1000000;
    
    -- 注文 60万件挿入(存在しないuser_idも混在)
    INSERT INTO orders_large (user_id, product)
    SELECT FLOOR(1 + RAND() * 1200000),
           CONCAT('Product_', FLOOR(1 + RAND() * 100))
    FROM (
      SELECT @row := @row + 1 AS seq
      FROM (SELECT 0 FROM information_schema.columns LIMIT 100) t1,
           (SELECT 0 FROM information_schema.columns LIMIT 100) t2,
           (SELECT 0 FROM information_schema.columns LIMIT 100) t3,
           (SELECT 0 FROM information_schema.columns LIMIT 100) t4,
           (SELECT 0 FROM information_schema.columns LIMIT 100) t5,
           (SELECT @row := 0) t0
    ) AS numbers
    LIMIT 600000;
    

    このようにして構築された2つのテーブル(users_largeorders_large)を使って、JOINの実行計画やパフォーマンスを比較していきます。メモリが足りずINSERTできない場合はt5の行を削除して一度の挿入量を減らしてみてください。


    INNER JOIN と LEFT JOIN + WHERE の実行計画比較

    まずはそれぞれのEXPLAIN出力を確認します。

    INNER JOIN の実行計画

    EXPLAIN SELECT *
    FROM users_large u
    INNER JOIN orders_large o ON u.id = o.user_id;

    実行結果

    1	SIMPLE	o	NULL	ALL	user_id	NULL	NULL	NULL	598774	100.00	Using where
    1	SIMPLESIMPLE\u	NULL	eq_ref	PRIMARY	PRIMARY	4	testdb.o.user_id	1	100.00	NULL

    LEFT JOIN + WHERE の実行計画

    EXPLAIN SELECT *
    FROM users_large u
    LEFT JOIN orders_large o ON u.id = o.user_id
    WHERE o.user_id IS NOT NULL;

    実行結果

    1	SIMPLE	o	NULL	ALL	user_id	NULL	NULL	NULL	598774	50.00	Using where
    1	SIMPLESIMPLE\u	NULL	eq_ref	PRIMARY	PRIMARY	4	testdb.o.user_id	1	100.00	NULL

    上記2つの実行計画を比較すると、テーブルの走査順やインデックス利用の有無が異なります。特に注目すべき点として:

    • ref: インデックスを使った参照結合
    • eq_ref: 1件の一致行に対するインデックス結合(最も効率的)
    • ALL: フルスキャン(インデックス未使用)

    実行時間の比較

    パフォーマンスの検証には主に2つの方法があります:

    • SHOW PROFILE: 最大15件までのクエリ実行詳細を確認できる(ただし非推奨)
    • performance_schema: 実行時間や頻度など統計的に分析できる(推奨)

    今回は MySQL 8.4.5 を使用しているため、performance_schema を使って計測を行いました。

    10回実行後の平均実行時間は以下のとおりです:

    クエリ 実行回数 合計時間(秒) 平均時間(秒)
    INNER JOIN 10 19.8914 1.9891
    LEFT JOIN + WHERE 10 19.7331 1.9733

    今回はほぼ同じ結果となりましたが、一般的には INNER JOIN の方が高速になるケースが多いです。(多いはずなんだけど…。)特にインデックスの設計やフィルタ条件、カラム数の違いが結果に影響します。


    INNER JOIN の実行計画(10回実行後)

    EXPLAIN SELECT *
    FROM users_large u
    INNER JOIN orders_large o ON u.id = o.user_id;

    実行結果

    1	SIMPLESIMPLE\u	NULL	ALL	PRIMARY	NULL	NULL	NULL	999453	100.00	NULL
    1	SIMPLE	o	NULL	ref	user_id	user_id	5	testdb.u.id	1	100.00	NULL

    クエリを10回実行したあと、INNER JOIN の実行計画に変化がありました。初回は orders_large テーブルを先にフルスキャンしていましたが、その後は users_large 側のフルスキャンに切り替わり、orders_large にインデックス参照(ref)を適用するように変化しました。

    このような最適化は、MySQLのクエリオプティマイザによって統計情報や実行履歴が蓄積された結果と考えられます。

    ここまでのまとめ

    本記事では、JOINの基本から始まり、LEFT JOINとINNER JOINを使った場合の実行計画(EXPLAIN)の違いと、パフォーマンスへの影響を検証しました。

    • 同じ結果を得られるクエリでも、MySQLは内部的に異なる実行計画を選択することがある
    • 結合順序やインデックスの使い方は、構文や統計情報によって変化する
    • LEFT JOIN + WHERE形式とINNER JOIN形式ではパフォーマンスがわずかに異なる場合がある
    • performance_schemaを使えば、長期的なクエリ実行傾向を可視化できる
    • INNER JOINではクエリを10回繰り返すことで、実行計画が変化した=MySQLのクエリ最適化が働いた可能性がある
    • (結局クエリの最適化があるから何度か実行してみないと実行時間ってわからないな。2時間動かして終わらなかったクエリが2回目は20分で終わったとかもあるし)

    このように、MySQLのJOINは単なる構文比較にとどまらず、実行環境・データ量・統計情報にも深く関係しています。次回以降では、JOINの組み合わせ周りをいじってみます。

    補足: 同じ結果を返すように見えるクエリでも、MySQLはクエリ構文や統計情報に基づいて異なる実行計画(EXPLAIN)を選択することがあります。たとえば、LEFT JOIN + WHERE句の形式とINNER JOINの形式では、結合順序や使われるインデックスが変わる場合があります。これはMySQLのクエリオプティマイザが最も効率的と判断した戦略に基づいており、書き方の違いがパフォーマンスに影響を与える可能性があることを意味します。詳細は MySQL公式ドキュメント(EXPLAIN) を参照してください。

  • MySQL JOIN系の基本とその挙動

    MySQL JOIN系の基本とその挙動

    SQLでよく使われるJOINは、複数テーブルを結合して、共通の情報を一つの結果にまとめるための機能です。

    この節では、JOINの種類とその挙動について、基本からまとめていきます。


    検証用の一時テーブルを作成する

    今回の検証では、セッション内でのみ有効な TEMPORARY TABLE(一時テーブル)を使用します。これにより、環境を汚すことなくテストできます。

    -- 一時テーブルがあれば削除
    DROP TEMPORARY TABLE IF EXISTS orders;
    DROP TEMPORARY TABLE IF EXISTS users;
    
    -- ユーザーテーブル(TEMPORARY)作成
    CREATE TEMPORARY TABLE users (
        id INT PRIMARY KEY,
        name VARCHAR(50)
    );
    
    -- 注文テーブル(TEMPORARY)作成
    CREATE TEMPORARY TABLE orders (
        id INT PRIMARY KEY,
        user_id INT,
        product VARCHAR(100)
    );
    
    -- データ挿入(users)
    INSERT INTO users (id, name) VALUES
    (1, 'Alice'),
    (2, 'Bob'),
    (3, 'Carol'),
    (4, 'David');
    
    -- データ挿入(orders)
    INSERT INTO orders (id, user_id, product) VALUES
    (1, 1, 'Book'),
    (2, 3, 'Laptop'),
    (3, 1, 'Pen');
    
    MySQLのクエリ画面
    クエリ実行画面

    ※TablePlusっていうアプリを使ってます

    一時テーブルの内容を確認する

    正しくデータが挿入されたか、以下のようにそれぞれのテーブルの中身を確認しておきましょう。

    users テーブルの確認

    SELECT * FROM users;

    実行結果

    +----+--------+
    | id | name   |
    +----+--------+
    | 1  | Alice  |
    | 2  | Bob    |
    | 3  | Carol  |
    | 4  | David  |
    +----+--------+
    

    orders テーブルの確認

    SELECT * FROM orders;

    実行結果

    +----+---------+---------+
    | id | user_id | product |
    +----+---------+---------+
    | 1  | 1       | Book    |
    | 2  | 3       | Laptop  |
    | 3  | 1       | Pen     |
    +----+---------+---------+
    

    この結果から、Alice(ID 1)は2件、Carol(ID 3)は1件の注文を持ち、Bob(ID 2)とDavid(ID 4)は注文がないことが分かります。

    JOINの種類

    INNER JOIN

    • 両方のテーブルに対応する行がある場合のみを結合
    • 最も基本的なJOIN
    • JOINと書いても使える(業務的にはINNER JOINに統一した方が使いやすいよ…)
    • ユースケース:顧客と注文の対応表
    SELECT *
    FROM users u
    INNER JOIN orders o ON u.id = o.user_id;
    

    実行結果

    +----+--------+----+---------+-----------+
    | id | name   | id | user_id | product   |
    +----+--------+----+---------+-----------+
    | 1  | Alice  | 1  | 1       | Book      |
    | 1  | Alice  | 3  | 1       | Pen       |
    | 3  | Carol  | 2  | 3       | Laptop    |
    +----+--------+----+---------+-----------+
    

    LEFT JOIN (LEFT OUTER JOIN)

    • 左側のテーブルはすべて結果に含める
    • 対応する行がなければNULLが入る
    • ユースケース:顧客の注文履歴
    SELECT *
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id;
    

    実行結果

    +----+--------+------+---------+-----------+
    | id | name   | id   | user_id | product   |
    +----+--------+------+---------+-----------+
    | 1  | Alice  | 1    | 1       | Book      |
    | 1  | Alice  | 3    | 1       | Pen       |
    | 2  | Bob    | NULL | NULL    | NULL      |
    | 3  | Carol  | 2    | 3       | Laptop    |
    | 4  | David  | NULL | NULL    | NULL      |
    +----+--------+------+---------+-----------+
    

    RIGHT JOIN (RIGHT OUTER JOIN)

    • LEFT JOINの逆で、右側のテーブルを保証
    • ユースケース:注文履歴と注文した顧客
    • (ぶっちゃけ可読性が悪くなるのでLEFT JOINだけ使って欲しい)
    SELECT *
    FROM users u
    RIGHT JOIN orders o ON u.id = o.user_id;
    

    実行結果

    +----+--------+----+---------+-----------+
    | id | name   | id | user_id | product   |
    +----+--------+----+---------+-----------+
    | 1  | Alice  | 1  | 1       | Book      |
    | 3  | Carol  | 2  | 3       | Laptop    |
    | 1  | Alice  | 3  | 1       | Pen       |
    +----+--------+----+---------+-----------+
    

    FULL OUTER JOIN

    • 両方のテーブルのすべての行を結果に含める
    • MySQLは本来サポートしていないため、UNIONなどで表現する必要あり
    • ユースケース:マスターとサブを比較して欠損データがないか確認する
    SELECT *
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
    UNION
    SELECT *
    FROM users u
    RIGHT JOIN orders o ON u.id = o.user_id;
    

    本記事ではJOINの種類と基本動作を確認しました。実行例はぜひ手元で試してみてください。

    次回の記事では、LEFT JOINとINNER JOINを使った場合の実行計画(EXPLAIN)の違いと、それがパフォーマンスに与える影響を具体的に検証していきます。

    更新情報はX(旧Twitter)で配信中。気になる方はフォローをお願いします!