データベースの選択 — RDB以外の選択肢
「全てをRDBに入れる」問題
Buzzが50万ユーザーを超えたころ、アキラは新たな壁にぶつかった。
新機能リクエスト(プロダクトチームから):
① フルテキスト検索(投稿・ユーザーを横断して高速検索)
② リアルタイムアクティビティフィード
(いいね・フォロー・メンション)
③ セッション管理(50万ユーザーのセッション)
④ レート制限(APIの乱用防止)
⑤ 「あなたへのおすすめユーザー」機能
全部PostgreSQLに実装しようとして限界を感じた...
実際に起きた問題:
① LIKE '%keyword%' は200万行で12秒かかる
② アクティビティの大量INSERTでDBがパンク
③ セッション50万件 × 平均2KB = 1GB のDBストレージ
④ INCR/EXPIREのような操作はSQLで実装すると遅い
⑤ グラフ推薦のJOINが重くてタイムアウト
「道具は用途に合わせて選ぶべきだ」とアキラは言った。「PostgreSQLは万能だが、特定の用途には特化したDBの方が10〜100倍速い。ただし複雑さが増すから、本当に必要なものだけを選ぶ」
INFO
「ポリグロット・パーシステンス(多言語永続化)」とは、ひとつのアプリケーションで用途に応じた複数のDBを使い分けるアーキテクチャパターンだ。銀の弾丸は存在しない——各DBには得意・不得意がある。
Amazon DynamoDB — セッションとアクティビティフィード
DynamoDBはキー・バリュー型のNoSQLで、単一桁ミリ秒のレイテンシが特徴だ。スキーマレスで書き込みが高速。ただしJOINができないため、「ユーザーIDで取得する」ような単純なパターンに向く。
セッション管理
# Gemfile
gem 'aws-sessionstore-dynamodb'# DynamoDB テーブル作成(TTL自動削除付き)
aws dynamodb create-table \
--table-name buzz-sessions \
--attribute-definitions AttributeName=session_id,AttributeType=S \
--key-schema AttributeName=session_id,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region ap-northeast-1
# TTL属性を有効化(期限切れセッションを自動削除)
aws dynamodb update-time-to-live \
--table-name buzz-sessions \
--time-to-live-specification "Enabled=true, AttributeName=expires_at"# config/initializers/session_store.rb
Rails.application.config.session_store(
:dynamo_db_store,
table_name: 'buzz-sessions',
table_hash_key: 'session_id',
ttl: 30.days.to_i, # セッション有効期限
ttl_attribute: 'expires_at', # TTLとして使うカラム名
region: 'ap-northeast-1',
pool: 10 # 接続プールサイズ
)DynamoDB セッション管理の効果:
PostgreSQLでの1,000万セッション:
ストレージ: ~20GB
読み取り速度: 5-50ms(インデックスがあれば)
TTL管理: 別途バッチが必要
DynamoDBでの1,000万セッション:
ストレージ: ~20GB(コストは大差ない)
読み取り速度: 1-3ms(一貫した低レイテンシ)
TTL管理: 自動(期限切れを自動削除)
PostgreSQL負荷: ゼロ(セッションテーブルがなくなる)
アクティビティフィード
SNSのアクティビティフィード(「AさんがBさんをフォローしました」「CさんがDさんの投稿にいいねしました」)はRDBに向かない。大量の書き込みと、ユーザーごとの最新N件という読み取りパターンだ。
# app/services/activity_feed_service.rb
class ActivityFeedService
DYNAMODB = Aws::DynamoDB::Client.new(region: 'ap-northeast-1')
TABLE_NAME = 'buzz-activity-feed'
# DynamoDBのテーブル設計:
# PK (パーティションキー): recipient_id(誰が受け取るか)
# SK (ソートキー): activity_id(SnowflakeID、時系列でソートできる)
# 追加属性: actor_id, action, target_type, target_id, created_at, ttl
# アクティビティを記録(全受信者のフィードに書き込む)
def self.record(actor_id:, action:, target_type:, target_id:, recipient_ids:)
activity_id = Buzz::SnowflakeId.generate.to_s
# 最大25件ずつバッチ書き込み
recipient_ids.each_slice(25) do |batch_ids|
requests = batch_ids.map do |recipient_id|
{
put_request: {
item: {
'recipient_id' => recipient_id.to_s,
'activity_id' => activity_id,
'actor_id' => actor_id.to_s,
'action' => action, # 'like', 'follow', 'comment', 'mention'
'target_type' => target_type, # 'Post', 'User'
'target_id' => target_id.to_s,
'created_at' => Time.current.iso8601,
'ttl' => (Time.current + 90.days).to_i # 90日後に自動削除
}
}
}
end
DYNAMODB.batch_write_item(
request_items: { TABLE_NAME => requests }
)
end
end
# フィードを取得(最新N件)
def self.get_feed(user_id:, limit: 20, next_token: nil)
params = {
table_name: TABLE_NAME,
key_condition_expression: 'recipient_id = :uid',
expression_attribute_values: { ':uid' => user_id.to_s },
scan_index_forward: false, # 降順(最新から)
limit: limit
}
# ページネーション
params[:exclusive_start_key] = JSON.parse(next_token) if next_token
result = DYNAMODB.query(params)
{
items: enrich_activities(result.items),
next_token: result.last_evaluated_key&.to_json
}
end
private
def self.enrich_activities(items)
# actor_idからユーザー情報をバッチ取得(Aurora)
actor_ids = items.map { |i| i['actor_id'].to_i }.uniq
actors = User.where(id: actor_ids)
.select(:id, :username, :avatar_url)
.index_by { |u| u.id.to_s }
items.map do |item|
actor = actors[item['actor_id']]
{
activity_id: item['activity_id'],
actor: {
id: actor&.id,
username: actor&.username,
avatar_url: actor&.avatar_url
},
action: item['action'],
target_type: item['target_type'],
target_id: item['target_id'].to_i,
created_at: item['created_at']
}
end
end
endINFO
DynamoDBのパーティションキー設計が性能の全てを決める。recipient_id をパーティションキーにすることで、ユーザーごとのフィード取得が常にO(1)になる。ただし、フォロワーが多い有名ユーザーへの書き込みがホットパーティションになる可能性がある。その場合は書き込みシャーディングを検討する。
DynamoDBのアクセスパターン設計
DynamoDBはアクセスパターンを事前に設計することが重要だ。後から変更が難しい。
Buzzのアクティビティフィードのアクセスパターン:
1. ユーザーのフィードを取得(最も重要)
→ PK: recipient_id | SK: activity_id(降順)
→ これだけで90%以上のクエリをカバー
2. 特定のactorのアクティビティを取得(管理者用)
→ GSI(グローバルセカンダリインデックス)
→ GSI PK: actor_id | GSI SK: created_at
3. 特定のターゲットへのアクティビティ(「この投稿へのいいね一覧」)
→ GSI PK: target_id | GSI SK: action+created_at
# GSI(グローバルセカンダリインデックス)を追加
aws dynamodb update-table \
--table-name buzz-activity-feed \
--global-secondary-index-updates '[
{
"Create": {
"IndexName": "actor-index",
"KeySchema": [
{"AttributeName": "actor_id", "KeyType": "HASH"},
{"AttributeName": "activity_id", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"},
"ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}
}
}
]'Amazon OpenSearch — 全文検索
投稿の全文検索はPostgreSQLのpg_trgmでも対応できるが、100万件を超えると限界が来る。OpenSearchは検索に特化している。日本語の形態素解析も標準サポートしている。
# Gemfile
gem 'opensearch-ruby'
gem 'searchkick' # Rails との統合を簡単にする# config/initializers/searchkick.rb
Searchkick.client = OpenSearch::Client.new(
url: ENV['OPENSEARCH_URL'],
transport_options: {
ssl: { verify: Rails.env.production? }
},
retry_on_failure: 3,
log: Rails.env.development?
)
# インデックス設定のデフォルト
Searchkick.timeout = 10
Searchkick.queue_name = :search_index # Sidekiqのキュー名# app/models/post.rb
class Post < ApplicationRecord
searchkick(
# 検索対象のフィールド設定
text_start: [:content, :hashtags],
highlight: [:content],
word_middle: [:content],
# インデックス名(バージョン付き)
index_name: "buzz_posts_#{Rails.env}",
# 日本語解析設定
settings: {
analysis: {
tokenizer: {
kuromoji: {
type: 'kuromoji_tokenizer',
mode: 'search' # 検索モード(より細かくトークン化)
}
},
analyzer: {
japanese: {
type: 'custom',
tokenizer: 'kuromoji',
filter: ['kuromoji_baseform', 'lowercase', 'cjk_width', 'kuromoji_stemmer']
},
japanese_index: {
type: 'custom',
tokenizer: 'kuromoji',
filter: ['kuromoji_baseform', 'lowercase', 'cjk_width']
}
}
}
},
# マッピング設定
mappings: {
properties: {
content: {
type: 'text',
analyzer: 'japanese_index',
search_analyzer: 'japanese'
},
hashtags: {
type: 'keyword' # 完全一致検索
},
likes_count: {
type: 'long'
},
created_at: {
type: 'date'
}
}
}
)
# OpenSearchに保存するデータ
def search_data
{
content: content,
hashtags: extract_hashtags,
user_id: user_id,
username: user.username, # デノーマライズ(JOIN不要にする)
likes_count: likes_count,
created_at: created_at,
is_deleted: deleted_at.present?
}
end
# 削除されたらインデックスからも削除
after_commit :reindex_async
private
def extract_hashtags
content.scan(/#[\w-ゟ゠-ヿ一-龯]+/)
.map(&:downcase)
.uniq
end
end# 検索の実装
class SearchController < ApplicationController
def index
query = params[:q].to_s.strip
page = params[:page]&.to_i || 1
per_page = 20
@results = Post.search(
query.presence || '*', # クエリが空の場合は全件
fields: [
'content^3', # contentフィールドのスコアを3倍に重み付け
'hashtags^2', # hashtagsフィールドは2倍
'username^1'
],
highlight: {
fields: {
content: {
fragment_size: 150,
number_of_fragments: 2,
pre_tag: '<mark>',
post_tag: '</mark>'
}
}
},
where: {
is_deleted: false,
created_at: { gte: 6.months.ago }, # 直近6ヶ月に絞る
_or: [
{ likes_count: { gte: 5 } }, # いいね5件以上
{ user_id: current_user.following_ids.first(100) } # フォロー中ユーザー
]
},
order: {
_score: :desc, # 関連度スコア降順
likes_count: :desc # 同スコアならいいね数が多い順
},
page: page,
per_page: per_page,
# タイポ(誤字)を許容
misspellings: {
below: 5, # 5文字以下はタイポ許容しない
edit_distance: 1 # 編集距離1まで(1文字違い)
},
# 検索キーワードの提案(もしかして?)
suggest: query.present?
)
end
end# インデックスの同期(非同期)
class SearchIndexJob < ApplicationJob
queue_as :search_index
discard_on ActiveRecord::RecordNotFound
def perform(post_id)
post = Post.find_by(id: post_id)
if post
post.reindex
else
# 削除された投稿はインデックスから削除
Post.search_index.remove("post_#{post_id}")
end
end
endElastiCache Redis — レート制限とカウンター
RedisはキャッシュだけでなくAPIレート制限にも使える。Sorted Setを使ったスライディングウィンドウ方式が正確だ。
# app/services/rate_limiter_service.rb
class RateLimiterService
REDIS = Redis.new(url: ENV['REDIS_URL'])
# スライディングウィンドウ方式のレート制限
# 「直近60秒間に10回まで」という制限を正確に実装
def self.allow?(identifier:, limit:, window: 60)
key = "rate_limit:#{identifier}"
now = Time.current.to_f
window_start = now - window
result = REDIS.multi do |pipeline|
# 古いリクエスト記録を削除(ウィンドウ外)
pipeline.zremrangebyscore(key, '-inf', window_start)
# 現在のウィンドウ内のリクエスト数
pipeline.zcard(key)
# 今回のリクエストを記録
pipeline.zadd(key, now, "#{now}-#{SecureRandom.hex(4)}")
# TTLを設定
pipeline.expire(key, window + 1)
end
count = result[1]
count < limit # limit未満なら許可
end
# 残り回数を返す
def self.remaining(identifier:, limit:, window: 60)
key = "rate_limit:#{identifier}"
now = Time.current.to_f
window_start = now - window
REDIS.zremrangebyscore(key, '-inf', window_start)
current = REDIS.zcard(key)
[limit - current, 0].max
end
end# app/controllers/api/v1/posts_controller.rb
module Api
module V1
class PostsController < ApiController
before_action :check_rate_limit, only: [:create]
def create
@post = PostCreator.new(user: current_user, params: post_params).call
render json: PostSerializer.new(@post).to_json, status: :created
end
private
def check_rate_limit
identifier = "user:#{current_user.id}:posts"
remaining = RateLimiterService.remaining(identifier: identifier, limit: 20, window: 60)
# ヘッダーにレート制限情報を返す(APIクライアントが参照できる)
response.headers['X-RateLimit-Limit'] = '20'
response.headers['X-RateLimit-Remaining'] = remaining.to_s
response.headers['X-RateLimit-Reset'] = (Time.current + 60).to_i.to_s
unless RateLimiterService.allow?(identifier: identifier, limit: 20, window: 60)
render json: {
error: 'rate_limit_exceeded',
message: '1分間に投稿できるのは20件までです',
retry_after: 60
}, status: :too_many_requests
end
end
def post_params
params.require(:post).permit(:content, :image)
end
end
end
endポリグロット・パーシステンスのアーキテクチャ
| データ種別 | DB | 理由 |
|---|---|---|
| ユーザー・投稿(コアデータ) | Aurora PostgreSQL | ACID保証、JOIN必要 |
| セッション | DynamoDB | 高速KV、TTL自動削除 |
| アクティビティフィード | DynamoDB | 大量書き込み、パーティション設計が最適 |
| タイムラインキャッシュ | ElastiCache Redis | サブミリ秒、データ構造豊富 |
| 全文検索 | OpenSearch | 関連性スコア、日本語形態素解析 |
| レート制限 | ElastiCache Redis | Sorted Set、原子操作 |
WARNING
ポリグロット・パーシステンスは強力だが、運用の複雑さが増す。各DBの障害パターン、バックアップ戦略、監視を別々に管理しなければならない。チームがそれぞれのDBの特性を理解していないと、トラブル時に対応できない。「本当に必要か?」を問ってから導入する。
障害対応とフォールバック設計
複数のDBを使うと、障害も複数のパターンが発生する。フォールバックを設計する。
# app/services/activity_feed_service.rb(フォールバック付き)
class ActivityFeedService
def self.get_feed(user_id:, limit: 20, next_token: nil)
# DynamoDBが使えない場合はAuroraにフォールバック
Timeout::timeout(3) do # 3秒でタイムアウト
get_feed_from_dynamodb(user_id: user_id, limit: limit, next_token: next_token)
end
rescue Aws::DynamoDB::Errors::ServiceError,
Timeout::Error => e
Rails.logger.error "DynamoDB error, falling back to Aurora: #{e.message}"
Sentry.capture_exception(e, level: :warning)
get_feed_from_aurora(user_id: user_id, limit: limit)
end
private
def self.get_feed_from_aurora(user_id:, limit:)
# Auroraからアクティビティを取得(遅いがフォールバックとして機能)
Notification.where(recipient_id: user_id)
.order(created_at: :desc)
.limit(limit)
.includes(:actor, :target)
.map { |n| serialize_notification(n) }
end
end# app/services/search_service.rb(フォールバック付き)
class SearchService
def self.search(query:, page: 1, per: 20)
# OpenSearchが使えない場合はPostgreSQLにフォールバック
Post.search(query, page: page, per_page: per)
rescue Faraday::ConnectionFailed,
OpenSearch::Transport::Transport::Errors::ServiceUnavailable => e
Rails.logger.error "OpenSearch error, falling back to PostgreSQL: #{e.message}"
search_with_postgres(query: query, page: page, per: per)
end
private
def self.search_with_postgres(query:, page:, per:)
# pg_trgm を使った全文検索(OpenSearchより遅いが動く)
Post.search_by_content(query)
.page(page).per(per)
end
endデータの一貫性管理
複数のDBにまたがってデータを更新するとき、一貫性をどう保つかが課題だ。
# Outboxパターン: DBとメッセージキューの一貫性を保つ
# 投稿作成時: AuroraとDynamoDBとOpenSearchを同期
class PostCreator
def initialize(user:, params:)
@user = user
@params = params
end
def call
post = nil
# 1. Aurora に投稿を保存(これが唯一の信頼できるソース)
ActiveRecord::Base.transaction do
post = Post.create!(
user: @user,
content: @params[:content]
)
# Outboxテーブルに「OpenSearchへの同期が必要」と記録
OutboxEvent.create!(
aggregate_type: 'Post',
aggregate_id: post.id,
event_type: 'PostCreated',
payload: post.as_json
)
end
# 2. DynamoDBへの同期(非同期)
SyncToDynamoJob.perform_later('Post', post.id, 'created')
# 3. OpenSearchへの同期(非同期、Outboxから)
SearchIndexJob.perform_later(post.id)
post
end
end
# Outboxテーブル(Aurora内)
class OutboxEvent < ApplicationRecord
# id, aggregate_type, aggregate_id, event_type, payload, processed_at
scope :pending, -> { where(processed_at: nil) }
end「RDBだけだと不可能だったことが、専用DBを使うと簡単になった」とアキラは言った。「でも次はデータが大量に流れるリアルタイム処理が必要になった。KinesisとEventBridgeでイベント駆動アーキテクチャに移行する時期だ」
次章では、ストリーミングとイベント駆動アーキテクチャでリアルタイムデータ処理を実現する方法を学ぶ。