LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

巨大サイズデータ処理に対して省メモリで立ち向かう

こんにちは。検索連動型ショッピング広告のレポートシステムを担当している眞井です。

レポートシステムでは、広告主が広告の成果の可視化や分析を行うためのレポートを作成する機能を提供しています。

作成するレポートはCSVやTSVなどのテキストファイルで、広告の運用状況によっては数GB規模の巨大なサイズになることもあります。

普段はなんてことない処理でも、扱うデータのサイズが巨大になると多くのメモリが要求されてサーバ費用が高くついたり、OOME(Out Of Memory Error)が発生して処理が失敗してしまうといった問題が発生します。

今回は、このような問題を回避するためにレポートシステムで行っている2つのテクニックを紹介します。

巨大なデータを扱う場合に発生するOOME

まず、どのようなシーンでメモリが要求されOOMEが発生するのかを、レポートシステムの例で説明します。

レポートシステムがレポートを作成するざっくりとした処理の流れは以下のとおりです。

  1. ユーザーの条件に沿ったデータをデータ元(集計サーバ)から取得する。
  2. データに対して必要な加工処理(単位調整や基幹データとの結合)や不要データの削除を行う。
  3. データを要求フォーマット(CSV、TSVなど)にしてテキストファイルとして書き出す。

これをコードにすると以下のようになります(疑似コードであり、実際のコードとは異なります)。

// 集計サーバからデータを取得する(1)
List<Data> allData = aggregationServer.getAll({取得条件});

// データを加工する
List<Data> processedData = process(allData);

// tsvファイルに書き出す
TsvWriter.write(processedData);

(1)の部分で集計サーバから取得したデータが allData に保持されています。
データ量が少ない場合はこれで問題ないですが、巨大だった場合はここでメモリが足りなくなりOOMEが発生してしまいます。

テクニック1: チャンクごとに分けて処理する

OOMEを避けるにはデータをチャンク単位に分割して受け取るようにします。

// 書き出すtsvファイルを作成してopenする
Writer writer = new TsvWriter();

// 10000ずつデータを取得する
for(List<Data> data : aggregationServer.getChunk10000({取得条件})) {
        // データを加工する
        List<Data> processedData = process(data);
        // tsvファイルに書き出す。
        writer.write(processedData);
    }
// ファイルをcloseする
writer.close();

こうすることで、一度に全データをメモリに乗せる必要がなくなりOOMEは発生しなくなります。
要求されるメモリも少なくなるので、サーバに載せるメモリも小さくて済むようになります。
※ 今回はサンプルで10000をチャンクとしましたが、積んでいるメモリ量や扱うデータ特性によって適切な数値は異なります。
メモリが許す範囲でできるだけ大きくすると、データ取得の回数が減るためパフォーマンスが良くなります。

テクニック2: 不要になったデータはすぐ解放する

Javaの場合、スコープ外の不要になったオブジェクトは自動的にGCの対象になりメモリが解放されます。
しかし、大きいサイズのデータを扱っている場合には、スコープを外れるのを待たずに解放したい場合があります。
その際は、不要なオブジェクトを参照する変数にnullをセットし、どこからも参照されない状態にすることでこれを実現しますが、気をつけないと意図どおりに解放できていない場合があります。
その例を次に示します。

List<Data> process(List<Data> beforeDataList) {             
    List<Data> afterDataList = new ArrayList<>();      
    for(var beforeData : beforeDataList) {
        // なんらかの加工処理(1)
        Data afterData = someConvert(beforeData);
        afterDataList.add(afterData);
    }

    // メモリ節約のため解放(2)
    beforeDataList = null;
}

このコードは、前述「テクニック1: チャンクごとに分けて処理する」にも登場しているprocess()関数の定義部分です。
process()は、Dataのリストを受け取って加工されたDataのリストを返却します。

注目してほしいのは(2)の部分です。ここで不要になったbeforeDataListにnullを入れることでGC対象とし、無駄にメモリが使用されないことを期待しています。

しかし、この(2)の直前ではbeforeDataListとafterDataListの両方にデータが確保された状態になっています。
beforeDataListのサイズが大きい場合は、afterDataListのサイズも同様に大きく、この両方がメモリに確保できなかった場合にはOOMEが発生してしまいます。

さらに、実はこのnull対応は関数の呼び出し元スコープには影響がないため、結局は加工前のデータは解放されず意図どおりにメモリは解放されません。

この状態を避けるには以下のようにします。

List<Data> process(LinkedList<Data> beforeDataList) {
        List<Data> afterDataList = new LinkedList<>();
        while(beforeDataList.size() > 0) {
                Data beforeData = beforeDataList[0];
                // なんらかの加工処理
                var afterData = someConvert(beforeData);
                afterDataList.add(afterData);

                // 不要になった直後にクリアする
                beforeDataList.remove(0);
        }
}

beforeDataListから0番目の要素を取得し、データを加工してafterDataListに追加したら、すぐにその0番目の要素を削除します(beforeDataList.remove(0))。

※ 0番目を削除することで、1番目の要素が0番目に繰り上がります。この処理をスムーズに行うため、LinkedList型を使用するようにします。
※ この関数が引数を破壊的に変更してしまう点については注意する必要があります(このprocess()関数は副作用を持つことになります)。

これなら、加工データが用意できた時点で、加工前のデータが消えるので(正確にはGC対象になり、次回GC時にクリアされる)メモリが余分に要求されることはありません。

おわりに

以上は、改めて考えると当たり前のことかもしれません。しかし、実装作業中は限られた少量のデータセットで検証していることが多いので、本番を想定して試験してみると想定以上にデータ量が大きく、そこで初めて問題に気づく――ということも意外とよくあるのではないでしょうか。

そうした場面の参考として、本記事が少しでもお役に立てば幸いです。