データベーススケーリング — レプリケーションとシャーディング
読み取りが書き込みの10倍
EchoTaskのユーザーが10万人を超えたとき、RDSのCPU使用率が常時80%を超えるようになった。
ハルトはCloudWatchでクエリを分析した。
# RDS Performance Insightsで確認
# 上位クエリ(実行回数順)
1. SELECT tasks.* FROM tasks WHERE project_id = ? → 45,000回/分
2. SELECT users.* FROM users WHERE id = ? → 38,000回/分
3. SELECT comments.* FROM comments WHERE task_id = ? → 22,000回/分
4. INSERT INTO tasks ... → 3,000回/分
5. UPDATE tasks SET status = ? WHERE id = ? → 2,500回/分読み取り(SELECT)が圧倒的に多い。典型的な読み取り重視のワークロードだ。
INFO
80:20の法則
Webアプリケーションのデータベースアクセスは、一般的に読み取りが80〜90%、書き込みが10〜20%程度だ。読み取りをスケールするだけで、多くの問題が解決する。
レプリケーションの仕組み — WALとログ転送
レプリケーションとは、データベースのコピー(レプリカ)を複数作成し、読み取りクエリをレプリカに分散する仕組みだ。しかしその内部では、どうやってデータを同期しているのか。
WAL(Write-Ahead Log)とは
PostgreSQLはすべての変更を**WAL(Write-Ahead Log)**と呼ばれるログに先行して記録する。これはデータのクラッシュリカバリのためにも使われるが、レプリケーションの基盤でもある。
【書き込みの流れ】
アプリ → INSERT/UPDATE → WALに記録 → データページに反映
↓
レプリカへ転送 → レプリカ側でWALをリプレイ
物理レプリケーション vs 論理レプリケーション
PostgreSQLには2種類のレプリケーション方式がある。
| 項目 | 物理レプリケーション | 論理レプリケーション |
|---|---|---|
| 転送単位 | ブロック(バイト列) | 論理的な変更(行・テーブル) |
| PostgreSQLバージョン | 同一バージョン必須 | 異なるバージョン間も可能 |
| レプリカのDML | 読み取り専用 | レプリカ側でも書き込み可 |
| 特定テーブルのみ | 不可 | 可能 |
| 主な用途 | HA・Read Replica | データ移行・部分同期 |
RDSやAuroraの読み取りレプリカは物理レプリケーション(ストリーミングレプリケーション)を使っている。WALの差分をリアルタイムでレプリカにストリーミングし続けることで、ほぼリアルタイムの同期を実現する。
-- プライマリ側でレプリケーション状況を確認
SELECT
client_addr,
state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
(sent_lsn - replay_lsn) AS replication_lag_bytes
FROM pg_stat_replication;レプリケーション — 読み取りを分散する
プライマリとレプリカの役割
| 役割 | 接続先 | クエリの種類 |
|---|---|---|
| プライマリ | DATABASE_URL | INSERT/UPDATE/DELETE |
| レプリカ | DATABASE_REPLICA_URL | SELECT |
Railsでの読み書き分離 — 詳細実装
RailsにはActiveRecordのhorizontal sharding / multiple databases機能が組み込まれている。
database.ymlの設定
# config/database.yml
production:
primary:
adapter: postgresql
url: <%= ENV['DATABASE_URL'] %>
pool: <%= ENV.fetch('RAILS_MAX_THREADS') { 5 } %>
database_tasks: false
primary_replica:
adapter: postgresql
url: <%= ENV['DATABASE_REPLICA_URL'] %>
pool: <%= ENV.fetch('RAILS_MAX_THREADS') { 5 } %>
database_tasks: false
replica: true # ← これがポイント。マイグレーションをスキップする# config/application.rb
module EchoTask
class Application < Rails::Application
config.active_record.reading_role = :reading
config.active_record.writing_role = :writing
end
end# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: {
writing: :primary,
reading: :primary_replica
}
endDatabaseSelector ミドルウェアによる自動切り替え
RailsにはDatabaseSelectorというRackミドルウェアが標準搭載されている。GETリクエストは自動的にレプリカへ、POST/PUT/PATCH/DELETEはプライマリへルーティングする。
# config/application.rb
module EchoTask
class Application < Rails::Application
# DatabaseSelectorミドルウェアを有効化
config.active_record.database_selector = { delay: 2.seconds }
config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
end
enddelay: 2.seconds は「書き込み後2秒間はプライマリから読む」という設定だ。これによりレプリケーションラグによる不整合を防ぐ。
コントローラーレベルの細かい制御
ミドルウェアでカバーできないケース(バックグラウンドジョブ、GraphQL、Webhookなど)はコントローラーで明示的に制御する。
# app/controllers/api/v1/tasks_controller.rb
class Api::V1::TasksController < ApplicationController
def index
# GETリクエストはレプリカを使う
tasks = ActiveRecord::Base.connected_to(role: :reading) do
Task.where(project_id: params[:project_id])
.includes(:assignee, :comments)
.order(created_at: :desc)
.limit(50)
end
render json: tasks
end
def create
# 書き込みはprimary(デフォルト)
task = Task.create!(task_params)
render json: task, status: :created
end
def complete
# 書き込み後の即時読み取りはprimaryから
ActiveRecord::Base.connected_to(role: :writing) do
task = Task.find(params[:id])
task.update!(status: :completed, completed_at: Time.current)
render json: task
end
end
end# app/jobs/daily_report_job.rb
class DailyReportJob < ApplicationJob
def perform(project_id)
# 集計クエリはレプリカで実行して負荷を分散
stats = ActiveRecord::Base.connected_to(role: :reading) do
{
total_tasks: Task.where(project_id: project_id).count,
completed: Task.where(project_id: project_id, status: :completed).count,
overdue: Task.where(project_id: project_id)
.where("due_date < ?", Time.current)
.where.not(status: :completed)
.count
}
end
# レポートの保存はプライマリへ
DailyReport.create!(project_id: project_id, stats: stats)
end
endAWS Aurora — マネージドレプリケーション
RDS PostgreSQLをAurora PostgreSQLに移行すると、レプリカ管理が大幅に簡単になる。
# Aurora Clusterの作成(CloudFormation 抜粋)
AuroraCluster:
Type: AWS::RDS::DBCluster
Properties:
Engine: aurora-postgresql
EngineVersion: "16.1"
DatabaseName: echo_task_production
StorageEncrypted: true
BackupRetentionPeriod: 7
DeletionProtection: true
VpcSecurityGroupIds: [!Ref RDSSecurityGroup]
DBSubnetGroupName: !Ref DBSubnetGroup
AuroraWriterInstance:
Type: AWS::RDS::DBInstance
Properties:
Engine: aurora-postgresql
DBClusterIdentifier: !Ref AuroraCluster
DBInstanceClass: db.r7g.large
PromotionTier: 0 # 小さいほどフェイルオーバー優先
AuroraReaderInstance1:
Type: AWS::RDS::DBInstance
Properties:
Engine: aurora-postgresql
DBClusterIdentifier: !Ref AuroraCluster
DBInstanceClass: db.r7g.large
PromotionTier: 1Auroraのエンドポイント
# config/database.yml(Aurora版)
production:
primary:
adapter: postgresql
# Writerエンドポイント(書き込みのみ)
url: <%= ENV['AURORA_WRITER_URL'] %>
primary_replica:
adapter: postgresql
# Readerエンドポイント(複数レプリカに自動分散)
url: <%= ENV['AURORA_READER_URL'] %>
replica: trueAurora Serverless v2 — いつ使うべきか
Aurora Serverless v2は**ACU(Aurora Capacity Unit)**単位でオートスケーリングする。通常のAuroraインスタンス(常時起動)とどちらを選ぶべきか。
| シナリオ | 推奨 | 理由 |
|---|---|---|
| 本番の安定トラフィック | 固定インスタンス | コスト予測が容易、レイテンシが安定 |
| 開発・ステージング環境 | Serverless v2 | アイドル時に最小ACUまで縮小 |
| トラフィックの波が激しい | Serverless v2 | スパイク時に自動スケール |
| 夜間ほぼゼロのSaaS | Serverless v2 | 夜間コストを大幅削減 |
コスト試算例(東京リージョン):
固定インスタンス db.r7g.large:
$0.26/時間 × 24時間 × 30日 = 約$187/月
Aurora Serverless v2(min 0.5 ACU, max 8 ACU):
アイドル時: 0.5 ACU × $0.12/ACU時間 × 16時間 ≈ $0.96/日
ピーク時: 8 ACU × $0.12/ACU時間 × 8時間 ≈ $7.68/日
合計: 約$3〜$8/日 → 月$90〜$240(利用パターン次第)
開発環境に固定インスタンスを使い続けているチームは Serverless v2 に切り替えるだけで月数万円削減できることが多い。
RDS Multi-AZ vs Aurora Failover
フェイルオーバーの挙動は RDS と Aurora で大きく異なる。
- RDS Multi-AZ: スタンバイインスタンスへフェイルオーバー。読み取りには使えないスタンバイが昇格するため60〜120秒かかる
- Aurora Failover: 既存のReaderインスタンスが即座にWriterに昇格。30秒以内に完了。
PromotionTierが小さいインスタンスが優先される
レプリケーションラグの監視・対処法
レプリカはプライマリからデータを非同期でコピーする。そのため、数ミリ秒〜数秒の遅延(レプリケーションラグ)が発生する。
CloudWatchメトリクスの監視
監視対象メトリクスは AuroraReplicaLag(名前空間: AWS/RDS)だ。1000ms(1秒)を超えたらアラームを上げる。
# CloudFormation: レプリカラグのアラーム設定
ReplicationLagAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: aurora-replica-lag-high
MetricName: AuroraReplicaLag
Namespace: AWS/RDS
Dimensions:
- Name: DBInstanceIdentifier
Value: !Ref AuroraReaderInstance1
Statistic: Average
Period: 60
EvaluationPeriods: 3
Threshold: 1000 # ミリ秒
ComparisonOperator: GreaterThanThreshold
AlarmActions: [!Ref OpsNotificationTopic]# CLIでのラグ確認(直近1時間)
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS \
--metric-name AuroraReplicaLag \
--dimensions Name=DBInstanceIdentifier,Value=echo-task-reader-1 \
--start-time $(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 60 --statistics Average --output tableRailsコードでのラグ対処
# 危険なシナリオ
def complete_task(task_id)
task = Task.find(task_id)
task.update!(status: :completed)
# ↑ primaryに書き込み
# すぐにレプリカから読む → まだ反映されていないことがある!
ActiveRecord::Base.connected_to(role: :reading) do
Task.find(task_id).status # → "in_progress" が返るかもしれない
end
end
# 解決策: DatabaseSelectorのsession情報を活用する
# DatabaseSelectorミドルウェアは書き込み後にcookieに書き込み時刻を記録し、
# delay以内のリクエストは自動的にprimaryへルーティングする
def complete_task(task_id)
task = Task.find(task_id)
task.update!(status: :completed)
# delay: 2.seconds の設定があれば、
# 次のGETリクエストも2秒間はprimaryから読まれる
task # そのままprimaryの結果を返す
endWARNING
レプリケーションラグを意識する
「書き込んだ直後に読む」シナリオでは、レプリカではなくプライマリから読む必要がある。ユーザーが「保存した内容がすぐ表示されない」という状況はレプリカラグが原因の場合が多い。DatabaseSelectorの delay 設定か、明示的な connected_to(role: :writing) で対処する。
DynamoDB vs PostgreSQL — 選択基準
スケーリング戦略を考えるとき、RDBMSに固執する必要はない。データの性質によってはDynamoDBのほうが適している場合がある。
DynamoDBが向くデータ
| ユースケース | 理由 |
|---|---|
| ユーザーセッション | Key-Valueアクセスのみ、TTL設定が簡単 |
| アクティビティログ | 書き込みが多く、読み取りは最新N件のみ |
| リアルタイム通知キュー | 高スループット、低レイテンシが必要 |
| 設定・フィーチャーフラグ | 構造がシンプル、読み取りが圧倒的に多い |
| IoTセンサーデータ | 時系列、スキーマが均一 |
PostgreSQLが向くデータ
| ユースケース | 理由 |
|---|---|
| タスク・プロジェクト管理 | 複雑なリレーション、集計クエリ |
| 請求・決済データ | トランザクション整合性が必須 |
| ユーザープロフィール | 複合検索(氏名・メール・組織) |
| レポート・分析 | JOINを多用する集計 |
| 権限・ロール管理 | 階層構造、ACL |
EchoTaskの場合、セッション管理とリアルタイム通知をDynamoDBに切り出すことで、RDSへの負荷を15%削減できた。
# DynamoDB接続(Railsから)
# Gemfile: gem 'aws-sdk-dynamodb'
class SessionRepository
TABLE_NAME = "echo-task-sessions"
def self.find(session_id)
client.get_item(table_name: TABLE_NAME, key: { session_id: session_id }).item
end
def self.create(session_id, user_id, ttl: 24.hours)
client.put_item(
table_name: TABLE_NAME,
item: {
session_id: session_id,
user_id: user_id,
created_at: Time.current.iso8601,
ttl: (Time.current + ttl).to_i # DynamoDBのTTL(Unix timestamp)
}
)
end
def self.client
@client ||= Aws::DynamoDB::Client.new(region: "ap-northeast-1")
end
endGolangでの接続プール実装 — pgx vs database/sql
バックエンドをGolangで書いている場合、接続プールの設定がスループットに直結する。
database/sql と pgx の比較
| 項目 | database/sql | pgx (pgxpool) |
|---|---|---|
| PostgreSQL固有機能 | 限定的 | フル対応(LISTEN/NOTIFY等) |
| 接続プール | 組み込み | pgxpoolで別途設定 |
| 型マッピング | 手動 | 自動(UUID, JSONB等) |
| パフォーマンス | 標準 | database/sqlより高速 |
| ドライバー | lib/pq等が必要 | pgxがドライバー兼ライブラリ |
pgxpoolの実装例
// internal/database/pool.go
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Config struct {
WriterDSN string
ReaderDSN string
MaxConns int32
MinConns int32
MaxConnLife time.Duration
MaxConnIdle time.Duration
}
type DB struct {
Writer *pgxpool.Pool
Reader *pgxpool.Pool
}
func NewDB(ctx context.Context, cfg Config) (*DB, error) {
writerPool, err := newPool(ctx, cfg.WriterDSN, cfg)
if err != nil {
return nil, fmt.Errorf("writer pool: %w", err)
}
readerPool, err := newPool(ctx, cfg.ReaderDSN, cfg)
if err != nil {
return nil, fmt.Errorf("reader pool: %w", err)
}
return &DB{Writer: writerPool, Reader: readerPool}, nil
}
func newPool(ctx context.Context, dsn string, cfg Config) (*pgxpool.Pool, error) {
poolCfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, err
}
poolCfg.MaxConns = cfg.MaxConns
poolCfg.MinConns = cfg.MinConns
poolCfg.MaxConnLifetime = cfg.MaxConnLife
poolCfg.MaxConnIdleTime = cfg.MaxConnIdle
poolCfg.AfterConnect = func(ctx context.Context, conn *pgxpool.Conn) error {
_, err := conn.Exec(ctx, "SET TIME ZONE 'Asia/Tokyo'")
return err
}
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping failed: %w", err)
}
return pool, nil
}// 使用例: Reader/Writerの切り替え
func (r *TaskRepository) FindByProject(ctx context.Context, projectID int64) ([]Task, error) {
// 読み取りは Reader プールへ
rows, err := r.db.Reader.Query(ctx,
`SELECT id, title, project_id, status, created_at
FROM tasks WHERE project_id = $1 ORDER BY created_at DESC`,
projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
return pgx.CollectRows(rows, pgx.RowToStructByName[Task])
}
func (r *TaskRepository) Create(ctx context.Context, title string, projectID int64) (*Task, error) {
// 書き込みは Writer プールへ
var task Task
err := r.db.Writer.QueryRow(ctx,
`INSERT INTO tasks (title, project_id, status, created_at)
VALUES ($1, $2, 'open', NOW())
RETURNING id, title, project_id, status, created_at`,
title, projectID,
).Scan(&task.ID, &task.Title, &task.ProjectID, &task.Status, &task.CreatedAt)
if err != nil {
return nil, err
}
return &task, nil
}INFO
接続プールのサイジング目安
MaxConns は「CPUコア数 × 2〜4」が出発点だ。db.r7g.large(2vCPU)なら MaxConns=8〜16。多すぎると PostgreSQL 側のコンテキストスイッチが増えてむしろ遅くなる。pgxpool の Stat() で AcquireDuration が高い場合はプールが足りていないサインだ。
シャーディング — 書き込みをスケールする
レプリケーションは読み取りのスケーリングに有効だ。しかし、書き込みが増えた場合は別の手法が必要になる——シャーディングだ。
シャーディングとは、データを複数のデータベースに分割して格納する手法だ。
シャーディングキーの選び方
良いシャーディングキーの条件はカーディナリティが高い(バラツキが大きい)ことと、アクセスパターンと一致していることだ。
# config/database.yml(シャーディング設定)
production:
shard_0:
adapter: postgresql
url: <%= ENV['DATABASE_SHARD_0_URL'] %>
shard_1:
adapter: postgresql
url: <%= ENV['DATABASE_SHARD_1_URL'] %>
# shard_2, shard_3 も同様に追加
# app/models/task.rb
class Task < ApplicationRecord
connects_to shards: {
shard_0: { writing: :shard_0 },
shard_1: { writing: :shard_1 }
}
end
# app/controllers/api/v1/tasks_controller.rb
class Api::V1::TasksController < ApplicationController
def index
ActiveRecord::Base.connected_to(shard: shard_for(current_user.id)) do
render json: Task.where(user_id: current_user.id).limit(50)
end
end
private
def shard_for(user_id) = :"shard_#{user_id % 4}"
endシャーディングキー設計のアンチパターン
シャーディングを導入する際、キーの選択を誤ると後々取り返しのつかない問題が生まれる。
アンチパターン1: 連番IDをキーにする — user_id % 4 で単純に割り当てると、初期は shard_0 にデータが集中してしまう。UUIDなどカーディナリティが高いキーを選ぶか、Consistent Hashingを使う。
アンチパターン2: 日付をキーにする — DATE(created_at) % 4 は常に「今日」のシャードにアクセスが集中する。古いシャードは死蔵される(ホットスポット問題)。
アンチパターン3: アクセスパターンと合わないキー — タスクを project_id でシャーディングしているのに「ユーザーの全タスク」を取得したい場合、全シャードへクエリを投げてマージするしかない。これは非常に非効率だ。
アクセスパターンと一致したキーを選ぶ。「最も頻繁に行うクエリの WHERE 句に含まれるカラム」がシャーディングキーの候補だ。
シャーディングの課題
| 課題 | 内容 |
|---|---|
| クロスシャードクエリ | 複数シャードをまたぐ検索が困難 |
| シャード追加 | 既存データの再分散が必要(Consistent Hashingで軽減) |
| トランザクション | シャードをまたいだトランザクションが使えない |
| 運用の複雑さ | スキーマ変更をすべてのシャードに適用が必要 |
WARNING
シャーディングは最後の手段
シャーディングは非常に複雑になる。まずはレプリカ増設・キャッシュ追加・クエリ最適化・インデックスチューニング・インスタンスアップグレードを順番に試みてから、それでも限界が来たときに検討する。多くのスタートアップはシャーディングが必要になるほど成長しない。Shopifyでさえ長年シャーディングなしで運用していた。
EchoTaskの改善結果
ハルトはAurora Clusterにレプリカ2台を追加し、DatabaseSelectorミドルウェアによる読み書き分離を実装した。
Before:
- RDS (db.r7g.large) CPU: 85%
- 読み取りクエリ: primary 1台で処理
- 平均レスポンスタイム: 340ms
After:
- Aurora Writer CPU: 15%(書き込みのみ)
- Aurora Reader CPU: 30% × 2台(読み取り分散)
- 読み取り応答時間: 平均120ms → 40ms
- 全体レスポンスタイム: 340ms → 95ms
# Auroraクラスターの状態確認
aws rds describe-db-clusters \
--db-cluster-identifier echo-task-aurora \
--query 'DBClusters[0].{Status:Status,Members:DBClusterMembers}' \
--output jsonデータベース層はひとまず安定した。しかし、毎回データベースにアクセスするのは非効率だ。
「同じデータを何度も読むなら、どこかに保存しておけばいいのでは?」
次の章では、キャッシュ戦略を学ぶ。
INFO
この章のキーポイント
- PostgreSQLのレプリケーションはWALのストリーミング転送で実現している
- 物理レプリケーションはBlock単位、論理レプリケーションは行・テーブル単位
- Railsの
DatabaseSelectorミドルウェアでGET/POST自動振り分けができる - Aurora Serverless v2は開発環境やトラフィックの波が激しい本番で特に有効
- Auroraのフェイルオーバーは30秒以内。RDS Multi-AZより大幅に速い
- レプリケーションラグは
AuroraReplicaLagメトリクスでCloudWatchで監視する - DynamoDBはセッション・ログ・通知など「Key-Valueに近いデータ」が向く
- pgxpoolはdatabase/sqlより高性能。MaxConnsは「vCPU数 × 2〜4」から始める
- シャーディングキーはアクセスパターンと一致させる。連番・日付はアンチパターン
- シャーディングは最後の手段。まずキャッシュとインデックス最適化を試す