14
2022

バッチ作成時に気を付けること

CATEGORY開発全般
負荷対策とかの話題に続いて、今度はバッチ処理を書く上での注意点。
普通のWeb APIなら書けます、って人にバッチを書いてもらうといろいろとやらかしちゃうことが多いので、バッチ特有の注意点みたいなのをまとめておく。

実行時間とデータ量を念頭に置け

まずは一つ目。というかこれが多くの注意点の要因なわけだけど、実行時間が長く、処理するデータ量も多いことを注意する必要がある。

例えば、Web APIなら特別なものじゃない限りは長くて数秒で処理が終わるから、同時アクセスやメモリ不足はそうそう問題にはならない。
でもバッチでは、データが数百万件で処理に数時間ってこともざらにあるので、普通に更新が競合したりメモリ不足で死んだりする。
なので、この後に出てくるようないろんな対策を考える必要がある。
まず時間がかかってデータ量も多いという大前提を念頭に置きましょう。

途中でデータが変わっていることがある

で、実行時間が長いために起きてくる問題がまずこれ。
処理をしている間にデータが誰かに書き換えられてしまうことがある。

例えば、オンラインゲームで特定のアイテムを持っているユーザーを対象に処理を行うバッチがあるとする。バッチには2時間かかるとする。
ところが、ゲームがメンテ中とかでない限り、ユーザーはこのアイテムを捨ててしまったりするかもしれない。
なので、バッチ処理ではそうした場合をどう扱うのか(例、メンテを入れる、xx時時点でアイテムを持っていた人を対象とする、処理のタイミングでアイテムを持っていた人を対象とする)事前に考えておく必要がある。

途中でソースが変わっていることもある!?

あまりないけど、システムによっては、バッチ実行中にソースが更新されてトラブルになる可能性もある。
元のソースを上書きするようなデプロイをしている場合、バッチが流れていることを忘れて再デプロイしてしまって、それにより問題が出たり。
自分自身はこの手の問題に遭遇したことはないが、過去にはこんなニュース沙汰になった話も。ご注意を。

ログを出せ

バッチの別の問題点として、実行結果の確認が難しいというのがある。
例えばDBデータを更新するバッチの場合、その結果を確認する方法は何も仕込んでいないとDBを見るしかない。
もちろんそれでは使い物にならない。ではどうするか、ログを出そう。

ログなしではそもそもバッチは実行されているか、正常に終了したか、エラーが出たのかもほぼ分からない。
まず開始ログ/終了ログ/エラーログを出そう。
またいつ動いたのかがわかるように、ログには必ずタイムスタンプも出そう。

さらにそのままではどこまで進んだかも分からない。進行状況もログに出そう。
(全100万件中の10万件目まで完了みたいな。)

データを処理する場合は、必要に応じてデータの処理内容をログに出そう。
処理対象をわかるようにする他、変更前変更後がわかればなおよい。

バッチ処理では、ログが本当に大事です。

ログをまとめろ

そしてログを出せと言われた貴方は、きっと大量のログを出すようにする。こんな感じに。
[2022-05-06T10:00:00] Batch Started.
[2022-05-06T10:00:00] UserId=100
[2022-05-06T10:00:00] ItemId=ITEM001 is found
[2022-05-06T10:00:00] Add Coins=100, Exp=1000
[2022-05-06T10:00:00] Succeeded.
[2022-05-06T10:00:00] UserId=101
[2022-05-06T10:00:00] ItemId=ITEM001 is not found
[2022-05-06T10:00:00] Skipped.
(以下100万行続く)
[2022-05-06T12:00:00] Batch Ended.
はいNG。バッチ処理では、数百万件のデータを処理することもざらにある。
そこで、1データに対して数行の細かいログを出していたらどうなるか?
見辛いことこの上ないし、そもそもディスクが埋まってしまう。

