バッチ処理の遅さは、気づいたときには手遅れになりやすいという特徴があります。誰も待っていないので苦情が出ず、件数が増えて時間枠を超えた瞬間に問題になるからです。
この回は、遅いバッチを速くする手順を扱います。結論から言うと、並列化は最後です。理由から説明します。
1. 並列化の効果には上限がある
が示すのは単純な事実です。並列化できない部分の割合が、高速化の上限を決めます。
| 直列部分の割合 | 無限に並列化したときの上限 |
|---|---|
| 50% | 2倍 |
| 20% | 5倍 |
| 10% | 10倍 |
| 5% | 20倍 |
| 1% | 100倍 |
1割が直列なら、何台並べても10倍以上にはなりません。しかも並列化には固有のコスト(分配、集約、調整)がかかるため、実際にはこの上限にも届きません。
def speedup(serial_ratio: float, workers: int) -> float: """直列部分の割合と並列度から、理論上の高速化倍率を出す。""" parallel = 1 - serial_ratio return 1 / (serial_ratio + parallel / workers)
for ratio in (0.5, 0.2, 0.1, 0.05): row = " ".join( f"{w:3d}並列={speedup(ratio, w):5.2f}倍" for w in (2, 4, 8, 16, 1000) ) print(f"直列 {ratio:4.0%}: {row}")この表を見ると、直列部分を減らすほうが、並列度を上げるより効く場面が多いと分かります。直列を20%から10%に減らせば上限は5倍から10倍になりますが、並列度を8から16に上げても得られるものはわずかです。
2. まず、やらなくて済む処理を消す
最も効くのは、処理そのものを減らすことです。バッチは長く動いているうちに、必要のない処理を抱え込んでいることがよくあります。
- 全件を対象にしているが、実際は差分で足りる — 前回以降に変わったものだけ処理する
- 誰も見ていない出力を作っている — 出力先が使われているか確認する
- 同じデータを何度も読み直している — 1回読んで使い回す
- 毎日やる必要がない — 週次で足りる処理が日次で動いていることがある
1番目の効果が圧倒的です。全件処理を差分処理に変えると、件数が増えても処理時間が伸びなくなります。ただし差分処理には「取りこぼしたときに気づけない」という弱点があるため、定期的に全件で突き合わせる仕組みを併せて用意してください。差分の判定に使う更新日時が、何らかの経路で更新されないまま変更されると、その行は永久に処理されません。速さと引き換えに正しさの保証が弱くなるという交換だと理解しておく必要があります。
長く運用しているバッチには、もう誰も見ていない出力が混ざっていることがあります。作るのをやめれば処理時間はそのぶん減ります。消す前に、本当に使われていないかをアクセス記録で確認してください。
3. まとめて処理する
1件ずつ処理すると、1件あたりの固定コストが件数分かかります。データベースへの往復がその代表です。まとめて処理すると、この固定コストを分散できます。
def chunked(items, size): """一定件数ずつに区切る。全件を一度にメモリへ載せない。""" buf = [] for item in items: buf.append(item) if len(buf) >= size: yield buf buf = [] if buf: yield buf
def process_all(source, size=1000): done = 0 for chunk in chunked(source, size): rows = [transform(x) for x in chunk] bulk_insert(rows) # まとめて1回の書き込み commit() # この単位でやり直せる done += len(chunk) print(f" {done} 件完了")区切る単位が設計の要点です。大きくすればまとめる効果は上がりますが、メモリを使い、失敗時にやり直す範囲も大きくなります。小さくすれば安全ですが固定コストが残ります。メモリに収まり、やり直しても許容できる範囲で決めます。
「まとめる」を極端にすると、全件をメモリに載せることになります。件数が増えた日に停止します。上のように区切って流す形にしておけば、件数が増えても使用メモリは一定です。
4. やり直せるようにする
速さの話から少し外れますが、バッチの性能改善はやり直しやすさとセットです。速くなっても、途中で落ちたときに最初からやり直すなら、実質的な所要時間は変わりません。
- 1どこまで終わったかを記録する — 途中から再開できる
- 2同じものを2回処理しても壊れないようにする — 再実行の安全性
- 3区切りごとに確定させる — 全部終わってから確定すると、失敗で全部やり直しになる
- 4失敗した対象を別に記録する — 全体を止めず、後から個別に処理できる
2番目は特に重要です。同じ処理を2回実行しても結果が変わらない設計にしておくと、再実行の判断が一気に軽くなります。逆にこれがないと、落ちるたびに「どこまで進んだか」を人が調べることになり、復旧に時間がかかります。夜間に落ちた場合、この調査が朝の時間を丸ごと消費することになります。
5. それから並列化する
ここまでやって、なお時間が足りなければ並列化します。並列化で問題になるのは、ほぼ共有している資源です。
| 共有しているもの | 起きること | 対処 |
|---|---|---|
| 同じテーブルへの書き込み | 対象を分割し、範囲を重ねない | |
| データベースへの接続 | の枯渇 | 並列度を接続数の上限に合わせる |
| 外部API | 先方の制限に当たる | 流量を制御する |
| ファイルや一時領域 | 上書き・競合 | 処理ごとに領域を分ける |
2行目は見落とされがちです。並列度を上げても、接続数の上限で待ちが発生するなら意味がありません。並列度は、最も細い部分に合わせて決める必要があります。増やしすぎると、待ち行列が伸びるだけで全体は遅くなります。
また、下流が処理しきれない量を送り込むと、待ちが積み上がってメモリを食い潰します。 ── 受け手が処理しきれないときに送り手へ「待て」を伝える仕組み ── を入れておくと、高負荷時に停止せずに済みます。
6. 手順の整理
着手順にまとめます。上から順にやるのが最も効率的で、順序を入れ替えると労力の割に成果が出ません。
- 1件数の伸び方を確認する — 比例を超えているなら構造の問題(EP.2)
- 2やらなくて済む処理を消す — 差分化が最も効く
- 3まとめて処理する — 区切りはメモリとやり直しの範囲で決める
- 4やり直せるようにする — 2回実行しても壊れない設計に
- 5最後に並列化する — 並列度は最も細い部分に合わせる
バッチが実際に破綻した事例は データ基盤トラブル事件簿 EP.04「バッチの連鎖障害」 にまとめてあります。1本の遅延が後続すべてを巻き込む構造は、時間枠が詰まってきたときに現実化します。
並列化の効果は直列部分に頭打ちにされる(1割直列なら上限10倍)。まずやらなくて済む処理を消す、次にまとめる、そしてやり直せるようにする。並列化は最後で、並列度は最も細い部分に合わせる。次回はフロント側の体感速度を扱います。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。