mybook

データ管理 — サービスごとのデータベース

「え、サービスごとにデータベースを分けるんですか?」

ケンジは驚いた顔をした。これはマイクロサービスを初めて学ぶ人が必ずカルチャーショックを受ける概念だ。

「そう。これが Database per Service パターン。マイクロサービスで最も重要なルールの一つだよ」


なぜデータベースを分けるのか

共有データベースは、マイクロサービスの独立性を根本から壊す。

Loading diagram...
# 共有DBの問題: スキーマ変更が全サービスに波及
# ProductServiceがproductsテーブルにカラムを追加
class AddSpecificationsToProducts < ActiveRecord::Migration[7.2]
  def change
    add_column :products, :specifications, :jsonb
    # → OrderServiceもこのスキーマ変更に対応しなければならない
    # → 全サービスの同時デプロイが必要になる
    # → マイクロサービスの「独立デプロイ」が実現できない
  end
end

Database per Service パターン

各サービスが独自のデータベースを持つ。

Loading diagram...

データベース技術の選択自由

サービスごとに最適なデータベースを選べるのも大きなメリット。

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

DynamoDB: カートサービスの実装

カートはセッション性が高く、スキーマが柔軟で、高スループットが必要。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: true

CQRS パターン: 読み書きを分離する

**CQRS(Command Query Responsibility Segregation)**は、書き込みと読み込みのモデルを分離するパターン。

Loading diagram...
# 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
end

WARNING

結果整合性を採用すると、商品情報の更新後、数秒間は検索結果に古い情報が表示されることがある。これをビジネスチームに説明して受け入れてもらうことが重要。「セールの開始から数秒の遅延は許容できるか?」を確認しよう。


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の管理コスト

次章では、この分散データ環境での「トランザクション」問題に立ち向かう。複数のサービスにまたがるビジネストランザクションをどう実現するか — サーガパターンを学ぶ。