mybook

読み取りスケーリング — レプリカとキャッシュ

読み取りと書き込みの非対称性

「Buzzのアクセスログを分析してみた」とユイがスライドを出した。

Buzzのリクエスト内訳(直近7日間):
  タイムライン表示:     45%  (READ)
  投稿詳細表示:         25%  (READ)
  ユーザープロフィール:  15%  (READ)
  投稿作成:              8%  (WRITE)
  いいね:                5%  (WRITE)
  その他:                2%  (READ/WRITE)
  ─────────────────────────────────
  READ: 85% / WRITE: 15%

「読み取りが85%か」アキラは言った。「だとすれば、書き込みと読み取りを分けられれば大幅に負荷が下がる」

これはほとんどのWebアプリケーションに共通するパターンだ。SNSアプリは「見る」より「投稿する」が少ない。この非対称性を利用して、読み取り専用のスケーリングをする戦略が有効だ。

Loading diagram...

INFO

読み取りと書き込みを分離する「CQRS(Command Query Responsibility Segregation)」パターンの実践だ。書き込み操作はCommandモデル(Primary)、読み取り操作はQueryモデル(Replica)が担当する。

Aurora Read Replica

Amazon Aurora は AWS のマネージドRDBで、最大15台のRead Replicaを持てる。プライマリに書き込み、レプリカから読み込む構成だ。通常のMySQL/PostgreSQLのレプリケーションとは異なり、Aurora独自のアーキテクチャで実現している。

Auroraのストレージアーキテクチャ

通常のレプリケーション:
  Primary → [ストレージA]
  ↓ binlogをコピー
  Replica → [ストレージB]
  ※ ストレージを2重に持つ → ストレージコストが2倍

Auroraのレプリケーション:
  Primary → [分散ストレージ(6コピー、3AZ)]
  ↑ 同じストレージ
  Replica → [分散ストレージ(6コピー、3AZ)]
  ※ ストレージは共有 → レプリカのストレージコストが安い
  ※ レプリカラグが非常に小さい(通常10-100ms)
Loading diagram...

INFO

Aurora は通常の MySQL/PostgreSQL レプリケーションと異なり、ストレージ層を共有している。レプリカラグが非常に小さく(通常10-100ms)、コストも低い。また、Auroraは自動フェイルオーバーをサポートしており、Primaryが落ちると自動でReplicaがPrimaryに昇格する。

Auroraクラスターの作成(Terraform)

# terraform/aurora.tf
 
resource "aws_rds_cluster" "buzz" {
  cluster_identifier      = "buzz-aurora-cluster"
  engine                  = "aurora-postgresql"
  engine_version          = "15.4"
  database_name           = "buzz_production"
  master_username         = var.db_username
  master_password         = var.db_password
  backup_retention_period = 7  # 7日間バックアップ保持
  preferred_backup_window = "02:00-03:00"  # 深夜2時にバックアップ
 
  # マルチAZ(高可用性)
  availability_zones = ["ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d"]
 
  # ネットワーク
  db_subnet_group_name   = aws_db_subnet_group.buzz.name
  vpc_security_group_ids = [aws_security_group.aurora.id]
 
  # 暗号化
  storage_encrypted = true
  kms_key_id        = aws_kms_key.aurora.arn
 
  # 削除保護
  deletion_protection = true
  skip_final_snapshot = false
  final_snapshot_identifier = "buzz-final-snapshot"
 
  tags = { Name = "buzz-aurora" }
}
 
# Writer インスタンス
resource "aws_rds_cluster_instance" "writer" {
  identifier          = "buzz-writer"
  cluster_identifier  = aws_rds_cluster.buzz.id
  instance_class      = "db.r7g.large"
  engine              = aws_rds_cluster.buzz.engine
  engine_version      = aws_rds_cluster.buzz.engine_version
 
  performance_insights_enabled = true  # パフォーマンスインサイト有効
}
 
