アプリケーションエンジニアの多くは、眠れない夜を過ごしたことがあるでしょう。特に月に一度の…「月末締めバッチ」の日は。
そんなデータ量の多い日や、初モノのバッチが動く日でも安心して眠れるためのバッチ設計を考えてみます。
ログの設計
まず何はなくともログです。きちんとしたメッセージを出せていれば、専任の人がリカバリ可能にもなるってものです。
Audit用のログなど業務要件の強いものを除いては、だいたい3種類に分けるようにしています。
- プログレスログ
- リカバリログ
- 例外ログ(調査のため)
この分類でファイル単位も分けます。ログを必要とする人が、それぞれ異なるからです。
プログレスログ
プログレスログは、特に長時間かかるバッチに対して、現在どのくらいまで処理が出来ているのかを目的として出力します。
トラブル発生時や、大規模移行作業時には、バッチの定期的なモニタリングと報告の必要が出てきます。「あと何分で終わるんだ。サービス開始までに間に合うのか!?」に応えなくてはならない。そんなときに、①必要なのはどこまで処理できていて、②あと何分かかるのか、です。
①,②を出力するためには、処理対象の総件数を取得しておいて、一定間隔(5分目安)ごとにプログレスログが出力されるようにします。
残件数とそのチェックポイントまで掛かった時間から、残り時間を算出しログに出力します。
出力例
2015-11-17T160041.793 売上データ取り込み処理を開始します
2015-11-17T160041.806 総処理対象は500000件で、終了予想時刻は16:10です。
2015-11-17T160052.954 10000件(2.0%)処理しました。現時点での終了予想時刻は16:09:51です。
2015-11-17T160104.124 20000件(4.0%)処理しました。現時点での終了予想時刻は16:09:52です。
2015-11-17T160115.268 30000件(6.0%)処理しました。現時点での終了予想時刻は16:09:52です。
2015-11-17T160126.275 40000件(8.0%)処理しました。現時点での終了予想時刻は16:09:52です。
なお、2行めで予測時間を出力するためには、バッチの実行時間の実績が必要になります。拙作JobStreamer上でJavaBatchを動かせば、これは自動的に出力されるようになる予定です。
リカバリログ
データの不整合など、業務として予測できるエラー(主に人的)の場合に出力します。運用と開発の分離は、J-SOX対応のために必須な現場がほとんどだと思います。そうした場合に、運用だけでリカバリ可能なイレギュラーパターンを事前に準備しておくことが、安心して眠れる夜のためには必要不可欠です。
リカバリログは、運用者がログメッセージにしたがって、リカバリ作業を確実に行えるようにするためのものです。
例えば、提携先システムからのファイルが届いてない場合にログを出力する場合、こういうメッセージをログに出力させます。
2015-11-12T030501.041 XXXからのファイルが到着予定時刻03:00を越えても届きませんでした。
リカバリ手順
□ 5:00までに/home/xxx/sales/20151111.md5のファイルが存在すれば、JOB0101_import_salesを再実行してください。
□ 5:00を越えて到着しない場合は、連絡ルートAにしたがって連絡してください。
例外ログ
バッチサーバの障害・バッチプログラムの障害と思われる場合に出力します。Javaの場合、いわゆるスタックトレースです。これは保守・開発チームで対応しなければならない類の例外です。通常バッチアプリケーション内ではハンドリングせず、上位のフレームワークまたはバッチコンテナでキャッチしログを出力します。
これは通常のLoggerの使い方で、レベルをINFOまたはWARNで出力するログの想定です。
その他のログ
実運用では、バッチはジョブコントローラで起動しモニタリングすることが多いと思います。その場合、ジョブフローとして一連のバッチの成否を知りたいので、以下のようなジョブコントローラまたは起動用のシェルから検知ログを出力し、ジョブフローの成否にかかわらずメール送信しておくとよいでしょう。
Subject: [FLOW1000]が異常終了しました
2015-11-18T032015.739 JOB1001 売上データ取り込み 開始しました。
2015-11-18T032503.925 JOB1001 売上データ取り込み 警告終了しました。
2015-11-18T032504.318 JOB1002 売上個社別日次集計 開始しました。
2015-11-18T033442.104 JOB1002 売上個社別日次集計 正常終了しました。
2015-11-18T033443.003 JOB1002 売上データホスト送信 開始しました。
2015-11-18T033445.286 JOB1002 売上データホスト送信 異常終了しました。
ここに情報のせすぎると、だんだんマジメに中身見なくなったりしがちなので、あくまでも検知用途として最低限に留めます。
また例外が発生した時に、スタックトレースだけでは、いまいち原因を特定しきれない、デバッグレベルで出力しておけばよかったー 、という後悔はありがちなことです。拙作タイムシフトロガーを使えば、遡って原因を特定することができるかもしれません。
例外の設計
前述のとおりログを出力し分けるには、例外の種類をリカバリ可能なものと、そうでないものに分類するのが簡単です。
これを防ぐために、業務的にリカバリ手順を定めることができるものだけを業務例外として設計し、ハンドリングしメッセージを出すのがよいかと思います。そのため業務例外は検査例外にしておくとハンドリング漏れがなくなってよいのではないでしょうか。
try {
if (file.exists()) {
throw new FileHasNotArrivedException(file);
}
} catch (FileHasNotArrivedException e) {
recoveryLog.warn(e);
}
エラーを全部同じようにハンドリングすれば、本質的でないテストケースが増えます。これは、仕事が増える以上の悪影響があって、さもケースを上げきったかのような錯覚に陥りやすくなる、という問題があるので、業務としてのバッチ実装は予期できる例外のみハンドリングします。1
例外の分類例を以下に示します。
リカバリ対象 (業務例外)
- 入力フォーマットが不正である。
- インタフェースファイルが届いてない。
- 終了予定時刻に、処理が終わらなかった。
リカバリ対象外 (システム例外)
- Oracleに接続できない。
- ファイルがコピーできない。
- 設定ファイルが見つからない。
これは運用者ではリカバリ不能であるため、エスカレーションルートにのせます。
差分にこだわる
「多い日も安心」するには、そもそものデータ処理対象を減らすことが重要です。仕様が複雑になりすぎて破綻するのでない限り、執拗に差分更新にこだわります。
- 「XXマスタデータ取り込み」みたいな、バッチは前回の取り込み成功したファイルとの差分をとり、異なる部分だけ取り込み対象とする。
- 月別集計で、当月分のデータの途中集計は、その月分を計算し直すのではなく、その日分だけ集計して足しこむ。2
- Materialized viewの高速リフレッシュを活用する。
など。
一括処理にこだわる
差分にこだわり抜いて対象データを極限まで減らしたら、それに対してできるだけ一括処理することを考えます。処理速度の面で大きなメリットになります。
- INSERT INTO 〜 SELECTでの一文で処理できないか。
- モデルをできるだけイミュータブルに設計することが重要 (イミュータブルデータモデル)
- [Oracleの場合] ダイレクトインサートが使えるなら使う。
- コミット間隔を可能な限り長めにとる。
- Java側にデータ持ってきても、必ずJDBCのバッチ更新を使うようにする。
バッチの引数設計
引数の数が1つ増えると、リランのミスの確率が2倍になるとかならないとか。とにかくバッチのリラン時のミスはなんとしても防がなくてはなりません。
リラン時の引数として、よく渡したくなるのは、処理対象の日付を渡すパターンです。
これは通常のリカバリ、未処理分の日のデータを取り込む、という目的であれば、引数を渡すのではなく、バッチの未処理日付を管理するテーブルを作っておけばそこからデータを取得して実行することが可能です。
特にファイルパス(の一部)を引数与えるのは、次のようなことがおこるのでやめたほうがよいです。
ファイル操作は1箇所にまとめる。
ファイルの操作は慎重にやらないと痛い目にあいます。リカーシブに消すようなインタフェースの削除機能があり、設定ファイルに書かれたベースディレクトリをスキャンしてファイルを消す、なんて設計にすると、設定ファイルから取得できなくて、ファイルシステムのルートから削除しにいく恐怖のバッチの一丁あがりです。
これを防ぐには、いろんな人にファイル操作をさせるのをやめるようにすべきです。以下のような、ファイルの管理をする基盤を提供するとよいでしょう。
作業台と倉庫
バッチの中でファイルをあつかう場所(ディレクトリ)を、以下のように決めます。
作業台: そのバッチの実行中だけで有効なファイルの置き場
倉庫: バッチの実行をまたいで有効なファイルの置き場
作業台はバッチの開始時に作り、バッチの終了時にディレクトリごと削除します。
したがって、必要なものは倉庫に格納しておきます(受領ファイルを一定期間保管しておく、など)。
File workFile = fileWorkbench.createFile("work");
batchFileStorage.store(workFile, "");
ゴミ箱
不要なファイルは、プログラミング言語のファイル削除APIを使うのではなく、「ゴミ箱に捨てる」機能を提供します。
TrashUtils.throwAway(file, 10);
と、ゴミ箱の保管期間を引数にゴミ箱に移動するようにします。
public static void throwAway(File uselessFile, long storagePeriod) {
LocalDate expiry = LocalDate.now().plus(storagePeriod, ChronoUnit.DAYS);
String fileName = DateTimeFormatter.ofPattern("yyyyMMdd").format(expiry) + "_" + uselessFile.getName();
Path trashPath = trashDirectory.resolve(fileName);
if (Files.exists(trashPath)) {
trashPath = trashDirectory.resolve(trashPath.getFileName() + "." + UUID.randomUUID().toString());
}
try {
Files.move(uselessFile.toPath(), trashPath);
} catch (IOException e) {
throw new IORuntimeException(e);
}
}
このときファイル名に、削除してよい日付を入れておきます。
ゴミ箱クリーニング
あとは、ゴミ箱の中の保管期間を過ぎたファイルを消す日次バッチを作っておけば、各バッチアプリケーションでファイル削除をしなくてもよくなります。
まとめ
多い日でも安心して眠れる人々が増えることを、心よりお祈り申し上げます。
コード例は、https://github.com/kawasima/goodbye-sleepless-nights にあります。