Featured image of post コード探偵ロックの事件簿【Saga】巻き戻せない300件〜分散トランザクションが生んだ幽霊注文〜

コード探偵ロックの事件簿【Saga】巻き戻せない300件〜分散トランザクションが生んだ幽霊注文〜

分散トランザクション(2PC)で決済タイムアウト時に在庫が宙に浮く300件の幽霊注文を、Saga Patternのexecute/compensateペアとOrchestrator逆順補償で構造的に解決する。Perl/Mooでの実装とテストを解説。

戦場帰りの来客

会議室のドアが開いた。CTOの木村さんが顔を出す。

「ロックさんが来たぞ」

木村さんの後ろから、長身の男が入ってきた。ツイードのジャケットにジーンズ。肩にかけた革鞄からは、古い紙束が覗いている。男は自分の顔を見ず、まず壁のホワイトボードを見た。

自分は立ち上がって会釈した。目の下にクマが残っているのはわかっている。デスクの空き缶は——いや、ここは会議室だ。自席の惨状は見せなくて済む。

木村さんが「あとは任せた」と出ていく。男——ロックさんは、ホワイトボードに近づいた。前の会議で誰かが書いた売上グラフが残っている。

「消していいかね」

返事を待たずにイレーサーを取り、ボードを白くした。そしてマーカーを手に取り、3つの四角を横に並べて描いた。

「在庫」「決済」「配送」。

自分が説明する前に、もう構造を知っている。木村さんから聞いているのだろう。

ロックさんは3つの四角を大きな楕円で囲み、楕円の上に「2PC」と書いた。そして自分を振り返った。

「……戦場帰りだね」

何のことか一瞬わからなかった。自分の顔を見てそう言ったのだと気づくのに、数秒かかった。

「木村さんから聞いています?」

「概要は。だが君の口から聞きたい。何が起きた」

自分は椅子に座り直した。報告モードに入る。インシデント対応と同じだ。時系列で、事実だけを。

「金曜の23時17分、決済サービスがタイムアウトしました。外部プロバイダのレスポンスが10秒を超えた。在庫サービスはすでに在庫を確保済み。配送サービスはまだ呼ばれていない」

ロックさんが頷く。

「で、問題は——全体を1つのトランザクションとして扱っていたので、決済が応答しない限り、在庫のコミットも配送の開始もできない。かといってロールバックも、決済サービスが応答しないのでロールバック要求が届かない」

ロックさんが楕円を指差す。

「在庫は確保されたまま宙に浮いた」

「300件」

間。

「土曜と日曜で手動で戻しました。1件ずつ」

ロックさんが楕円に大きく「×」を描いた。自分を見る。

「二度とやりたくないだろう」

「……はい」

凶器は全体ロック

ロックさんが3つの四角の間に矢印を描いた。在庫→決済→配送。

「処理の流れを教えてくれ」

「注文が来たら、まず在庫サービスで在庫を確保。次に決済サービスで支払い処理。最後に配送サービスで配送手配。3つ全部成功したら、全体をコミット」

「失敗したら?」

「全体をロールバック……のはずでした」

「はずだった?」

「決済サービスが応答しないとき、ロールバック要求を送っても応答がない。在庫サービスにはロールバック要求が届くが、決済の結果が不明なのでどうしていいかわからない。結局——何もできない」

ロックさんが楕円の中に、3つの四角を繋ぐ鎖を描いた。

「2PC——Two-Phase Commit。全員が『準備完了』と答えるまでロックを保持し、全員が揃ったら一斉にコミット。全員が揃わなければ一斉にロールバック。鎖の強度は、一番弱い輪で決まる

「決済が一番弱い輪だった」

「そして弱い輪が切れたとき、鎖全体が使い物にならなくなった。在庫は確保されたまま、決済は宙に浮き、配送は始まらない。——全体を守ろうとして、全体を殺した

