投稿者: odyssey

  • 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)で配信中。気になる方はフォローをお願いします!

  • AWS LightsailでMySQL環境を最小構成で構築する(後編)

    AWS LightsailでMySQL環境を最小構成で構築する(後編)

    前編では、AWS LightsailでUbuntuの最小構成インスタンスを作成し、スワップの追加や日本語ロケールの設定までを行いました。後編では、DockerとMySQLコンテナのセットアップ、接続確認、トラブル対応までを解説します。

    ステップ①:DockerとDocker Composeのインストール

    LightsailのUbuntuはDocker非搭載のため、まずDocker本体とCompose(v1)をインストールします。

    sudo apt install docker.io docker-compose

    最新版のdocker-compose(v2)をインストールしなくても、本記事の内容は動作します。v2を入れた場合は docker compose というようにスペース付きでコマンドを実行してください。

    ステップ②:MySQLコンテナ用ディレクトリと構成ファイルの準備

    任意のディレクトリ(例:mysql-env)を作成し、その中に docker-compose.yml ファイルを作成します。

    以下のコマンドでエディタを開き、内容を手入力してください。
    インデントを必ずつけてください。入力後は Ctrl + XYEnter で保存できます。

    mkdir ~/mysql-env
    cd ~/mysql-env
    nano docker-compose.yml

    設定例:

    version: '3.8'
    services:
      mysql:
        image: mysql:8
        container_name: mysql
        environment:
          MYSQL_ROOT_PASSWORD: ****
          MYSQL_DATABASE: testdb
        ports:
          - "3306:3306"
        volumes:
          - mysql-data:/var/lib/mysql
    volumes:
      mysql-data:

    ****部分は任意で変更してください。
    ※ 本記事の設定は学習用のサンプルです。本番環境ではパスワード変更などセキュリティ対策を十分に行ってください。

    ステップ③:コンテナの起動と確認

    docker-compose up -d

    起動状況の確認:

    docker ps

    STATUSの項目がUP~と表示されていたら起動完了です。
    MySQLに接続してみます:

    docker exec -it mysql mysql -uroot -p****

    プロンプト(mysql>)が出れば接続成功です。
    ****ここは作成したパスワードです。

    MySQL接続画面
    MySQLにSSHに接続完了画面

    ステップ④:トラブル対応と補足

    ◻︎yamlの構文エラー(インデントや文字化け)

    コピー&ペーストではインデントや改行が崩れて yaml.parser.ParserError が出ることがあります。手入力がお勧めです。

    ◻︎MySQLコンテナが起動するが接続できない

    この症状は、実は メモリ不足が原因でMySQLが内部でクラッシュしているケースがあります。特にLightsail最小構成(512MB)では起こりやすいです。

    次のようなログが繰り返されていたら、メモリ不足の可能性大です:

    Initializing database files
    Killed
    Can't connect to local MySQL server...

    この場合、スワップ追加で解消することがあります。

    ◻︎スワップを追加しても消えてしまう

    再起動後にスワップが消えていた場合、/etc/fstab への記述がない可能性があります。以下のように追記してください:

    /swapfile none swap sw 0 0

    追記後、再起動して sudo swapon --show で有効化を確認します。

    まとめ

    AWS Lightsailの最小構成でDocker+MySQL環境を構築する一連の手順を紹介しました。

    • Lightsailでの構築は低コストで始めやすい
    • Dockerで環境を簡潔に構築・削除可能
    • yamlの記述エラーやメモリ不足に注意
    • スワップは永続化しておくと安心

    開発環境や学習目的であれば、Lightsail+Dockerは非常に便利な構成です。ぜひお試しください。

    MySQLの試運転
    バージョン確認や一時テーブル作成

    ▼ 前編はこちら:
    LightsailでDocker+MySQL環境を最小構成で構築する(前編)

  • AWS LightsailでMySQL環境を最小コストで構築する(前編)

    AWS LightsailでMySQL環境を最小コストで構築する(前編)

    この記事では、AWS Lightsail 上に Docker を使って MySQL 環境を構築する手順を、スクリーンショット付きでわかりやすく解説します。前半ではサーバー作成まで立ち上げまで行います。

    それでは作成に入っていきましょう

    ステップ①:Lightsailインスタンスの作成

    1. AWS Lightsailにログインし、「インスタンスの作成」を選択
    2. プラットフォームは Linux/Unix を選択
    3. 設計図(Blueprint)は OSのみ → Ubuntu 22.04 LTS を選択
    4. インスタンスプランは最小構成($5プラン:0.5 GB RAM, 1vCPU, 40GB SSD)を選択
    5. インスタンス名を入力(例:mysql-env
    6. 「作成」ボタンをクリック

    インスタンスが起動すると、パブリックIPアドレスが割り当てられます。

    Lightsailの設定画像1
    プラットフォームと設計図の選択
    Lightsailの設定画像2
    ネットワークタイプとサイズ
    Lightsailダッシュボード
    設定完了画面(画面左:立ち上げすぐなので開始中になっています)

    ※ 以前は $3.5 プランもありましたが、2025年7月時点では $5 プランが最小構成になります。

    ステップ②:SSH接続と初期セットアップ

    1. Lightsailのインスタンス一覧から、作成したインスタンスを選択
    2. 「接続」タブから「SSHを使用して接続」からブラウザベースのSSHコンソールを起動
    3. 以下のコマンドでパッケージ情報を更新します:
    sudo apt update && sudo apt upgrade -y

    このコマンドをコピー&ペーストして実行したとき、以下のような「謎の記号」やエラー風の表示が出ることがあります:

    ��sudo: command not found

    これは「sudoが存在しない」という意味ではなく、貼り付け時の文字化けが原因で、コマンドが正しく認識されなかった状態です。

    このような場合は、コマンドをもう一度「手打ち」で入力してください。

    sudo apt update && sudo apt upgrade -y

    ブラウザ版SSHでは、コピー&ペースト時にエンコードが乱れることがあるため、最初のセットアップでは手打ちが確実です。

    インスタンス個別画面
    作成したインスタンスの個別画面

    SSH接続画面
    接続画面

    ここまででサーバーの立ち上げが完了しました。

    ステップ③:Dockerとは?導入する理由

    MySQLをこのまま直接インストールすることもできますが、本記事では Docker を利用します。

    Docker は「コンテナ型仮想環境」を作成するツールで、次のようなメリットがあります:

    • 環境構築が簡単(数行のコマンドで完了)
    • 環境が汚れない(削除すればすぐに元通り)
    • MySQLのバージョン切り替えや使い分けが楽
    • 開発・テスト環境に最適(手元のマシンと分離できる)

    今回は MySQLをDockerコンテナで動かす ことで、設定の簡略化やトラブルへの対処を学びながら進めていきます。

    後編では Docker のインストールから始めましょう。
    ▼ 後編はこちら:
    LightsailでDocker+MySQL環境を最小構成で構築する(前編)

  • AWS Cloud Practitioner 合格体験記とおすすめ教材まとめ

    2025年5月、AWS Certified Cloud Practitioner(CLF-C02)に合格しました!

    公式スコアは 731点 でした(合格ラインは700点)。この記事では、勉強法や試験の感想をまとめておきます。

    なぜこの資格を受けたのか?

    まず、AWSの基礎を理解しておきたかったという理由があります。
    次に、クラウドの全体像を体系的に把握したいと考えていました。
    そして、将来的なキャリアやプロジェクトのための第一歩として、資格取得を目指しました。

    勉強に使った教材・サービス

    学習スケジュールと時間

    • 学習期間:3ヶ月弱
    • 平均学習時間:1日40分程度
    • 合計学習時間:約50時間

    試験当日の感想とポイント

    • 試験時間:90分(時間には余裕あり)
    • 難易度:全体的にはやさしめだが、知らない単語が2割くらいあった
    • ポイント:
      • セキュリティ / 請求 / サポートプラン の知識は特に重要
      • 模擬試験の解説に出てくる単語は必ず調べて、どんなサービスか、似たような役割がある場合はメリット・デメリットを説明できるようにしておくと安心

    今後の予定

    次のAWS系の資格は AWS Certified Solutions Architect – Associate(SAA) に挑戦予定です。

    CLFで得た基礎知識と、今後の実務経験をうまく組み合わせて、引き続き学習を進めていきます!

    最後までお読みいただきありがとうございました!

  • Odysseytechlab 開設!ブログを立ち上げた理由と今後の予定

    自己紹介

    こんにちは、Odysseyといいます。
    普段はインフラエンジニアとして、AWSやネットワーク周りの設計・構築・運用をしています。

    このブログでは、仕事や趣味で触れた技術の記録や、試行錯誤の過程をゆるく残していくつもりです。

    入社当初は営業部に配属され、その後、分析力を評価いただいてマーケティング部に異動。
    現在はIT部門で2年目になり、社内データのデータウェアハウス化を担当しています。

    プログラミングはほとんど独学で、昔Pythonでマリオブラザーズ風のゲームを作ろうとして挫折したことがあります(笑)。

    今後は、仕事では統計の勉強を進めて顧客満足度やチャーンレートの解析をやってみたいです。
    私事ではWebサービスの開発にも挑戦していきたいと考えています。

    エンジニアとしてはまだまだ勉強中ですが、実務や学びの中で得た知見を、誰かの参考になる形で残していけたらと思います。

    なぜブログを始めたのか

    技術のインプットは日々しているものの、アウトプットの場がなかったので、
    「忘れる前に書く」「積み上げとして記録する」ことを目的にブログを始めました。

    また、業務では触れにくい技術や、自分用に構築した環境(今回のブログ環境も含む)もまとめておきたかったからです。

    このブログで書いていく予定のこと

    以下のような内容を気ままに書いていくつもりです。

    • AWS関連(Lambda、Lightsail、 資格など)
    • FastAPIやPythonまわりの開発メモ
    • SQLの実験
    • 自作ツールやスクレイピングの話
    • 試験勉強(AWS資格や統計)など

    基本は技術系中心ですが、たまに雑記もあるかもしれません。

    今回やったこと(技術的サマリ)

    このブログは、以下の構成で立ち上げました。

    • AWS Lightsail に Bitnami WordPress をデプロイ
    • お名前.com で独自ドメイン(odysseytechlab.com)を取得
    • DNSをLightsailに向けて、Let’s EncryptでSSL化
    • HTTP→HTTPSリダイレクト&wwwなしに統一
    • WordPressのURL設定、パーマリンク最適化 など

    最初からWordPressテーマを作り込むのではなく、まずは「投稿する環境を整えること」を優先しました。

    今後について

    記事数が増えてきたら、カテゴリーの整理やデザインの調整もしていく予定です。
    また、将来的に自作のサービスやツールを公開できる場所にもしていきたいと考えています。

    まずは気張らず、楽しくやっていきます。

    最後に

    まだ模索中の部分も多いですが、少しずつ形にしていけたらと思います。
    ここまで読んでいただき、ありがとうございました。