Kata: ECサイト — 在庫と決済を分離する
課題の提示
ブログ Kata を終えて2週間後、ナオミから次の課題が届いた。
Kata 2: ECサイト
スタートアップのECサイトをゼロから構築してほしい。
- 商品数: 10,000点
- 月間注文数: 50,000件(ピーク時は通常の10倍)
- 在庫管理: リアルタイムで正確に(二重注文は絶対NG)
- 決済: クレジットカード決済(Stripe使用)
- 複数の販売者が商品を登録できる(マーケットプレイス型)
- 開発体制: エンジニア5名、6ヶ月
タクミは要件を読んで、前回とは違う重さを感じた。「二重注文は絶対NG」という言葉が引っかかった。
「ナオミさん、これはブログと何が違うんですか?」
「ブログの記事が重複しても実害はほぼない。でもECの在庫はお金と直結している。設計ミスはビジネスの存続に関わる。何が怖いか、まず考えなさい」
設計判断
怖いもの分析(リスク先行)
優れたアーキテクトは、「何がうまくいくか」より「何が壊れるか」を先に考える。
| リスク | 発生シナリオ | 影響 |
|---|---|---|
| 二重注文 | 在庫1個の商品に2人同時注文 | 顧客への過剰販売、クレーム |
| 決済失敗後の在庫減少 | 決済エラーでも在庫が戻らない | 販売機会損失 |
| 決済後の在庫不足 | 決済完了後に在庫がない | 注文キャンセル、信頼失墜 |
| ピーク時のタイムアウト | セール時に注文が詰まる | 売上損失 |
WARNING
在庫と決済は「整合性」の塊。どちらかが失敗したとき、もう一方をどう扱うかが設計の核心になる。
サービス分割の判断
判断: 3つのドメインに分割する。ただし、最初からマイクロサービスにはしない。
ナオミが言った。「分割は良い判断。でも注意。分割すると整合性を自分で保証しなければならない。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
endWARNING
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
endAWSインフラ構成
ECサイトはブログよりはるかに要求が高い。
インフラ選択の理由
| サービス | 選択 | 理由 |
|---|---|---|
| ECS Fargate | コンテナ実行 | オートスケール、サーバー管理不要 |
| Aurora PostgreSQL | DB | Multi-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つ挙げなさい」とナオミは言った。
タクミはノートに書いた。
- リスクから設計を始める: 何が壊れるかを先に考える
- 整合性境界がサービス境界になる: 同じトランザクションに入れたいものは同じサービスに
- 分散は複雑さを生む: 分割するなら、その複雑さを受け入れる覚悟が必要
INFO
Kata 2 の学び: サービス分割は「整合性」と「スケール」の要件が違う境界で行う。分割した後の整合性保証は自分の責任になる。Saga パターンはその一つの解。
トレードオフの記録
| 決定 | メリット | デメリット |
|---|---|---|
| サービス分割 | スケール、障害分離 | 分散トランザクションの複雑さ |
| 悲観的ロック | 整合性保証 | スループット低下 |
| ECS Fargate | 自動スケール | コスト高(常時起動) |
「次は、もっとリアルタイム性が求められる課題よ」とナオミは言った。「チャットアプリ。ユーザーは"今すぐ"返信が届くことを期待している」