LoginSignup
32
20

More than 1 year has passed since last update.

いまさら訊けないバッチ実装の心得 6選(超基本編)

Last updated at Posted at 2022-12-15

はじめに

これまでWEBアプリケーションをメインで作ってきましたが、最近新規のバッチジョブをまるっと1つ作るという経験をしました。
バッチ実装レベルとしては、既存のバッチ処理の不具合修正を何件かこなしたことがある程度。新規実装は初体験でした。
バッチ実装経験者であれば息を吸うかのように判断できるであろうことに、残念ながらとても悩んでしまう場面が多々ありました。

そんな状態ではありつつ、上司や同僚に質問・相談しながらなんとかローンチに漕ぎつけました。
その際に学んだり、自分なりに行き着いたりした、初めてバッチ実装する際には悩んだり盲点になったりすると思われる超基本的な心得を明文化しておこうと思います。

なお、今回実装したバッチ処理は超シンプルなデータ取込処理だったので、「こういう場合はどうするんだろう?たぶんこうするんだろうな」という考察・憶測も記載しています。そちらに関する編集リクエスト、アドバイス、異論反論、歓迎いたします。むしろご教授ください。

前提

  • 開発環境
    • Java8
    • Oracle 12c(12.2.0)
  • こちらの記事における業務処理の実装のお話です
  • CSVファイルを利用したトランザクションデータの取込処理を実装したので、データ取込処理に偏った内容になっていますがご容赦ください

先に要点だけ

  1. mainメソッドはなるべくシンプルに
  2. INPUTデータは抱えすぎない
  3. マスタデータは必要なものだけ取得し、なるべく再利用する
  4. DB更新はある程度まとめて実行する
  5. エラーが発生した時のふるまいを定める
  6. ログは調査のしやすさとサイズを考慮した"いい塩梅"で出力する

では詳細行ってみましょう。

心得その1. mainメソッドはなるべくシンプルに

バッチ処理に限らずですが、mainメソッドは簡潔を心がけます。

・・・てのは重々承知なんですが、大規模WEBアプリケーションの開発をしていると、業務ロジックでメイン処理って意外と書かなくないですか?
なので結構悩みました。

今回も以下の3つほどしか書きませんでした。

  • 前処理:実行時引数のチェック
  • 実処理:処理を実行するクラスやメソッドの呼び出し
  • 終了処理

こんな感じです。

public static void main(String[] args) {
    Arguments arguments = new Arguments(args);
    if (arguments.isInvalid()) {
        fin(EndStatus.ERROR));
    }
    fin(new Executor(arguments).execute());
}

心得その2. INPUTデータは抱えすぎない

基本路線はメモリに優しく。大量件数を扱うことが間々あるバッチ処理では、いかにメモリ内にデータを抱え込まないかが重要です。

今回はCSVファイルのサイズがそこまで大きくならないと予想できること、またCSVファイルをシンプルに上から読んでいけば問題ありませんでした。
そのため、CSVファイルは1ファイルごとに読み込み、CSVファイル内のレコードも1件ずつ処理しました。

一時テーブルの利用を検討してもよかったかな・・・と未だにもやもやしています。

(考察)一時テーブルを使うべきケースとは?

今回は使わなかったのですが、以下のようなケースでは一時テーブルを使うといいのではと思いました。

  • INPUTとなるファイルサイズが大きくなる可能性がある場合
    → ファイルをメモリ内に持ち続けるとOutOfMemoryError(OOM)が発生し得る

  • INPUTデータを上から舐めるだけではダメな場合
    → あるマスタデータに紐づくレコードをまとめて処理が必要(だけどレコードがそんなにきれいに並んでいる保証はない)場合

このような場合、一旦CSVファイルの内容を中間テーブルに入れた後、ROWNUMで指定件数分を取得したり、コードで対象データのみを取得して処理、とするとよいのではと考えています。

心得その3. マスタデータは必要なものだけ取得し、なるべく再利用する

これもバッチ処理に限った話ではありませんが、基本路線はメモリに優しく。
件数が一定かつ少ないなどの例外を除き、関連するマスタデータの安易な一括取得は避けます。

ただし、レコードの中には同じマスタに紐づくデータも少なからずあります。何度も利用する可能性があるデータはキャッシュして再利用します。
そうすることで、DBアクセス頻度を減らして処理時間を短くします。

心得その4. DB更新はある程度まとめて実行する

JDBC経由でのDB更新処理(INSERT, UPDATE, DELETE)の実行時間については、何件ごとに更新を走らせるかによって処理時間に大きく影響があります。
こちらの記事addBatch, executeBatchの頻度変更による影響のセクションを参考にすると、環境にも依ると思いますが、100~10,000件ごとに更新処理をするとよさそうです。

ただし、バッチ処理の要件によっては必ずしも機械的に件数を決めて更新していいとは限りません。

  • 機械的な件数で更新して問題ない?
  • ファイル内のレコードを一括更新が必要?
  • マスタデータなど、任意の紐づき単位ごとに更新が必要?

といったポイントで更新の単位を考慮する必要がありそうです。

なお、今回の実装要件では、以下のような理由から同一ファイル内であれば、機械的に100件ごとでDB更新することにしました。
ファイルが分かれる場合は、100件未満だったとしてもDB更新をした後で次のファイルを読むようにしました。

  • ファイル内のレコード間の相関関係は強くない(極論個別に更新しても問題ない)
  • マスタデータとの紐づきはあるものの、同じマスタに紐づくものをまとめて取り込む必要はない
  • ファイルは複数件取り込む可能性があり、かつ各ファイルのレコード数は不定
  • ファイルを分ける場合は、対象期間別/マスタ別/取込頻度別など、業務上何らかのグルーピングの意図があるはず

