TECH NOW

システム開発

2022.01.31

冪等性とバッチ処理の話

こんにちは、システム部の五十嵐です。

今回はバッチ処理の設計・実装を行うときに役立つ冪等性(べきとうせい)という概念について書きます。

冪等性とは何か

冪等性とは「ある操作を1回行っても複数回行っても結果(状態)が同じになる性質」です。わかるようなわからないような概念ですね。

例えば500ml入るグラスに500mlの水を注ぐと、当たり前ですがグラスが水で満タンになります。その状態のグラスにもう一度500mlの水を注ぐと、これも当たり前ですが注いだ端から水がガンガンこぼれて最終的にグラスの水は満タンのままです。その状態のグラスにもう一度…という感じで何度同じ操作(グラスに500mlの水を注ぐ)を行っても、グラスの状態(水が満タン)は変わりません。これが冪等性です。

逆に冪等性のない操作はどんなものかというと、500ml入るグラスに100mlの水を注ぐような操作です。グラスが空の状態から始めたとして、5回目までは操作が終わるたびにどんどんグラスの水量が増え続ける、つまり操作が終わった後の状態が変化し続けます。それ以降は水が溢れるだけなのでグラスの状態は変わらないですが、これは「操作の前にグラスが水で満たされている」という前提があっての冪等性です。

情報工学における冪等性

上記の例でピンと来た方もいると思いますが、冪等性における「操作後の状態」というのは「特定の視点における状態」です。先ほどの例で言えば「グラス内の水量」という特定の視点にフォーカスした場合の話であり、森羅万象のありとあらゆるものが何度操作しても変化しないということではありません(水を注ぐたびにこぼれ落ちていく水の総量は増えます)。

情報工学における冪等性も「○○についての冪等性とは、□□という操作を何度行っても△△の状態が常に同じになることである」という定義のようなものがあります。具体例を2つ紹介します。

関数の冪等性

引数xを取る関数f(x)について、次の式が成り立つものは冪等性があります。

有名な例は引数xの絶対値を返すabs関数です。abs関数に-100を渡すとその絶対値である100が返るわけですが、その100をさらにabs関数に渡すとやはり100が返ります。その100をさらにabs関数に…、何度やっても同じ結果になりますね。グラスと水の例を参考にすると「何回-100を渡しても100が返るから冪等」と考えてしまいそうなのですが、関数についてはそうではなく「戻り値をその関数に渡す操作を何度繰り返しても同じ値が返るから冪等」ということのようです。

RESTFul API

何度同じパラメーターで同じAPIをコールしても、リソースの状態が変わらないものは冪等性があります。例えば記事の一覧を20件ずつ分けたときの3ページ目を取得する

 

のようなAPIを何度コールしても、その度にリソースが増えたり減ったり内容が変更されることはないので冪等性があります。逆に記事を1件作成する

はコールするたびに記事の数が増えるので冪等性はありません。

ここで注意したいのは、RESTFul APIの冪等性はあくまでリソースの状態にフォーカスしたものであり、レスポンスの内容は気にしないということです。例えばID1の記事を削除する

というAPIを初めてコールすると、ターゲットの記事が削除されレスポンスステータス204が返却されます。そのあともう一度同じAPIをコールした場合、既にターゲットの記事が削除済みなのでレスポンスステータス404が返ります。レスポンスステータスは1回目と2回目で異なりますが、あくまでリソースという視点での状態変化を考えると、1回目の後も2回目の後もリソースに変化はありません(いずれもID1が削除された後の状態)。よってこのAPIは冪等性があるということになります。

バッチ処理と冪等性

ようやく本題です。バッチ処理の振る舞いを設計する際、冪等性のある振る舞いを心がけることで色々なメリットを享受できる可能性があります。ただし、これはどんなバッチにでも有用なものではなく、「同じ起動パラメーターで何度も実行される可能性があるバッチ」で力を発揮します。とはいえ、リリース時のデータ移行用バッチのような本来1度しか利用しないようなものであったとしても、そのバッチで実行時エラーが発生した際には同じ起動パラメーターで再実行することになるので、実はほぼすべてのバッチに有用と考えてもいいかもしれません。

では冪等性のあるバッチとないバッチでそれぞれ何が違ってくるのか。次のような要件の集計バッチを例に見ていきます。

お題の要件

  • 毎日午前3時に昨日分の商品購入データ集計を行う。
  • 集計完了後に取引先(商品発注先)に集計結果をメール送信する。
  • メール送信先は昨日購入された商品の発注先のみ(すべての取引先にメール送信はしない)。

