mybook

データの一貫性 — CAP定理と実践

「片方だけ成功した」問題

EchoTaskの成長とともに、ハルトはある厄介な問題に直面した。

タスクを「完了」にすると、2つのことが起きる:

  1. Task Serviceがタスクのステータスを更新する
  2. Notification Serviceが担当者にメールを送る

コードはこうだった:

# app/services/task_completion_service.rb
def complete(task_id)
  task = Task.find(task_id)
  task.update!(status: :completed)  # Task DBに書き込み
 
  NotificationService.send(
    user_id: task.assignee_id,
    type: 'task_completed',
    payload: { task_id: task.id }
  )  # ← これが失敗したら?
end

ある夜、NotificationServiceが一時的にダウンした。タスクは「完了」になったが、担当者への通知は送られなかった。別の日には、Task DBへの書き込みが失敗したのに通知だけが先走って送られた。

Slackに「完了したはずのタスクが未完了になってる」「通知来たけどタスクが見当たらない」という報告が相次いだ。

「2つの操作が両方成功するか、両方失敗するかを保証したい」——これが分散システムにおけるデータ一貫性の核心だ。


CAP定理

分散システムは、以下の3つの性質のうち2つしか同時に満たせないというのがCAP定理だ。

  • C(Consistency): 全ノードが常に同じデータを返す
  • A(Availability): リクエストに対して常にレスポンスを返す
  • P(Partition Tolerance): ネットワーク分断が発生しても動き続ける
Loading diagram...

INFO

「P」は選べない

ネットワーク分断は必ず起きる(NICの故障、ケーブル断線、データセンター障害など)。実際には「CPかAPか」の選択になる。多くのWebサービスはAP(可用性+分断耐性)を選ぶ——少々古いデータを見せても、サービスを止めないほうがいい。

CP系 vs AP系の具体例

選択プロダクト例ネットワーク障害時の挙動
CPPostgreSQL, HBase, ZooKeeperエラーを返す(整合性優先)
APDynamoDB, Cassandra, CouchDB古いデータを返す(可用性優先)

PostgreSQL(CP)の挙動:
Primaryノードへの接続が切れると、Replicaへの書き込みは受け付けない。整合性を犠牲にして古いデータを返すよりも、エラーを返すほうを選ぶ。

DynamoDB(AP)の挙動:
リージョン間のレプリカが分断されても、各リージョンで読み書きを受け付ける。最終的に同期されるが、分断中は異なる値を返す可能性がある。ただし、ConsistentRead: true を指定するとCP相当の強整合性読み込みも可能だ。


PACELC定理 — CAPの拡張

CAPはネットワーク分断時(P)の話だが、**通常運用時(Else)**はどうか?——それを補完するのがPACELC定理だ。

If Partition → (Availability OR Consistency)
Else         → (Latency     OR Consistency)

通常時でも、低レイテンシ(速い応答)と強整合性(正確なデータ)はトレードオフになる。

システム分断時通常時特徴
DynamoDBAPEL(低レイテンシ)通常時も速さ優先
PostgreSQLCPEC(整合性)通常時も正確さ優先
CassandraAPELチューニング可能
SpannerCPECGoogle製、グローバル強整合性

ハルトはこう考えた。EchoTaskのタスク一覧は「1秒前のデータ」でも許容できる。しかし、請求処理や権限変更は「今この瞬間の正確なデータ」が必要だ。場面ごとにCP/APを使い分けることが大事なのだ。


強整合性と結果整合性

強整合性(Strong Consistency)

書き込みが成功したら、即座に全ノードで最新データが読める。

# 単一DBトランザクション(強整合性)
ActiveRecord::Base.transaction do
  task.update!(status: :completed)
  task.project.touch
  AuditLog.create!(action: 'task_completed', resource: task)
  # 全部成功 or 全部ロールバック
end

単一データベース内なら、Railsの transaction で強整合性を保てる。

結果整合性(Eventual Consistency)

即座には一致しないが、最終的には全ノードで同じデータになる。

# 非同期イベントで結果整合性
def complete(task_id)
  task = Task.find(task_id)
  task.update!(status: :completed)
 
  # 非同期でイベントを発行(NotificationServiceは後で受け取る)
  EventBus.publish('task.completed', {
    task_id: task.id,
    assignee_id: task.assignee_id,
    completed_at: Time.current.iso8601
  })
  # タスクの更新は成功。通知は「結果的に」送られる