# Reader インスタンス(最大15台)
resource "aws_rds_cluster_instance" "reader" {
  count               = 2  # 2台のReplicaから始める
  identifier          = "buzz-reader-${count.index}"
  cluster_identifier  = aws_rds_cluster.buzz.id
  instance_class      = "db.r7g.large"
  engine              = aws_rds_cluster.buzz.engine
  engine_version      = aws_rds_cluster.buzz.engine_version
 
  # ReaderはWriterとは別AZに配置(負荷分散)
  availability_zone   = element(
    ["ap-northeast-1c", "ap-northeast-1d"],
    count.index
  )
}
 
# エンドポイント(Writer用とReader用が自動作成される)
output "writer_endpoint" {
  value = aws_rds_cluster.buzz.endpoint  # 書き込み用
}
 
output "reader_endpoint" {
  value = aws_rds_cluster.buzz.reader_endpoint  # 読み取り用(ラウンドロビン)
}

Railsでのレプリカ設定

Rails 6以降はマルチプルデータベース機能が標準で使える。

# config/database.yml
default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  connect_timeout: 5
  checkout_timeout: 5
 
production:
  primary:
    <<: *default
    url: <%= ENV['DATABASE_PRIMARY_URL'] %>  # Aurora Writer endpoint
 
  primary_replica:
    <<: *default
    url: <%= ENV['DATABASE_REPLICA_URL'] %>  # Aurora Reader endpoint
    replica: true  # 読み取り専用フラグ
# config/application.rb
module Buzz
  class Application < Rails::Application
    # 書き込み後の読み取りは2秒間Primaryを使う(レプリカラグ対策)
    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
# app/models/post.rb
class Post < ApplicationRecord
  # 明示的にレプリカを使う(読み取り専用処理)
  def self.timeline_for(user_id:, page: 1)
    connected_to(role: :reading) do
      following_ids = Follow.where(follower_id: user_id).select(:followee_id)
 
      where(user_id: following_ids)
        .includes(:user)
        .order(created_at: :desc)
        .page(page).per(20)
    end
  end
 
  # 書き込みは明示的にPrimaryへ
  def self.create_post!(user_id:, content:, **attrs)
    connected_to(role: :writing) do
      create!(user_id: user_id, content: content, **attrs)
    end
  end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # GET リクエストは自動的にレプリカへ
  # POST/PUT/PATCH/DELETE は自動的にPrimaryへ
  # Railsの DatabaseSelector ミドルウェアが自動でルーティングする
 
  # 書き込み後にリダイレクトした場合は、次のリクエストもPrimaryを使う
  # session[:last_write_at] にタイムスタンプを記録して判断
 
  private
 
  # 手動でPrimaryを強制する場合
  def force_primary_read
    ActiveRecord::Base.connected_to(role: :writing) do
      yield
    end
  end
end

PgBouncerで接続プールを最適化

Auroraの前段にPgBouncerを置いて接続数を削減する。

# ECS タスク定義(サイドカーパターン)
{
  "family": "buzz-pgbouncer",
  "containerDefinitions": [
    {
      "name": "pgbouncer",
      "image": "pgbouncer/pgbouncer:1.21.0",
      "environment": [
        { "name": "POSTGRESQL_HOST", "value": "buzz-aurora.cluster-xxx.rds.amazonaws.com" },
        { "name": "PGBOUNCER_POOL_MODE", "value": "transaction" },
        { "name": "PGBOUNCER_MAX_CLIENT_CONN", "value": "1000" },
        { "name": "PGBOUNCER_DEFAULT_POOL_SIZE", "value": "25" },
        { "name": "PGBOUNCER_SERVER_IDLE_TIMEOUT", "value": "600" }
      ],
      "portMappings": [{ "containerPort": 6432, "protocol": "tcp" }]
    }
  ]
}
PgBouncer 導入効果:
  Rails 50タスク × 5接続 = 250接続(Aurora に届く前に圧縮)
  PgBouncer後: Aurora への実接続 = 25接続のみ
  接続数: 90%削減!
  Aurora のCPU使用率: 45% → 18%(接続オーバーヘッド削減)

Redisキャッシュ

Read Replicaはデータベースの負荷を分散するが、同じクエリが何度も走ることは変わらない。Redisキャッシュは同じクエリを何度も実行しないことで負荷を削減する。

Loading diagram...