アクティブユーザーの少ない深夜帯に重いデータ集計をバッチで行うのはよくある話ですね。上記要件を満たすバッチの振る舞いを設計する際に、冪等性の有無で起動IFは次のようになります。もちろん必ずこうなる、ということではなくあくまで一つのやり方です。

  • 【冪等性がない場合】
    • 起動した日を起点に前日分の商品データを集計する。
    • 毎日午前3時にこのバッチをcronなどのスケジューラーで起動する。
    • バッチ実行時、常に前日分の集計結果データをすべて削除してから改めて集計を実施する。
    • バッチ実行時、常に通知メール送信対象となる取引先には通知メールを送信する。
  • 【冪等性がある場合】 
    • 起動パラメーターとして渡された集計対象日の商品データを集計する。
    • 毎日午前3時にこのバッチをcronなどのスケジューラーで起動する際、集計対象日を前日に指定して起動する。
    • バッチ実行時、対象日の集計結果データが存在する場合は集計処理を行わない。
    • 通知メール送信後、「どの取引先にどの日付の通知メールを送信したか」を履歴に残す。
    • バッチ実行時、対象日・対象取引先の通知メール送信履歴が存在する場合は通知メールを送信しない。

ここで注意点ですが、メール送信についての冪等性は「送信すること(=操作)」ではなく「送信されたこと(=結果)」に生じます。つまりバッチ実行時に常にメールを送信するのであれば、「送信された」という結果がバッチ再実行のたびに増えていくため冪等性はないということです。

例外対応

さて。毎日この集計が100%正常に走る世界線ではどのようにこのバッチを作っても問題ないのですが、もちろんそんな世界は実在しません。この手の集計では下記のような例外対応が必ずと言っていいほどに発生してしまいます。

  1. 10日前の商品購入データに誤りがあり修正を行った。正しい商品購入データを元に再集計を行いたい。
  2. 3ヶ月前に行った集計ロジックの改修にバグがあることが判明。バグを修正したので90日前〜昨日までの再集計を行いたい。ただし取引先への通知メールを60通も送ってしまうとクレームが上がる可能性もあるため、通知メール送信は行いたくない。

これらの例外ケースについても本来は、要件定義時に考慮が行われ機能要件へ落とし込まれれるべきです。しかしこのような運用の例外に関連した要件というのは往々として網羅的な考慮が難しく、「ある程度何が起きても柔軟に対応できるように」というエンジニアの思いやり設計によるカバーで対応することになりがちです。今回の話も根本はこのような思いやり設計をウマくやろうよ、というものです。

ではこの2ケースの例外対応について、冪等性の有無がどのような結果をもたらすかを含めて説明していきます。

例外ケース1: 10日前の商品購入データに誤りがあり修正を行った。正しい商品購入データを元に再集計を行いたい。

まず冪等性がないバッチでは、そもそもこのケースに対応するのが非常に困難です。現在から必ず前日分を集計する仕様なので、それこそバッチサーバーのシステム日付を9日前に戻した上で集計を走らせるような、他バッチやミドルウェアへの影響が大きい高リスクな対応が必要となります。

また仮に集計対象日は起動パラメーターで指定できるようにしていたとしても、再集計が完了したタイミングで取引先にメールが必ず飛びます。このケースでメールを飛ばすべきかどうかは取引先と業務担当者間でのやり取りによってくるところがあります。必ずメールを飛ばしてしまうとその後の取引先対応に問題が出てくるかもしれません。

対して冪等性があるバッチでは集計対象日を指定できますし、既に一度メールを送っている取引先にはメールを再送しません。この2点に関しては明らかに冪等性のないバッチに対するメリットがありますね。しかし肝心な箇所に問題があります。冪等性があるバッチでは既に集計結果データが存在する場合は集計を行いません。つまり何度バッチを実行しても再集計が行われないのです。これでは意味がありません。

どうすればいいかと言えば、再集計したい取引先の集計結果データをバッチ実行前に削除しておけばよいです。集計結果データがなければ集計が実施され、新たな集計結果データが蓄積されます。これは一見すると不便なのですが、手間と引き換えに誤ったデータ更新を行なってしまうリスクを最小限に抑えることができます。例えば10/15にこのバッチの集計ロジックを改修した際に、バグを埋め込んでしまったとします。その状態で10/5のデータを再集計した場合、冪等性のないバッチでは全取引先の集計データを誤ったロジックで洗い替えてしまいますが、冪等性のあるバッチであればそれを特定の加盟店のみに抑えられます。

例外ケース2: 3ヶ月前に行った集計ロジックの改修にバグがあることが判明。バグを修正したので90日前〜昨日までの再集計を行いたい。ただし取引先への通知メールを60通も送ってしまうとクレームが上がる可能性もあるため、通知メール送信は行いたくない。

このケースも1つ目とほとんど同じで、そもそも冪等性のないバッチでは3ヶ月前からの再集計にも、メールの送信抑制にも対応できません。そして冪等性のあるバッチではどちらも可能です(データ削除 & 90日分のバッチ実行が面倒であはりますが)。またひとつ柔軟な点として、例えば「A取引先にだけはメールを送信したい」や「直近10日分だけはメールを送信したい」という場合にも対応が可能です。再送したい分だけの通知メール送信履歴を削除すればいいだけですからね。

まとめ

再集計対象を指定するためにデータの削除を行う手間があるところなどでわかると思いますが、冪等性はバッチ実装を行う際のベストプラクティスということではありません。ただ、冪等性を意識することでバッチの再実行や実行時のリスク検討など、ついつい検討を漏らしがちな事項についても考えることができるようになります。今まで意識できていなかったエンジニアの方は、この機会に気をつけるようにしてみてください。