コードを見せようとしてノートPCを開きかけたが、ロックさんが先に書き始めた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
sub process_order ($order) {
    my $inventory_ok = $inventory_service->reserve($order);
    my $payment_ok   = $payment_service->charge($order);
    my $shipping_ok  = $shipping_service->arrange($order);

    if ($inventory_ok && $payment_ok && $shipping_ok) {
        $inventory_service->commit($order);
        $payment_service->commit($order);
        $shipping_service->commit($order);
    } else {
        $inventory_service->rollback($order);  # 応答なかったら?
        $payment_service->rollback($order);     # 応答なかったら?
        $shipping_service->rollback($order);    # そもそも始まってない
    }
}

「これが凶器だ。ロールバックを呼ぶ相手が応答しない場合を想定していない

自分は何も言えなかった。そのコードは、2年前に先輩が書いたものだ。自分はそれを運用してきた。動いていたから。

「でも……決済サービスのタイムアウトを短くすれば——」

「決済が速くなっても問題は消えない」

ロックさんがマーカーのキャップで楕円を叩く。

「在庫サービスがロックを保持する時間が短くなるだけだ。ゼロにはならない。そしてゼロでない限り、同じ事故は必ず再発する。タイムアウトの長さが変わっても、構造は変わっていない」

自分は椅子の背に体を預けた。天井を見る。蛍光灯がちらついている。

「……構造の問題ですか」

「構造の問題だ。3つのサービスを束ねる鎖を1本にしている限り、1つが倒れれば全部倒れる。——鎖を切るんだ。それぞれを独立させる。そして、倒れたときに起き上がる方法を、それぞれに教える」

ロックさんが鞄から紙束を取り出して机上に置いた。古い英語の論文。表題を読む。“SAGAS”——1987年。

「この事件の解法は、39年前に書かれている」

「Saga……」

「Garcia-Molina と Salem。長時間トランザクションを小さなトランザクションの連鎖に分解し、各ステップに補償トランザクションを定義する。全体が成功すれば前に進む。途中で失敗したら、完了済みのステップを逆順に巻き戻す」

ロックさんが楕円を消した。3つの四角のそれぞれに丸を描く——独立したトランザクション。そして各四角の下に、逆向きの矢印を描いた。

「在庫確保が成功し、決済が失敗したら——在庫確保の補償として在庫を解放する。全体をロールバックするのではなく、完了した分だけを逆順に戻す

「……手動でやっていたことを、自動化するということですか」

「そうだ。君が土日にやった作業を、コードにさせる。——ただし、もっと賢く」

補償の連鎖

ロックさんがホワイトボードに新しいコードを書き始めた。自分はノートPCのメモ帳を開いて記録する。

「まず、全ステップが守るべき契約だ」

1
2
3
package Role::SagaStep;
use Moo::Role;
requires qw(execute compensate);

「execute と compensate のペア。実行できるなら、補償もできなければならない。これが契約だ。requires は、この Role を取り込むクラスがその2つのメソッドを必ず実装することを強制する」

「実装し忘れたら?」

「クラスの構築時に Moo がエラーを出す。コンパイル時に。本番で障害が起きる前に

自分はメモに「requires = 契約。コンパイル時検出」と書いた。

「在庫確保のステップを書こう」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package SagaStep::ReserveInventory;
use Moo;
with 'Role::SagaStep';

has inventory_service => (is => 'ro', required => 1);

sub execute ($self, $context) {
    my $result = $self->inventory_service->reserve(
        $context->{order_id},
        $context->{items},
    );
    $context->{reservation_id} = $result->{reservation_id};
    return $context;
}

sub compensate ($self, $context) {
    $self->inventory_service->release(
        $context->{reservation_id},
    );
    return $context;
}

「release……在庫を戻す処理ですね」

「そうだ。execute で確保し、compensate で解放する。ビジネスロジックとしての逆操作だ。DB の ROLLBACK ではない。在庫サービスが自分で、自分の確保を解除する」

決済と配送も同じ構造だ。ロックさんが続けて書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package SagaStep::ProcessPayment;
use Moo;
with 'Role::SagaStep';

