mybook

Kata: ECサイト — 在庫と決済を分離する

課題の提示

ブログ Kata を終えて2週間後、ナオミから次の課題が届いた。


Kata 2: ECサイト

スタートアップのECサイトをゼロから構築してほしい。

  • 商品数: 10,000点
  • 月間注文数: 50,000件(ピーク時は通常の10倍)
  • 在庫管理: リアルタイムで正確に(二重注文は絶対NG)
  • 決済: クレジットカード決済(Stripe使用)
  • 複数の販売者が商品を登録できる(マーケットプレイス型)
  • 開発体制: エンジニア5名、6ヶ月

タクミは要件を読んで、前回とは違う重さを感じた。「二重注文は絶対NG」という言葉が引っかかった。

「ナオミさん、これはブログと何が違うんですか?」

「ブログの記事が重複しても実害はほぼない。でもECの在庫はお金と直結している。設計ミスはビジネスの存続に関わる。何が怖いか、まず考えなさい」


設計判断

怖いもの分析(リスク先行)

優れたアーキテクトは、「何がうまくいくか」より「何が壊れるか」を先に考える。

リスク発生シナリオ影響
二重注文在庫1個の商品に2人同時注文顧客への過剰販売、クレーム
決済失敗後の在庫減少決済エラーでも在庫が戻らない販売機会損失
決済後の在庫不足決済完了後に在庫がない注文キャンセル、信頼失墜
ピーク時のタイムアウトセール時に注文が詰まる売上損失

WARNING

在庫と決済は「整合性」の塊。どちらかが失敗したとき、もう一方をどう扱うかが設計の核心になる。

サービス分割の判断

Loading diagram...

判断: 3つのドメインに分割する。ただし、最初からマイクロサービスにはしない。

Loading diagram...

ナオミが言った。「分割は良い判断。でも注意。分割すると整合性を自分で保証しなければならない。DBのトランザクションは使えない」


実装

在庫管理 — 排他制御の核心

在庫の整合性は、アーキテクチャ全体で最も重要な部分だ。

# app/models/inventory.rb
class Inventory < ApplicationRecord
  belongs_to :product
 
  validates :quantity, numericality: { greater_than_or_equal_to: 0 }
 
  # 悲観的ロックで在庫を確保する
  def self.reserve!(product_id, quantity, order_id)
    transaction do
      # FOR UPDATE で他のトランザクションをブロック
      inventory = where(product_id: product_id).lock("FOR UPDATE").first
 
      raise InsufficientStockError, "在庫不足" if inventory.quantity < quantity
 
      inventory.decrement!(:quantity, quantity)
 
      # 在庫引当の記録(ロールバック用)
      InventoryReservation.create!(
        inventory: inventory,
        order_id: order_id,
        quantity: quantity
      )
 
      inventory
    end
  end
 
  def self.release!(order_id)
    transaction do
      reservation = InventoryReservation.find_by!(order_id: order_id)
      inventory = where(product_id: reservation.inventory.product_id).lock("FOR UPDATE").first
      inventory.increment!(:quantity, reservation.quantity)
      reservation.destroy!
    end
  end
end

注文フロー — 2フェーズの整合性

# app/services/order_creation_service.rb
class OrderCreationService
  def initialize(user, cart, payment_method)
    @user = user
    @cart = cart
    @payment_method = payment_method
  end
 
  def call
    # フェーズ1: 在庫確保
    reservations = reserve_inventory!
 
    # フェーズ2: 決済実行
    payment_intent = execute_payment!
 
    # フェーズ3: 注文確定
    order = confirm_order!(reservations, payment_intent)
 
    order
  rescue InsufficientStockError => e
    raise OrderError, "在庫が不足しています: #{e.message}"
  rescue Stripe::CardError => e
    # 決済失敗: 在庫を解放する
    release_reservations!(reservations) if reservations
    raise OrderError, "決済に失敗しました: #{e.message}"
  rescue StandardError => e
    # 予期しないエラー: クリーンアップ
    release_reservations!(reservations) if reservations
    cancel_payment!(payment_intent) if payment_intent
    raise
  end
 
  private
 
  def reserve_inventory!
    @cart.items.map do |item|
      Inventory.reserve!(item.product_id, item.quantity, SecureRandom.uuid)
    end
  end
 
  def execute_payment!
    Stripe::PaymentIntent.create(
      amount: @cart.total_amount_cents,
      currency: "jpy",
      payment_method: @payment_method,
      confirm: true,
      metadata: { user_id: @user.id }
    )
  end
 
  def confirm_order!(reservations, payment_intent)
    Order.create!(
      user: @user,
      items: @cart.items.map { |item| build_order_item(item) },
      total_amount: @cart.total_amount,
      payment_intent_id: payment_intent.id,
      status: :confirmed
    )
  end
 
  def release_reservations!(reservations)
    reservations.each { |r| Inventory.release!(r.order_id) }
  rescue StandardError => e
    # 解放失敗はアラートを上げて人が対応
    AlertService.critical("在庫解放失敗: #{e.message}")
  end