ElastiCache for Redis のセットアップ

# terraform/elasticache.tf
 
resource "aws_elasticache_replication_group" "buzz" {
  replication_group_id          = "buzz-redis"
  description                   = "Buzz Redis Cluster"
  node_type                     = "cache.r7g.large"
  num_cache_clusters            = 2  # Primary + 1 Replica
  automatic_failover_enabled    = true
  multi_az_enabled              = true
 
  # セキュリティ
  at_rest_encryption_enabled    = true
  transit_encryption_enabled    = true
  auth_token                    = var.redis_auth_token  # Redis AUTH
 
  # ネットワーク
  subnet_group_name             = aws_elasticache_subnet_group.buzz.name
  security_group_ids            = [aws_security_group.redis.id]
 
  # メンテナンスウィンドウ
  maintenance_window            = "sun:03:00-sun:04:00"
  snapshot_retention_limit      = 7  # 7日間スナップショット保持
  snapshot_window               = "02:00-03:00"
 
  tags = { Name = "buzz-redis" }
}
# config/environments/production.rb
config.cache_store = :redis_cache_store, {
  url: ENV['REDIS_URL'],
  ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE },
 
  # 接続プール
  pool_size:    ENV.fetch('RAILS_MAX_THREADS', 5).to_i,
  pool_timeout: 5,
 
  # タイムアウト(短めに設定してDBフォールバックを速くする)
  connect_timeout: 0.5,
  read_timeout:    0.5,
  write_timeout:   0.5,
  reconnect_attempts: 2,
 
  # エラー時の動作(Redisが落ちてもアプリが止まらないように)
  error_handler: -> (method:, returning:, exception:) {
    Sentry.capture_exception(exception,
      level: :warning,
      tags: { redis_method: method }
    )
  }
}

キャッシュ戦略の実装

パターン1: フラグメントキャッシュ(最も単純)

# app/models/post.rb
class Post < ApplicationRecord
  CACHE_TTL         = 5.minutes
  POPULAR_CACHE_TTL = 1.hour  # 人気投稿は長めにキャッシュ
 
  def self.cached_timeline(user_id:, page: 1)
    cache_key = "timeline/v3/user/#{user_id}/page/#{page}"
 
    Rails.cache.fetch(cache_key, expires_in: CACHE_TTL) do
      timeline_for(user_id: user_id, page: page).to_a
    end
  end
 
  # 投稿が作成・更新されたらキャッシュを削除
  after_commit :invalidate_timeline_cache, on: [:create, :update, :destroy]
 
  private
 
  def invalidate_timeline_cache
    # 投稿者のフォロワーのタイムラインキャッシュを削除
    # ただし、フォロワーが多い場合は非同期で処理
    if user.followers.count < 10_000
      user.followers.pluck(:id).each do |follower_id|
        Rails.cache.delete_matched("timeline/v3/user/#{follower_id}/*")
      end
    else
      # フォロワー数が多い場合は非同期で削除(ファンアウト問題の回避)
      CacheInvalidationJob.perform_later(user_id: id)
    end
  end
end
# app/jobs/cache_invalidation_job.rb
class CacheInvalidationJob < ApplicationJob
  queue_as :cache
 
  def perform(user_id:)
    user = User.find(user_id)
    user.followers.find_in_batches(batch_size: 500) do |followers|
      followers.each do |follower|
        Rails.cache.delete_matched("timeline/v3/user/#{follower.id}/*")
      end
    end
  end
end

パターン2: Russian Doll(入れ子)キャッシュ

# app/models/user.rb
class User < ApplicationRecord
  # updated_at が変わるとキャッシュキーも変わる
  def cache_key_with_version
    "users/#{id}-#{updated_at.to_i}"
  end
end
 
# app/models/post.rb
class Post < ApplicationRecord
  # ユーザーが更新されたらポストのキャッシュキーも無効化
  def cache_key_with_version
    user_updated = user.updated_at.to_i
    "posts/#{id}-#{updated_at.to_i}/u#{user_updated}"
  end
