OpenWork Tech Blog

社員クチコミサービスを運営しているオープンワークエンジニアによるテックブログです。

【Symfony】データを小分けに取得してバッチのメモリ使用量を減らす

良い感じにリファクタできました

Webアプリエンジニアの加瀬です。

バッチのリファクタを行ったのですが、データ取得の方法を少し工夫してメモリ使用量を小さくすることができました。 色々な場面で活用できそうと思ったので書き留めておこうと思います。

環境

PHP: v8.1.13
Symfony: v5.4.11

経緯

ユーザーへのメッセージの自動再送を行うバッチがあり、送信対象の件数が急増した際に設定したメモリ上限値を超えてOOM(Out Of Memory)エラーが発生してしまうことがありました。

メモリ上限を引き上げる暫定対応を行ったのですが、今後送信件数が増加した場合に再発する可能性があったのでリファクタを実施しました。

リファクタ前のコードの問題点

リファクタ前のコードには以下の問題点がありました。

  • データ取得処理:
    • 対象データをインデックスを貼っていないカラムを用いて検索していたため、取得に時間がかかる
    • 対象データの取得と関連エンティティの紐付けをまとめて行っているため、対象データ数が増えるとメモリ使用量が増加する
  • データ送信処理:
    • 取得したデータをコレクション化し、foreachで1件ずつ処理していた
    • メモリ解放などの処理を入れていないため、処理データ数が増加すればメモリ使用量や実行時間がどんどん増加していく状態となっていた

送信件数が増えれば増えるほどメモリ使用量が比例的に増えるような状態でした。メモリ使用量を抑えるために、設計を見直す必要がありました。

対応方針

どのようにリファクタするか悩んだのですが、対応方針として以下の形をとりました。

  • データ取得処理(データ取得+関連エンティティ紐付け)を以下のように分ける:
    • 対象データのIDを取得する
    • ID指定で対象データを取得・関連エンティティを紐付ける
  • 対象データのIDリストを取得後、IDリストを1000件ごとに分けて以下処理を実施する:
    • ID指定でデータを取得する
    • 本処理(DB登録、メッセージ送信など)を実施する
    • メモリを解放する

図1. 対応方針のイメージ

リファクタで行ったこと

データ取得と関連エンティティの紐付けを分離する

データ取得時に関連エンティティの紐付けを行っていましたが、データ取得をする段階では不要(データを処理する際のみ必要)なものも含まれていました。

そのため、「対象データのIDを取得する処理」(データ取得時に不要なエンティティはjoinしないようにする)と「ID指定で対象データを取得・関連エンティティを紐付ける処理」に分離しました。

Before

対象データを取得・関連エンティティを紐付ける処理:
(※内容を一部変更しています。エンティティ名などは実際のものと異なります。)

<?php

/**
 * 指定した期間のデータを取得
 * @return ResendMessage[]
 */
public function getListByReservationTime(\DateTime $from, \DateTime $to): array
{
    // 実際には30近くのエンティティの紐付けを行っている
    $qb = $this->createQueryBuilder('rm')
        ->select(
            'rm',
            'tu',
            'wl',
            'c',
            'i',
            'jl',
            'rmr'
        )
        ->innerJoin('rm.targetUser', 'tu')
        ->leftJoin('tu.wishList', 'wl')
        ->leftJoin('rm.company', 'c')
        ->leftJoin('wl.item', 'i')
        ->leftJoin('c.jobListing', 'jl')
        ->leftJoin('rm.resendMessageResult', 'rmr')
        ->where('rm.deletedAt IS NULL')
        ->andWhere('rmr IS NULL')
        ->andWhere('rm.reservationTime BETWEEN :from AND :to')
        ->setParameters(
            [
                'from' => $from,
                'to'   => $to,
            ]
        )
    ;

    return $qb
        ->getQuery()
        ->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true)
        ->getResult()
    ;
}

After

対象データのIDを取得する:

<?php

/**
 * @return int[]
 */
