続々・リトライと冪等性のデザインパターン - あらゆる操作を冪等にする方法
いつも心に冪等性。古橋です。
リトライと冪等性のデザインパターンの完結編です。
だいぶ間が空いてしまいましたが! 最後に冪等性を実装する汎用的な実装手法についてまとめていきます。
パターン6:操作ログとリクエストIDでUPDATEを冪等にする
同じIDで識別される値がUPDATEされる場合、つまりmutableである値の管理は、一般に冪等に行うのが難しい。
例えば、ユーザーごとに「最後に購入したアイテム」を更新する操作を考えてみると:
1. ユーザーAが最後に購入したアイテムをアイテム1に変更する(UPDATE)
2. ユーザーAが最後に購入したアイテムをアイテム2に変更する(UPDATE)
この操作に何の対策もなくリトライを実装した場合、後続のUPDATE処理の結果を古い内容で上書きしてしまう可能性がある:
1. ユーザーAが最後に購入したアイテムをアイテム1に変更する(UPDATE)→失敗!
2. ユーザーAが最後に購入したアイテムをアイテム2に変更する(UPDATE)→成功
3. ユーザーAが最後に購入したアイテムをアイテム1に変更する(UPDATE)をリトライ→成功。内容が巻き戻る!
このような処理を冪等にする方法はいくつもあるが、操作ログとリクエストIDを使う手法は非常に汎用性が高い。覚えておけば色々な場面に適用できる。
具体的には、mutable(書き換え可能)な値を扱う代わりに、immutable(不変)なログを扱うことにする:
1. 購買操作ID XXX 08:00=ユーザーAがアイテム1を購入(Create)→失敗!
2. 購買操作ID YYY 10:00=ユーザーAがアイテム2を購入(Create)→成功
3. 購買操作ID XXX 08:00=ユーザーAがアイテム1を購入(Create)をリトライ→成功
値を取り出すときは、操作に付けられた時刻に基づいてログをソートし、最新の物を使えばいい。
操作ログに付けるIDと時刻は、操作自体が行われた時に生成した物を使う必要がある。操作ログを挿入する瞬間の時間を使うと、リトライ時に書き込んだ古い内容が新しい操作だと誤判定されてしまうので要注意。
操作ログを扱う場合、時間が経つにつれてレコード数が増えていき、ストレージ容量を圧迫してくる。このため、古い操作ログを削除したり別ストレージに移す処理を追加する必要が出てくる。しかし、ゴミ掃除が必要になるほど操作ログが溜まるということは、良く使われるサービスを提供しているということなので、最初のうちは実装をサボるのも手かもしれない *1 。
パターン7:最終奥義「トランザクション」
あらゆる操作は冪等であることが望ましい。冪等ならば自動的にリトライできるので信頼性を高めやすいし、リトライしても副作用が無いと分かっていれば障害発生時に対処もしやすい。
しかし冪等性を組み込もうと考えると、どうしてもコードが複雑(難解)になってしまう。
そういう場合にはトランザクションを活用することができる。トランザクションは一連の操作を「すべて成功」(全体を確定する)または「すべて失敗」(全体を巻き戻す)のどちらかになるように保証してくれる。一連の操作の中に一つでもリトライを検出できる操作が入っていれば、簡単に全体の冪等性をできる。
例えば、「アイテムが購入されたら、所定の更新処理を色々と行う」というケースを考えてみる。「所定の更新処理」は複雑で、どうにも冪等な実装が煩雑だとしよう。一方で「アイテムを購入する」の方は、操作にIDを付けて冪等にしたとする。これら全体を単一のトランザクションとして実行することで、全体を冪等にすることができる:
1. アイテム1を購入 操作ID XXX→成功
2. アイテム1の購入に紐付く、所定の更新Aを行う→成功
3. アイテム1の購入に紐付く、所定の更新Bを行う→成功
4. アイテム1の購入に紐付く、所定の更新Cを行う→失敗!
5. すべての処理を完全に巻き戻す
6. アイテム1の購入をリトライ
7. …
これなら所定の更新操作A,B,Cの個々の操作は冪等ではなくても、リトライされるときには何も無かったことにされているか、アイテム1の購入 操作ID XXXが既に完了しているので、全体としては冪等性になる。パターン6の例も、この方法を使えばよりシンプルに冪等にすることができる。
ただし、トランザクションが使えるのは、(現在一般に運用されている技術では)単一のRDBMS上で処理が完結する場合くらいで、外部のREST APIや外部のストレージへの操作を伴う場合は、それら個々の操作を冪等にする必要がある。
XAプロトコルによるTwo-phase commitなど、複数のDBにまたがった操作を単一のトランザクションで実行する方法もあるが、分散トランザクションの技術についてはここでは触れないことにする。
パターン4:操作を細かくして信頼性を高める の冒頭でも触れたように、リトライの粒度を大きくすれば信頼性は落ちるという点には注意が必要。例えば上記のケースでは、「所定の更新操作C」が3%の確率で失敗する場合、「アイテム1の購入」も3%の確率で道連れにされ失敗する。信頼性が要求される操作と失敗しやすい操作が混じっている場合は、同一のトランザクションで実行すると悪影響が大きい。失敗しやすい操作は非同期で実行することで、トランザクションの粒度は小さく保った方がいいだろう。
おわりに
3回に渡る記事(初回・2回目)となりましたが、いかがだったでしょうか。何とか完結させました…元々はTreasure Dataの同僚とリトライに関する議論をするときに、基礎知識として共有する内容を書こうと思ったのがきっかけでしたが、あらゆる場面で分散システムの知識が要求される昨今のプログラミング環境においては、リトライと冪等性は必修科目なのではないかと思われます。
FluentdやEmbulkなどのデータを扱うシステムはリトライを前提にした設計をする必要があり、Digdagもエラーを重複なくリトライすることが重要なシステムなので、大いに役に立っています。Digdagの実装では、実際にほぼすべてのREST APIとDB操作が冪等になっており、エラーが起きても自動でリトライするようになっています。いま手がけているリアルタイムデータ処理システムでも、PlazmaDBの実装で使用しているパターンの多くを再利用しています。
*1:この手抜きは多くの場合に妥当だと思う。ただし消費ストレージ容量をモニタリングする監視やアラートは追加しておいた方がいい。