第17章|今さら学ぶ「DB・SQL」
📚 シリーズ目次はこちら → 「今さら学ぶ」シリーズ — はじめに
🗺️ KnowledgeNoteの設計を確認 → 設計マップ
この章でわかること
- 正規化 — なぜテーブルを分けるのか
- インデックス — 本の索引がなぜ必要か
- 外部キー制約 — 「存在しない人を参照しない」ための約束
- N+1問題 — 1人ずつ電話する vs 一斉メール
- トランザクション — 「全部成功」か「全部取り消し」の仕組み
- Seed / Faker — 開発用のダミーデータを一発投入する
🏠 たとえ話で掴む「データベース設計」
データベース設計は 住所録の整理 にたとえるとわかりやすいです。
友人の連絡先を管理するとき、全員の情報を1枚の巨大な紙にまとめると、引っ越しのたびに何箇所も書き直す羽目になります。「住所は住所の表」「名前は名前の表」と分けて管理し、番号で紐づければ、変更は1箇所で済みます。
この「分けて紐づける」がデータベース設計の核心です。
📖 リレーショナルデータベースの基本用語
技術的に整理すると、KnowledgeNoteが使っている リレーショナルデータベース(RDB) は、データを テーブル(表) の形で管理するデータベースです。PostgreSQL、MySQL、SQLiteなどが代表的な製品です。
| 用語 | 意味 | たとえ |
|---|---|---|
| テーブル | データの入れ物(表) | Excelのシート |
| 行(レコード / row) | 1件のデータ | シートの1行 |
| カラム(列 / column) | データの項目 | シートの列見出し |
| 主キー(Primary Key) | 各行を一意に識別するID | 社員番号 |
| 外部キー(Foreign Key) | 他のテーブルの主キーを参照するカラム | 部署番号(部署テーブルへのリンク) |
| SQL | RDBに対する問い合わせ言語 | データベースへの「注文書」 |
RailsではActiveRecord(→ 第15章)がSQLを自動生成してくれますが、この章ではActiveRecordの裏側で何が起きているかを理解するため、DB設計の原則とSQLの基礎を掘り下げます。
📐 正規化 — テーブルを分ける理由
正規化していないテーブルの問題
❌ 正規化していない1枚テーブル
+------+--------+-----------------+-----------+
| 記事 | 著者名 | 著者メール | タグ |
+------+--------+-----------------+-----------+
| Ruby | 田中 | tanaka@mail.com | Ruby,初心者 |
| Rails| 田中 | tanaka@mail.com | Rails |
| SQL | 鈴木 | suzuki@mail.com | DB,SQL |
+------+--------+-----------------+-----------+
問題①:田中さんのメールが変わったら2行修正が必要(更新異常)
問題②:田中さんの記事を全部消すと、田中さんの情報も消える(削除異常)
問題③:タグが「Ruby,初心者」とカンマ区切り → 検索しにくい
正規化したテーブル
✅ 正規化(テーブルを分ける)
users テーブル articles テーブル tags / article_tags
+----+------+---------+ +----+-------+---------+ +----+------+
| id | name | email | | id | title | user_id | | id | name |
+----+------+---------+ +----+-------+---------+ +----+------+
| 1 | 田中 | t@m.com | | 1 | Ruby | 1 | | 1 | Ruby |
| 2 | 鈴木 | s@m.com | | 2 | Rails | 1 | | 2 | 初心者|
+----+------+---------+ | 3 | SQL | 2 | | 3 | Rails|
+----+-------+---------+ +----+------+
→ 田中さんのメールを変えても users テーブルの1行だけ修正すればOK
→ タグは1つずつ独立した行なので検索しやすい
正規化の段階(第1〜第3正規形)の詳細は奥が深いですが、面接で聞かれる基本は以下の通りです。
| 正規形 | ルール | KnowledgeNoteでの例 |
|---|---|---|
| 第1正規形 | 1つのセルに複数の値を入れない | タグを「Ruby,Rails」とカンマ区切りにしない → tags テーブルに分離 |
| 第2正規形 | 主キーの一部にだけ依存するカラムを排除する | 中間テーブル article_tags に tag_name を持たせない(tag_name は tags テーブルの属性) |
| 第3正規形 | 主キー以外のカラムに依存するカラムを排除する | articles に author_email を持たせない(email は users テーブルの属性) |
実務で意識するのは主に以下の3点です。
① 1つのセルに複数の値を入れない(「Ruby,Rails」→ 別テーブルに分ける)
② 部分的な重複を排除する(著者情報を articles に持たず users に分ける)
③ 推移的な依存を排除する(AがBに依存、BがCに依存 → Cも別テーブルに)
📑 インデックス — 本の索引
インデックス は、DBの検索を高速にするための仕組みです。本の巻末にある「索引」と同じです。
索引がなければ、「ポリモーフィック」という単語を探すのに300ページを1ページずつめくる必要があります。索引があれば「ポリモーフィック → p.152」と一発でたどり着けます。
インデックスの追加方法
# マイグレーションでインデックスを追加
class AddIndexToArticles < ActiveRecord::Migration[8.0]
def change
# 単一カラムのインデックス
add_index :articles, :user_id
# 複合インデックス(ユーザーごとの記事を日付順で検索する場合)
add_index :articles, [:user_id, :created_at]
# ユニークインデックス(重複を防ぐ)
add_index :users, :email_address, unique: true
end
end
どこにインデックスを貼るか
■ 必ず貼る
→ 外部キー(user_id, article_id 等)
→ uniqueness バリデーションがあるカラム(email_address 等)
■ 貼ると効果的
→ WHERE句でよく使うカラム(published, status 等)
→ ORDER BY でよく使うカラム(created_at 等)
■ 貼りすぎ注意
→ INSERT / UPDATE が遅くなる(インデックスの更新コスト)
→ めったに検索しないカラムには不要
🔗 外部キー制約 — 存在しないデータを参照させない
外部キー制約 は、「存在しないユーザーの記事を作らせない」ための仕組みです。
# マイグレーション
create_table :articles do |t|
t.references :user, null: false, foreign_key: true
# ↑ foreign_key: true で外部キー制約をDBレベルで設定
end
# 外部キー制約がないと…
Article.create(title: "テスト", body: "本文", user_id: 99999)
# → user_id: 99999 のユーザーが存在しなくても保存できてしまう!
# 外部キー制約があると…
Article.create(title: "テスト", body: "本文", user_id: 99999)
# → ActiveRecord::InvalidForeignKey エラー(DBが拒否)
Rails(Ruby)のバリデーションだけでなく、 DBレベルでもデータの整合性を守る のが実務のベストプラクティスです。
🔁 N+1問題 — 1人ずつ電話する vs 一斉メール
第15章では includes による解決方法を紹介しました。ここではもう少し掘り下げて、 複数のアソシエーションを含むケース と 検出ツール(Bullet gem) を扱います。
たとえ話
30人のクラスメイトに連絡するとき、1人ずつ電話をかけると30回の通話が必要です。でも一斉メールなら1通で済みます。
# ❌ N+1問題(1人ずつ電話)
articles = Article.all # 1回目のSQL
articles.each do |article|
puts article.user.name # 記事の数だけSQLが走る(N回)
puts article.tags.map(&:name).join(",") # さらにN回!
end
# → 合計 1 + N + N 回のSQL
# ✅ includes で解決(一斉メール)
articles = Article.includes(:user, :tags).all # 3回のSQLで全部取得
articles.each do |article|
puts article.user.name # 追加SQLなし
puts article.tags.map(&:name).join(",") # 追加SQLなし
end
N+1問題の検出
# Gemfile(開発環境で使うN+1検出ツール)
group :development do
gem "bullet"
end
Bullet gem を入れると、N+1問題が発生したときに ブラウザ上に警告 を表示してくれます。パフォーマンス全般については(→ 第24章で詳しく扱います)。
🔒 トランザクション — 全部成功か全部取り消し
トランザクション は、「複数の処理を1つのまとまりとして扱い、途中で失敗したら全部なかったことにする」仕組みです。
データベースのトランザクションには ACID と呼ばれる4つの特性があります。
| 特性 | 意味 | 一言で |
|---|---|---|
| A tomicity(原子性) | 全部成功か全部取り消し。中途半端な状態にならない | 「半分だけ完了」はありえない |
| C onsistency(一貫性) | トランザクション前後でデータの整合性が保たれる | 残高がマイナスにならない等 |
| I solation(分離性) | 同時に走る他のトランザクションの影響を受けない | 並行処理でも結果が狂わない |
| D urability(永続性) | コミット済みのデータは障害が起きても失われない | 停電しても消えない |
面接で「トランザクションの特性は?」と聞かれたら、ACIDの4つを挙げられれば十分です。
たとえ話:銀行振込
AさんからBさんに1万円を振り込むとき、「Aの口座から引く」と「Bの口座に足す」は必ずセットで実行されないと困ります。Aから引いた後にエラーが起きてBに入金されなかったら、1万円が消えてしまいます。
# KnowledgeNoteでの例:いいね+通知を同時に処理
ActiveRecord::Base.transaction do
like = current_user.likes.create!(likeable: article) # いいねを保存
Notification.create!( # 通知を作成
user: article.user,
notifiable: like,
action_type: "liked"
)
end
# → 両方成功すればコミット(確定)
# → どちらかが失敗すればロールバック(全部取り消し)
トランザクションが必要な場面
■ 必要な場面
→ 複数テーブルを同時に更新するとき
→ 「いいね + 通知」「購入 + 在庫減少」「送金 + 入金」
■ 不要な場面
→ 1つのレコードだけの保存(saveは内部でトランザクションを使っている)
⚠️ トランザクション内では
saveではなくsave!(create!/update!)を使います。saveは失敗時にfalseを返すだけでロールバックしませんが、save!は例外を発生させるのでトランザクションがロールバックされます。
🌱 Seed / Faker — 開発用ダミーデータの投入
Seed(シードデータ)
開発環境でアプリを動かすには、DBにデータが必要です。 Seed は初期データを一発で投入する仕組みです。
# db/seeds.rb
puts "🌱 シードデータを作成中..."
# ユーザーを作成
users = 5.times.map do |i|
User.create!(
name: Faker::Name.name,
email_address: "user#{i + 1}@example.com",
password: "password",
password_confirmation: "password"
)
end
# タグを作成
tag_names = %w[Ruby Rails SQL JavaScript Docker Git]
tags = tag_names.map { |name| Tag.create!(name: name) }
# 記事を作成
users.each do |user|
rand(3..8).times do
article = user.articles.create!(
title: Faker::Lorem.sentence(word_count: 5),
body: Faker::Lorem.paragraphs(number: 5).join("\n\n"),
status: [:draft, :published, :published].sample # 公開記事を多めに
)
# ランダムにタグをつける
article.tags << tags.sample(rand(1..3))
end
end
puts "✅ 完了!ユーザー: #{User.count}, 記事: #{Article.count}, タグ: #{Tag.count}"
$ rails db:seed # シードデータ投入
$ rails db:reset # DB削除 → 再作成 → マイグレーション → シード
Seed の冪等性 — 何度実行してもOKにする
上のシードファイルは rails db:seed を2回実行すると、同じデータが重複して作られてしまいます。何度実行しても同じ結果になる( 冪等性がある)設計にしておくと安全です。
# find_or_create_by! を使うと、既に存在すれば作成をスキップする
tag_names = %w[Ruby Rails SQL JavaScript Docker Git]
tags = tag_names.map { |name| Tag.find_or_create_by!(name: name) }
# ユーザーも同様
users = 5.times.map do |i|
User.find_or_create_by!(email_address: "user#{i + 1}@example.com") do |u|
u.name = Faker::Name.name
u.password = "password"
u.password_confirmation = "password"
end
end
find_or_create_by! は「見つかればそれを返す、なければ作る」メソッドです。チーム開発ではこのパターンを使っておくと、誰がいつ rails db:seed を実行しても安心です。テスト用データの生成(FactoryBot)については(→ 第22章で詳しく扱います)。
Faker — ダミーデータ生成
# Gemfile
group :development, :test do
gem "faker"
end
# デフォルトは英語。日本語名を使う場合はロケールを設定する
Faker::Config.locale = 'ja'
Faker::Name.name # => "佐藤 太郎"(ja ロケール時)
Faker::Internet.email # => "taro.sato@example.com"
Faker::Lorem.sentence # => ダミーの日本語テキスト
Faker::Lorem.paragraphs(number: 3) # => 3段落のダミーテキスト
🛠️ KnowledgeNoteでの具体例:SQL を見てみよう
ActiveRecordが生成するSQLを確認してみます。
# Rubyコード
Article.published.recent.includes(:user, :tags).page(1).per(20)
-- 生成されるSQL
SELECT "articles".* FROM "articles"
WHERE "articles"."status" = 1
ORDER BY "articles"."created_at" DESC
LIMIT 20 OFFSET 0;
SELECT "users".* FROM "users"
WHERE "users"."id" IN (1, 2, 3);
SELECT "tags".* FROM "tags"
INNER JOIN "article_tags" ON "tags"."id" = "article_tags"."tag_id"
WHERE "article_tags"."article_id" IN (1, 2, 3, 4, 5);
3回のSQLで、記事20件 + 著者 + タグを全て取得できています。ActiveRecordを使うことでSQLインジェクションも防げます(→ 第20章で詳しく扱います)。
💼 面接で聞かれたら?
Q:N+1問題について説明してください。
「N+1問題とは、ループ内で関連データを取得するたびにSQLが発行される問題です。記事一覧で各記事の著者名を表示する場合、記事の取得に1回、著者の取得にN回(記事の数だけ)SQLが走り、合計N+1回になります。
includesを使って関連データを事前にまとめて読み込むことで、2〜3回のSQLに削減できます。」深掘りされたら:
- 「インデックスとは?」→ DBの検索を高速にするための索引。外部キーやWHERE句で頻繁に使うカラムに設定する。ただし更新時のコストが増えるため、必要な箇所にだけ貼る。
- 「トランザクションとは?」→ 複数のDB操作を1つのまとまりとして扱い、全て成功すればコミット、1つでも失敗すればロールバックする仕組み。データの整合性を保証する。
🔗 もっと深く知りたい人へ(1次情報リンク)
- Rails ガイド:Active Record クエリインターフェイス — includes / joins / where の全パターン
- Rails ガイド:Active Record マイグレーション(インデックス) — add_index の使い方
- PostgreSQL 公式ドキュメント:インデックス — インデックスの内部動作
- Faker(GitHub) — Fakerの全ジェネレーター一覧
まとめ
- ✅ 正規化はテーブルを分けて重複を排除する設計手法。更新・削除の異常を防ぐ
- ✅ インデックスは検索高速化の「索引」。外部キーとWHERE頻出カラムには必ず貼る
- ✅ 外部キー制約でDBレベルのデータ整合性を守る
- ✅ N+1問題は
includesで解決。Bullet gem で検出できる - ✅ トランザクションで複数テーブルの更新を「全部成功 or 全部取り消し」にする
- ✅ Seed + Faker で開発用ダミーデータを一発投入
📚 シリーズ目次:「今さら学ぶ」シリーズ — はじめに