mybook

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
end

INFO

いつ非正規化するか

パフォーマンスのために意図的に非正規化することがあります。例えば注文の合計金額を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
end
Loading 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  # キャッシュから取得
    }
  }
end

Bullet 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
end

WARNING

デッドロックに注意

複数テーブルをlockする場合、常に同じ順序でロックを取得してください。異なる順序でロックするとデッドロックが発生します。

# 常にUser → Orderの順でロック(順序を統一)
user = User.lock.find(user_id)
order = Order.lock.find(order_id)

RDS vs DynamoDB の選択

「RDBMSとNoSQLの使い分けも重要なポイント。」

Loading diagram...
観点RDS AuroraDynamoDB
データモデルリレーショナル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つ見つかった。