end
<%# app/views/timeline/_post.html.erb %>
<% cache post do %>
  <div class="post-card">
    <% cache post.user do %>
      <%# ユーザーが更新されるまでキャッシュ(アバター変更etc)%>
      <img src="<%= post.user.avatar_url %>" alt="<%= post.user.username %>">
      <span><%= post.user.username %></span>
    <% end %>
    <p><%= post.content %></p>
    <span><%= post.likes_count %> いいね</span>
  </div>
<% end %>

パターン3: ライトスルーキャッシュ(書き込み時に同時にキャッシュ更新)

# app/services/post_creator.rb
class PostCreator
  def initialize(user:, params:)
    @user   = user
    @params = params
  end
 
  def call
    ActiveRecord::Base.transaction do
      post = Post.create!(
        user:    @user,
        content: @params[:content]
      )
 
      # 書き込み時に同時にキャッシュを更新(ライトスルー)
      update_cache(post)
 
      post
    end
  end
 
  private
 
  def update_cache(post)
    # ユーザー自身のタイムラインを即時更新
    cache_key = "timeline/v3/user/#{@user.id}/page/1"
    Rails.cache.delete(cache_key)  # 次のリクエストで再生成させる
 
    # ソーシャルグラフは非同期で更新
    FanOutTimelineJob.perform_later(post.id)
  end
end

キャッシュのデータ型を使い分ける

Redisはキャッシュだけでなく、様々なデータ構造を提供する。

# app/services/trending_service.rb
class TrendingService
  REDIS = Redis.new(url: ENV['REDIS_URL'])
 
  # Sorted Set でいいね数ランキング(リアルタイム集計)
  def self.increment_likes(post_id:, user_id:)
    # トランザクション的に実行
    REDIS.multi do |pipeline|
      # トレンドスコアを加算
      pipeline.zincrby("trending:posts:daily:#{Date.today}", 1, post_id.to_s)
      pipeline.zincrby("trending:posts:weekly", 1, post_id.to_s)
 
      # TTL設定(日次は翌日、週次は7日後)
      pipeline.expireat("trending:posts:daily:#{Date.today}",
                        (Date.today + 1).beginning_of_day.to_i)
      pipeline.expire("trending:posts:weekly", 7.days.to_i)
    end
  end
 
  def self.top_posts(limit: 10, period: :daily)
    key = period == :weekly ? "trending:posts:weekly" :
                              "trending:posts:daily:#{Date.today}"
    post_ids = REDIS.zrevrange(key, 0, limit - 1)
    Post.where(id: post_ids).index_by { |p| p.id.to_s }.values_at(*post_ids).compact
  end
 
  # HyperLogLog でユニークビュー数(近似値、超メモリ効率的)
  # 1億ユーザーのビューカウントを12KBで管理(Setだと数GBが必要)
  def self.track_view(post_id:, user_id:)
    REDIS.pfadd("post:#{post_id}:views:#{Date.today}", user_id.to_s)
  end
 
  def self.unique_views_today(post_id)
    REDIS.pfcount("post:#{post_id}:views:#{Date.today}")
    # 精度: 標準誤差0.81% ← 正確なカウントが不要なら十分
  end
 
  # Bitmap でデイリーアクティブユーザー管理
  # 100万ユーザーをわずか125KBで管理
  def self.mark_active(user_id)
    date_key = "dau:#{Date.today}"
    REDIS.setbit(date_key, user_id, 1)
    REDIS.expire(date_key, 30.days.to_i)
  end
 
  def self.daily_active_users
    REDIS.bitcount("dau:#{Date.today}")
  end
 
  def self.active_last_7_days
    keys = 7.times.map { |i| "dau:#{Date.today - i}" }
    temp_key = "dau:7days:#{Date.today}"
    REDIS.bitop("OR", temp_key, *keys)
    count = REDIS.bitcount(temp_key)
    REDIS.del(temp_key)
    count
  end
end

WARNING

Redisのキャッシュはキャッシュスタンピード(大量のキャッシュミスが同時に発生してDBが溢れる現象)に注意。人気のキャッシュが一斉に期限切れになると、大量のリクエストが同時にDBに向かう。race_condition_ttl オプションで対策する。