end

Railsでのロック戦略

並行リクエストがある場面では、同じレコードへの競合書き込みを防ぐためにロックが必要だ。

楽観的ロック(Optimistic Locking)

競合は「たまにしか起きない」という前提。書き込み時にバージョンを確認し、競合していたらエラーにする。

# マイグレーション
add_column :tasks, :lock_version, :integer, default: 0
 
# モデル(lock_versionカラムがあれば自動的に有効)
class Task < ApplicationRecord
  # lock_versionが自動的に使われる
end
 
# コントローラ
def update
  task = Task.find(params[:id])
  task.update!(task_params)
  # lock_versionが一致しない場合、ActiveRecord::StaleObjectErrorが発生
rescue ActiveRecord::StaleObjectError
  render json: { error: '他のユーザーが先に更新しました。再読み込みしてください。' },
         status: :conflict
end

フォームに lock_version を hidden フィールドで埋め込んでおくことで、「自分が読み込んだ後に誰かが変更した」ことを検出できる。

悲観的ロック(Pessimistic Locking)

競合が「頻繁に起きる」前提。最初から行をロックして、他のトランザクションが触れないようにする。

# SELECT ... FOR UPDATE
Task.transaction do
  task = Task.lock.find(params[:id])  # 他のトランザクションをブロック
  task.update!(status: :in_progress, assignee_id: current_user.id)
end
 
# with_lock を使う書き方(より簡潔)
task.with_lock do
  task.update!(priority: :high)
  task.subtasks.update_all(priority: :high)
end
比較楽観的ロック悲観的ロック
向いている場面競合が少ない・読み取り多め競合が多い・書き込み集中
パフォーマンス高い(ロック取得不要)低い(他をブロック)
失敗時の対処エラーをユーザーに返すそもそも競合させない
デッドロックリスクなしあり(順序を統一して防ぐ)

EchoTaskでは、タスク一覧の更新には楽観的ロック、請求処理(同一ユーザーへの二重課金防止)には悲観的ロックを使い分けた。


分散トランザクション(2PC)の詳細

2フェーズコミット(2PC)は複数サービスをまたぐトランザクションを強整合性で実現する。

Loading diagram...

タイムライン:

t=0  コーディネーター → Task Service:    "PREPARE"
t=1  コーディネーター → Notify Service:  "PREPARE"
t=2  Task Service    → コーディネーター: "READY"
t=3  Notify Service  → コーディネーター: "READY"
t=4  コーディネーター → Task Service:    "COMMIT"
t=5  コーディネーター → Notify Service:  "COMMIT"
-- もしt=3で "ABORT" が返ってきたら --
t=4  コーディネーター → Task Service:    "ROLLBACK"

2PCの問題点:

  • コーディネーターがPhase 2の途中で落ちると、参加者は「コミットすべきかロールバックすべきか」わからずブロック
  • ネットワーク往復が多く、レイテンシが高い(p99レイテンシが特に悪化)
  • マイクロサービスでは各サービスのDBが異なり、XAトランザクションの実装が必要で複雑

EchoTaskでは2PCは採用しなかった。代わりにSagaパターンを選んだ。


Sagaパターン — 分散処理の実践解

Sagaはトランザクションをローカルトランザクションとイベントに分解し、失敗時は補償トランザクション(巻き戻し処理)を実行する。

オーケストレーション vs コレオグラフィ

Sagaには2つの実装スタイルがある。

Loading diagram...

オーケストレーション(Orchestration):
中央の「オーケストレーター」が全サービスに対して命令を出す。処理の流れが一箇所で管理されるため、デバッグが容易。ただしオーケストレーター自体が単一障害点になりうる。

コレオグラフィ(Choreography):
各サービスがイベントを購読し、自律的に動く。オーケストレーターが不要で疎結合だが、全体の流れを把握しにくい。

比較オーケストレーションコレオグラフィ
処理の見通し一箇所で把握できる各サービスを追う必要がある
結合度オーケストレーターへの依存あり疎結合
デバッグ容易難しい
向いている場面複雑なビジネスロジック単純なイベント連鎖

EchoTaskではコレオグラフィを採用した。サービス数が少なく、イベントの連鎖もシンプルだったからだ。

Choreography-based Saga の実装

