Rustで書き直したバッチ処理の性能改善──メモリ安全性の獲得とGCオーバーヘッド削減の実測値

現場で起きているバッチ処理の悩み

既存のバッチ処理システムを運用していると、こんな状況に直面することがあります。

毎日深夜に走るデータ処理が、月に数回は不可解なメモリリークで失敗する。ヒープダンプを取ってみると、どこで参照が残っているのか特定が難しい。GCの一時停止時間も増えており、処理時間が予測不可能になってきた。言語の置き換えは大きな決断だが、既存コードのリファクタリングだけでは限界が見える──。

こうした課題に直面したとき、Rustへの移行を検討する企業は増えています。ただし、「Rustなら速い」という漠然とした期待だけでは、実装の難度や学習コストを見誤ります。実際のところ、どの程度のメモリ改善が期待でき、どんな制約が生じるのか。定量的な見通しを持つことが、判断の質を大きく左右します。

検証の目的と前提条件

今回は、実際に既存のバッチ処理をRustで書き直し、メモリ使用量、処理時間、リソース効率を測定してみました。

検証の対象:

  • 日次で数百万件のデータを読み込み、加工、集約するバッチ処理
  • 元の実装:JVM言語(Javaに準じたメモリ管理)
  • 新しい実装:Rust

比較観点:

  • ピークメモリ使用量
  • 処理時間(実行時間、GC時間の有無)
  • CPU効率
  • メモリ確保・解放のオーバーヘッド

前提条件:

  • 両者とも同じデータセット、同じロジックで実装
  • JVM版は最新の安定版、Rustはリリース版でコンパイル
  • テスト環境は同一マシン(メモリ8GB、CPU4コア)
  • 複数回実行して平均値を取得

実装と測定の構成

処理フロー

両実装とも以下の流れです:

  1. CSV形式のデータファイルを行単位で読み込む
  2. 各行をパースして構造体に変換
  3. メモリ上で集約(グループ化、集計)
  4. 結果をファイルに書き出す

データサイズは約500MBで、行数は約200万行です。

Rust実装の骨子

use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader, Write};

#[derive(Debug, Clone)]
struct Record {
    id: String,
    category: String,
    value: i64,
}

fn main() -> std::io::Result<()> {
    let file = File::open("input.csv")?;
    let reader = BufReader::new(file);
    
    let mut aggregated: HashMap<String, i64> = HashMap::new();
    
    for line in reader.lines() {
        let line = line?;
        let parts: Vec<&str> = line.split(',').collect();
        
        if parts.len() >= 3 {
            let key = parts[1].to_string();
            let value: i64 = parts[2].parse().unwrap_or(0);
            
            *aggregated.entry(key).or_insert(0) += value;
        }
    }
    
    let mut output = File::create("output.csv")?;
    for (key, sum) in aggregated.iter() {
        writeln!(output, "{},{}", key, sum)?;
    }
    
    Ok(())
}

このコードは、Rustのメモリ管理の特徴を活かしています。文字列は所有権で管理され、スコープを抜けると自動的に解放されます。GCのような全体的な停止がありません。

測定結果と分析

メモリ使用量

実装 ピークメモリ 平均メモリ 変動
JVM版 約1.2GB 約950MB ±150MB
Rust版 約320MB 約280MB ±30MB

所見: Rust版は約3.75倍のメモリ削減を達成しました。JVM版の変動が大きいのは、GCが複数回発生し、その度にヒープが伸縮しているためです。Rust版は安定しており、メモリリークの兆候も見られません。

処理時間

実装 総実行時間 GC時間 平均値
JVM版 32秒 約4.2秒 3回の平均
Rust版 18秒 0秒 3回の平均

所見: Rust版は約1.8倍高速です。GC時間がないため、予測可能な実行時間が得られます。JVM版の処理時間は、GCのタイミングに左右されるため、実行ごとに±3秒の変動がありました。

CPU効率

Rust版はシングルスレッドで18秒でしたが、複数スレッド化(rayon crate使用)すると12秒に短縮できました。JVM版でも同様にマルチスレッド化を試みましたが、スレッド間の同期オーバーヘッドが大きく、12.5秒止まりでした。

実装時に直面した課題と解決方法

文字列操作のコスト

Rustで文字列を扱う際、所有権と借用のルールが厳しいため、不用意な複製が生じやすいです。初期実装では毎行ごとに文字列を複製していたため、メモリ削減効果が半減していました。

改善策: &strを活用して参照のまま処理し、必要な場合だけto_string()で所有権を取得するようにしました。

エラーハンドリングの手厚さ

RustのResult型を使うと、エラーハンドリングが強制されます。JVM版では暗黙的に無視していた例外も、明示的に処理する必要があります。これは手間ですが、本番環境での予期しない停止を減らす効果があります。

学習曲線の急さ

所有権の概念に慣れるまで、コンパイルエラーとの格闘が続きます。特にバッチ処理のような単純なロジックを書く場合、Rustの厳密さが「過剰」に感じることもあります。ただし、複雑なデータ構造を扱う場合は、この厳密さが設計の誤りを事前に防ぎます。

実務投入時の確認ポイント

Rust版への置き換えを本格化させる前に、以下を確認することをお勧めします。

1. 依存ライブラリの成熟度

Rustのエコシステムは成長していますが、特定の用途(例えば、レガシーなデータベースドライバ)では選択肢が限られることがあります。事前に必要なライブラリが存在し、保守されているか確認してください。

2. 運用チームのスキル

Rustで書かれたバッチが本番で問題を起こした場合、デバッグできる人材がいるか。ログレベルの調整やメモリプロファイリングは、言語固有の知識が必要です。

3. ビルドとデプロイの自動化

Rustはコンパイル言語のため、ビルド時間が発生します。既存のCI/CDパイプラインに組み込む際、ビルドキャッシュやクロスコンパイルの設定が必要になる場合があります。

4. 部分的な導入の検討

全体を一度に書き直すのではなく、特に負荷が高いバッチから段階的に移行する方が、リスクが低いです。

向いている案件、そうでない案件

Rustへの置き換えが効果的な場合

  • メモリ使用量が制約になっている(本来はより小さなマシンで動かしたい)
  • 処理時間が予測不可能で、SLAを達成しにくい
  • バッチの失敗が連鎖的な影響を起こす
  • 処理規模が増える見込みがある

慎重に検討すべき場合

  • バッチのロジックが頻繁に変わる(言語の学習コストが割に合わない)
  • 既存チームにRustの知見がなく、採用の見込みもない
  • 処理規模が小さく、既存実装で十分に動いている
  • デバッグやトラブルシューティングの負担を増やしたくない

最後に

Rustへの移行は「速さ」や「安全性」という抽象的な利点だけでは判断できません。実装の難度、学習コスト、運用負荷、チームのスキルセット──これらの現実的な制約とのバランスを見ることが重要です。

今回の検証では、メモリ削減と処理時間の短縮が定量的に確認できました。ただし、これは「既存コードを丁寧に移植した場合」の結果です。実際の案件では、既存ロジックの複雑さ、データベース連携、エラーハンドリングの要件によって、得られる効果は変わります。

判断を急がず、小さな検証から始めることをお勧めします。