データベース設計 — RDBの基礎
スロークエリの悪夢
EchoTaskのユーザーが5万人を突破したある日、ハルトはダッシュボードを見て固まった。タスク一覧を表示するAPIが、平均2秒かかっていた。
$ grep "duration" /var/log/postgresql/postgresql.log | tail -5
2024-05-01 09:23:15 JST LOG: duration: 2341.234 ms
statement: SELECT tasks.*, users.name FROM tasks
LEFT JOIN users ON tasks.user_id = users.id
WHERE tasks.project_id = 123
ORDER BY tasks.created_at DESC LIMIT 20インデックスがない。正規化が適切でない。クエリが非効率だ。「データベース設計を最初からやり直す必要がある」——ハルトは覚悟を決めた。
正規化(Normalization)
正規化とは、データの冗長性を排除し、整合性を保ちやすくする設計原則だ。第1・第2・第3正規形(1NF/2NF/3NF)を段階的に適用する。
正規化前(問題のある設計)
-- 非正規化テーブル(悪い例)
CREATE TABLE tasks (
id BIGINT PRIMARY KEY,
title VARCHAR(255),
user_id BIGINT,
user_name VARCHAR(100), -- usersテーブルと重複
user_email VARCHAR(255), -- 同じく重複
project_name VARCHAR(100), -- projectsテーブルと重複
tag1 VARCHAR(50), -- タグが増えたら対応不能
tag2 VARCHAR(50),
tag3 VARCHAR(50)
);問題点:ユーザー名を変更すると全行UPDATE必須。タグが4個以上になるとスキーマ変更が必要。
第1正規形(1NF)— 繰り返し列を排除する
1NFの条件は「各セルに単一の値のみを持つこと」だ。タグ列を別テーブルに切り出す。
CREATE TABLE tags (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE task_tags (
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, tag_id)
);第2正規形(2NF)— 部分関数従属を排除する
2NFは「すべての非キー列が主キー全体に依存していること」。複合主キーのテーブルで問題が起きやすい。
-- 悪い例: project_name は project_id にのみ依存(部分依存)
CREATE TABLE task_assignments (
task_id BIGINT,
project_id BIGINT,
project_name VARCHAR(100), -- ← 部分依存
assigned_at TIMESTAMPTZ,
PRIMARY KEY (task_id, project_id)
);
-- 修正: project_name は projects テーブルで管理する
CREATE TABLE task_assignments (
task_id BIGINT NOT NULL REFERENCES tasks(id),
project_id BIGINT NOT NULL REFERENCES projects(id),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (task_id, project_id)
);第3正規形(3NF)— 推移的従属を排除する
3NFは「非キー列が他の非キー列に依存していないこと」だ。
-- 悪い例: department_name は department_id を介して依存(推移的依存)
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
department_id BIGINT,
department_name VARCHAR(100) -- ← 推移的依存
);
-- 修正: departments を独立させる
CREATE TABLE departments (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE
);
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
department_id BIGINT REFERENCES departments(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);正規化後のEchoTaskスキーマとER図
CREATE TABLE projects (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
name VARCHAR(255) NOT NULL,
tasks_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
assignee_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
status SMALLINT NOT NULL DEFAULT 0,
priority SMALLINT NOT NULL DEFAULT 1,
due_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);INFO
正規化は「どこまでやるか」が重要
第3正規形まで正規化するのが基本だが、パフォーマンスのために意図的に非正規化(denormalization)するケースもある。重要なのは「なぜ非正規化しているか」を明示的に理解した上で決断することだ。
インデックス設計
B-treeインデックスの仕組み
PostgreSQLのデフォルトはB-tree(Balanced Tree)構造だ。
- 等値検索
WHERE id = 123→ O(log n) でルートから葉まで辿る - 範囲検索
WHERE id BETWEEN 100 AND 200→ 開始点を特定し葉ノードを横断 - ソート
ORDER BY id→ すでにソート済みなのでそのまま使える
インデックスを追加する
-- インデックスなし
EXPLAIN SELECT * FROM tasks WHERE project_id = 123;
-- Seq Scan on tasks (cost=0.00..4832.00 rows=20 width=100)
-- → 100万行を全スキャン# db/migrate/20240101000010_add_indexes_to_tasks.rb
class AddIndexesToTasks < ActiveRecord::Migration[7.1]
def change
add_index :tasks, :project_id
add_index :tasks, :assignee_id
add_index :tasks, [:project_id, :status]
# 未完了タスクのみを対象にした部分インデックス
add_index :tasks, [:due_date, :status],
where: "status NOT IN (2, 3)",
name: "index_tasks_on_due_date_active"
end
end-- インデックス追加後
EXPLAIN SELECT * FROM tasks WHERE project_id = 123;
-- Index Scan using index_tasks_on_project_id on tasks
-- (cost=0.43..8.45 rows=20 width=100)
-- → コスト 4832 → 8.45 に激減WARNING
インデックスは万能ではない
インデックスは読み取りを速くするが、書き込み(INSERT/UPDATE/DELETE)を遅くしストレージを消費する。クエリパターンを分析してから追加すること。カーディナリティ(種類数)が低いカラム(booleanなど)へのインデックスは効果が薄い。
EXPLAINの読み方
EXPLAIN ANALYZE はクエリの実行計画と実測値の両方を示す最強のデバッグツールだ。
EXPLAIN (ANALYZE, BUFFERS)
SELECT t.id, t.title, t.status, u.name AS assignee_name
FROM tasks t
LEFT JOIN users u ON t.assignee_id = u.id
WHERE t.project_id = 123 AND t.status IN (0, 1)
ORDER BY t.due_date ASC NULLS LAST
LIMIT 20;Limit (cost=1.15..5.42 rows=20 width=120)
(actual time=0.312..0.789 rows=20 loops=1)
-> Nested Loop Left Join
(actual time=0.308..0.761 rows=20 loops=1)
-> Index Scan using index_tasks_on_project_id_and_status on tasks t
(actual time=0.289..0.690 rows=20 loops=1)
Index Cond: (project_id = 123) AND (status = ANY ('{0,1}'))
-> Index Scan using users_pkey on users u
(actual time=0.003..0.003 rows=1 loops=20)
Index Cond: (id = t.assignee_id)
Planning Time: 1.234 ms
Execution Time: 0.912 ms
| キーワード | 意味 |
|---|---|
cost=X..Y | 最初の行..全行を返すコスト(推定) |
actual time=X..Y | 実際にかかったミリ秒 |
rows=N | 推定行数(actualと大きくズレていたら統計が古い) |
Seq Scan | 全テーブルスキャン(大テーブルで出たら要注意) |
Index Scan | インデックスを使ったスキャン |
VACUUM・ANALYZE・インデックス監視
-- デッドタプル数を確認
SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'tasks';
-- 手動VACUUM + 統計更新
VACUUM ANALYZE tasks;
-- インデックスの使用状況を確認
SELECT
indexrelname AS index_name,
idx_scan AS times_used,
idx_tup_read AS rows_read
FROM pg_stat_user_indexes
WHERE relname = 'tasks'
ORDER BY idx_scan DESC;times_used = 0 のインデックスは不要だ。削除することでINSERT/UPDATEのオーバーヘッドを減らせる。
-- 使われていないインデックスをロックなしで削除
DROP INDEX CONCURRENTLY index_tasks_on_updated_at;pg_stat_statementsでスロークエリを特定
SELECT
LEFT(query, 80) AS query_snippet,
calls,
ROUND(mean_exec_time::numeric, 2) AS avg_ms,
ROUND(total_exec_time::numeric / 1000, 2) AS total_sec
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;N+1問題とcounter_cache
# 問題: タスクごとにSELECTが発生(N+1)
@tasks = Task.where(project_id: params[:project_id])
@tasks.map { |t| t.assignee&.name } # 20タスク → 21クエリ
# 解決: includes で関連を一括ロード
@tasks = Task
.where(project_id: params[:project_id])
.includes(:assignee, :tags) # 合計3クエリで済むcounter_cacheでCOUNTを排除する
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :project, counter_cache: true
# projects.tasks_count が自動更新される
end
# db/migrate/20240601000001_add_tasks_count_to_projects.rb
class AddTasksCountToProjects < ActiveRecord::Migration[7.1]
def change
add_column :projects, :tasks_count, :integer, default: 0, null: false
reversible do |dir|
dir.up { Project.find_each { |p| Project.reset_counters(p.id, :tasks) } }
end
end
endGolangでのデータベースアクセス(pgx)
// internal/repository/task_repository.go
package repository
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Task struct {
ID int64
ProjectID int64
Title string
Status int
AssigneeName *string
}
type TaskRepository struct{ pool *pgxpool.Pool }
func (r *TaskRepository) ListByProject(ctx context.Context, projectID int64) ([]Task, error) {
const query = `
SELECT t.id, t.project_id, t.title, t.status, u.name AS assignee_name
FROM tasks t
LEFT JOIN users u ON t.assignee_id = u.id
WHERE t.project_id = $1 AND t.status NOT IN (2, 3)
ORDER BY t.priority DESC, t.due_date ASC NULLS LAST
LIMIT 50`
rows, err := r.pool.Query(ctx, query, projectID)
if err != nil {
return nil, fmt.Errorf("ListByProject: %w", err)
}
defer rows.Close()
return pgx.CollectRows(rows, pgx.RowToStructByName[Task])
}INFO
pgx v5 の pgxpool を使う理由
database/sql は汎用インターフェースだが、pgx は PostgreSQL 固有の型(UUID, JSONB, ARRAY)を効率的に扱える。pgxpool はコネクションプールを内包しており、並行リクエストが多い環境で安全に使える。
AWS RDSの設定
# CloudFormation: RDSインスタンス
RDSInstance:
Type: AWS::RDS::DBInstance
Properties:
DBInstanceClass: db.t3.medium
Engine: postgres
EngineVersion: "16.3"
DBName: echo_task_production
MasterUsername: !Sub "{{resolve:secretsmanager:echo-task-db-secret:SecretString:username}}"
MasterUserPassword: !Sub "{{resolve:secretsmanager:echo-task-db-secret:SecretString:password}}"
AllocatedStorage: 100
StorageType: gp3
StorageEncrypted: true
MultiAZ: true
DeletionProtection: true
EnablePerformanceInsights: true
PerformanceInsightsRetentionPeriod: 7
BackupRetentionPeriod: 7
PreferredBackupWindow: "17:00-18:00"
VPCSecurityGroups:
- !Ref RDSSecurityGroup
DBSubnetGroupName: !Ref DBSubnetGroupRDS Proxyで接続数を制御する
ECSタスクが増えるとDB接続数が爆発する。RDS Proxyがコネクションプーリングで解決する。
【RDS Proxy なし】
100 ECSタスク × 5 Pumaスレッド = 500接続 → RDS上限超え
【RDS Proxy あり】
ECSタスク → Proxy(500接続受け付け)→ RDS(10〜20接続のみ)
RDSProxy:
Type: AWS::RDS::DBProxy
Properties:
DBProxyName: echo-task-proxy
EngineFamily: POSTGRESQL
RoleArn: !GetAtt RDSProxyRole.Arn
Auth:
- AuthScheme: SECRETS
SecretArn: !Ref DBSecret
IAMAuth: DISABLED
VpcSubnetIds: [!Ref PrivateSubnet1, !Ref PrivateSubnet2]
VpcSecurityGroupIds: [!Ref RDSProxySecurityGroup]
ConnectionPoolConfig:
MaxConnectionsPercent: 90
MaxIdleConnectionsPercent: 50
ConnectionBorrowTimeout: 120# config/database.yml
# DATABASE_URL の接続先を Proxy エンドポイントに変更するだけ
# echo-task-proxy.proxy-xxxxxx.ap-northeast-1.rds.amazonaws.com
production:
adapter: postgresql
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
url: <%= ENV['DATABASE_URL'] %>無停止マイグレーション戦略
カラム追加は安全(PostgreSQL 11+)
class AddPinnedToTasks < ActiveRecord::Migration[7.1]
def change
add_column :tasks, :pinned, :boolean, default: false, null: false
# PostgreSQL 11+ では NOT NULL + DEFAULT の追加は即時完了
end
endカラムリネームは段階的に行う
rename_column は既存コードが旧名前を参照している間にエラーを起こす。
# Step 1: 新カラムを追加し、両方に書き込む
add_column :tasks, :title_v2, :string
# モデルで before_save { self.title_v2 = title } を追加
# Step 2: 既存データをバックフィル
Task.find_in_batches(batch_size: 1000) do |batch|
Task.where(id: batch.map(&:id)).update_all("title_v2 = title")
end
# Step 3: 読み取りを title_v2 に切り替えてデプロイ
# Step 4: 旧カラムを削除
remove_column :tasks, :title, :string大テーブルへのインデックスはCONCURRENTLYで
class AddIndexTasksOnDueDate < ActiveRecord::Migration[7.1]
disable_ddl_transaction! # CONCURRENTLY に必要
def change
add_index :tasks, :due_date,
algorithm: :concurrently,
name: "index_tasks_on_due_date"
end
endWARNING
strong_migrations を導入する
strong_migrations gem を入れると危険なマイグレーション(デフォルトなしNOT NULL追加、rename_column 等)を検出して警告してくれる。本番DBを持つすべてのRailsプロジェクトに必須だ。
gem 'strong_migrations'パフォーマンスモニタリング
Performance Insightsの主要待機イベント
| 待機イベント | 意味と対処 |
|---|---|
io/datafileread | ディスクI/O多発 → インデックス不足を疑う |
Lock:tuple | 行ロック競合 → トランザクション設計を見直す |
Client:ClientRead | アプリ側が遅い → ネットワークかアプリのボトルネック |
CPU | ソート・集計が多い → クエリ最適化かスペックアップ |
Rakeタスクでスロークエリを定期確認
# lib/tasks/db_stats.rake
namespace :db do
desc "スロークエリTOP10を表示"
task slow_queries: :environment do
rows = ActiveRecord::Base.connection.execute(<<~SQL)
SELECT LEFT(query, 100) AS q, calls,
ROUND(mean_exec_time::numeric, 2) AS avg_ms
FROM pg_stat_statements
ORDER BY mean_exec_time DESC LIMIT 10;
SQL
rows.each { |r| puts "#{r['avg_ms']}ms (#{r['calls']}calls): #{r['q']}" }
end
end結果
インデックス、クエリ最適化、RDS Proxy適用の結果:
Before: 2341ms(Full Table Scan + N+1クエリ)
After: 43ms(インデックス + eager_load + Proxy)
改善倍率: 約54倍
しかし、ユーザーが10万人を超えたとき、次の問題が現れ始めた。
「読み取りクエリが多すぎて、1台のRDSではさばけなくなってきた。」
次の章では、データベースの水平スケーリング——レプリケーションとシャーディングを学ぶ。
INFO
この章のキーポイント
- 1NF→2NF→3NF を段階的に適用し、冗長性と更新異常を排除する
- B-treeインデックスはO(log n)の検索を実現するが、書き込みコストとのトレードオフがある
EXPLAIN ANALYZEで実行計画と実測値を比較し、pg_stat_user_indexesで不要インデックスを整理する- カラムリネーム・大テーブルへのインデックス追加は段階デプロイ+
CONCURRENTLYで無停止にする - RDS ProxyはECSコンテナ増加時のコネクション爆発を防ぐ必須コンポーネントだ
pg_stat_statements+ Performance Insightsでボトルネックを継続的に監視する