DBへのコミットの単位も熟慮する

業務アプリの場合、どこまでを1つのトランザクションととらえるかによって、コミットのタイミングも気を付ける必要があります。

今回も、すでにDBにあるレコードをDELETE-INSERTするケースがありました。1
そのため、DELETE処理が終了時点ではコミットせず、INSERTが完了したタイミングでコミットをしています。

public void upsert(Connection conn, Records records) throws SQLException {
    try {
        conn.setuAutoCommit(false);
        delete(conn, records);
        insert(conn, records);
        conn.commit();
    } catch (SQLException e) {
        conn.rollback();
    } finally {
        conn.setAutoCommit(true);
    }
}

その他にも、以下のような場合は、それらすべての処理が終わった後でコミットを走らせる必要があります。

  • 複数のテーブルに対して更新処理を走らせる必要がある場合
  • 取り込んだレコード間に強い相関関係がある場合

心得その5. エラーが発生した時のふるまいを定める

データ取込処理の場合、カラム数不足やデータ不整合、型や書式誤りなど、様々な理由で特定レコードをそのままでは取り込めない場合があります。
取り得る対応策は以下のあたりかと思います。

  • 問答無用で処理全体を異常終了させる
  • 該当レコードのみ処理をスキップし、全体の処理は続行する
  • 警告のみで取込対象とする

警告時の取込ルールは決め打ちでいいのか?ユーザーに決めてもらうのか?も考える必要があります。

どの対応を取るのかは、そもそもの処理内容の毛色にもよりますし、エラーの発生箇所や内容にもよりますが、処理の根幹に関わってくる部分なので、熟慮したうえで定めるべきだと考えています。

心得その6. ログは調査のしやすさとサイズを考慮した"いい塩梅"で出力する

想定通りに処理が実行されていない場合、その調査のためにログは重要な役割を担います。
裏を返すと、ログを見るのは何か問題が起きたときです。

そのため、問題が起きたときに欲しい情報を、できるだけ簡潔・明瞭に出力するようにします。

INFOログとDEBUGログを分け、通常処理ではログ出力は最小限にとどめる

ログの出力すなわちファイルへの書き込み処理です。書き込んだらその分だけ処理速度は落ちます。
INFOログとDEBUGログを書き分け、バッチ処理を通常モード(INFOレベル以上のみ出力)とデバッグモード(DEBUGレベルも含めて出力)それぞれで実行できるようにしておくのが基本路線です。
今回私は書きませんでしたが、さらに詳細なTRACEログまで必要かも処理内容によって判断するとよさそうです。

想定外が起きたときに該当ログにたどり着ける、さらには問題が特定できるような材料を提供する、というのがべ-スとなりますが、どのログレベルにどんな内容のログを出力すべきかについては、こちらが大変参考になります。

参考までに私が各ログに記載した内容です。

基本的に各レコードに関するログは、正常に取り込めたものについては基本的にDEBUGログに任せ、エラーや警告のみが際立つようにする、という方針にしました。

INFOログとして出力した内容

  • バッチ処理開始
  • ファイル読込開始 ※複数ファイルを読み込む処理だったため
  • ファイル読込終了
    • 全読込件数/取込完了件数/エラー件数の内訳
  • バッチ処理終了

DEBUGログとして出力した内容

  • レコード読込開始
  • 具体的なデータの必須項目(個人を特定する可能性があるもの以外)
  • レコード読込終了
  • DB更新完了 ※100件ごとのDELETE-INSERT時

エラーや警告に関する情報にすぐにたどり着けるようにする

前述したとおり、ログファイルの利用用途は想定外が発生した際の調査です。
特にエラーが何行目で発生したのかが一目でわかるようにするため、検索しやすいようなプレフィックス、行数、エラー内容などを簡潔・明解に記載するよう心がけました。

[ImportService-ERROR] [X行目] エラーが発生したため、このレコードの処理をスキップします。エラー内容:・・・
[ImportService-WARN] [X行目] ・・・

上記のようにしておけば、[ImportService-ERROR] [ImportService-WARN]などで簡単に抽出できます。

また、エラー発生時のExceptionをログに出力するかについても悩みましたが、今回は以下の方針で実装しました。

  • レコード読み込み時の不整合データをはねるために明示的に出したException:エラーメッセージで内容がわかる上、件数が多くなる可能性もあるのでログには出力しない
  • DBアクセス時エラー、CSV読み込み時エラー、その他予期せぬエラー:Exceptionの中身を見て判断する必要がある可能性が高いので、Exceptionをログに出力する

個人が特定できる情報(が入り得る項目)は出力しない

場合によってはセキュリティ事故に繋がりかねないため、データをログに出力する際は細心の注意を払います。
氏名やユーザーIDカラムなどは言わずもがな、何が入るかわからない汎用的な値項目なども避けた方が無難です。

おわりに

うん、普通のことを偉そうに書いているなという感じですね。
もっと経験を積んで基本編、応用編と書けるようになりたいです。

参考URL

  1. DELETE-INSERTとUPDATEどちらを採用するべきかについての議論は今回は割愛します

32
20
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
20