この記事はSmartHR社Advent Calendar 2025の4日目の記事です。
SmartHR社でエンジニアをしているdelhi09です。
本記事では、業務での実経験をベースに、数十万件以上の大規模データ移行バッチ処理について、設計から運用までの流れで重要だったポイントを紹介します。
前提として使用技術は以下の通りですが、バッチ処理全般で応用できる汎用的な内容になるよう意識して書いています。
技術スタック
- WEBアプリケーションフレームワーク: Ruby on Rails
- バッチ処理: rails runner
- インフラ: GCP
- DB: PostgreSQL
- バッチジョブ管理: Rundeck
やりたいこと
以下のようにテーブルAからテーブルBへの移行を行います。サービス稼働中に移行を行うため、サービスへの影響を出さないことを前提とします。
以下の段階に分けてやることをそれぞれ説明します。
- バッチ処理実行前
- バッチ処理の実装
- 実行
- 実行後
1.バッチ処理実行前
対象データ件数の確認
まずはSQLやrails consoleで移行対象データの件数を確認します。対象件数によってバッチ処理の設計が変わります。
例えば移行対象データが数百件程度であれば、後述の「分割処理」や「ドライランモード」などを用意するのは「やりすぎ」になる可能性があります。
同期処理の先行リリース
移行バッチを本番で実行する前に、同期処理(テーブルAにデータが新規作成されたタイミングで、テーブルBにも対応するデータを作成する処理)を先に実装・リリースしておきます。
これを先に実施しておかないと、以下のような「モグラ叩き状態」に陥り、データ移行が永久に完了しません。
※ 🦔🔨モグラ叩き状態
- データ移行バッチ処理を実行
- 実行直後に新しい移行対象データが発生
- 再度バッチ処理を実行
- ...(1.〜3.のループ)
同期処理の実装方法
同期処理の実装方法には以下の2つがあります。
- テーブルAにデータを作成している処理に直接実装する
- ActiveRecordのコールバック関数を使う
1.テーブルAにデータを作成している処理に直接実装するの方が明示的で可読性も高い一方、対応すべき箇所が漏れてしまうと同期漏れのリスクがあるためケースバイケースで使い分けます。
今回は事前の調査で同期元のテーブルにデータを作成しているロジックをすべて把握できていたため、1.を採用しました。
副次的なメリットとして、同期処理が発生する箇所の調査結果を結合テストケースの設計に活かすことができました。
2.バッチ処理の実装
分割処理を前提にする
移行バッチは、以下の2つのオプションにより処理件数を柔軟に制御できる「分割処理可能な設計」を前提とします。
- 総処理件数: 1 回のバッチ処理実行で処理する件数の上限
- バッチ件数: 1 回のループで処理する件数
1. はバッチ実行そのものを複数回に分割可能にするためのものです。(例: 150,000件を10,000件 × 15回に分割処理する)
2. は単一バッチ内でのメモリ消費やSQL負荷を抑えるためのものです。(例: 10,000件を1,000件 × 10回に分割処理する)
分割処理の主なメリットは以下です。
試験的な少量実行ができる
本番実行時に「まず1000件を100件ずつ」など小さい単位で性能を確かめながら進められます。段階的に実行できるため、リスクを抑えて進めることができます。
処理遅延・タイムアウトの回避
実行環境(今回は Rundeck)のタイムアウトにかからないように実行できます。
また、バッチ実行が想定よりも長引くことで会社が定める業務時間を超えてしまいそうになり、強制終了せざるを得ない状況を避けることができます。
コマンドライン引数で件数を指定可能にする
総処理件数とバッチ件数はコマンドライン引数で指定できるようにします。
コマンドライン引数の例:
-
--total-count: 移行する総件数 -
--batch-count: 一度に処理する件数
コマンドライン引数にする主なメリットは以下です。
オプションの変更が容易
ハードコーディングや設定ファイルではなくコマンドライン引数で指定することで、バッチ件数や総件数をデプロイなしで簡単に変更できます。
弊社ではRundeckを使っているため、画面上から引数の値を変えて再実行ボタンを押すだけで、お手軽にオプションを変えて実行できました。
テスト容易性が高い
上記に関連して、バッチのテスト容易性を高めることができます。
例えば以下のように分割実行の挙動をテストできます。
- 移行対象条件を満たすデータを5件作成する
-
--total-count=5 --batch-count=2でバッチを実行する - 期待結果: 3回ループして5件処理されること
大量のテストデータ作成は不要ですし、一時的にコードや設定ファイルを書き換える必要もありません。
余談ですが、コマンドライン引数化によるテスト容易性の向上は、処理対象の範囲を日時で指定するようなバッチにも当てはまります。
1回の処理件数を安直に決めない
1回の処理件数(=--batch-count)を1000件や5000件などキリの良い数字で適当に決めたくなりますが、これはおすすめできません。
一般的に、1回のバルクINSERTにまとめる件数が多いほど、バッチの処理速度が向上してデータ移行が短時間で完了します。従ってDBプロダクトの制約の範囲内で上限近くを攻めた方がよいです。
今回はPostgreSQLのquery parametersの最大が65,535であることと移行対象のカラム数を考慮して、一回のバルクINSERTの件数を約10000件としました。
指定する--batch-countでバルクINSERTがエラーにならないことを事前に確認することも必要です。
※ 補足
データ移行は一回きりの処理だから性能はそこまで重要ではないという意見もあると思います。しかし、本番で想定よりも実行に時間がかかったり、移行ロジックのバグで全件やり直す必要が出てくることは十分考えられます。そうしたケースでバッチの処理速度がボトルネックになることがありうるので、性能にはある程度こだわった方がいいです。
ドライランモードを提供する
処理対象件数とバッチ処理の実行計画のみを出力して終了する「ドライランモード」を提供しておくのがおすすめです。以下のようなメリットがあります。
- コマンドライン引数を意図した通りに渡せていることを確認できる
- 事前に処理対象件数とバッチ処理の実行計画を把握できる
- 本番環境での実行前に一度ドライランすることで心理的な安心感が得られる
ドライランモードのイメージ
$ rails runner script.rb
...
[2025-12-04 18:00:01] 処理対象の総件数: 103112件
[2025-12-04 18:00:01] ループ回数の見積もり: 11回 (10000件ずつ実行)
[2025-12-04 18:00:01] ドライランモードのため処理を終了します
ドライランモードを実装する場合、--dry-runのようなオプションを提供するよりも、バッチ実行のデフォルトをドライランモードにするのがおすすめです。具体的には以下のようなイメージです。
コマンドライン引数の例:
-
script.rb: ドライラン -
script.rb --apply: 本実行
デフォルトをドライランモードにしておくことで、コマンドライン引数のつけ忘れやタイポで意図せず本実行してしまうリスクを防ぐことができます。
「セキュア・バイ・デフォルト」という言葉もあるそうです。
対象のユーザーや組織を指定可能にする
オプションで対象のユーザーや組織を限定できると尚良いです。本番の動作確認用や身内のユーザー、組織を指定して実行することで、より段階的かつ安全な本番リリースが可能になります。
コマンドライン引数の例:
--organization-id=100--user-ids=1,2,3
リラン可能性の担保
バッチ処理は全件移行完了するまで繰り返し実行します。従って、オプションを意識せずリラン可能な設計になっていることは重要です。
今回は以下のように処理済みデータをNOT EXISTS句を使って除外するようなSQLで処理対象データを抽出しました。SELECT結果が0件になったら移行完了です。
SELECT *
FROM table_a
WHERE NOT EXISTS
(SELECT 1
FROM table_b
WHERE table_b.column_a = table_a.column_a)
LIMIT 10000;
これによって、リラン時にデフォルトで前回の続きからデータ移行を再開する挙動を可能にしています。
ロギング
以下のようなログを出力しました。
- 実行前ログ
- 進捗を把握できるログ
- 実行結果のログ
ログのイメージ
[2025-12-04 18:00:00] 処理を開始します
[2025-12-04 18:00:01] 処理対象の総件数: 103112件
[2025-12-04 18:00:01] ループ回数の見積もり: 11回 (10000件ずつ実行)
[2025-12-04 18:00:32] 1/11回目の処理終了
[2025-12-04 18:01:02] 2/11回目の処理終了
...
[2025-12-04 18:05:57] 11/11回目の処理終了
[2025-12-04 18:05:58] 処理を終了します (合計: 103112件)
1.実行前ログ
[2025-12-04 18:00:01] 処理対象の総件数: 103112件
[2025-12-04 18:00:01] ループ回数の見積もり: 11回 (10000件ずつ実行)
実行前に処理対象件数と実行計画をログ出力します。このログはドライランモードでも出力します。処理対象と実行計画が意図した通りになっているかを確認する目的です。
2.進捗を把握できるログ
[2025-12-04 18:00:32] 1/11回目の処理終了
「1/11回目の処理終了」のようなログを出すことで、現在の進捗状況がわかります。加えて、1回のループにかかる時間がわかるので、完了時間を見積もることができます。
3.実行結果のログ
[2025-12-04 18:05:58] 処理を終了します (合計: 103112件)
バッチの実行完了時に、正常終了したことと処理した件数をログに出力します。これを見ただけでバッチの実行結果のサマリーが分かるようになっていると便利です。実際の業務ではこの部分をSlackにコピペしてチームに実行結果を共有していました。
エラーハンドリング
エラーハンドリングは大きく以下の2パターンが考えられます。
- エラーが発生したら即バッチを異常終了させる
- 失敗はログ出力しつつ処理は継続する
1.は異常を早期に検知しやすく、2.は移行できないデータがブロッカーにならないので、移行完了までの時間を短縮しやすいというメリットがあります。
今回はリランしやすい設計にしていたことも踏まえ、「異常を早期に検知しやすい」メリットを重視して1.を採用しました。
並列実行可能にするかを決める
並列実行可能な仕様にするかを決めておく必要があります。
本記事での「並列実行」はバッチ実行用のGCPインスタンスを2台以上起動して、それぞれのインスタンスで独立したプロセスのデータ移行用バッチ処理を起動することを指します。
並列実行可能な仕様にしておくと移行時間を短縮できる可能性があります。他方で考慮事項も多くなるため、各工程の工数が増えます。
今回は並列実行には対応しない方針としました。
対応しないと決めたなら、本番で実行速度が遅くても横着して並列実行してはいけません。並列実行時の安全性がテストで担保できていないためです。
3.実行
バッチ処理を実行する時間を決める
DBに負荷をかける可能性があるので、サービスがよく使われている時間帯でのバッチ実行は避けるのが無難です。モニタリングツールを導入している場合には、メトリクスからサービスがよく使われている時間帯をある程度把握することができます。
弊社ではB2B SaaSという性質上、一般的な企業様の営業時間外に実施しました。
ドライランする
まずはドライランを実行して、処理対象データの件数とバッチの実行計画が意図どおりであることを確認します。
少ない件数で実行する
最初は100件〜1000件など少量で実行し、以下のような観点を確認します。
- 処理が正常に成功するか
- 1ループあたりの処理速度
分割実行する
少量での実行が速かったからといって、残りを全件一気に移行しようとするのは危険です。--total-countと--batch-countの値を大きくしたり、移行先テーブルのデータが増えたりすることによるバッチ処理の性能劣化の可能性があります。
例えば移行対象件数が10万件なら1万件 x 10回など、様子を見ながら段階的に実行しましょう。
モニタリング
本番でのバッチ実行中は以下を確認して、異常を早期検知できるようにしておきます。
- DBのメトリクス
- アプリケーションのアラートの発生有無
4.実行後
データ移行完了後も考慮漏れがないかしばらく経過観察する必要があります。
移行前と移行後のテーブルの主キーを照合して差分を検出するSQLを事前に用意しておき、安定するまでは定期的に実行して想定外のデータが発生していないかをチェックしましょう。
最後に
最後まで読んでいただきありがとうございました!
今回実装したバッチ処理はコード量自体少なかったのですが、改めて振り返ると色んなポイントがあったなと感じました。
本記事は私が代表して執筆しましたが、チームのメンバーの皆さんに頂いたアイディアもたくさん含まれています。この場を借りて感謝申し上げたいと思います!