上の例のようにデータの処理内容をログに出す場合は、最大でも1データに対して1件のログになるようにしましょう。
それなら行数も減るし、最悪でもgrepすれば対象データのログを一発で探すことができる。
処理対象だけを出力して、対象外だったデータは出力しないとかも大事。
[2022-05-06T10:00:00] Batch Started.
[2022-05-06T10:00:00] UserId=100 : OldCoins=100, OldExp=1000, NewCoins=200, NewExp=2000
[2022-05-06T10:00:01] UserId=110 : OldCoins=150, OldExp=1020, NewCoins=250, NewExp=2020
(以下5万行続く)
[2022-05-06T12:00:00] Batch Ended.
ただし、そもそも全員に一律同じ処理を行うなら、1件1件の細かいログは要らないかもしれない。
その場合は、進捗表示のログだけに留めましょう。
進捗表示のログも、以下のように大きな単位でまとめれば、ずっとわかりやすくなる。
(何件ごとに進捗表示するかは、バッチの処理速度も鑑みて調節。)
[2022-05-06T10:00:00] Batch Started.
[2022-05-06T10:06:00] 10000/200000 records done.
[2022-05-06T10:12:00] 20000/200000 records done.
(以下18行続く)
[2022-05-06T12:00:00] Batch Ended.
過剰なログを出し過ぎないよう注意しましょう。

戻り値を正しく返そう

ここでいう戻り値というのは、Linuxの終了ステータスとかそういうやつ。
正常時の0は何もしなくても返るけど、エラー時のコードは例外をキャッチして終了してると出てなかったりするので、ちゃんと明示的に0以外の値が返るようにしましょう。
(1で十分だけど、終了ステータスの値で細かくエラーを区別できるならなお良い。)

バッチを単品で手動で実行する分には大丈夫でも、バッチを何かしらの仕組みで動かそうと思った時に…特に「前のバッチが成功したときだけ後続のバッチを動かす」みたいな仕組みが必要になったときに、これやってないと大問題になる。大した手間じゃないので、ちゃんと返しましょう。

中断時に途中まで保存するとか

今度はバッチが途中でエラーになった場合の対処について。
バッチは当然ながら、想定外のデータがあったり、またすべてが正常でもI/O障害や通信エラーでエラーになったりする。
で、どうするか。

これが数分で終わるバッチなら話は簡単で、全部一つのトランザクションにして処理が成功したときだけコミット、失敗したらロールバック、データやソースを修正して再実行で話は終わり。
が、これが10時間かかるバッチの9時間終わったところでエラーになったら?

当然ながら、正常に終わったところまでは保存して、問題があったデータだけエラーにして欲しいですよね。
なので、トランザクションを1件~数十件ごととかに分けて、細かくコミットできるようにしましょう。

中断時のリトライを考えておけ

上の続き。さてバッチが9時間終わったところでエラーになり、異常データを直した。これでバッチは今度こそ動くはず、となったときに、また先頭データから9時間やり直しだったり、既に処理済みのデータをもう一度処理してしまっては困る。リトライ方法を考えておきましょう。
よくあるのが、対象データをID順とかでソートして上から順番に処理するようにしておいて、エラーのときはエラーが起きたIDをログ出力、次回実行時は引数でIDを指定したらそこから再開、みたいなやつ。
これやその応用でだいたい事足りる。
データがソートされてないと、たとえ処理済みのIDをログ出力していても簡単には再開できないので、ソート大事。

カーソル使え

最初に書いたように、バッチでは処理対象のデータが数百万件にわたることも珍しくない。
普通のDBアクセス処理は、たいていSELECTしたデータを全部メモリ上に読み込んで処理する。
ので今どきの高性能なマシンとは言え、数百万件を読み込めばすぐにメモリ不足で死んでしまう。

どのDBやフレームワークにも、いわゆるカーソル(データをDBから1件ずつ読み込んでストリーム的に処理する機構)が備わっているはずなので、データ取得ではそちらを使いましょう。
フレームワークによってはページングで代用することもできるけど、ページングは毎回新しく取得に行く関係上、時間が経つ間にデータが更新されてしまっている可能性もあるので注意。
(カーソルなら取得自体は最初の瞬間だけなのでSELECT単体での不整合はない。)

再取得しろ

上でカーソルならSELECT単体での不整合はないって書いたが、つまりそれ以外では時間が経つ間にデータ不整合は起こり得る。
具体的には、最初のSELECTの時点ではアイテムを持っていたため処理対象になったユーザーが、いざ処理をしようとした瞬間にはアイテムを捨てているかもしれない。
(バッチは時間がかかるので、FOR UPDATEでロックし続けるのは論外。)