has payment_service => (is => 'ro', required => 1);

sub execute ($self, $context) {
    my $result = $self->payment_service->charge(
        $context->{order_id},
        $context->{total_amount},
    );
    $context->{payment_id} = $result->{payment_id};
    return $context;
}

sub compensate ($self, $context) {
    $self->payment_service->refund(
        $context->{payment_id},
    );
    return $context;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package SagaStep::ArrangeShipping;
use Moo;
with 'Role::SagaStep';

has shipping_service => (is => 'ro', required => 1);

sub execute ($self, $context) {
    my $result = $self->shipping_service->arrange(
        $context->{order_id},
        $context->{shipping_address},
    );
    $context->{shipment_id} = $result->{shipment_id};
    return $context;
}

sub compensate ($self, $context) {
    $self->shipping_service->cancel(
        $context->{shipment_id},
    );
    return $context;
}

「charge の逆は refund。arrange の逆は cancel。補償は常にビジネスの言葉で語られる。delete でも undo でもない」

自分のメモには、3つのステップがきれいに並んでいた。reserve/release。charge/refund。arrange/cancel。前に進む操作と、戻す操作のペア。

「これをどうやって繋ぐんですか」

「ここが核心だ」

ロックさんがホワイトボードの空いたスペースに書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package SagaOrchestrator;
use Moo;
use Types::Standard qw(ArrayRef ConsumerOf);
use Carp qw(croak);

has steps => (
    is       => 'ro',
    isa      => ArrayRef[ConsumerOf['Role::SagaStep']],
    required => 1,
);

sub execute ($self, $context) {
    my @completed;

    for my $step ($self->steps->@*) {
        eval {
            $context = $step->execute($context);
            push @completed, $step;
            1;
        } or do {
            my $err = $@;
            $self->_compensate(\@completed, $context);
            croak "Saga failed at "
                . ref($step) . ": $err";
        };
    }

    return $context;
}

sub _compensate ($self, $completed, $context) {
    for my $step (reverse $completed->@*) {
        eval { $step->compensate($context) };
        warn "Compensation failed for "
            . ref($step) . ": $@" if $@;
    }
}

reverse $completed->@*。自分はその行を二度読んだ。

逆順

「最後に成功したステップから順に巻き戻す。LIFO——Last In, First Out。在庫を確保し、決済を処理し、配送手配で失敗したら——まず決済を返金し、次に在庫を解放する。積み上げたものを、上から順に降ろす

「……それが、自分が土日にやったことです。ただし300件分、手動で」

「二度とやらなくていい」

ロックさんが組み立てのコードを書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
my $saga = SagaOrchestrator->new(
    steps => [
        SagaStep::ReserveInventory->new(
            inventory_service => $inventory_svc,
        ),
        SagaStep::ProcessPayment->new(
            payment_service => $payment_svc,
        ),
        SagaStep::ArrangeShipping->new(
            shipping_service => $shipping_svc,
        ),
    ],
);

my $result = $saga->execute({
    order_id         => $order->{id},
    items            => $order->{items},
    total_amount     => $order->{total},
    shipping_address => $order->{address},
});

自分はコードの全体像を見た。Role で契約し、ステップを配列に入れて、順に実行する。失敗したら完了済みだけを逆順に補償する。

「……ロックさん。これ、Pipeline に似てませんか。Role で型を縛って、配列で順に実行する構造」

ロックさんが少し笑った。この人が笑うのを初めて見た。

「よく気づいたね、ワトソン君」

いつの間にかワトソン君と呼ばれていた。訂正する気力はない。……正直、今はどうでもいい。

「Pipeline はステージを順次実行して流れを作る。Saga はステージを順次実行して、さらに巻き戻す能力を各ステップに持たせる。Pipeline が片道切符なら、Saga は往復切符だ。——行けるだけ行って、だめなら戻ってくる」

補償が失敗したら

自分は聞かなければならないことがあった。

「一つ聞いていいですか」

「何かね」

「補償が失敗したらどうなるんですか。返金APIが落ちていたら。在庫解放がタイムアウトしたら。——結局、手動作業に戻るんじゃないですか」

間。ロックさんがマーカーのキャップを付け直した。

「正直に言おう。補償も失敗する可能性がある」

自分は眉を上げた。木村さんが「この人なら解決できる」と言っていたのに——

「だが、問題の性質が変わるんだ」

ロックさんがホワイトボードに2つの図を並べて描いた。左に大きな×印が全体を覆う図——「2PC」。右に小さな×印が1つの四角だけにつく図——「Saga」。

「2PCでは全体がハングし、300件がまとめて宙に浮いた。Sagaでは——ステップ3で失敗したら、ステップ2と1の補償を試みる。ステップ2の補償が失敗したら、そのステップだけが要対応になる。300件がまとめて死ぬのではなく、1件の1ステップが残る」

ロックさんが左の図と右の図を交互に指差した。

全壊から局所故障に変わる。300件を手動で戻す代わりに、1件の返金を手動でリトライすればいい。——それが Saga の本当の価値だ。完全な解決策ではない。だが、夜中に300件を戻す地獄からは、確実に君を救い出す」

自分はホワイトボードの2つの図を見比べた。大きな×と、小さな×。

「……全壊か局所か。そういうことですか」

「そういうことだ」

見えているが確定していない

もう一つ。運用者として聞いておかなければならないことがある。

「途中で別の注文が在庫を見たら、確保済みの数が見えるんですか。Sagaのステップ1で在庫を確保してコミットした瞬間、他の処理からは在庫が減って見える。でもSagaが途中で失敗して戻すかもしれない」

ロックさんが頷いた。

「見える。これが Saga の代償だ」

ロックさんがホワイトボードに「ACID」と書き、「I」に大きく×をつけた。

「2PC は ACID の全てを守ろうとして可用性を犠牲にした。Saga は分離性——Isolation——を手放して可用性を得る。トレードオフのないアーキテクチャは存在しない

「運用上、問題にならないんですか」

「対策はある。Semantic Lock だ」

ロックさんが在庫テーブルのステータス遷移を書いた。

1
2
status: confirming → confirmed
           ↘ released(補償時)

「在庫を確保したとき、ステータスを confirming にする。他の処理はこの在庫を『確定済み』とは扱わない。Saga が完了したら confirmed に変える。補償で戻すなら released にして解放する。中間状態を明示的にマークすることで、他の処理が誤った前提で動くのを防ぐ

「……なるほど。完全に見えなくするのではなく、見えているけど『まだ確定していない』と伝える」

「その通りだ、ワトソン君」

自分はメモに書いた。「Semantic Lock: confirming / confirmed / released」。

テストが証明するもの

ロックさんがノートPCを借りてテストコードを書き始めた。自分は隣で画面を見ている。

「全ステップ成功のケース」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use Test::More;
use Test::Exception;

my $saga = SagaOrchestrator->new(
    steps => [
        SagaStep::ReserveInventory->new(
            inventory_service => $inv_svc),
        SagaStep::ProcessPayment->new(
            payment_service   => $pay_svc),
        SagaStep::ArrangeShipping->new(
            shipping_service  => $ship_svc),
    ],
);

my $result = $saga->execute({
    order_id         => 'ORD-001',
    items            => [{ sku => 'ITEM-A', qty => 2 }],
    total_amount     => 5000,
    shipping_address => 'Tokyo',
});
ok $result->{reservation_id}, '在庫確保済み';
ok $result->{payment_id},     '決済完了';
ok $result->{shipment_id},    '配送手配済み';

「そして——決済失敗のケース。君が経験した状況だ」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
my $fail_saga = SagaOrchestrator->new(
    steps => [
        SagaStep::ReserveInventory->new(
            inventory_service => $inv_svc),
        SagaStep::ProcessPayment->new(
            payment_service   => $fail_pay_svc),
        SagaStep::ArrangeShipping->new(
            shipping_service  => $ship_svc),
    ],
);

throws_ok {
    $fail_saga->execute({
        order_id => 'ORD-002',
        items    => [{ sku => 'ITEM-A', qty => 1 }],
        total_amount     => 3000,
        shipping_address => 'Osaka',
    });
} qr/Saga failed at SagaStep::ProcessPayment/,
    '決済失敗でSaga中断';

ok $inv_svc->was_released('ORD-002'),
    '在庫が自動で解放された';

自分は画面の最後の行を見た。

在庫が自動で解放された

テストが通った。

「……これが、金曜の夜にあれば」

「300件は発生しなかった」

自分は姿勢を正した。目に力が戻る。

「……実装します。今日中に」

往復切符

ロックさんが鞄に論文を戻した。帰り支度だ。ホワイトボードには3つの四角と補償の矢印が残っている。

「報酬の話だが」

「あ、はい。木村さんに請求書を——」

「報酬は——いや、やめておこう」

自分は面食らった。

「君は戦場帰りだ。次に来たときでいい——次があればの話だが」

「……来ない方がいいんですよね。次がないということは、障害がないということだから」

ロックさんが微かに笑った。今日2回目だ。

「その通りだ。——木村さんには請求書を送る。君への請求ではない。安心したまえ」

ロックさんがドアに手をかけた。振り返らずに。

「ワトソン君。全体を守ろうとするな。各部品に、自分の後始末を教えろ。それが分散の世界での生き方だ」

ドアが閉まった。

自分は会議室に一人残された。ホワイトボードを見る。在庫→決済→配送。各四角から伸びる逆向きの矢印。ロックさんが描いた図。消さない。

自席に戻った。モニターには先週末のtailコマンドが残っている。障害ログだ。そのターミナルを閉じた。

エディタを開く。新しいファイル。

1
2
3
4
package Role::SagaStep;
use Moo::Role;
requires qw(execute compensate);
1;

execute と compensate。前に進む力と、戻る力。

300件を手で戻した夜は、もう来ない。

まず在庫確保から書こう。ReserveInventory。execute で確保、compensate で解放。次に決済。ProcessPayment。charge と refund。

全体を守ろうとするな。各部品に、自分の後始末を教えろ。

コーヒーを一口飲んだ。冷めている。気にしない。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
分散トランザクション(2PC的全体ロック)— 1サービスの障害で全体がハングSaga Pattern(Orchestration型)— ステップ単位の実行と逆順補償障害が局所化。全壊→局所故障に
ロールバック前提の設計 — 応答しない相手にロールバック要求execute/compensate ペア — 各ステップが自分の後始末を知っている補償がビジネスロジックとして独立
分離性の暗黙的仮定 — 中間状態が見える問題を無視Semantic Lock — ステータスフラグで中間状態を明示他処理が仮データを確定扱いしない

推理のステップ

  1. 全体を1つのトランザクションで束ねている構造を特定する(2PCの発見)
  2. 各処理ステップを独立したオブジェクトとして分離する(SagaStep の定義)
  3. Role::SagaStepexecute / compensate の契約を強制する
  4. SagaOrchestrator で順次実行し、失敗時に逆順補償を自動化する
  5. Semantic Lock で中間状態の可視性問題を軽減する

ロックより

ワトソン君、よく耐えた。300件の手動復旧は、君の責任ではなく構造の責任だ。

全体を一本の鎖で束ねる設計は、全員が健全である前提でしか機能しない。分散の世界では、その前提は必ず裏切られる。Saga は鎖を切り、各部品に自分の後始末を教える設計だ。execute と compensate、この2つのメソッドが揃っていれば、どんな障害も「全壊」にはならない。

ただし忘れるな——Saga は分離性というコストを払っている。完全な解決策ではない。だがそのコストは、深夜に300件の在庫を手動で戻すコストより、遥かに安い。次があればの話だが——障害のない世界を目指したまえ、ワトソン君。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。