続・リトライと冪等性のデザインパターン - リトライはいつ成功するか

三度の飯よりエラー処理。古橋です。

大変好評をいただいた序章リトライと冪等性のデザインパターンの続編です。
前回はほぼ前置きでしたが、今回は冪等でない操作を冪等にする具体的なテクニックもまとめていきます。

パターン2:エラーを区別してDELETEを冪等にする

リソースに常に一意なIDが振られていれば、Deleteを冪等にするのは難しくない。そもそも同じリソースを2度削除することはできない。

一つ注意するべきなのは、削除されたリソースのIDが再利用されるケースでは、Deleteの冪等性は保証されない。例えば、kill -KILL <pid> コマンドはDelete系のAPIと考えられるが、pidは再利用されるので、何度も繰り返すと意図しないプロセスを殺してしまう可能性がある。

一般にIDの生成は非常に難しい問題だが、Deleteに関してのみ言えば再利用されなければいいので、単調増加する整数(AUTO INCREMENT)で問題ない。

ただし、DeleteもCreateと同様に、クライアント側のエラーハンドリングは少し難しい。「指定されたIDは既に削除済みだ」というエラーを受け取ったとき、それが以前から存在していなかったのが原因なのか、リトライしたことが原因なのかは区別するできない。これもケースバイケースだが、やはり決定性なエラーなのでリトライしてはいけない。

つまりDelete系のAPIでは、HTTPの404 Not Foundに相当するエラーコードや例外クラスを定義しておき、クライアントが区別できるようにしてしておいた方がいい*1

パターン3:操作をまとめて冪等にする

状態を持たないCreateやDeleteを冪等にする方法は簡単すぎるので、書くまでも無かったかもしれない。しかし世の中はそれほどシンプルでは無いので、次のようなケースが良く発生する:

1. 新しい空のプロジェクトを作る(Create)
2. 作ったプロジェクトにアイテムAを5個加える(Append)
3. 作ったプロジェクトにアイテムBを10個加える(Append)

一般にAppendやIncrementなどの操作は冪等にするのが難しい。言い換えれば、以前の状態に依存して操作後の状態が変わる操作は、冪等にするのが難しい*2

上記の例では、ステップ2.や3.が失敗した場合にその操作をリトライすると、予定よりも多くのアイテムが入ったプロジェクトが出来上がってしまう可能性がある。これではマズい。

こういう場合は、一連の操作をまとめてリトライできないか考えてみる。例えば上記の例では、Append中に失敗した場合は、一連の操作を一度最初からやり直すことで冪等にすることができる:

1. 新しい空のプロジェクトPを作る(Create)→成功
2. プロジェクトPにアイテムAを5個加える(Append)→成功
3. プロジェクトPにアイテムBを10個加える(Append)→失敗!
4. プロジェクトPを削除し(Delete)、最初からやり直す

あるいは、すべての操作をまとめた1つの処理を作ってしまう方法でも良い:

1. アイテムAが5個とアイテムBが10個入ったプロジェクトPを作る(Create)

パターン4:操作を細かくして信頼性を高める

前節では操作を大きな粒度にまとめてリトライする方法を紹介したが、リトライの粒度を大きくするほど信頼性は落ちることに注意する必要がある。

例えば、以下のような近ごろ大変良くあるケースを考えてみる:

1. 新しいテーブルを作る(Create)
2. 作ったテーブルにレコードを10,000件加える(Append)
3. 作ったテーブルにレコードを10,000件加える(Append)
4. 作ったテーブルにレコードを10,000件加える(Append)
....
50,000. 合計5億件加える

構造的には前節と同じなので、全体をまとめることで安全にリトライできる。

ここで、10,000件のAppend操作が、0.01%の確率で失敗してしまうと仮定する。一連の5万回の操作が一度も失敗せずに完遂する確率は何%だろうか?

各操作が独立だとすれば、99.99%の5万乗なので、たったの0.67% となる。一連の操作全体を毎回リトライしていたら、いつもどこかで失敗してしまう。こういうプログラムはいつまで経っても終わらないプログラムと言われる。残念、使い物にならない。

つまり、リトライの粒度を大きくするほどリトライのオーバーヘッドは大きく膨らみ、全体としてスループットは低下する。このような場合では、個々の操作を冪等にし、細かい粒度でリトライできるようにする必要がある。1つの解決策として、一つ一つの操作に名前を付けることで、AppendにCreateに変更する方法がある:

1. レコードを10,000件含んだ部分データSplit-1を作る(Create)
2. レコードを10,000件含んだ部分データSplit-2を作る(Create)
3. レコードを10,000件含んだ部分データSplit-3を作る(Create)
...
50000. 作成したSplit-1〜Split-50000含んだテーブルTを作成する(Create)

しかしこのように、AppendやIncrementを冪等にする仕組みは大がかりになりがちなので、本当に細かい粒度でリトライする必要があるかどうかは熟考が必要。場合によっては最終奥義『トランザクション』に頼った方が良いケースもある。いかにシンプルで妥当な方法を見つけ出すかは、プログラマの腕の見せ所かもしれない。

パターン5:操作ログとリクエストIDでUPDATEを冪等にする

次回に続く…

*1:あわせて読んでおきたいコメント:https://twitter.com/kazuho/status/476942055373934595 https://twitter.com/frsyuki/status/476944444676001793

*2:逆に言えば、以前の状態を無視して(依存せずに)新しいセットを宣言する操作は、簡単に冪等にできる。そういうAPIには、操作後の状態を単純に宣言する名前を付けることができる。例えば setXyz、enableXyz、ensureXyzIsCreated、ensureXyzIsDeleted などのようになる。