BEMAロゴ

エンジニアの
成長を支援する
技術メディア

バッチ処理の設計・実装で失敗しないための鉄則8選

はじめに

こんにちは、池本です。
この記事は、これからバッチ処理の設計や保守に関わるバックエンドエンジニアやWebアプリ開発者向けに、実務で役立つ設計上の注意点や工夫を解説しています。

バッチ処理って何?

バッチ処理はざっくり言うと、リアルタイムで1件ずつ処理するのではなく、ある程度の量のデータをまとめて処理する方式のことです。その実行のきっかけ(トリガー)は、主に次の2つのパターンがあります。

1つ目はスケジュール実行(定時実行)です。最も一般的な形式で、指定されたスケジュールに基づいて定期的に処理を実行します。次のように様々な形式のスケジュール実行が存在します。

  • 毎分、5分ごと
  • 毎時0分、3時間ごと
  • 毎日8時0分
  • 毎週火曜日7時0分
  • 毎月1日、毎月第1月曜日
  • 毎年10月1日

2つ目はイベント駆動実行(非同期処理)です。ユーザーの操作やシステム上の特定の出来事(イベント)をきっかけに、処理を開始します。このとき、応答時間を悪化させないよう、重い処理はバックグラウンドで非同期に実行されます。

  • 例: ユーザーによるファイルのアップロード、レポート作成ボタンのクリックなど
  • 用途: アップロードされた動画のエンコード、大量のメール送信、リクエストに応じた大規模データの集計など

スケジュール実行が「時間」を起点とするのに対し、イベント駆動実行は「出来事」を起点とします。後者は必ずしも「まとめて」処理するとは限りませんが、ユーザーの操作をきっかけに大規模なデータ処理(=バッチ処理)を開始する場合があるため、広い意味でバッチ処理の文脈で語られます。

失敗したときを考える

バッチ処理はちゃんと設計しないとあとでかなり痛い目を見ます。
例えば、以下のようなケースが挙げられます。

  • 10,000件処理したときに、9,999件は成功したが最後が失敗したので最初からやり直し
  • 失敗したときにデータがおかしくなったのでデータを手作業で修正する
  • 外部APIを叩いてデータ取得したあとの後続処理でバグがあって全部やり直し
  • リトライが次の日になったときに遡って処理するのが面倒
  • 件数が多すぎて予定時間内に処理が終わらない、しかも後続処理がその影響でエラー

データを手作業で修正することになれば、現場の負担も非常に大きくなります。

リトライを考慮する

まず考えないといけないのは、リトライが可能かどうかです。このために必要なのは「冪等性」という考え方です。「べきとうせい」と読みます。詳しくは以下の記事にありますが、「同じ操作を何度繰り返しても、同じ結果が得られる性質」です。

具体的にはこの2つが必要です。

  • 正常に完了したレコードは再度処理しない
  • 完了しなかったレコードは再度処理する

ただし、後者の「完了しなかったレコード」は注意が必要です。この先で詳しく説明しますが、きちんと状態管理を行わないと、一部の処理だけ完了しているなどの中途半端な状態になり、リトライするためにデータを手作業で修正するといった運用上の負担が生じます。

1件ごとにトランザクション制御を行う

「10,000件処理したときに、9,999件は成功したが最後が失敗したので最初からやり直し」という事態を防ぐためには、処理の単位を小さくし、1件の失敗が全体に影響しないようにすることが重要です。

基本的な考え方は次のとおりです。ここで「処理対象かどうかチェックする」を入れているのは、最初に処理対象レコードを取得したときは処理対象だったものが、別の処理でレコードが更新されることにより、途中で有効でなくなる可能性があるからです。

  1. 処理対象レコードを取得しループさせる
  2. 1件ごとに処理する(同一トランザクション)
    1. 処理対象かどうかチェックする
    2. 処理を行う
    3. 成功したらコミット、失敗したらロールバックする
    4. 失敗したらそのレコードの処理はスキップする

この基本的な考え方では、同一トランザクションで対応できるシステム内部処理におけるエラーには十分対応できます。しかしシステムの外側処理のエラー対応としては不十分です(後述します)。

処理件数が少ない場合はループ処理でも十分ですが、より堅牢でスケーラブルな設計として、メッセージキュー(Amazon SQSなど)を活用する方法があります。処理を依頼する側(Producer)と実行する側(Consumer / Worker)を分離するアーキテクチャです。

  1. Producer: 処理対象となるレコードのIDなどを、1件ずつメッセージとしてキューに送信します。
  2. Consumer / Worker: キューからメッセージを1件ずつ受信し、対応する処理(トランザクション制御を含む)を実行します。

この方法には、耐障害性の向上(Workerがクラッシュしてもメッセージはキューに残り、処理を安全に再開できる)や、高度なリトライ制御(キューの機能で自動リトライや失敗タスクの隔離ができる)といった大きなメリットがあります。

ステータスを細かく分ける

先ほど書いた通り、単に1件ずつ同一トランザクションで処理するだけでは、システムの外側処理のエラー対応としては不十分です。具体的には次のような場合に問題が生じます。

  1. 外部APIを実行
  2. 後続処理でエラーが起きて、ロールバックされる
  3. リトライすると再度外部APIが実行されてしまう

この問題を防ぐ方法は一様に決められず、その外部APIの性質によって個別に考える必要があります。例えば「外部APIを呼ぶ」がデータ取得など副作用がない場合、再実行されても問題がないこともあります。しかし外部API呼び出しによって何かが変わる場合(副作用がある場合)は何らかの考慮が必要です。