public function getIdsByReservationTime(\DateTime $from, \DateTime $to): array
{
    // whereで使用しないエンティティはjoinしない
    // IDのみ取得する
    $qb = $this->createQueryBuilder('rm')
        ->select('rm.id')
        ->leftJoin('rm.resendMessageResult', 'rmr')
        ->where('rm.deletedAt IS NULL')
        ->andWhere('rmr IS NULL')
        ->andWhere('rm.reservationTime BETWEEN :from AND :to')
        ->setParameters(
            [
                'from' => $from,
                'to'   => $to,
            ]
        )
    ;

    return $qb->getQuery()->getResult();
}


ID指定で対象データを取得・関連エンティティを紐付ける:

<?php

/**
 * @param int[] $ids
 * @return ResendMessage[]
 */
public function getListByIds(array $ids): array
{
    // ID指定でデータを取得し、関連エンティティを紐付ける
    $qb = $this->createQueryBuilder('rm')
        ->select(
            'rm',
            'tu',
            'wl',
            'c',
            'i',
            'jl',
            'rmr'
        )
        ->innerJoin('rm.targetUser', 'tu')
        ->leftJoin('tu.wishList', 'wl')
        ->leftJoin('rm.company', 'c')
        ->leftJoin('wl.item', 'i')
        ->leftJoin('c.jobListing', 'jl')
        ->leftJoin('rm.resendMessageResult', 'rmr')
        ->where('rm.id IN (:ids)')
        ->setParameter('ids', $ids)
    ;

    return $qb
        ->getQuery()
        ->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true)
        ->getResult()
    ;
}

IDリストの取得だけであればメモリをあまり使用せず、またIDにインデックスを貼っているため、ID指定でデータを取得することで実行時間の短縮を狙うことができます。

データを小さな塊ごとに取得して処理する

データ取得部分の処理を分けた後、データ処理部分を変更しました。

送信対象のデータのIDリストを取得した後、IDリストを1000件ごとのチャンクに分けてforeachで回します。各チャンクごとに「ID指定でデータを取得」「本処理(DB登録、メッセージ送信など)」「メモリ解放」を実施するようにしました。

Before

データ送信処理:

<?php

$resendMessages = $this->resendMessageRepository->getListByReservationTime(
    $this->from,
    $this->to,
);
$resendMessageCollection = new ResendMessageCollection($resendMessages);

foreach ($resendMessageCollection as $resendMessage) {
    // DB登録、メッセージ送信などの処理
    ...

    // メモリ解放処理を行っていない
}

After

データ送信処理:

<?php

// 対象データのIDリストを取得
$resendMessageIds = $this->resendMessageRepository->getIdsByReservationTime(
    $this->from,
    $this->to,
);
// IDリストをチャンクごとに分ける(BATCH_SIZE = 1000)
$chunkedIds = array_chunk($resendMessageIds, self::BATCH_SIZE);

// チャンクごとに処理を実施
foreach ($chunkedIds as $ids) {
    // ID指定でデータを取得
    $resendMessages = $this->resendMessageRepository->getListByIds($ids);
    $resendMessageCollection = new ResendMessageCollection($resendMessages);

    // 処理内容は変更なし
    foreach ($resendMessageCollection as $resendMessage) {
        // DB登録、メッセージ送信などの処理
        ...
    }

    // メモリ解放処理を追加
    $entityManager->clear();
    gc_collect_cycles();
}

効果

リファクタにより、実行時間を短縮+メモリ使用量を減少させることができました。リファクタ前後の各平均値を記載します。

表1:平均値(同じ曜日・同じ時間帯のデータ)


メモリ使用量[MB] 実行時間[s] 送信件数[件]
修正前 540.36 1395.13 1487.13
修正後 422.67 783.88 1519.75

図2. メモリ使用量の変化(※冒頭の画像と同じもの)

図3. 実行時間の変化

メモリ使用量が減少し、送信件数が急増した際のOOMエラーの発生リスクを減らすことができました。実行時間が結構短くなっているのも嬉しかったです(指数関数的に増加しなくなったのは大きい)。

最後に

データ取得・処理方法を少し工夫しただけで実行時間及びメモリ使用量を減らすことができました。メモリ使用量を減らしたい場合等、少しでも実装の参考になれば幸いです。

弊社では一緒に開発していただけるエンジニアを募集しています!ご興味のある方、ぜひご検討いただけると嬉しいです。

www.openwork.co.jp