データベース
読了目安 55 分
- JOIN を使ったクエリを書ける
- INDEX が効かないケースを説明できる
- Spanner で連番主キーを避ける理由を説明できる
データベースは、アプリで最も遅くなりやすい場所です。
新人が書いたコードが本番で遅い時、原因の大半はここにあります。 そしてレビューで最も多く指摘されるのも、この章の内容です。
テーブルと正規化
リレーショナルデータベースは、データを表(テーブル)で持ちます。
users orders
┌────┬───────┬────────┐ ┌────┬─────────┬────────┐
│ id │ name │ email │ │ id │ user_id │ amount │
├────┼───────┼────────┤ ├────┼─────────┼────────┤
│ 1 │ 田中 │ t@... │ │ 10 │ 1 │ 3000 │
│ 2 │ 佐藤 │ s@... │ │ 11 │ 1 │ 1500 │
└────┴───────┴────────┘ └────┴─────────┴────────┘
↑ users.id を指す(外部キー)
同じ情報を1箇所にだけ持つのが正規化です。 注文テーブルにユーザー名をコピーしていると、名前を変えた時に食い違います。
SQL の基本
SELECT o.id, o.amount, u.name
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.amount > 1000
ORDER BY o.created_at DESC
LIMIT 20;読む順番は、書く順番と違います。
FROM / JOIN → どのテーブルから
WHERE → どの行に絞る
GROUP BY → どうまとめる
HAVING → まとめた結果をどう絞る
SELECT → どの列を出す
ORDER BY → どう並べる
LIMIT → 何件返す
JOIN の種類
| 種類 | 意味 |
|---|---|
INNER JOIN | 両方にある行だけ |
LEFT JOIN | 左は全部、右は無ければ NULL |
「注文がないユーザーも含めて一覧したい」なら LEFT JOIN です。
SELECT * FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.amount > 1000; -- ← ここで NULL が除外されるLEFT JOIN で NULL になった行が、WHERE で消えます。
結果的に INNER JOIN と同じになります。
条件を JOIN 側に書けば意図どおりになります。
LEFT JOIN orders o ON o.user_id = u.id AND o.amount > 1000INDEX
INDEX は本の索引です。無ければ全ページを1枚ずつ見る(フルスキャン)ことになります。
-- INDEX がないと、全行を調べる
SELECT * FROM orders WHERE user_id = 42;
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- これで user_id から直接たどれる効かないケース
INDEX を付けたのに速くならない、という相談は非常に多いです。
| 書き方 | 効くか |
|---|---|
WHERE user_id = 42 | ✅ |
WHERE name LIKE '田中%' | ✅(前方一致) |
WHERE name LIKE '%田中%' | ❌(中間一致は効かない) |
WHERE YEAR(created_at) = 2026 | ❌(列を関数で加工すると効かない) |
WHERE created_at >= '2026-01-01' | ✅(同じ意味だが、こちらは効く) |
EXPLAIN SELECT * FROM orders WHERE user_id = 42;推測せず、実行計画を見てください。 「INDEX を使っているか」「何行スキャンする見込みか」が分かります。
遅いクエリを直す作業は、EXPLAIN を見ることから始まります。
付けすぎない
INDEX は読み取りを速くしますが、書き込みを遅くします(更新のたびに索引も更新するため)。 何にでも付けるのではなく、実際に遅いクエリに対して付けます。
トランザクション
複数の操作を「全部成功」か「全部なかったこと」にする仕組みです。
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
COMMIT; -- 途中で失敗したら ROLLBACK で両方なかったことに送金の途中で落ちて「引かれたけど入っていない」を防ぎます。
BEGIN;
UPDATE orders ...
await paymentAPI.charge(...) // ← 数秒かかるかもしれない
COMMIT;
トランザクション中は行がロックされます。 外部 API が遅いと、その間ずっと他の処理が待たされ、最悪デッドロックになります。
トランザクションは短く。 外部通信は外に出してください。
N+1 問題
レビューで最も指摘される問題です。
// 注文を100件取得
const orders = await db.query('SELECT * FROM orders LIMIT 100');
// 各注文のユーザーを取得
for (const order of orders) {
order.user = await db.query('SELECT * FROM users WHERE id = ?', [order.userId]);
}クエリの回数は 1 + 100 = 101回です。これが N+1 です。
1回のクエリが 2ms でも、101回なら 200ms。 ネットワーク越しなら往復コストも掛かって、さらに遅くなります。
直し方
// 1. JOIN でまとめて取る
const rows = await db.query(`
SELECT o.*, u.name AS user_name
FROM orders o JOIN users u ON u.id = o.user_id
LIMIT 100
`);
// 2. または、IN でまとめて取ってから結合する
const orders = await db.query('SELECT * FROM orders LIMIT 100');
const userIds = [...new Set(orders.map((o) => o.userId))];
const users = await db.query('SELECT * FROM users WHERE id IN (?)', [userIds]);
const userById = new Map(users.map((u) => [u.id, u]));2回で済みます。
ORM を使っていると、コードの見た目では気づきにくいです。
orders.map((o) => o.user.name) // ここで裏側でクエリが飛んでいるかも開発環境で発行 SQL をログに出す設定にしておくと、 同じクエリが大量に並んでいるのが一目で分かります。 これは新人のうちから設定しておく価値があります。
一覧APIが遅いので調べたら、SQL が101回発行されていました。何が起きていますか。
分散データベースと Spanner
ここから、普通の RDBMS とは前提が変わります。
MySQL や PostgreSQL は基本的に1台のサーバー(+レプリカ)で動きます。 Spanner は最初から複数ノードに分散しています。
| 通常の RDBMS | Spanner | |
|---|---|---|
| データの置き場所 | 1台 | 主キーの範囲でノードに分割 |
| スケール | 縦(マシンを大きく) | 横(ノードを足す) |
| 主キー設計 | あまり気にしない | 最重要 |
主キーがデータの置き場所を決める
Spanner は主キーの順序でデータを並べ、範囲ごとに別のノードに割り当てます。
ノードA: id 0000〜3333
ノードB: id 3334〜6666
ノードC: id 6667〜9999
ホットスポット
ここで連番の主キーを使うとどうなるでしょうか。
id = 1, 2, 3, 4, 5, ... と増えていく
↓
新しいデータは常に「一番大きい範囲」に入る
↓
書き込みが全部ノードCに集中する
ノードを10台に増やしても、書き込みは1台にしか行きません。 これがホットスポットです。
タイムスタンプを主キーの先頭に置くのも同じ問題を起こします (時間は常に増えるため)。
対策
| 方法 | 内容 |
|---|---|
| UUID / ランダムな ID | 全ノードに散らばる。最も単純 |
| 先頭をハッシュにする | hash(user_id) + user_id のように分散させる |
| 逆順のタイムスタンプ | 時系列データで、新しい順に並べたい時 |
MySQL では AUTO_INCREMENT の主キーが定石です。Spanner ではこれが最悪手になります。
負荷試験までは問題なく動き、本番でトラフィックが増えた瞬間に 書き込みが詰まる、というのが典型的な事故の流れです。
主キーは後から変えられません。 設計時に必ず確認してください。
インターリーブ
Spanner には、親子関係のあるデータを物理的に近くに置く仕組みがあります。
CREATE TABLE Orders (
user_id STRING(36) NOT NULL,
order_id STRING(36) NOT NULL,
amount INT64,
) PRIMARY KEY (user_id, order_id),
INTERLEAVE IN PARENT Users ON DELETE CASCADE;こうすると、あるユーザーの注文がそのユーザーの行のすぐ隣に置かれます。 「ユーザーとその注文をまとめて取る」が非常に速くなります。
Spanner で注文テーブルの主キーを連番の order_id にしました。何が起きますか。
マイグレーション
スキーマを変更する作業です。本番で最も緊張する作業の1つです。
破壊的変更は2段階に分ける
第5章で触れた blue/green と同じ理由です。 新旧のコードが同時に動く時間があるため、片方が壊れる変更はできません。
❌ 1回で: カラム old_name を削除
→ まだ old_name を読む古いコードが動いていて落ちる
✅ 2回に分ける(expand → contract):
1回目: new_name を追加。両方に書き、両方を読めるコードをデプロイ
2回目: 古いコードが全部入れ替わったら old_name を削除
数千万行あるテーブルにカラムを追加すると、 DB によってはテーブル全体をロックして数分〜数十分止まります。
本番で実行する前に、 「このテーブルは何行あるか」「ロックはかかるか」を必ず確認してください。 分からなければ聞いてください。これは1人で判断してよい作業ではありません。
レプリカと遅延
読み取り負荷を分散するため、読み取り専用の複製(レプリカ)を使うことがあります。
書き込み → プライマリ ──複製──→ レプリカ(読み取り専用)
↑ ここに遅延がある(数ms〜数秒)
await db.write('UPDATE users SET name = ? WHERE id = ?', ['新しい名前', id]);
const user = await db.readReplica('SELECT * FROM users WHERE id = ?', [id]);
// 古い名前が返ってくることがある「保存したのに画面に反映されない」というバグの典型的な原因です。
書き込み直後の読み取りはプライマリから読むか、 書き込んだ値をそのまま画面に使う設計にします。
実務の落とし穴まとめ
- N+1 問題 — レビュー指摘の最多。SQL ログを出して気づけるようにする
- INDEX が効かない書き方 — 中間一致
LIKE '%x%'、列を関数で加工 - トランザクション内の外部 API 呼び出し — ロックが長引く
- LEFT JOIN + WHERE — NULL が除外されて INNER JOIN と同じになる
- Spanner で連番主キー — ホットスポット。後から変えられない
- 1回で破壊的マイグレーション — 移行期間中に落ちる
- レプリカ遅延 — 書いた直後に読むと古い
まとめ
- SQL は
FROM → WHERE → GROUP BY → SELECT → ORDER BYの順に評価される - INDEX は索引。中間一致や関数での加工では効かない。
EXPLAINで確認する - トランザクションは短く。中で外部 API を呼ばない
- N+1 は JOIN か IN でまとめる。SQL ログを出して気づけるようにする
- Spanner は主キーがデータの配置を決める。連番はホットスポットを生む
- 破壊的なスキーマ変更は expand → contract の2段階
- レプリカには遅延がある。書いた直後の読み取りに注意
章末問題
WHERE YEAR(created_at) = 2026 というクエリが遅いです。INDEX は created_at に付いています。原因は?
本番のテーブルからカラムを1つ削除したい。安全な進め方は?
ユーザー情報を更新した直後に取得すると、古い値が返ることがあります。考えられる原因は?
次の章では、これらのサービスを実際に動かす場所——コンテナと Kubernetes を扱います。