end

WARNING

release_reservations! が失敗した場合は人間が対応する必要がある。完全な自動化は難しく、アラートと運用手順が設計の一部になる。

商品カタログ — 読み込み最適化

商品閲覧は書き込みより100倍多い。読み込み最適化が重要だ。

# app/models/product.rb
class Product < ApplicationRecord
  belongs_to :seller
  has_one :inventory
  has_many :product_images
  has_many :reviews
 
  scope :active, -> { where(status: :active) }
  scope :with_stock, -> { joins(:inventory).where("inventories.quantity > 0") }
 
  # 検索用のスコープ
  scope :by_category, ->(category) { where(category: category) }
  scope :price_range, ->(min, max) { where(price: min..max) }
end
 
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
    @products = Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
      Product.active
             .with_stock
             .includes(:seller, :product_images, :inventory)
             .page(params[:page])
             .to_a
    end
  end
 
  private
 
  def cache_key
    "products/#{params[:category]}/#{params[:page]}/#{Product.maximum(:updated_at).to_i}"
  end
end

AWSインフラ構成

ECサイトはブログよりはるかに要求が高い。

Loading diagram...

インフラ選択の理由

サービス選択理由
ECS Fargateコンテナ実行オートスケール、サーバー管理不要
Aurora PostgreSQLDBMulti-AZ自動、性能がRDS PostgreSQLより高い
ElastiCache Redisキャッシュ商品一覧のキャッシュ、セッション管理
ALBロードバランサーパスベースルーティングで各サービスに振り分け

オートスケール設定

# ECS サービス定義(ピーク時の対応)
service:
  name: inventory-service
  desiredCount: 2
  autoScaling:
    minCapacity: 2
    maxCapacity: 20
    targetTrackingScalingPolicy:
      targetValue: 70  # CPU使用率70%でスケール
      scaleInCooldown: 300
      scaleOutCooldown: 60  # スケールアウトは素早く

INFO

ピーク時の10倍トラフィックに対応するため、スケールアウトは素早く(60秒)、スケールインは緩やか(300秒)に設定する。急に縮小すると次のピークに間に合わない。


分散システムの落とし穴

ナオミは実装レビューで言った。「在庫サービスと注文サービスを分けた。でも、一つ問題がある。何だかわかる?」

タクミは考えた。「ネットワーク障害?」

「そう。在庫確保に成功して、決済の途中でネットワークが切れたら?」

これが分散トランザクション問題だ。

# Saga パターンで補償トランザクションを実装
class OrderSaga
  STATES = %w[pending inventory_reserved payment_processed completed failed].freeze
 
  def self.execute(order_params)
    saga = create!(state: :pending, params: order_params)
    saga.run!
  end
 
  def run!
    # ステップ1
    reserve_inventory!
    update!(state: :inventory_reserved)
 
    # ステップ2
    process_payment!
    update!(state: :payment_processed)
 
    # 完了
    complete_order!
    update!(state: :completed)
  rescue => e
    compensate!(e)
  end
 
  private
 
  def compensate!(error)
    case state
    when "payment_processed"
      refund_payment!
      release_inventory!
    when "inventory_reserved"
      release_inventory!
    end
    update!(state: :failed, error_message: error.message)
    AlertService.notify("注文Saga失敗: #{id}")
  end
end

振り返り

「Kata 2 で学んだことを3つ挙げなさい」とナオミは言った。

タクミはノートに書いた。

  1. リスクから設計を始める: 何が壊れるかを先に考える
  2. 整合性境界がサービス境界になる: 同じトランザクションに入れたいものは同じサービスに
  3. 分散は複雑さを生む: 分割するなら、その複雑さを受け入れる覚悟が必要

INFO

Kata 2 の学び: サービス分割は「整合性」と「スケール」の要件が違う境界で行う。分割した後の整合性保証は自分の責任になる。Saga パターンはその一つの解。

トレードオフの記録

決定メリットデメリット
サービス分割スケール、障害分離分散トランザクションの複雑さ
悲観的ロック整合性保証スループット低下
ECS Fargate自動スケールコスト高(常時起動)

「次は、もっとリアルタイム性が求められる課題よ」とナオミは言った。「チャットアプリ。ユーザーは"今すぐ"返信が届くことを期待している」