データ管理 — サービスごとのデータベース
「え、サービスごとにデータベースを分けるんですか?」
ケンジは驚いた顔をした。これはマイクロサービスを初めて学ぶ人が必ずカルチャーショックを受ける概念だ。
「そう。これが Database per Service パターン。マイクロサービスで最も重要なルールの一つだよ」
なぜデータベースを分けるのか
共有データベースは、マイクロサービスの独立性を根本から壊す。
# 共有DBの問題: スキーマ変更が全サービスに波及
# ProductServiceがproductsテーブルにカラムを追加
class AddSpecificationsToProducts < ActiveRecord::Migration[7.2]
def change
add_column :products, :specifications, :jsonb
# → OrderServiceもこのスキーマ変更に対応しなければならない
# → 全サービスの同時デプロイが必要になる
# → マイクロサービスの「独立デプロイ」が実現できない
end
endDatabase per Service パターン
各サービスが独自のデータベースを持つ。
データベース技術の選択自由
サービスごとに最適なデータベースを選べるのも大きなメリット。
services:
user-service:
database: Aurora PostgreSQL
reason: "構造化データ、ACID トランザクション、複雑なクエリ"
product-service:
database: Aurora PostgreSQL
reason: "カテゴリ・属性の柔軟なスキーマ(JSONB活用)"
order-service:
database: Aurora PostgreSQL
reason: "注文ライフサイクルのトランザクション整合性"
session-service:
database: ElastiCache Redis
reason: "TTL付きセッション管理、高速読み書き"
search-service:
database: OpenSearch
reason: "全文検索、ファセット、スコアリング"
cart-service:
database: DynamoDB
reason: "高スループット、スケールアウト、TTL対応"Aurora での DB 分離設計
-- ユーザーサービス専用DB
-- database: shopnova_users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_digest VARCHAR(255) NOT NULL,
display_name VARCHAR(100),
points_balance INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 注文サービス専用DB
-- database: shopnova_orders
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- ← 外部キーなし!参照のみ
status VARCHAR(50) NOT NULL DEFAULT 'pending',
total_amount_cents INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id),
product_id UUID NOT NULL, -- ← 外部キーなし!参照のみ
product_name_snapshot VARCHAR(255) NOT NULL, -- 注文時点の名前を保存
unit_price_snapshot_cents INTEGER NOT NULL, -- 注文時点の価格を保存
quantity INTEGER NOT NULL
);INFO
user_id や product_id は他サービスのデータへの参照だが、外部キー制約は設定しない。サービスをまたいだ参照整合性はデータベース側ではなく、アプリケーションロジックで担保する。
データの参照: APIを通じた取得
# app/models/order.rb(注文サービス)
class Order < ApplicationRecord
has_many :order_items, dependent: :destroy
# ユーザー情報は常にAPIから取得(DBには持たない)
def user
@user ||= UserServiceClient.find(user_id)
end
# 商品の最新情報はAPIから取得
# ただし order_items には注文時点のスナップショットがある
def products_current_info
product_ids = order_items.map(&:product_id)
ProductServiceClient.find_batch(product_ids)
end
endスナップショットパターン: 変更に強いデータ設計
# order_items にはスナップショットを保存する
class OrderItem < ApplicationRecord
belongs_to :order
def self.from_product(product, quantity:)
new(
product_id: product['id'],
product_name_snapshot: product['name'], # 注文時点の名前
unit_price_snapshot_cents: product['price_cents'], # 注文時点の価格
quantity: quantity
)
end
end
# 商品名が後で変わっても、注文履歴は正しい名前・価格を表示できる
class OrdersController < ApplicationController
def show
order = Order.find(params[:id])
# スナップショットから表示するので、商品サービスを呼ばなくてよい
render json: {
id: order.id,
items: order.order_items.map { |item|
{
product_id: item.product_id,
name: item.product_name_snapshot,
price: item.unit_price_snapshot_cents / 100.0,
quantity: item.quantity
}
}
}
end
endDynamoDB: カートサービスの実装
カートはセッション性が高く、スキーマが柔軟で、高スループットが必要。DynamoDB が最適。
# app/services/cart_service.rb
class CartService
DYNAMODB = Aws::DynamoDB::Client.new(region: 'ap-northeast-1')
TABLE_NAME = ENV.fetch('CART_TABLE_NAME', 'shopnova-carts')
TTL_SECONDS = 7.days.to_i
def self.get(user_id)
result = DYNAMODB.get_item(
table_name: TABLE_NAME,
key: { user_id: user_id }
)
result.item ? result.item['items'] : []
end
def self.add_item(user_id, product_id, quantity)
items = get(user_id)
existing = items.find { |i| i['product_id'] == product_id }
if existing
existing['quantity'] += quantity
else
items << { 'product_id' => product_id, 'quantity' => quantity }
end
DYNAMODB.put_item(
table_name: TABLE_NAME,
item: {
user_id: user_id,
items: items,
updated_at: Time.current.to_i,
ttl: Time.current.to_i + TTL_SECONDS # 7日後に自動削除
}
)
items
end
def self.clear(user_id)
DYNAMODB.delete_item(
table_name: TABLE_NAME,
key: { user_id: user_id }
)
end
end# DynamoDB テーブル定義(CloudFormation)
CartTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: shopnova-carts
BillingMode: PAY_PER_REQUEST # オンデマンドキャパシティ
AttributeDefinitions:
- AttributeName: user_id
AttributeType: S
KeySchema:
- AttributeName: user_id
KeyType: HASH
TimeToLiveSpecification:
AttributeName: ttl
Enabled: trueCQRS パターン: 読み書きを分離する
**CQRS(Command Query Responsibility Segregation)**は、書き込みと読み込みのモデルを分離するパターン。
# CQRS: 書き込み側(Commandモデル)
class ProductCommand
def create(attributes)
product = Product.create!(attributes)
# 書き込み後にイベントを発行
ProductEventPublisher.publish(
event_type: 'product.created',
product_id: product.id,
payload: product.attributes
)
product
end
def update_price(product_id, new_price_cents)
product = Product.find(product_id)
old_price = product.price_cents
product.update!(price_cents: new_price_cents)
ProductEventPublisher.publish(
event_type: 'product.price_changed',
product_id: product.id,
payload: {
old_price_cents: old_price,
new_price_cents: new_price_cents
}
)
end
end
# CQRS: 読み込み側(Queryモデル)
class ProductQuery
# 読み込み専用レプリカを使用
def find(id)
Product.connected_to(role: :reading) do
Product.find(id)
end
end
def search(query, filters: {})
# 検索はOpenSearchを使用
SearchServiceClient.search(query, filters: filters)
end
end# config/database.yml — Aurora Read Replica 設定
production:
primary:
adapter: postgresql
url: <%= ENV['DATABASE_URL'] %> # Aurora Primary(書き込み)
reading:
adapter: postgresql
url: <%= ENV['DATABASE_REPLICA_URL'] %> # Aurora Read Replica(読み込み)
replica: trueデータ整合性: 結果整合性を受け入れる
マイクロサービスでは、**強整合性(Strong Consistency)ではなく結果整合性(Eventual Consistency)**が基本。
# 商品が更新されたとき、検索インデックスは「少し後に」更新される
class ProductEventHandler
include Shoryuken::Worker
shoryuken_options queue: ENV.fetch('PRODUCT_EVENTS_QUEUE_URL')
def perform(sqs_msg, body)
event = JSON.parse(body)
case event['event_type']
when 'product.created', 'product.updated'
# 少し遅延するが、最終的には整合性が取れる
SearchIndexer.upsert(
index: 'products',
id: event['product_id'],
document: build_search_document(event['payload'])
)
when 'product.deleted'
SearchIndexer.delete(index: 'products', id: event['product_id'])
end
sqs_msg.delete
end
private
def build_search_document(payload)
{
name: payload['name'],
description: payload['description'],
price_cents: payload['price_cents'],
category: payload['category'],
indexed_at: Time.current.iso8601
}
end
endWARNING
結果整合性を採用すると、商品情報の更新後、数秒間は検索結果に古い情報が表示されることがある。これをビジネスチームに説明して受け入れてもらうことが重要。「セールの開始から数秒の遅延は許容できるか?」を確認しよう。
Aurora Global Database: リージョン間の可用性
将来のグローバル展開に向けた設計。
# CloudFormation — Aurora Global Database
AuroraGlobalCluster:
Type: AWS::RDS::GlobalCluster
Properties:
GlobalClusterIdentifier: shopnova-global
Engine: aurora-postgresql
EngineVersion: '15.4'
# 東京リージョン(Primary)
AuroraPrimaryCluster:
Type: AWS::RDS::DBCluster
Properties:
GlobalClusterIdentifier: !Ref AuroraGlobalCluster
Engine: aurora-postgresql
DBClusterIdentifier: shopnova-primary-tokyo
# シンガポール(Secondary、読み取り専用)
AuroraSecondaryCluster:
Type: AWS::RDS::DBCluster
Properties:
GlobalClusterIdentifier: !Ref AuroraGlobalCluster
Engine: aurora-postgresql
DBClusterIdentifier: shopnova-secondary-singaporeまとめ
「データを分けるのは怖いけど、その怖さを上回るメリットがある」とミサキは言った。
Database per Service のメリット:
✓ スキーマ変更が他サービスに影響しない
✓ サービスに最適なDB技術を選択できる
✓ 独立したスケーリング
✓ 独立したバックアップ・リストア
コスト:
✗ サービスまたぎのJOINができない
✗ 結果整合性(強整合性ではない)
✗ データの冗長化(スナップショット)
✗ 複数DBの管理コスト
次章では、この分散データ環境での「トランザクション」問題に立ち向かう。複数のサービスにまたがるビジネストランザクションをどう実現するか — サーガパターンを学ぶ。