# キャッシュスタンピード対策: race_condition_ttl
def self.cached_trending_posts(limit: 10)
  Rails.cache.fetch(
    "trending:posts:v2",
    expires_in: 15.minutes,
    race_condition_ttl: 30.seconds  # 同時ミス時は古いキャッシュを返しつつ1つだけ再計算
  ) do
    top_posts(limit: limit)
  end
end
 
# もっと強力な対策: Redis ロック付きフェッチ
def self.cached_with_lock(key, expires_in:, &block)
  value = Rails.cache.read(key)
  return value if value.present?
 
  lock_key = "#{key}:lock"
  acquired = REDIS.set(lock_key, "1", nx: true, ex: 10)  # 10秒間のロック
 
  if acquired
    begin
      result = yield
      Rails.cache.write(key, result, expires_in: expires_in)
      result
    ensure
      REDIS.del(lock_key)
    end
  else
    # ロック取得失敗: 少し待って再試行
    sleep 0.1
    Rails.cache.read(key) || yield
  end
end

キャッシュの階層設計

大規模システムでは複数のキャッシュ層を組み合わせる。

# app/services/cache_service.rb
class CacheService
  # L1: プロセス内メモリキャッシュ(最速、サーバー再起動でクリア)
  LOCAL_CACHE = ActiveSupport::Cache::MemoryStore.new(
    size: 64.megabytes  # プロセスあたり64MB
  )
 
  # L2: Redis(速い、分散、永続化可能)
  REDIS_CACHE = Rails.cache
 
  # L3: データベース(遅い、正確)
 
  def self.fetch(key, expires_in: 5.minutes, &block)
    # L1チェック
    value = LOCAL_CACHE.read(key)
    return value if value.present?
 
    # L2チェック
    value = REDIS_CACHE.fetch(key, expires_in: expires_in) do
      yield  # L3(DB)から取得
    end
 
    # L1に短期保存(5秒)
    LOCAL_CACHE.write(key, value, expires_in: 5.seconds)
    value
  end
end

改善効果の計測

Redis キャッシュ + Aurora Read Replica 導入後:

タイムライン:
  キャッシュヒット率: 78%
  ヒット時レスポンス: 45ms → 3ms(Redisから返却)
  ミス時レスポンス: 45ms(Aurora Read Replicaから取得)
  平均レスポンス: 45ms → 12ms

Aurora:
  Primary CPU: 45% → 12%(Read Replicaにリダイレクト)
  Read Replica 2台で 10,000 RPS 処理可能
  レプリカラグ: 通常 15-50ms

Redis:
  メモリ使用量: 4.2 GB(キャッシュヒット率78%に必要)
  QPS(Queries Per Second): 25,000
  P99レイテンシ: 1.2ms

月次コスト(追加分):
  Aurora Read Replica × 2: $340/月
  ElastiCache r7g.large × 2: $540/月
  合計追加: $880/月
  → 10万ユーザーを安定して捌ける構成
Loading diagram...

「10万ユーザーでも十分耐えられる構成になった」アキラは満足そうに言った。「でも書き込みは?100万ユーザーが同時に投稿したら、プライマリDBが耐えられるか?」

次章では、書き込みのスケーリング——シャーディングとパーティショニングに踏み込む。読み取りを制したアキラは、次の難題に立ち向かう。


付録: キャッシュ設計チェックリスト

基本設計:
  □ キャッシュキーはバージョン付きか(v1, v2...)
  □ TTL(有効期限)は適切か
  □ キャッシュ無効化のタイミングは明確か
  □ race_condition_ttl でスタンピード対策をしているか

運用:
  □ キャッシュヒット率を監視しているか(目標80%以上)
  □ Redisのメモリ使用量を監視しているか
  □ maxmemory-policy を設定しているか(allkeys-lru推奨)
  □ Redisが落ちたときのフォールバックがあるか(graceful degradation)

セキュリティ:
  □ セッション情報をキャッシュに保存していないか
  □ 個人情報をキャッシュに保存する場合、TTLを短くしているか
  □ Redis AUTH (パスワード) を設定しているか
  □ VPC内のプライベートサブネットにRedisを配置しているか