【ITニュース解説】Idempotent APIs Without Idempotency Tokens
2026年09月17日に「Dev.to」が公開したITニュース「Idempotent APIs Without Idempotency Tokens」について初心者にもわかりやすく解説しています。
ITニュース概要
APIで同じ処理を複数回実行しても安全な「冪等性」は重要だ。通常トークンを使うが、リクエスト内容自体からキーを作り、データベースのユニーク制約で重複を防ぐ方法がある。これにより、リトライ時の多重実行や競合問題をシンプルに解決できる。ただし、基盤データの変更に対応するには工夫が必要だ。
ITニュース解説
ITシステムを開発する際、API(アプリケーション・プログラミング・インターフェース)は異なるソフトウェア同士が情報をやり取りするための重要な手段だ。Webブラウザからウェブサイトを操作したり、スマートフォンのアプリで情報を見たりするとき、背後ではたくさんのAPIが動いている。今回取り上げるのは、あるSaaS(Software as a Service)プラットフォームでのAPI設計に関する話で、特に「冪等性(べきとうせい)」という少し難しいが非常に重要な概念を、システムエンジニアを目指す初心者の皆さんにも分かりやすく解説する。
このSaaSプラットフォームは、電力やガスなどの公共料金(ユーティリティ)の請求業務を支援するもので、顧客が自分の利用状況に対して、現在の料金プランだけでなく、他の複数の料金プランを適用した場合に請求額がどう変わるかを比較できる機能が必要とされた。たとえば、「基本料金型」のプランから「時間帯別料金型」のプランに切り替えたら、どれくらい安くなるか、あるいは高くなるか、といったことを知りたいという要望に応える機能だ。
この「料金比較機能」の計算は、想像以上に複雑でコストがかかる。なぜなら、顧客の過去の電力使用量データ(数分や数時間ごとの詳細なデータ)を丸ごと取得し、それぞれの料金プランの複雑な計算ルールを適用して、仮想の請求額を算出する必要があるからだ。この計算処理は非常に時間がかかるため、APIは「同期的に」実行される。つまり、クライアント(Webサイトやモバイルアプリなど)がサーバーにリクエストを送信したら、サーバーが計算を完了して結果を返すまで、クライアントは待機し続けなければならない。
ここで問題が発生する。計算に時間がかかると、クライアント側で設定された「タイムアウト」の時間を超えてしまうことがある。タイムアウトが発生した場合、クライアントは「サーバーからの応答がなかった」と判断し、同じリクエストをもう一度送り直す(リトライする)のが一般的な動作だ。もしサーバー側でこのリトライに対する対策が何もされていないと、次のような困った事態が起こりうる。
例えば、最初の計算リクエストがまだサーバーで処理中に、クライアントがタイムアウトしてリトライしたとする。すると、サーバーは同じ計算を二重に開始してしまう。その結果、データベースに同じ比較結果が二つ記録されたり、あるいはネットワークの遅延などでリトライされた計算結果がわずかに異なり、顧客の画面に同じプランなのに異なる比較額が表示されてしまったりする可能性がある。顧客は、料金プランの変更という重要な意思決定をこの比較結果に基づいて行うため、表示される数字に不信感を与えてしまうことは、システムの信頼性を根底から揺るがすことになる。このような問題を避けるために、APIには「冪等性」が求められる。冪等性とは、「同じリクエストを何度行っても、システムの状態が初めてリクエストを行ったときと同じになる」という性質のことだ。簡単に言えば、同じリクエストは何度送っても一度しか処理されないように見せる、ということである。
一般的なAPIの冪等性の実装方法として、「冪等性トークン」を使う手法がある。これは、クライアントがリクエストを送信する前にUUID(Universally Unique Identifier)のようなユニークな識別子を生成し、それをリクエストヘッダーに含めてサーバーに送るというものだ。サーバー側では、このトークンを使って過去のリクエストと結果を記憶しておき、同じトークンを持つリクエストが来た場合は、再計算せずに以前のキャッシュされた結果を返す。この方法は汎用的で、特に支払い処理のように、同じ顧客が同じ商品を複数回購入したとしても、それはそれぞれ独立した「異なるイベント」であると見なされるような場合に非常に有効だ。
しかし、今回の料金比較機能のケースでは、状況が少し異なる。料金比較のリクエストは、「どの顧客の、どの請求期間で、どの料金プランのセットを比較するか」という三つの情報によって完全に定義される。これらの情報がすべて同じであれば、それは論理的にまったく同じ質問であり、二度目のリクエストは「以前と同じ計算を要求している」と見なせる。つまり、この三つの情報自体が、リクエストの一意性を識別する「自然なキー」として使えるのだ。わざわざクライアントでトークンを生成し、それを管理する仕組みを別途用意する必要はない。
そこで採用された設計は、この「自然なキー」を活用し、データベースの持つユニーク制約を最大限に利用するというものだった。まず、リクエストに含まれる顧客ID、請求期間ID、そして比較対象の料金プランIDのセットを組み合わせて、一意の文字列を生成する。この際、料金プランIDのセットについては、順番が入れ替わっても論理的には同じ比較を意味するため、あらかじめソートしてから結合する。例えば、「プランA、プランB」と「プランB、プランA」は同じ比較なので、どちらも同じキーになるようにする。
生成されたこのキーを使って、計算結果をデータベースに保存する際に、そのキーに対するユニーク制約を設ける。リクエストが来た際、サーバーはまず計算を実行し、その結果をデータベースに挿入しようとする。もし、まだそのキーを持つ結果がデータベースに存在しなければ、挿入は成功し、新しい計算結果としてクライアントに返される。一方、もし既に同じキーを持つ結果が存在していれば、データベースのユニーク制約に違反し、挿入は失敗する。サーバーは、この挿入失敗(永続化例外)を検知し、失敗した場合は既存の同じキーを持つ結果をデータベースから取得して、クライアントに返す。
この仕組みの優れている点は、複数の同じリクエストがごく短い時間差で同時にサーバーに到達した場合でも、データベースが「どちらか一方だけを成功させ、もう一方はユニーク制約違反とする」ことを保証してくれる点だ。アプリケーションのロジックで「データが存在するかチェックし、存在しなければ挿入する」という方法を取ると、チェックした直後から挿入するまでのわずかな間に、別のリクエストが割り込んで先に挿入を完了してしまう「競合状態」が生じる可能性がある。しかし、データベースのユニーク制約を利用すれば、そのような競合の隙間をなくし、常に正しい結果を保証できる。
ただし、この自然なキーを使った方法にも限界がある。それは、「重複したリクエスト」と「同じ識別子を持つが、背後にあるデータが更新されたために再計算が必要なリクエスト」を区別できない点だ。例えば、顧客の使用量データが後から訂正された場合、過去に計算された料金比較結果は古くなってしまうため、同じ顧客、同じ請求期間、同じ料金プランのセットであっても、新しいデータに基づいて再計算し直す必要がある。しかし、自然なキーはこれらを区別できない。
この問題に対応するため、データベースのテーブル設計に工夫を凝らした。各計算結果の行にsuperseded_at(上書きされた日時)というタイムスタンプの列を追加する。そして、ユニーク制約は、superseded_atがNULLである(つまり、現在有効な)行にのみ適用されるようにする。これは「部分インデックス」と呼ばれる機能で、CREATE UNIQUE INDEX ... WHERE superseded_at IS NULL;のように記述する。
再計算が必要になった場合は、まず現在有効な(superseded_atがNULLの)古い結果行のsuperseded_atを現在時刻に更新して「無効化」する。これにより、その行は部分インデックスの対象外となり、ユニーク制約の対象から外れる。その後、新しい計算結果を、superseded_atがNULLの新しい行として挿入する。これにより、古い結果は履歴として残りつつ、新しい結果が「現在有効なもの」となる。これは請求業務において、「顧客に実際にどのような数字を見せたか」という履歴を残す上で非常に役立つ。
この部分インデックスの構文は、PostgreSQL、SQLite、SQL Serverなどで直接サポートされているが、MySQLやOracleでは別の仕組み(例えば、生成列や関数ベースのインデックス)で同様の効果を実現する必要があるため、使用するデータベースエンジンに合わせた対応が必要だ。
ちなみに、何がトリガーとなって再計算が必要だと判断するのか、という点については、この冪等性パターンでは直接解決されない。それは、顧客の使用量データが修正されたことを検知するシステムからのイベントかもしれないし、定期的にデータをチェックするバッチ処理かもしれないし、サポート担当者が手動で開始するかもしれない。これは、システムの要件やデータの更新頻度、再計算のコストなどに応じて、個別に設計すべき別の問題となる。
最後に、このような冪等性を実装した場合のテストについても触れておこう。単にデータベースのユニーク制約が正しく定義されているかをユニットテストで確認するだけでは不十分だ。なぜなら、本当にテストすべきは、複数のリクエストが同時に発生したときに、アプリケーションコードがユニーク制約違反を正しくハンドリングし、既存の結果をフェッチして返す、という一連の挙動だからだ。そのため、実際に複数の重複リクエストを並行して実行し、データベースには一意の結果が一つだけ記録され、かつすべてのクライアントに同じ結果が返されることを検証する統合テストが非常に重要となる。このテストによって、冪等性に関するバグが潜んでいないかを確実にチェックできる。
まとめると、自然なキーがリクエストの内容から導き出せる場合は、クライアントが生成する冪等性トークンよりも、データベースのユニーク制約を利用する方がシンプルで堅牢な冪等性を実現できる。キーを導出する際は正規化を忘れずに行い、競合状態の解決はデータベースに任せる。そして、再計算が必要なケースに対応するために、履歴保持と部分インデックスを組み合わせることで柔軟性を高めることができる。ただし、部分インデックスの構文はデータベースエンジンによって異なるため注意が必要だ。そして、このような複雑なAPIは、実際の並行処理を想定したテストを行うことで、初めてその信頼性を確保できる。