複数のテーブルと JOIN
読了目安 約4分
テーブルを分ける理由と、JOIN で複数のテーブルを突き合わせて読む方法を学びます。
この章の目次
なぜ 1 枚の巨大テーブルにしないのか
掲示板のデータを 2 枚に分けず、投稿者の名前とメールアドレスを posts に直接持たせる案から考えます。
posts(分けない案)
+----+-----------+-------------------+--------------------------+
| id | user_name | user_email | body |
+----+-----------+-------------------+--------------------------+
| 1 | alice | [email protected] | はじめまして! |
| 2 | bob | [email protected] | 今日は暑いですね |
| 3 | alice | [email protected] | ラーメン食べたい |
+----+-----------+-------------------+--------------------------+この形には二つの問題があります。
- 重複:alice の名前とメールアドレスが、alice が投稿するたびにコピーされる。1000 回投稿すれば 1000 回書かれる。
- 更新漏れ:alice が名前を変えたら、alice の全投稿の行を書き換える必要がある。1 行でも漏れると古い名前と新しい名前が混在し、どちらが正しいのかデータからは判定できなくなる。
そこで、ユーザーの情報は users に 1 回だけ書き、posts からは番号で参照します。 posts.user_id のように、他のテーブルの主キーの値を持って参照する列を外部キーと呼びます。 「一つの事実は一つの場所にだけ書く」方向へテーブルを整理する設計手法が正規化です。
データをそろえる
前章で posts の中身をいじったので、次のブロックを丸ごと貼り付けて作り直します。
DROP TABLE は、テーブルを丸ごと消す文です。
DROP TABLE IF EXISTS posts;
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
body TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO posts (user_id, body, created_at) VALUES
(1, 'はじめまして!', '2026-03-05 09:00:00'),
(2, '今日は暑いですね', '2026-03-05 12:00:00'),
(1, 'ラーメン食べたい', '2026-03-06 12:30:00'),
(3, '精進します', '2026-03-07 21:00:00'),
(2, 'ISUCON に出たい', '2026-03-08 10:00:00'),
(1, '眠い', '2026-03-08 23:00:00');alice(id が 1)が 3 件、bob(2)が 2 件、carol(3)が 1 件投稿し、dave(4)と eve(5)は未投稿です。
SELECT だけでは名前が出ない
posts を SELECT すると、投稿者は user_id という番号でしか出てきません。
SELECT user_id, body FROM posts LIMIT 3;+---------+--------------------------+
| user_id | body |
+---------+--------------------------+
| 1 | はじめまして! |
| 2 | 今日は暑いですね |
| 1 | ラーメン食べたい |
+---------+--------------------------+「alice: はじめまして!」の形にするには、posts の各行を users と突き合わせる JOIN を使います。
INNER JOIN
SELECT posts.id, users.name, posts.body
FROM posts
JOIN users ON posts.user_id = users.id;+----+-------+--------------------------+
| id | name | body |
+----+-------+--------------------------+
| 1 | alice | はじめまして! |
| 2 | bob | 今日は暑いですね |
| 3 | alice | ラーメン食べたい |
| 4 | carol | 精進します |
| 5 | bob | ISUCON に出たい |
| 6 | alice | 眠い |
+----+-------+--------------------------+FROM posts JOIN users でつなぎ、ON に書いた式が突き合わせの条件です。
「posts の user_id と users の id が等しい行同士を組にせよ」と読みます。
列は posts.id のように「テーブル名.列名」の形で区別します。
この JOIN は正確には INNER JOIN と呼ばれ、INNER は省略できます。
行はどう組み合わされるのか
posts users
(id=1, user_id=1, body='はじめまして!') ─┐
(id=3, user_id=1, body='ラーメン食べたい') ─┼─→ (id=1, name='alice')
(id=6, user_id=1, body='眠い') ─┘
(id=2, user_id=2, body='今日は暑いですね') ─┬─→ (id=2, name='bob')
(id=5, user_id=2, body='ISUCON に出たい') ─┘
(id=4, user_id=3, body='精進します') ───→ (id=3, name='carol')ON の条件に合って組が見つかった行だけが返るので、投稿のない dave と eve は現れません。
LEFT JOIN
相手が見つからない行(投稿ゼロのユーザーなど)も残したいときは LEFT JOIN を使います。 FROM 側(左側)のテーブルの行はすべて残り、相手がいない行では相手側の列が NULL になります。
SELECT users.name, posts.body
FROM users
LEFT JOIN posts ON posts.user_id = users.id;+-------+--------------------------+
| name | body |
+-------+--------------------------+
| alice | はじめまして! |
| alice | ラーメン食べたい |
| alice | 眠い |
| bob | 今日は暑いですね |
| bob | ISUCON に出たい |
| carol | 精進します |
| dave | NULL |
| eve | NULL |
+-------+--------------------------+競プロの言葉で言えば
JOIN の中身は 2 つの配列の突き合わせで、素朴に書けば二重ループの O(N × M) です。 競プロなら片方を map に入れておくところで、DB も同じ工夫をします。 その道具がインデックス、探すためにテーブルとは別に持っておくデータ構造です。
JOIN や WHERE の速さは、突き合わせる列のインデックスの有無で桁違いに変わります。 仕組みは /sql/07-index/ で扱います。