🔢

バッチ処理(レート計算):30分→5分への大幅高速化

に公開

概要

大学時代に情報系専攻ではなかったものの、情報系の科目を履修しており、計算機やアルゴリズムに関する基本的な知識はざっと知っていました。
ただ、実務でも大量データを扱う機会があまりなく、計算量について肌で実感しないまま、ここまでやってきました。

趣味で新スポーツ「キャップ野球」の試合記録システムを作っており、毎日、打席単位の勝敗をもとに選手のレート計算(イロレーティンググリコ2レーティング)を行っています。

https://cap-scorebook.com

そのバッチ処理は約30分かかっていましたが、Claude Codeの支援によるデータベース最適化とアルゴリズム改善を実施し、約5分まで短縮しました。

問題の背景

システム

  • 用途: 野球競技の選手レーティングシステム
  • 処理内容: ELO・Glicko2 レーティングの計算と順位付け
  • 実行頻度: 毎日定時実行(Cron ジョブ)
  • 処理対象: 全期間(2021年3月~)と過去1年分の打席データ
    • 全期間だと約13万件の打席データ

問題点

  • 実行時間: 30 分
  • 原因: 大量の個別 INSERT 文による DB 負荷
  • 影響: サーバーリソースの占有、他処理への影響

パフォーマンスボトルネックの分析

  1. 処理フローの問題

改善前:個別 INSERT 文を大量発行
INSERT処理は一瞬で終わるのでバルクインサートでなくても問題ないと思っていましたが、塵も積もれば山となる...。1件あたりの処理時間の差が0.01秒でも10万件もあれば1000秒の差が出てしまいます。

for (let i = 0; i < rankHistory.length; i++) {
  await new rateHistoryService().insertRankHistory(rankHistory[i]);
}
  1. 非効率なランキング計算

改善前:O(n²)の計算量
全選手のランキングを個別に計算するため、選手数をnとすると、各選手につきO(n)の検索が必要となり、全体でO(n²)の計算量になっていました。

// 各選手のランキングを個別に計算(非効率)
const batterRank =
  sortedByBattingRateArray.findIndex((row) => {
    return row.player_id === player_id;
  }) + 1;
  1. データベース操作の問題
  • 個別 INSERT 文:各打席ごと(最大 20 列 ×9 行 ×2 チーム × 試合数)
  • トランザクション未使用
  • バルクインサート非対応

改善策の実装

  1. バルクインサートの導入
// 順位履歴のバルクインサート
public async bulkInsertRankHistory(data: RankHistory[]): Promise<void> {
    if (data.length === 0) return;
    
    // MySQLのプレースホルダー数制限に対応
    const BATCH_SIZE = 9000; // 6カラム × 9000レコード = 54,000 < 65,535
    
    for (let i = 0; i < data.length; i += BATCH_SIZE) {
      const batch = data.slice(i, i + BATCH_SIZE);
      const values = batch.map(obj => [
        obj.game_date, obj.player_id, obj.mode,
        obj.batter_rank, obj.pitcher_rank, obj.catcher_rank
      ]);
      const placeholders = batch.map(() => "(?,?,?,?,?,?)").join(",");
      const sql = `INSERT INTO rank_history
        (game_date, player_id, mode, batter_rank, pitcher_rank, catcher_rank)
        VALUES ${placeholders}`;
      await db.execQuery(sql, values.flat());
    }
}
  1. ランキング計算の最適化

Map/Set を使った高速検索

private calculateRankingsOptimized(playerRates: PlayerRate[]): RankingMaps {
    // 1回のソートで全ての順位を計算
    const sortedByBatting = [...playerRates].sort(
        (a, b) => b.batting_rate - a.batting_rate
    );
    const sortedByPitching = [...playerRates].sort(
        (a, b) => b.pitching_rate - a.pitching_rate
    );
    const sortedByCatching = [...playerRates].sort(
        (a, b) => b.catching_rate - a.catching_rate
    );

    // Mapを使ってO(1)の高速な順位検索
    const battingRankMap = new Map<string, number>();
    const pitchingRankMap = new Map<string, number>();
    const catchingRankMap = new Map<string, number>();
    
    sortedByBatting.forEach((player, index) => {
        battingRankMap.set(player.player_id, index + 1);
    });
    sortedByPitching.forEach((player, index) => {
        pitchingRankMap.set(player.player_id, index + 1);
    });
    sortedByCatching.forEach((player, index) => {
        catchingRankMap.set(player.player_id, index + 1);
    });

    return { battingRankMap, pitchingRankMap, catchingRankMap };
}
  1. 非同期並列処理

レート更新の並列化

// 改善後:Promise.all で並列処理
const updatePromises = players.map(playerId => 
    Promise.all([
        playerService.updateRate(playerId, {
            batting_rate: rates.batting,
            pitching_rate: rates.pitching,
            catching_rate: rates.catching
        }),
        playerService.updateAllPeriodRate(playerId, {
            all_batting_rate: allRates.batting,
            all_pitching_rate: allRates.pitching,
            all_catching_rate: allRates.catching
        })
    ])
);
await Promise.all(updatePromises);
  1. パフォーマンス計測の追加

詳細なログとメトリクス

private logPerformance(step: string, startTime: number, extra?: any): void {
    const elapsed = Date.now() - startTime;
    const memory = process.memoryUsage();
    const memoryMB = Math.round(memory.heapUsed / 1024 / 1024);
    const rssMemoryMB = Math.round(memory.rss / 1024 / 1024);
    
    console.log(
        `[PERF] ${step}: ${elapsed}ms (${(elapsed/1000).toFixed(2)}s) | ` +
        `Heap: ${memoryMB}MB | RSS: ${rssMemoryMB}MB`
    );
    
    if (extra) {
        console.log(`[PERF] Additional info:`, extra);
    }
}

結果

パフォーマンス改善

項目 改善前 改善後 改善率
実行時間 30 分 約 5 分 83%短縮
DB 負荷 高(個別 INSERT) 低(バルク INSERT) 大幅軽減
メモリ使用量 不明 計測可能 可視化

技術的成果

  1. バルクインサート: 65,535 プレースホルダー制限に対応
  2. アルゴリズム最適化: O(n²) → O(n log n)
  3. 並列処理: Promise.all による非同期化
  4. パフォーマンス監視: 詳細なメトリクス計測

得られた知見

  1. データベース最適化
  • バルクインサートの威力: 個別 INSERT と比較して劇的な性能向上
  • MySQL 制限の理解:
    準備済みステートメントの制限(65,535 プレースホルダー)
  • バッチサイズ設計: レコード構造に応じた最適化
  1. アルゴリズム設計
  • 計算量の重要性: O(n²) → O(n log n)への改善効果
  • データ構造選択: Map/Set による高速検索
  • 重複処理の排除: findIndex/splice の非効率性
  1. 非同期処理活用
  • Promise.all: 独立した処理の並列実行
  • 適切な粒度: 過度な並列化によるリソース競合回避
  1. 運用改善
  • パフォーマンス計測: ボトルネック特定の重要性
  • 進捗可視化: 長時間処理の監視とユーザビリティ
  • エラーハンドリング: バルク処理での例外処理

まとめ

パフォーマンス計測やボトルネックの洗い出し、問題点への対処により、レート計算バッチ処理は 30 分から約 5
分へと大幅短縮を実現しました。特に効果的だったのは:

  1. バルクインサートの導入(最も効果的)
  2. ランキング計算アルゴリズムの最適化
  3. 非同期並列処理の活用

大量データ処理においては、データベース操作の最適化と計算量改善が劇的な性能向上をもたらすことを肌で実感できました。

Discussion