データの一貫性 — CAP定理と実践
「片方だけ成功した」問題
EchoTaskの成長とともに、ハルトはある厄介な問題に直面した。
タスクを「完了」にすると、2つのことが起きる:
- Task Serviceがタスクのステータスを更新する
- 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): ネットワーク分断が発生しても動き続ける
INFO
「P」は選べない
ネットワーク分断は必ず起きる(NICの故障、ケーブル断線、データセンター障害など)。実際には「CPかAPか」の選択になる。多くのWebサービスはAP(可用性+分断耐性)を選ぶ——少々古いデータを見せても、サービスを止めないほうがいい。
CP系 vs AP系の具体例
| 選択 | プロダクト例 | ネットワーク障害時の挙動 |
|---|---|---|
| CP | PostgreSQL, HBase, ZooKeeper | エラーを返す(整合性優先) |
| AP | DynamoDB, 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)
通常時でも、低レイテンシ(速い応答)と強整合性(正確なデータ)はトレードオフになる。
| システム | 分断時 | 通常時 | 特徴 |
|---|---|---|---|
| DynamoDB | AP | EL(低レイテンシ) | 通常時も速さ優先 |
| PostgreSQL | CP | EC(整合性) | 通常時も正確さ優先 |
| Cassandra | AP | EL | チューニング可能 |
| Spanner | CP | EC | Google製、グローバル強整合性 |
ハルトはこう考えた。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
})
# タスクの更新は成功。通知は「結果的に」送られる
endRailsでのロック戦略
並行リクエストがある場面では、同じレコードへの競合書き込みを防ぐためにロックが必要だ。
楽観的ロック(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)は複数サービスをまたぐトランザクションを強整合性で実現する。
タイムライン:
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つの実装スタイルがある。
オーケストレーション(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
endOutboxパターン — イベント発行の信頼性
「DBに書き込みは成功したが、イベント発行に失敗した」という問題を解決するのがOutboxパターンだ。
# マイグレーション
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
endINFO
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
endDynamoDBのトランザクション機能
「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
endEchoTaskでの実装結果
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でユーザー体験を損なわずに結果整合性を活用する