# Task Service: Sagaを開始する
class TaskCompletionSaga
  def start(task_id, user_id)
    task = Task.find(task_id)
 
    ActiveRecord::Base.transaction do
      task.update!(status: :completed, completed_by: user_id)
 
      # Saga追跡ログ(同一トランザクションで書く)
      SagaLog.create!(
        saga_id: SecureRandom.uuid,
        step: 'task_completed',
        task_id: task_id,
        status: :in_progress
      )
    end
 
    EventBus.publish('task.completed', {
      saga_id: SagaLog.last.saga_id,
      task_id: task_id,
      assignee_id: task.assignee_id
    })
  rescue => e
    SagaLog.find_by(task_id: task_id)&.update!(status: :failed)
    raise
  end
end
 
# Notification Service: イベントを受け取って処理する
class TaskCompletedHandler
  def handle(event)
    saga_id  = event[:saga_id]
    assignee = User.find(event[:assignee_id])
 
    NotificationMailer.task_completed(assignee, event[:task_id]).deliver_now
    EventBus.publish('notification.sent', { saga_id: saga_id })
  rescue => e
    # 通知失敗 → 補償イベントを発行
    EventBus.publish('notification.failed', {
      saga_id: saga_id,
      reason: e.message
    })
  end
end
 
# Task Service: 補償トランザクション(タスクを未完了に戻す)
class NotificationFailedHandler
  def handle(event)
    saga = SagaLog.find_by!(saga_id: event[:saga_id])
    task = Task.find(saga.task_id)
    task.update!(status: :pending)
    saga.update!(status: :compensated)
  end
end

べき等性の保証

分散システムでは同じメッセージが複数回届くことがある(ネットワーク再送、At-least-once配信)。べき等性がないと「通知が2回届く」「二重課金される」という問題が起きる。

# イベントハンドラーのべき等処理
class TaskCompletedHandler
  def handle(event)
    # 既に処理済みなら何もしない(べき等性の保証)
    return if ProcessedEvent.exists?(event_id: event[:event_id])
 
    ActiveRecord::Base.transaction do
      ProcessedEvent.create!(
        event_id: event[:event_id],
        processed_at: Time.current
      )
      send_notification(event)
    end
  rescue ActiveRecord::RecordNotUnique
    # 同時に2つのWorkerが処理しようとした場合、一方はユニーク制約で弾かれる
    Rails.logger.info "Event #{event[:event_id]} already processed, skipping"
  end
end

Outboxパターン — イベント発行の信頼性

「DBに書き込みは成功したが、イベント発行に失敗した」という問題を解決するのがOutboxパターンだ。

Loading diagram...
# マイグレーション
create_table :outbox_events do |t|
  t.string :aggregate_id,   null: false
  t.string :aggregate_type, null: false
  t.string :event_type,     null: false
  t.jsonb  :payload,        null: false, default: {}
  t.datetime :published_at
  t.integer  :retry_count,  default: 0
  t.timestamps
end
add_index :outbox_events, :published_at
 
# モデル: DBと同じトランザクション内にOutboxレコードを書く
class Task < ApplicationRecord
  after_update :write_to_outbox, if: :saved_change_to_status?
 
  private
 
  def write_to_outbox
    OutboxEvent.create!(
      aggregate_id:   id.to_s,
      aggregate_type: 'Task',
      event_type:     "task.#{status}",
      payload:        { task_id: id, status: status, updated_at: updated_at.iso8601 }
    )
    # DBトランザクションが成功したら、Outboxにも必ず記録される
    # DBトランザクションが失敗したら、Outboxへの書き込みも巻き戻る
  end
end
 
# 別プロセス(Sidekiqジョブ)がOutboxを読んでSNSに発行
class OutboxPollerJob < ApplicationJob
  def perform
    OutboxEvent.where(published_at: nil).order(:created_at).limit(100).each do |event|
      EventBus.publish(event.event_type, event.payload)
      event.update!(published_at: Time.current)
    rescue => e
      event.increment!(:retry_count)
      Rails.logger.error "Outbox publish failed: #{e.message}"
    end
  end
end

INFO

Outboxパターンの保証

DBへの書き込みとOutboxへの書き込みが同一トランザクションで行われるため、「DBには書けたがイベントを忘れた」という状況がなくなる。Pollerが一時的に落ちても、再起動後に未発行イベントを処理できる。At-least-once配信になるため、受信側でのべき等処理と組み合わせるのが鉄則だ。