なので、最初に取ったデータでは処理をせずに、実際に更新などを行うタイミングで、もう一度データを取り直して再チェックとかも必要に応じて行いましょう。
(もちろん、DBアクセスが増えて遅くなってしまうので、必要がなければやらないべきだが。)

dryrunも実装しろ

バッチはたいていDBを更新する。更新してしまうと、更新前のデータはなくなってしまう。つまり、開発中に何度も確認するのが面倒くさい。
なので、ログを出力するもののデータは更新しない、いわゆるdryrunモードみたいなものも実装しておきましょう。

dryrunモードの実装は、単純に更新処理の前とかに if (!dryrun) { とかで制御を入れるのが手っ取り早い。
くれぐれもdryrunモードと通常モードで別々のメソッドを呼ぶ、みたいなつくりにしないようにしましょう。
そうすると、本来の挙動を検証できないので。

複数並列実行も考えろ

バッチ処理は全データを処理したりでものによっては数時間どころか数日かかったりすることもある…のだが、それならマシンを増やして並列実行できるんじゃね?というケースもある。
例えば、単純にバッチに処理対象のID範囲を指定できるようにして、ユーザーIDが0~100万まではマシンAで、100万から200万はマシンBで、200万から300万はマシンCで…とやれば3倍で処理できる。
また、言語によってはバッチ自体のソースをマルチスレッドで実装して、もっと複数並列で処理するとかもできる。

もちろんバッチの種類によってはこの手は使えないが、高速化が必要で使えるケースでは検討の余地はある。
(他のデータが影響するオンラインゲームの対戦相手のマッチングだとか、またはDBがボトルネックになっちゃうケースだとかでは難しい。)

時間が掛かってもいいケースも

上では高速化について書いたけど、今度は逆の発想。
例えば、バッチ処理は時間的な制約が無ければ、また他に影響がなければ、極端な話別にずっと裏で流しっぱなしにしていてもいい。
特に一回しか流さないようなバッチとかなら、実装に工数をかけるよりも、諦めてメンテに入れて数時間流しっぱなしにしてしまうとかでもいい。
状況によっては無理に性能対策する必要はないので、そこは切り分けていこう。

本番で流す前に時間を測れ

時間が掛かってもいいとは言ったがその前に、本番で何時間ぐらいかかりそうか、事前に開発環境で測定しておきましょう。
メンテが数時間で終わるのか、それとも数時間のつもりが数日必要かかってしまうのか、本番で流してみて初めて分かるでは困る。
これ絶対時間かかるでしょ見たいなバッチは、ある程度データを用意して測ってみて、本番の件数ではどれくらいかかりそうか、事前に確認しましょう。
(もし可能であれば、実際の本番DBバックアップとかで試すのが望ましい。)

バッチ以外の方法も考えろ

ここまで延々とバッチ処理を実装する上での注意点とかを書いてきたけど、そもそも必ずしもバッチでやる必要のない処理、ってのも世の中にはいっぱいある。

例えば、オンラインゲームのイベント参加者全員に報酬付与とか、普通に考えると、まあがーっと付与するバッチを作って、イベント終わったらバッチを流そうと思っちゃう。
でもそれって実際には、更新に時間がかかるからできるだけ差が生じないように一斉に付与しなきゃとかで結構大変。

だけど、実はそれってバッチじゃなくてもできる。例えばログイン時にイベント参加者なら報酬付与するような処理を入れれば、それで解決する。時間差の問題も生じないし、そっちの方がスムーズに解決したりもする。

もちろん、こういうのは仕様によってできたりできなかったりする。
でも、一見バッチでやるような処理でも必ずしもバッチでなければできないとは限らない、というのは念頭に置いておいた方がよい。


以上、長くなったがバッチ処理を書く上での注意点でした。いろんな技術が登場しても、まだまだバッチ処理が要らなくなったりはしていないので、こういうのも頭の片隅に入れておいてくださいm(__)m
スポンサーサイト



Tag: プログラミング バッチ 性能問題

0 Comments

Leave a comment