読み取りスケーリング — レプリカとキャッシュ
読み取りと書き込みの非対称性
「Buzzのアクセスログを分析してみた」とユイがスライドを出した。
Buzzのリクエスト内訳(直近7日間):
タイムライン表示: 45% (READ)
投稿詳細表示: 25% (READ)
ユーザープロフィール: 15% (READ)
投稿作成: 8% (WRITE)
いいね: 5% (WRITE)
その他: 2% (READ/WRITE)
─────────────────────────────────
READ: 85% / WRITE: 15%
「読み取りが85%か」アキラは言った。「だとすれば、書き込みと読み取りを分けられれば大幅に負荷が下がる」
これはほとんどのWebアプリケーションに共通するパターンだ。SNSアプリは「見る」より「投稿する」が少ない。この非対称性を利用して、読み取り専用のスケーリングをする戦略が有効だ。
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)
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
endPgBouncerで接続プールを最適化
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キャッシュは同じクエリを何度も実行しないことで負荷を削減する。
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
endWARNING
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万ユーザーを安定して捌ける構成
「10万ユーザーでも十分耐えられる構成になった」アキラは満足そうに言った。「でも書き込みは?100万ユーザーが同時に投稿したら、プライマリDBが耐えられるか?」
次章では、書き込みのスケーリング——シャーディングとパーティショニングに踏み込む。読み取りを制したアキラは、次の難題に立ち向かう。
付録: キャッシュ設計チェックリスト
基本設計:
□ キャッシュキーはバージョン付きか(v1, v2...)
□ TTL(有効期限)は適切か
□ キャッシュ無効化のタイミングは明確か
□ race_condition_ttl でスタンピード対策をしているか
運用:
□ キャッシュヒット率を監視しているか(目標80%以上)
□ Redisのメモリ使用量を監視しているか
□ maxmemory-policy を設定しているか(allkeys-lru推奨)
□ Redisが落ちたときのフォールバックがあるか(graceful degradation)
セキュリティ:
□ セッション情報をキャッシュに保存していないか
□ 個人情報をキャッシュに保存する場合、TTLを短くしているか
□ Redis AUTH (パスワード) を設定しているか
□ VPC内のプライベートサブネットにRedisを配置しているか