OutboxパターンのRSpecテスト

# spec/models/task_spec.rb
RSpec.describe Task, type: :model do
  describe 'Outboxへの書き込み' do
    let(:task) { create(:task, status: :pending) }
 
    it 'ステータス変更時にOutboxEventが作成される' do
      expect {
        task.update!(status: :completed)
      }.to change(OutboxEvent, :count).by(1)
 
      event = OutboxEvent.last
      expect(event.aggregate_id).to eq(task.id.to_s)
      expect(event.event_type).to eq('task.completed')
      expect(event.published_at).to be_nil  # まだ未発行
    end
 
    it 'ステータス変更以外ではOutboxEventが作成されない' do
      expect {
        task.update!(title: '新しいタイトル')
      }.not_to change(OutboxEvent, :count)
    end
 
    it 'DB書き込み失敗時はOutboxEventも作成されない' do
      allow(task).to receive(:update!).and_raise(ActiveRecord::RecordInvalid)
      expect {
        task.update!(status: :completed) rescue nil
      }.not_to change(OutboxEvent, :count)
    end
  end
end
 
# spec/jobs/outbox_poller_job_spec.rb
RSpec.describe OutboxPollerJob, type: :job do
  it '未発行イベントをSNSに発行し、published_atを更新する' do
    event = create(:outbox_event, event_type: 'task.completed', published_at: nil)
    allow(EventBus).to receive(:publish)
 
    OutboxPollerJob.perform_now
 
    expect(EventBus).to have_received(:publish).with('task.completed', event.payload)
    expect(event.reload.published_at).not_to be_nil
  end
end

DynamoDBのトランザクション機能

「DynamoDB(AP系)なのにトランザクション?」と思うかもしれない。実はDynamoDBにはTransactWriteItemsというAPIがある。

# AWS SDK for Ruby でのDynamoDBトランザクション
dynamodb = Aws::DynamoDB::Client.new
 
dynamodb.transact_write_items({
  transact_items: [
    {
      update: {
        table_name: 'Tasks',
        key: { 'TaskId' => task_id },
        update_expression: 'SET #s = :completed',
        condition_expression: '#s = :pending',  # 条件付き(べき等性)
        expression_attribute_names:  { '#s' => 'Status' },
        expression_attribute_values: {
          ':completed' => 'completed',
          ':pending'   => 'pending'
        }
      }
    },
    {
      put: {
        table_name: 'OutboxEvents',
        item: {
          'EventId'   => SecureRandom.uuid,
          'TaskId'    => task_id,
          'EventType' => 'task.completed',
          'CreatedAt' => Time.current.iso8601
        },
        condition_expression: 'attribute_not_exists(EventId)'
      }
    }
  ]
})

DynamoDBのトランザクションは同一リージョン内での強整合性を保証する。ただしクロスリージョンは依然として結果整合性だ。EchoTaskでは将来のグローバル展開を考え、Rails + PostgreSQLベースで設計し、DynamoDBはセッションストアと通知履歴の読み取り高速化に限定した。


Golangでのイベント駆動処理

EchoTaskでは、通知配信のマイクロサービスだけGolangで実装した。goroutineとchannelを使ったイベント処理の例を紹介する。

// notification_worker.go
package main
 
import (
    "context"
    "encoding/json"
    "log"
    "sync"
)
 
// TaskEvent はSQSから受け取るイベントの構造体
type TaskEvent struct {
    EventID    string `json:"event_id"`
    TaskID     string `json:"task_id"`
    AssigneeID string `json:"assignee_id"`
    EventType  string `json:"event_type"`
}
 
// NotificationWorker はイベントを並行処理するワーカー
type NotificationWorker struct {
    eventCh    chan TaskEvent
    processedIDs sync.Map  // べき等性のための処理済みIDセット
    wg         sync.WaitGroup
}
 
func NewNotificationWorker(workerCount int) *NotificationWorker {
    w := &NotificationWorker{
        eventCh: make(chan TaskEvent, 100), // バッファ付きチャネル
    }
 
    // 指定した数のgoroutineでイベントを並行処理
    for i := 0; i < workerCount; i++ {
        w.wg.Add(1)
        go w.processEvents()
    }
    return w
}
 
