プログラマのための IT 教科書
第5部 サービスをつくる

データベース

読了目安 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 です。

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 > 1000

INDEX

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 で確認する
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 で両方なかったことに

送金の途中で落ちて「引かれたけど入っていない」を防ぎます。

トランザクションの中で外部APIを呼ばない
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回で済みます。

N+1 に気づく方法

ORM を使っていると、コードの見た目では気づきにくいです。

orders.map((o) => o.user.name)   // ここで裏側でクエリが飛んでいるかも

開発環境で発行 SQL をログに出す設定にしておくと、 同じクエリが大量に並んでいるのが一目で分かります。 これは新人のうちから設定しておく価値があります。

一覧APIが遅いので調べたら、SQL が101回発行されていました。何が起きていますか。

分散データベースと Spanner

ここから、普通の RDBMS とは前提が変わります。

MySQL や PostgreSQL は基本的に1台のサーバー(+レプリカ)で動きます。 Spanner は最初から複数ノードに分散しています。

通常の RDBMSSpanner
データの置き場所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 のように分散させる
逆順のタイムスタンプ時系列データで、新しい順に並べたい時
RDBMS の常識で設計すると必ず事故る

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 を削除
大きなテーブルへの ALTER

数千万行あるテーブルにカラムを追加すると、 DB によってはテーブル全体をロックして数分〜数十分止まります。

本番で実行する前に、 「このテーブルは何行あるか」「ロックはかかるか」を必ず確認してください。 分からなければ聞いてください。これは1人で判断してよい作業ではありません。

レプリカと遅延

読み取り負荷を分散するため、読み取り専用の複製(レプリカ)を使うことがあります。

書き込み → プライマリ ──複製──→ レプリカ(読み取り専用)
                          ↑ ここに遅延がある(数ms〜数秒)
書いた直後に読むと古い
await db.write('UPDATE users SET name = ? WHERE id = ?', ['新しい名前', id]);
const user = await db.readReplica('SELECT * FROM users WHERE id = ?', [id]);
// 古い名前が返ってくることがある

「保存したのに画面に反映されない」というバグの典型的な原因です。

書き込み直後の読み取りはプライマリから読むか、 書き込んだ値をそのまま画面に使う設計にします。

実務の落とし穴まとめ

  1. N+1 問題 — レビュー指摘の最多。SQL ログを出して気づけるようにする
  2. INDEX が効かない書き方 — 中間一致 LIKE '%x%'、列を関数で加工
  3. トランザクション内の外部 API 呼び出し — ロックが長引く
  4. LEFT JOIN + WHERE — NULL が除外されて INNER JOIN と同じになる
  5. Spanner で連番主キー — ホットスポット。後から変えられない
  6. 1回で破壊的マイグレーション — 移行期間中に落ちる
  7. レプリカ遅延 — 書いた直後に読むと古い

まとめ

  • 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 を扱います。

読み終わったら記録しておくと、目次で進み具合が分かります。