分散トランザクションの迷宮:Fiberと補償トランザクションによる非同期・高スループット制御の極意
テックリードの私たちがコードレビューで最も頭を悩ませる問題の一つが、マイクロサービスアーキテクチャにおける「分散トランザクション」の整合性担保だ。
モノリスであれば、Zendエンジンは`PDO::beginTransaction()`からコミット/ロールバックまでの間、ストレージエンジン(InnoDB等)の行ロックとundoログを駆使してACID特性を完全に保証してくれる。しかし、サービス境界を越えた瞬間、データベースの悲観的ロックは通用しなくなる。ネットワークの向こうのAPIがタイムアウトし、片側のDBだけが書き込みを完了してしまった時、君はどうやってその不整合を収束させるのか?
「とりあえずリトライすればいい」「Sagaパターンで補償トランザクションを叩く」──そう言うのは簡単だ。だが、それをブロッキングI/Oの嵐の中で実装すれば、PHP-FPMのプロセスプールは一瞬で枯渇し、CPUはコンテキストスイッチのオーバーヘッドで窒息する。
今回は、PHP 8.1で導入されたFiber(ファイバー)を低レイヤのイベントループと結合させ、分散環境における補償トランザクション(Sagaパターン)をノンブロッキングかつ美しく制御する極限の設計パターンを伝授する。
—
1. なぜ従来のPHPでは分散トランザクション制御が破綻するのか
多くのプログラマは、非同期処理というと「ReactPHPやAmpを使ったイベント駆動」を思い浮かべる。しかし、従来のコールバック地獄やPromiseのチェインは、ビジネスロジックの可読性を致命的に破壊する。「注文作成 → 決済実行 → 在庫引き当て」という一連の処理の中で、途中でエラーが起きた場合に「決済の取り消し(返金)」と「在庫の差し戻し」を逆順で呼び出すロジックをPromiseで書くと、コードは一気にメンテナンス不可能なスパゲッティと化す。
ここでFiberの出番だ。
Fiberは、「コールスタックを独立したヒープ上に保存し、任意の場所で実行を中断(Suspend)し、再開(Resume)できる協調的マルチタスク(Coroutine)」のプリミティブを提供する。
Zend VMの視点で見れば、Fiberの生成はスタックフレームの退避と復元に過ぎない。しかし、これを利用することで、「同期的な直感的コード記述」を維持したまま、I/O待ちの間にイベントループに制御を明け渡し、数千の分散リクエストを単一プロセスで並行処理することが可能になる。
—
2. 堅牢な補償トランザクション(Sagaパターン)の設計思想
分散トランザクションにおいて、原子性(Atomicity)を2層コミット(2PC)で無理やり担保しようとすると、ロックの保持期間が長引き、可用性が劇的に低下する。現代の大規模Webシステムにおいて、2PCはアンチパターンだ。
代わりに採用すべきがSagaパターン(結果整合性モデル)である。
Sagaでは、一連のローカル・トランザクションを順番に実行し、途中で失敗した場合は、「それまでに成功したトランザクションの逆順で、状態を打ち消すための補償トランザクション(Compensating Transaction)」を実行する。
これをFiberベースの非同期オーケストレーター上で実装する。
内部構造のイメージ
[Client]
│
▼
[Fiber Orchestrator] ──(非同期)──> [Order Service] (成功)
│
├──(非同期)──> [Payment Service] (成功)
│
└──(非同期)──> [Stock Service] (失敗!) ──> 【補償フェーズ発動】
│
├──> [Payment 補償: 返金]
└──> [Order 補償: キャンセル]
—
3. 実装:Fiber駆動型・補償トランザクション・オーケストレーター
実務でそのまま耐えうる、型安全かつ堅牢なリファレンスコードを提示する。ここでは擬似的な非同期HTTPクライアントとイベントループを前提に、Fiberを用いたSagaオーケストレーターを構築している。
declare(strict_types=1);
namespace App\DistributedTransaction;
use Fiber;
use Exception;
use Throwable;
/
- トランザクションステップの定義
/
class Step
{
public function __construct(
public readonly string $name,
/ @var callable(): bool アクション実行 /
public readonly $action,
/ @var callable(): void 補償(ロールバック)アクション /
public readonly $compensate
) {}
}
/
- FiberベースのSagaオーケストレーター
- 逐次的な記述性を保ちつつ、各ステップの非同期実行と
- 障害時の逆順補償トランザクションを保証する。
/
class SagaOrchestrator
{
/ @var Step[] /
private array $steps = [];
/ @var Step[] 成功したステップを記録(補償用) /
private array $executedSteps = [];
public function addStep(string $name, callable $action, callable $compensate): self
{
$this->steps[] = new Step($name, $action, $compensate);
return $this;
}
public function execute(): bool
{
// メインの処理をFiberでラップする
$fiber = new Fiber(function () {
foreach ($this->steps as $step) {
// 非同期I/Oのシミュレーション(実際はイベントループへサスペンド)
$success = $this->invokeAsync($step->action);
if (!$success) {
throw new Exception(“Step [{$step->name}] failed. Initiating compensation.”);
}
// 成功したステップを記録
array_unshift($this->executedSteps, $step);
}
return true;
});
try {
// Fiberの初回起動
$fiber->start();
// Fiberが中断(Suspend)している間、イベントループが他のタスクを処理可能
while (!$fiber->isTerminated()) {
// ※実運用ではここでEventLoop::tick()などを回す
$fiber->resume();
}
return true;
} catch (Throwable $e) {
// 失敗時:補償トランザクションの逆順実行
$this->compensate($e);
return false;
}
}
private function invokeAsync(callable $action): bool
{
// 【低レイヤ視点】
// ここでHTTPリクエスト等のブロッキングが発生する場合、
// Fiber::suspend() を使ってイベントループに処理を委譲する。
// 例: NonBlockingHttpClient::get(‘/api’, fn($res) => Fiber::current()->resume($res));
// 今回はシンプルに実行結果を返す
return $action();
}
private function compensate(Throwable $e): void
{
echo “[-] 障害検知: ” . $e->getMessage() . “\n”;
echo “[-] 補償トランザクション(ロールバック)を開始します…\n”;
foreach ($this->executedSteps as $step) {
try {
echo ” > 補償実行: {$step->name}\n”;
($step->compensate)();
} catch (Throwable $compError) {
// 【超重要】補償トランザクションの失敗はシステム整合性の致命傷(要アラート&手動介入キュー)
error_log(sprintf(
“[CRITICAL] Compensation failed for step [%s]: %s”,
$step->name,
$compError->getMessage()
));
}
}
}
}
// ==========================================
// 実行例(Usage)
// ==========================================
$saga = new SagaOrchestrator();
// 1. 注文サービス
$saga->addStep(
‘Create Order’,
function () {
echo “[+] 注文レコードを作成中…\n;
return true; // 成功
},
function () {
echo “[Rollback] 注文ステータスを「キャンセル」に更新\n”;
}
);
// 2. 決済サービス
$saga->addStep(
‘Process Payment’,
function () {
echo “[+] 決済APIを呼び出し中…\n”;
return true; // 成功
},
function () {
echo “[Rollback] 決済の全額返金APIを呼び出し\n”;
}
);
// 3. 在庫サービス(ここで失敗するシナリオ)
$saga->addStep(
‘Allocate Stock’,
function () {
echo “[+] 在庫を引き当て中…\n”;
// 在庫切れにより失敗をシミュレート
return false;
},
function () {
echo “[Rollback] 引き当てた在庫を解放(実行されないはず)\n”;
}
);
$result = $saga->execute();
var_dump($result ? “Saga Completed Successfully” : “Saga Failed & Compensated”);
—
4. コードレビュー:なぜこの設計が実務で安全なのか
私のチームでこのコードをレビューする場合、以下の3点を厳しくチェックする。
1. メモリリークとFiberのライフサイクル管理
Fiberはスコープを抜ければガベージコレクトされるが、イベントループの参照(クロージャ内の変数キャプチャなど)が残っていると、循環参照によるメモリリークを引き起こす。上記のコードでは、Fiber内部の状態が外部の長寿命オブジェクトに強く結合しないよう、完結したスコープに閉じ込めている。
2. 補償トランザクションの「冪等性(Idempotency)」の担保
コード内で `($step->compensate)()` を呼び出しているが、ネットワーク障害等で補償リクエストがタイムアウトし、リトライされた場合を想像してほしい。補償トランザクションは必ず冪等でなければならない。
例えば「返金する」ではなく「特定のトランザクションIDに対する返金を確定させる(何度実行しても結果が同じ)」設計が必須だ。補償自体が失敗した場合はログに記録し、デッドレターキュー(DLQ)へ送るアプローチが実務の現場では求められる。
3. 部分的な成功と「結果整合性」のタイムラグ
Sagaパターンは即時整合性(ACID)ではなく結果整合性を採用するため、「在庫引き当てに失敗して補償が終わるまでの数ミリ秒の間、ユーザーから見ると一時的に決済だけが完了しているように見える」瞬間が存在する。このラグを許容するUI/UXの設計、あるいはステータスを「処理中 (Processing)」「失敗 (Failed)」に厳密に分離するステートマシンの設計が、バックエンドエンジニアの腕の見せ所だ。
—
最後に:PHPを「Webのグルー言語」で終わらせるな
「PHPはスクリプト言語だから非同期や分散処理は苦手だ」──そんな古い常識を信じているプログラマに、そろそろ退場してもらおう。
Zend VMのメモリ管理、コールスタックの挙動、そしてFiberの本質を理解したエンジニアが書くPHPコードは、Node.jsやGoの並行処理モデルに引けを取らない高スループットと堅牢性を発揮する。
分散システムの荒海において、君の書くコードが正確に整合性を保ち続けるための羅針盤として、このFiber駆動型Sagaパターンの知見を役立ててほしい。