これは外部APIの仕様にもよりますが、次の3パターンの対応方法が考えられます。

  • 外部APIを実行する前に、処理済みかどうかを確認するAPIを実行する
  • 外部APIの実行前後でステータスを分離する
  • 外部API自体が冪等性に対応しており、単に再実行して問題ない

これらの対応ができれば、「外部API実行でエラーが起きた結果データがおかしくなったので手作業で修正する」とか「外部API実行後の後続処理でバグがあって、外部API実行処理から全部やり直し」といった問題はほぼ解消できます。

外部APIを読んだ直後にシステムがダウンしてしまうなどの致命的な障害が発生した場合は手作業で修正する必要がありますが、その場合もごく少数のみ手作業が必要となり、運用の負荷は減ります。

日付に依存しない処理を作成する

バッチ処理はなるべく日付に依存しないように作る必要があります。例えば毎月28日に処理するバッチがありエラーが出たとします。そのプログラムが2/28にエラーが出て、プログラム修正が3/1になったとします。そのときに修正したプログラムを2/28に実行したものとして扱えるかどうかが重要です。バッチ処理に使う日付が「現在」を基準にしていると、2月のデータを処理するつもりが、3月のデータを処理してしまうといった不具合が発生します。

この時にまず必要なのは、「現在」に依存する処理を分離することです。そのために一番簡単なアプローチとしては、処理を次の2つの関数に分けることです。

  1. 日付や時刻をパラメータに持つ関数
  2. 今日の日付や現在の時刻を取得し、1.を呼び出す関数

これはバッチ処理としての正しさとは別に、テスト容易性とも大きな関係があります。次の記事の「アプローチ1: シンプルに対象メソッドの引数に渡す」が該当します。

この記事にあるgreet関数をPythonで定義すると次のようになります。

from datetime import datetime
def greet(current_time):
    if 5 <= current_time.hour < 12:
        print("おはようございます")
    elif 12 <= current_time.hour < 18:
        print("こんにちは")
    else:
        print("こんばんは")

def greet_now():
    greet(datetime.now())

greet(datetime(2022, 10, 11, 4, 59, 59))
greet(datetime(2022, 10, 11, 5, 0, 0))
greet(datetime(2022, 10, 11, 11, 59, 59))
greet(datetime(2022, 10, 11, 12, 0, 0))
greet(datetime(2022, 10, 11, 17, 59, 59))
greet(datetime(2022, 10, 11, 18, 0, 0))
greet_now()  

なお、バッチ処理のプログラムだけでなく、データベース(テーブル)設計にも注意が必要です。例えばレコードの更新時刻(updated_at)を基準に処理対象かどうかを判定すると、リトライ時に処理対象に入らなくなる問題が生じます。

また、マスターテーブルにも日付の考慮が必要です。例えば消費税は2019/10/01から8%→10%になりました(軽減税率がない場合)。このときプログラムで10%に固定されていると、過去に遡って計算をするのが困難になります。

例えば次のようなテーブルを作っておいて、任意の日に対応する消費税率を取得できるようにしておくといいでしょう(軽減税率の考慮は省略しています)。

開始日

終了日

税率(%)

1000/01/01

1989/03/31

0

1989/04/01

1997/03/31

3

1997/04/01

2014/03/31

5

2014/04/01

2019/09/30

8

2019/10/01

9999/12/31

10

突き抜け対応

「予定時間内に処理が終わらない」というのを「突き抜け」と言います。
例えば2時間ごとに動くバッチ処理が3時間かかったら、同時に2つのバッチ処理が動く可能性があります。

これを防ぐためには、処理が同時実行されないようにする仕組みが必要です。自前で実装するのは難しいため、専用のライブラリやフレームワークを利用するのがオススメです。PythonではCeleryOpen in new tabが有名です(ちなみにセロリと呼びます。野菜のセロリです)。
Laravelには標準でこのような仕組み(WithoutOverlapping)が標準でついています。

バッチAPI、ファイルAPIを利用する

外部のAPIを使う場合、そもそもAPI側にバッチ処理が用意されている可能性があります。1件のリクエストでまとめて送る他に、ファイルをSFTPなどでアップロードするものもあります。

バッチ処理を動かす環境

定時処理の定番はcronですが、その使い方には注意が必要です。というのも、バッチ処理は実行中だけ多くのリソースを必要とし、それ以外の時間はほとんどリソースを使いません。この繁閑の差が激しい処理のために、cronを動かすサーバー自体を常時稼働させておくのは、リソースとコストの無駄遣いになりがちです。

その対策として、Herokuのエンジニアが提唱したThe Twelve-Factor AppOpen in new tabにある管理タスクを1回限りのプロセスとして実行するOpen in new tabというやり方を推奨します。

Amazon ECSを使う場合、バッチ用のタスクを定義し、スケジュールされたタスク機能Open in new tabを使うといいでしょう。また、Webhookとして定義して、外部からアクセスさせる方法もあります。この場合はWebhookのURLが外部から推測されない仕組みが必要です。

おわりに

バッチ処理は運用フェーズでのつまずきが多く、設計時の工夫がそのまま品質や保守性に直結します。
本記事が、日々の業務におけるバッチ設計のヒントになれば幸いです。

この記事が役に立ったと思ったら、
ぜひ「いいね」とシェアをお願いします!

リンクをコピーXでシェアするfacebookでシェアする

この記事を書いた人

Hideki Ikemoto
Hideki Ikemoto
2002年以来バックエンドを中心としたキャリアを積み、2019年に株式会社メンバーズエッジ(当時)に中途入社。入社後はリードエンジニアとしてPython/Djangoを使った案件に携わり、現在は様々なチームへの技術支援を行っている。Scrum Alliance認定スクラムデベロッパー(CSD®)。
詳しく見る
ページトップへ戻る