冪等性
Idempotency ・ べきとうせい
同じ操作を何度繰り返しても、1回だけ行ったのと同じ状態になる性質。安全な再送の土台。
✦ 30秒で試す — 航海送ったのか、届かなかったのか。アプリには区別できない送り直しを受けたサーバは、どう動く?予想してみる概要
冪等性(べきとうせい)とは、「同じ操作を1回実行しても、2回実行しても、100回実行しても、結果として残る状態が変わらない」という性質のことです。たとえば「照明のスイッチをオフにする」操作は冪等です。何度オフにしても、照明が消えているという状態は同じだからです。一方「照明のスイッチを切り替える」操作は冪等ではありません。実行するたびに点いたり消えたりして、状態が変わってしまいます。
ここで同じでなければならないのは「状態」で、返ってくる応答ではない点に注意してください。「このファイルを削除する」操作は、1回目は「削除しました」、2回目は「もうありません(404)」と応答が変わりますが、ファイルが無いという状態は同じなので冪等です。
この性質が重要になるのは、API 呼び出しやメッセージ処理のように「同じ要求が意図せず何度も届く」状況です。ネットワーク越しの通信では、要求が本当に失敗したのか、成功したのに返事が届かなかっただけなのかを、送り手が確実に見分けることはできません。そのため送り手は「念のためもう一度送る」再送をしますが、受け手の処理が冪等でなければ、この再送が二重の注文・二重の課金・二重の在庫引き当てといった事故を起こします。冪等性は、再送が避けられない世界で信頼性を保つための土台です。
なぜ生まれたか
1台のマシンで完結する処理なら、関数を呼べば成功か失敗かがその場で分かり、「二重に実行された」心配はほとんどありません。ところがネットワークをまたいだ分散システムでは、通信のどこで何が起きたかを送り手が完全には知りえない、という根本的な不確かさが生まれます。
決済の要求をアプリからサーバに送ったのに、応答が返ってこない場面を考えてみましょう。原因は大きく二通りあります。一つは「サーバに届く前に要求が消えた」場合で、このときは再送すべきです。もう一つは「サーバは決済を完了させたが、その応答が途中で消えた」場合で、このときに再送すると二重決済になります。送り手には、この二つを区別する手立てがありません。
「返事がないなら再送する」という自然な戦略と、「二重処理は絶対に避けたい」という要求が、ここで真正面からぶつかります。この矛盾を解く鍵が、「何度届いても結果が一つに収束するなら、安心して再送できる」という発想です。再送を恐れるのではなく、再送されても壊れない受け手を作る方向への転換です。
「冪等」という言葉自体は、1870年に数学者ベンジャミン・パースが代数の用語として作ったもので、「何度掛けても変わらない」(x × x = x)という意味でした。それが、失敗と再試行が日常になったネットワークの時代に、設計原則として重視されるようになりました。HTTP の仕様は早くから「冪等なメソッド」を定義し、2010年代には Stripe などの決済 API が、再送を安全にするための Idempotency-Key ヘッダを広め、現在は IETF で標準化が進められています。
詳細
HTTPメソッドに刻まれた冪等性
冪等性は、HTTP の仕様(現在は RFC 9110)に組み込まれた概念です。GET・HEAD・PUT・DELETE・OPTIONS は冪等なメソッドと定められています。GET は状態を変えない読み取りなので当然冪等です。PUT は「このリソースを、この内容で置き換える」なので、同じ内容で何度実行しても結果は同じです。DELETE も「このリソースを消す」なので、2回目以降は「すでに無い」だけで、消えているという状態は変わりません(応答は 404 に変わってもかまいません)。
冪等でないのが POST と PATCH です。POST は「新しいリソースを作る」「注文を確定する」といった、実行するたびに新しい状態を生む操作に使われます。PATCH は部分的な変更で、「名前を X にする」なら冪等ですが、「在庫を1減らす」「配列に1件足す」のような相対的な変更なら冪等ではありません。REST API を設計するときは、更新を「最終的な状態を丸ごと送る」PUT で表せる場面ではそうしておくと、再送に強い API になります。裏を返せば、POST を使わざるをえない「新規作成・課金」のような処理でこそ、冪等性を別の仕組みで作り込む必要があります。
また、HTTP の仕様が「冪等であるべき」と言っても、実装がそうなっているとは限りません。GET で状態を変えてしまう実装や、PUT の中でカウンタを増やす実装は、プロキシやブラウザによる自動の再送で壊れます。
冪等キーで二重処理を防ぐ
冪等でない POST を安全にする定石が「冪等キー(idempotency key)」です。クライアントが「この操作を一意に識別する使い捨ての値」(UUID など)を生成して要求に添え、サーバは「同じキーの要求は一度しか処理しない」ことを保証します。決済 API などでは、Idempotency-Key ヘッダに載せる形で広く使われています。ポイントは、サーバがキーごとに「処理結果」を記録しておき、2回目以降は処理をやり直さず、記録した結果をそのまま返すことです。
最初の要求で、サーバはまず冪等キーを保存領域(データベースなど)に「処理中」として登録します。未登録だったなら決済を実行し、その結果をキーとひも付けて記録し、クライアントに成功を返します。ここで応答が消え、クライアントが同じキーで再送したとします。サーバはキーが処理済みであることを見つけ、決済を実行し直すことなく、記録しておいた前回の結果を返します。クライアントから見れば、何度送っても決済は一度だけで、同じ結果が返ります。
下の部品で、飛んでいる応答の粒を押して消し、冪等キーのあり・なしで再送を比べてください。要求の粒を消した場合も試すと、「要求が消えた」と「応答が消えた」がアプリからは同じに見えること、冪等キーがあればどちらでも同じ再送で正しく一度だけになることが確かめられます。
「支払う」を押すと要求が飛びます。飛んでいる粒を押すと、その通信が途中で消えます。まずは戻ってくる応答の粒を消して、アプリから何が見えるかを確かめてください。
実装の落とし穴
一つ目は、確認と登録の間の隙間です。「キーが処理済みか確かめる」と「処理中として登録する」を別々に行うと、ほぼ同時に届いた二つの要求が両方とも「未登録」と判断して、二重に処理します。キーに一意制約を付けて INSERT し、失敗したら登録済みとみなす、のように一つの不可分な操作にします(トランザクションの中で行う、または分散ロックで守る)。
二つ目は、処理中の重複です。1回目の処理がまだ終わっていないうちに再送が届いたら、結果は返せません。IETF の Idempotency-Key ヘッダの草案では、この場合 409 Conflict を返し、クライアントに少し待ってから再送させることを勧めています。同じキーで中身の違う要求が届いた場合(金額が違うなど)は、クライアントのバグなので 422 などのエラーで拒否します。キーだけで判断して前回の結果を返すと、別の支払いを「成功」と誤って伝えてしまいます。
三つ目は、キーの保存期間です。短すぎると、期限が切れた後の遅れた再送で二重処理が起き、長すぎると保存領域を圧迫します。Stripe はキーを少なくとも24時間保持するとしています。再送の間隔(リトライと指数バックオフの最大待ち時間)より十分長くしておくのが目安です。
最後に、サーバの外への副作用です。決済処理の中でメールを送る、別の外部 API を呼ぶ、といった処理は、キーを使って自分のデータベースを守っても二重に走りえます。外部の呼び出しにも冪等キーを引き継ぐ、または副作用をキューに積んで一度だけ実行されるようにする、といった配慮が要ります。
メッセージ処理と分散設計での位置づけ
冪等性が最も真価を発揮するのが、メッセージキューを使った非同期処理です。多くのキューは、確実な配送のために「少なくとも1回(at-least-once)」の配送保証を採ります。「メッセージが消えるくらいなら、重複して届くほうがまし」という割り切りで、裏を返せば同じメッセージが複数回届くことがあります。このとき受け手が冪等なら、重複配送は何の問題も起こしません。「ちょうど1回(exactly-once)」の配送を通信だけで保証するのは原理的に難しく、実際には「少なくとも1回の配送+冪等な処理」で「効果がちょうど1回」を実現するのが、分散システムの定石です。
同じ理由で、複数のサービスにまたがる処理を段階的に進めるサーガパターンでも、各ステップと、失敗時の巻き戻し処理(補償トランザクション)は冪等に設計するのが鉄則です。障害からの再試行でステップが再実行されても状態が壊れないことが、安全な回復の前提になるからです。設計の早い段階で「この操作は再送されても大丈夫か」を問う習慣が、堅牢なシステムを作る分かれ目になります。
試問
アプリ・決済API・キーの保存領域が並んでいます。「支払う」を押すと要求の粒が飛び、飛んでいる粒(要求・応答)をタップするとその通信が途中で消えます。アプリは応答が来なければ「再送する」で同じ支払いを送り直せ、上の切り替えで冪等キー(支払いごとの使い捨ての番号)を付けるかどうかを選べます。
「支払う」を押すと要求が飛びます。飛んでいる粒を押すと、その通信が途中で消えます。まずは戻ってくる応答の粒を消して、アプリから何が見えるかを確かめてください。
