はじめに
「バッチが終わらない」
これほどエンジニアを憂鬱にさせる一文もそうないと思います。今回は、深夜にキックして朝には終わっているはずが、気づけば業務時間まで食い込んでいたPython製バッチ処理を、Goへ全面移行して処理時間を6時間→38分(約89%短縮)、月間クラウドコストを約64%削減した話を書きます。
「なぜPythonが遅かったのか」「なぜGoにしたら速くなったのか」だけでなく、移行の過程で踏んだ地雷や、数字だけでは見えてこない泥臭い部分もあわせて紹介します。
お題:日次で数百万件を処理するバッチ
対象は、日次で数百万件規模のレコードを処理する集計・変換バッチです。データベースから対象データを抽出し、複数のビジネスルールに基づいて変換・検証したうえで、別のデータストアへ書き込む、という一般的な構成でした。
問題は「昔は数十万件だったデータ量が、事業成長とともに数百万件に膨れ上がっていた」ことです。処理時間はデータ量にほぼ比例して伸び続け、ある日ついに実行時間が業務時間帯に突入するという事態に。ここでようやく「そろそろやばい」と全社的に認識される、という、多くの現場でありがちな流れでした。
なぜPythonは遅かったのか
Pythonが「遅い言語」というのは半分正しく半分誤解です。実際にボトルネックを分解していくと、以下のような要因が複合的に絡んでいました。
- GILによる並列処理の制約:CPUバウンドな変換処理を並列化しても、GIL(Global Interpreter Lock)の存在でマルチコアの恩恵をほぼ受けられていなかった
- ORMのN+1問題:ループの中で律儀に1件ずつクエリを発行していた箇所が複数あり、数百万件になると致命的な待ち時間になっていた
- メモリ非効率なデータ構造:全件をいったんメモリ上のリストに展開してから処理する設計になっており、GCの負荷も高かった
- シングルプロセス前提の設計:そもそもマルチプロセス化やワーカー分散を前提にしていない、素朴なfor文ベースの実装だった
つまり「Pythonが遅い」のではなく、「データ量が想定を超えた時点でスケールしない設計になっていた」というのが正確なところです。とはいえ、Pythonのままこれらを一つずつ直していくよりも、並行処理をネイティブに扱えるGoへの全面移行の方が、長期的なメンテナンス性も含めて筋が良いと判断しました。
なぜGoを選んだか
いくつか選択肢はありましたが、最終的にGoにした理由は以下の3点です。
- goroutineによる軽量な並行処理:数千〜数万の並行処理をスレッドより圧倒的に低コストで扱える
- 静的型付け+シンプルな言語仕様:チームメンバーが増えても学習コストが低く、レビューもしやすい
- 単一バイナリでのデプロイ:Pythonの依存関係地獄(venv、requirements.txtのバージョン衝突)から解放される
移行のポイント
1. worker poolパターンで並行数を制御する
goroutineは軽量とはいえ、無制限に立ち上げるとDBやAPIの接続数が枯渇します。以下のようなworker poolパターンで、並行数を明示的にコントロールしました。
func processBatch(items []Item, workerCount int) []Result {
itemCh := make(chan Item, len(items))
resultCh := make(chan Result, len(items))
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range itemCh {
resultCh <- process(item)
}
}()
}
for _, item := range items {
itemCh <- item
}
close(itemCh)
wg.Wait()
close(resultCh)
results := make([]Result, 0, len(items))
for r := range resultCh {
results = append(results, r)
}
return results
}
ポイントは、並行数(workerCount)をDBのコネクションプール上限やAPIのレート制限に合わせてチューニングすることです。ここを闇雲に増やすと、今度はDB側がボトルネックになって本末転倒になります。実際、最初は「goroutineは軽量だから」と並行数を上げすぎて、DB接続エラーを大量発生させるという失敗もしました。
2. N+1問題をbulk処理に置き換える
1件ずつクエリを発行していた箇所は、database/sqlでのbulk insert / bulk selectに置き換えました。
// Before: N+1が発生するパターン(イメージ)
for _, id := range ids {
row := db.QueryRow("SELECT * FROM records WHERE id = ?", id)
// ...
}
// After: IN句でまとめて取得
query, args, _ := sqlx.In("SELECT * FROM records WHERE id IN (?)", ids)
rows, err := db.Query(db.Rebind(query), args...)
たったこれだけの変更ですが、数百万件規模になるとラウンドトリップの回数が桁違いに減るため、全体の処理時間への寄与度は非常に大きいものでした。
3. ストリーミング処理でメモリを節約する
全件をメモリに展開する設計をやめ、DBからのSELECT結果をストリーミングで1件ずつ(あるいは一定バッチサイズごとに)処理する形に変更しました。これによりメモリ使用量が安定し、GC起因のスループット低下もなくなりました。
結果
| 指標 | Before(Python) | After(Go) | 改善率 |
|---|---|---|---|
| 処理時間 | 約6時間 | 約38分 | 約89%短縮 |
| 月間クラウドコスト | 基準値 | 約36% | 約64%削減 |
処理時間の短縮そのものはもちろんですが、個人的に一番効いたのは「バッチが業務時間に食い込む心配がなくなった」という運用上の安心感でした。数字に表れない部分ですが、深夜バッチの失敗を朝イチで発見して慌てる、という運用ストレスがなくなったのは大きな収穫です。
移行で学んだこと
- 「言語を変える」は最終手段であって最初の選択肢ではない。まずはボトルネックを計測し、N+1やアルゴリズムの問題を洗い出すべきです。今回もPythonのまま改善できた部分は一定ありました。それでも、将来のデータ量増加を見据えると、並行処理をネイティブに扱える言語への移行が長期的に理にかなっていました。
- 並行数は「増やせば速くなる」わけではない。ボトルネックがDBやAPIに移動するだけなので、システム全体のキャパシティを見ながらチューニングする必要があります。
- 移行の効果測定は「処理時間」だけでなく「コスト」「運用ストレス」も含めて評価すべき。数字として見えにくい部分こそ、実は現場の体感としては一番効いていたりします。
おわりに
「遅いから言語を変える」という判断は簡単に口にできますが、実際にやってみると、ボトルネックの特定、並行処理の設計、既存データとの整合性確認など、地味な作業の積み重ねです。それでも、数百万件規模のデータを扱うプロジェクトでは、こうした地道な改善が事業のスケールを支える土台になると実感しています。
同じようにバッチ処理の高速化に取り組んでいる方の参考になれば幸いです。

