mybook

データベーススケーリング — レプリケーションとシャーディング

読み取りが書き込みの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;

レプリケーション — 読み取りを分散する

Loading diagram...

プライマリとレプリカの役割

役割接続先クエリの種類
プライマリDATABASE_URLINSERT/UPDATE/DELETE
レプリカDATABASE_REPLICA_URLSELECT

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

DatabaseSelector ミドルウェアによる自動切り替え

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
end

delay: 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
end

AWS 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: 1

Auroraのエンドポイント

# 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: true

Aurora Serverless v2 — いつ使うべきか

Aurora Serverless v2は**ACU(Aurora Capacity Unit)**単位でオートスケーリングする。通常のAuroraインスタンス(常時起動)とどちらを選ぶべきか。

シナリオ推奨理由
本番の安定トラフィック固定インスタンスコスト予測が容易、レイテンシが安定
開発・ステージング環境Serverless v2アイドル時に最小ACUまで縮小
トラフィックの波が激しいServerless v2スパイク時に自動スケール
夜間ほぼゼロのSaaSServerless 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 で大きく異なる。

Loading diagram...
  • 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 table

Railsコードでのラグ対処

# 危険なシナリオ
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の結果を返す
end

WARNING

レプリケーションラグを意識する

「書き込んだ直後に読む」シナリオでは、レプリカではなくプライマリから読む必要がある。ユーザーが「保存した内容がすぐ表示されない」という状況はレプリカラグが原因の場合が多い。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
end

Golangでの接続プール実装 — pgx vs database/sql

バックエンドをGolangで書いている場合、接続プールの設定がスループットに直結する。

database/sql と pgx の比較

項目database/sqlpgx (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 が高い場合はプールが足りていないサインだ。


シャーディング — 書き込みをスケールする

レプリケーションは読み取りのスケーリングに有効だ。しかし、書き込みが増えた場合は別の手法が必要になる——シャーディングだ。

シャーディングとは、データを複数のデータベースに分割して格納する手法だ。

Loading diagram...

シャーディングキーの選び方

良いシャーディングキーの条件はカーディナリティが高い(バラツキが大きい)ことと、アクセスパターンと一致していることだ。

# 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」から始める
  • シャーディングキーはアクセスパターンと一致させる。連番・日付はアンチパターン
  • シャーディングは最後の手段。まずキャッシュとインデックス最適化を試す