Stage 7: データベース設計 — スキーマ設計とクエリ最適化
データはアプリの核
「マイさん、今日はDBの話ですね。Railsはマイグレーションがあるから、あまり意識しなくていいと思ってました。」
マイは首を振った。「それが最大の誤解。アーキテクチャはある程度やり直せる。でもデータのスキーマは変えにくい。データが積み上がれば積み上がるほど、修正コストは増える。」
「だから、最初の設計が重要なんですね。」
「そう。そして良い設計とは、正しく正規化されていて、適切にインデックスが貼られていて、N+1問題がないスキーマのこと。」
正規化: データの整合性を保つ
第1正規形(1NF): 繰り返しを排除
# 悪い例: 繰り返しグループ
# ordersテーブル
# | id | user_id | item1_name | item1_price | item2_name | item2_price |
# | 1 | 1 | りんご | 100 | みかん | 80 |
# 良い例: 繰り返しを別テーブルに
class CreateOrders < ActiveRecord::Migration[7.2]
def change
create_table :orders do |t|
t.references :user, null: false, foreign_key: true
t.string :status, null: false, default: "pending"
t.timestamps
end
create_table :order_items do |t|
t.references :order, null: false, foreign_key: true
t.references :product, null: false, foreign_key: true
t.integer :quantity, null: false
t.decimal :unit_price, precision: 10, scale: 2, null: false
t.timestamps
end
end
end第3正規形(3NF): 推移的依存を排除
# 悪い例: 注文テーブルに顧客情報を持つ
# | order_id | customer_id | customer_name | customer_prefecture | prefecture_tax_rate |
# 問題: prefecture_tax_rateはcustomer_prefectureに依存(推移的依存)
# 良い例: 責務ごとにテーブルを分ける
class CreateUsersAndOrders < ActiveRecord::Migration[7.2]
def change
create_table :prefectures do |t|
t.string :name, null: false
t.decimal :tax_rate, precision: 5, scale: 4, null: false
end
create_table :users do |t|
t.string :name, null: false
t.string :email, null: false
t.references :prefecture, foreign_key: true
t.timestamps
end
create_table :orders do |t|
t.references :user, null: false, foreign_key: true
t.string :status, null: false, default: "pending"
t.timestamps
end
end
endINFO
いつ非正規化するか
パフォーマンスのために意図的に非正規化することがあります。例えば注文の合計金額をorders.total_amountとして保持する(order_itemsの合計を毎回計算しない)。ただし、非正規化は「なぜそうするか」をコメントやドキュメントに残しましょう。
インデックス設計: クエリを速くする
「次はインデックス。正しく設計しないと、データが増えるにつれてクエリが遅くなる。」
class AddIndexesToOrders < ActiveRecord::Migration[7.2]
def change
# 単一カラムインデックス
add_index :orders, :status
add_index :orders, :created_at
# 複合インデックス(WHERE user_id = ? AND status = ?)
add_index :orders, [:user_id, :status]
# 部分インデックス(statusがpendingのものだけ)
add_index :orders, :created_at,
where: "status = 'pending'",
name: "idx_pending_orders_created_at"
# ユニークインデックス
add_index :users, :email, unique: true
end
end# インデックスが効くクエリ vs 効かないクエリ
class Order < ApplicationRecord
# 良い: インデックスを使う
scope :pending_for_user, ->(user_id) {
where(user_id: user_id, status: "pending")
}
# 悪い: LIKE '%..%' は先頭ワイルドカードでインデックス不使用
scope :search_by_note, ->(keyword) {
where("note LIKE ?", "%#{keyword}%")
}
# 改善: pg_search gemで全文検索
# include PgSearch::Model
# pg_search_scope :search_by_note, against: :note
endLoading diagram...
N+1問題: ActiveRecordの罠
「N+1問題はRails開発者が必ず踏む罠。」
# N+1問題: Orderが100件あれば、クエリが101回発行される
def index
@orders = Order.all # 1クエリ
render json: @orders.map { |order|
{
id: order.id,
user_name: order.user.name # N回のクエリが発生!
}
}
end
# 解決: includes でEager Loading
def index
@orders = Order.includes(:user).all # 2クエリで完結
render json: @orders.map { |order|
{
id: order.id,
user_name: order.user.name # キャッシュから取得
}
}
endBullet gem でN+1を検出
# Gemfile
gem "bullet", group: :development
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.rails_logger = true
Bullet.add_footer = true
end複雑な集計はSQL直書き
class Order < ApplicationRecord
# 月別売上集計: N+1なし、DBに計算させる
def self.monthly_revenue_report(year)
select(
"DATE_TRUNC('month', created_at) AS month",
"COUNT(*) AS order_count",
"SUM(total_amount) AS total_revenue",
"AVG(total_amount) AS avg_order_value"
)
.where("EXTRACT(YEAR FROM created_at) = ?", year)
.where(status: "completed")
.group("DATE_TRUNC('month', created_at)")
.order("month")
end
endトランザクションと整合性
class OrderFulfillmentService
def fulfill(order_id)
ActiveRecord::Base.transaction do
order = Order.lock.find(order_id) # SELECT FOR UPDATE
raise "既に処理済み" if order.fulfilled?
# 在庫を減らす
order.items.each do |item|
product = Product.lock.find(item.product_id)
raise InsufficientStockError, product.name if product.stock < item.quantity
product.decrement!(:stock, item.quantity)
end
order.update!(status: "fulfilled", fulfilled_at: Time.current)
end
rescue ActiveRecord::RecordNotFound => e
{ error: "注文が見つかりません: #{e.message}" }
rescue InsufficientStockError => e
{ error: "在庫不足: #{e.message}" }
end
endWARNING
デッドロックに注意
複数テーブルをlockする場合、常に同じ順序でロックを取得してください。異なる順序でロックするとデッドロックが発生します。
# 常にUser → Orderの順でロック(順序を統一)
user = User.lock.find(user_id)
order = Order.lock.find(order_id)RDS vs DynamoDB の選択
「RDBMSとNoSQLの使い分けも重要なポイント。」
Loading diagram...
| 観点 | RDS Aurora | DynamoDB |
|---|---|---|
| データモデル | リレーショナル | Key-Value / Document |
| クエリ | 複雑なJOIN・集計 | PK/SK基準のシンプル検索 |
| スケール | 垂直スケール主体 | 水平スケール(ほぼ無制限) |
| 向いている用途 | ECサイト、SaaS | セッション管理、IoT、ゲーム |
# AWS SDK for RubyでDynamoDBを使う
class SessionRepository
TABLE_NAME = "user-sessions"
def self.client
@client ||= Aws::DynamoDB::Client.new(region: "ap-northeast-1")
end
def self.get(session_id)
response = client.get_item(
table_name: TABLE_NAME,
key: { "session_id" => session_id }
)
response.item
end
def self.put(session_id, data, ttl: 24.hours.from_now)
client.put_item(
table_name: TABLE_NAME,
item: {
"session_id" => session_id,
"data" => data.to_json,
"ttl" => ttl.to_i # DynamoDBのTTL機能
}
)
end
endマイグレーション戦略
本番DBでのスキーマ変更は慎重に。
# カラム追加は安全
class AddPhoneToUsers < ActiveRecord::Migration[7.2]
def change
add_column :users, :phone, :string
end
end
# カラム削除は2ステップで
# Step 1: コードからカラムを参照しなくする(デプロイ)
class User < ApplicationRecord
self.ignored_columns = %w[old_field] # アプリから参照しない
end
# Step 2: マイグレーションでカラムを削除(次のデプロイ)
class RemoveOldFieldFromUsers < ActiveRecord::Migration[7.2]
def change
remove_column :users, :old_field, :string
end
end
# カラム名変更は最も危険: 必ずゼロダウンタイム対応で
# Step 1: 新カラム追加
# Step 2: 書き込みを両方に
# Step 3: バックフィル(データ移行)
# Step 4: 読み込みを新カラムに
# Step 5: 旧カラム削除Stage 7 のまとめ
「データベース設計は、アプリの骨格だ。あとから変えるのが一番コストが高い。」マイが言った。
| 観点 | 原則 | 実践 |
|---|---|---|
| スキーマ | 正規化でデータ整合性 | 3NFを基本、必要に応じて非正規化 |
| インデックス | クエリパターンに合わせる | 複合インデックス、部分インデックス |
| クエリ | N+1を作らない | includes、Bullet gemで検出 |
| トランザクション | 整合性を保つ | lockとトランザクションを適切に使う |
| DB選択 | 用途に合わせる | RDS for RDBMS、DynamoDB for KV |
「次はテスト戦略。良いDBスキーマを設計したら、それを守るためのテストが必要だ。」
ヒロシはEXPLAIN ANALYZEの結果を初めてじっくり読んでみた。インデックスが効いていないクエリが5つ見つかった。