func (w *NotificationWorker) processEvents() {
    defer w.wg.Done()
    for event := range w.eventCh {
        // べき等性チェック(sync.Mapでスレッドセーフ)
        if _, loaded := w.processedIDs.LoadOrStore(event.EventID, true); loaded {
            log.Printf("Event %s already processed, skipping", event.EventID)
            continue
        }
 
        if err := w.sendNotification(event); err != nil {
            log.Printf("Failed to send notification for event %s: %v", event.EventID, err)
            // エラー時は処理済みフラグを削除してリトライ可能にする
            w.processedIDs.Delete(event.EventID)
        }
    }
}
 
func (w *NotificationWorker) sendNotification(event TaskEvent) error {
    log.Printf("Sending notification to user %s for task %s", event.AssigneeID, event.TaskID)
    // 実際のメール送信処理(SES等)
    return nil
}
 
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
 
    worker := NewNotificationWorker(5) // 5並行でイベント処理
 
    // SQSからイベントをポーリングしてチャネルに流す
    go pollSQS(ctx, worker.eventCh)
 
    worker.wg.Wait()
}

goroutineによる並行処理で、ピーク時でも遅延なく通知を配信できるようになった。


結果整合性のユーザー体験設計

技術的に「結果整合性」を選んでも、ユーザーには「データが消えた」「反映されていない」と感じさせてはいけない。

楽観的UI更新

ハルトはフロントエンドで楽観的UI更新を採用した。

1. ユーザーが「完了」をクリック
2. APIレスポンスを待たずに、即座にUIを「完了」表示に切り替える
3. バックグラウンドでAPIリクエストを送る
4. 成功 → そのまま
5. 失敗 → UIを元の状態に戻して、エラーメッセージを表示

WARNING

楽観的UI更新の注意点

楽観的UIは「成功すると思って先に表示する」設計だ。失敗率が高い操作(支払い処理、権限変更など)では逆効果になる。失敗時の「元に戻す」ロジックを必ず実装すること。また、楽観的UIを使った箇所では、「処理中」インジケーターではなく完了した状態を先に見せるため、UIロールバックが不自然に見えることがある。

読み取り後書き込みの一貫性

「自分が書いたデータは、すぐに自分に見える」という体験は重要だ。これを Read-Your-Writes 一貫性 という。

# セッションに書き込みタイムスタンプを保存
def complete(task_id)
  task = Task.find(task_id)
  task.update!(status: :completed)
  session[:last_write_at] = Time.current.to_i
end
 
# 読み取り時にセッションを確認
def show
  task = if session[:last_write_at].present?
    # 直近に書き込んだユーザーはPrimary(強整合性)から読む
    Task.connected_to(role: :writing) { Task.find(params[:id]) }
  else
    # それ以外はReplica(結果整合性)から読む
    Task.find(params[:id])
  end
end

EchoTaskでの実装結果

Saga + Outbox + べき等処理を組み合わせた結果:

Before: 直接RPC呼び出し
  - 不整合発生率: 0.3%(月間50件程度の報告)
  - データ不整合によるサポート対応: 月10件
  - インシデント: 月2回(通知の未送信・二重送信)

After: Saga + Outboxパターン
  - 不整合発生率: 0%(リトライで最終的に成功)
  - データ不整合によるサポート対応: 月0件
  - 最大遅延: 数秒(通知が遅れることはあるが、消えることはない)

「完璧な整合性より、壊れにくい設計のほうが大事だ」——ハルトはその夜、コードレビューのコメントにそう書いた。

一貫性の問題を乗り越えたハルト。しかし、今度は「落ちない」システムへの挑戦が待っていた。

「サービスが落ちても、ユーザーへの影響を最小にするにはどうすればいいか?」

次の章では、可用性と障害対策を学ぶ。

INFO

この章のキーポイント

  • CAP定理: 一貫性・可用性・分断耐性のうち2つしか選べない。実質はCP vs APの選択
  • PACELC定理: 分断時だけでなく、通常時の「レイテンシ vs 整合性」もトレードオフ
  • Railsの楽観的ロック(lock_version)と悲観的ロック(with_lock)を場面で使い分ける
  • 2PCは強整合性だが、コーディネーター障害でブロックするリスクがある
  • SagaはCP/APではなく「補償トランザクション」で整合性を回復する設計
  • OutboxパターンでDB書き込みとイベント発行を同一トランザクションに束ねる
  • べき等性により、重複イベントを安全に無視できる
  • 楽観的UIでユーザー体験を損なわずに結果整合性を活用する