PHP

【PHP】CSV読み込みでメモリ不足にならないコツ(パフォーマンスチューニング)

以前書いた記事で、PHPでファイルを取り込む処理についてまとめました。

今回はその流れで、CSVを取り込む際のパフォーマンスを検証してみましたのでメモしておきます。

処理の中でもファイル取込みはパフォーマンスに影響が出やすい部分だと思います。PHPは取り込むための方法が多く存在するので、やり方を間違えるとメモリの許容量を超えてしまい、最悪の場合バッファオーバーフローでエラーになる可能性もあるので、しっかり考えてプログラムするようにしましょう。

ポイント

パフォーマンスといっても処理速度とメモリ使用量の2つが大きいポイントになるかと思います。

パフォーマンスを考える際、両方とも改善することに越したことはないですが、個人的にはメモリ使用量の方を重視しています。

なぜなら、上記にも書いていますがメモリ使用量が多い処理だとバッファオーバーフローになるので、そもそも処理が完了できずシステムとしての体裁が保てません。

処理速度の場合は、要求されている非機能要件に応じて重量度が異なりますが、ある程度許容できる範囲があると思うからです。

ではバッファオーバーフローにならないためのポイントとして気を付けたいことは何かというと、CSVの中身を一度に変数へ格納するのではなく、1行ごと(または数行ごと)読み込みながら処理することでメモリ使用量を最小限にすることができます。

一度にすべてのデータを取り込む造りにしてしまうと、どんなに高スペックなサーバでもメモリは有限であるため、膨大な行数のCSVを取り込む場合いつかメモリが枯渇してしまいます。

1行ごと読み込んで処理し終わればメモリを解放して次の行を読み込むようにしておくことで、どんなに膨大な容量のCSVでもメモリ使用量は1行分のデータ量で済みます。ただその分処理は遅くなります。そのバランスが重要となります。

検証

まず検証用ソースのひな形として以下のようなプログラムを用意しました。これを使って30万件のCSVファイルを取り込む処理を検証します。

検証用サンプルソース

<?php
// タイムゾーンの設定
date_default_timezone_set('Asia/Tokyo');_
$start_date  = microtime(true);
print "実行開始[メモリ使用量]:". memory_get_usage() / 1024 ."KB\n";
print "実行開始[メモリ最大使用量]:". memory_get_peak_usage() / 1024 ."KB\n";

/**** CSV読み込み処理 ****

  ・・・・・

*************************/

print "実行終了[メモリ使用量]:". memory_get_usage() /  1024 ."KB\n";
print "実行終了[メモリ最大使用量]:". memory_get_peak_usage() / 1024 ."KB\n";

$end_date = microtime(true);
print "[経過時間]:". ($end_date - $start_date) . "秒\n";
?>

上記サンプルの「CSV読み込み処理」の部分をいろいろ変えて試してみて、パフォーマンスの検証をしたいと思います。

バッファオーバーフローになる例

いきなりダメな例を試してみます。

file関数を使って30万件ある「report_300000.csv」を一気に取り込み、変数$dataに代入しています。これをしてしまうと30万件のすべてのデータが$dataに格納されることになり、その分メモリも消費されることになります。

今回はエラーにならないギリギリの件数で攻めましたが、40万件を超えたところでバッファオーバーフローになってしまいました。

[サンプル1]

/**** CSV読み込み処理 ****/
// ファイルの内容を配列に取り込みます。
// この例ではHTTPを通してURL上のHTMLソースを取得します。
$data = file('./report_300000.csv');
$counter = 0;
// 配列をループしてHTMLをHTMLソースとして表示し、行番号もつけます。
foreach ($data as $line_num => $line) {
  // ループ回数をカウント
  $counter++;
  // 100,000ループごとメモリ使用量を確認
  if($counter%100000 === 0) {_
    print " {$counter}ループ[メモリ使用量]:". memory_get_usage() / 1024 ."KB\n";
    print " {$counter}ループ[メモリ最大使用量]:". memory_get_peak_usage() / 1024 ."KB\n";
  }
}

print "処理2実行後[メモリ使用量]:". memory_get_usage() / 1024 ."KB\n";
print "処理2実行後[メモリ最大使用量]:". memory_get_peak_usage() / 1024 ."KB\n";
// メモリの解放
$lines = NULL;
/*************************/

[結果]

実行開始[メモリ使用量]:229.9765625KB
実行開始[メモリ最大使用量]:242.6640625KB
 100000ループ[メモリ使用量]:84848.4140625KB
 100000ループ[メモリ最大使用量]:119009.6328125KB
 200000ループ[メモリ使用量]:84848.4140625KB
 200000ループ[メモリ最大使用量]:119009.6328125KB
 300000ループ[メモリ使用量]:84848.4140625KB
 300000ループ[メモリ最大使用量]:119009.6328125KB
処理2実行後[メモリ使用量]:84848.3515625KB
処理2実行後[メモリ最大使用量]:119009.6328125KB
実行終了[メモリ使用量]:230.984375KB
実行終了[メモリ最大使用量]:119009.6328125KB
[経過時間]:0.16201996803284秒

データを1行ずつ取り込む例

以降はCSVのデータを1行ずつ取り込むサンプルとなります。3種類のサンプルソースを用意しました。

最初に紹介するのが、fopen関数を使ったサンプルです。

サンプル1で使っていたfile関数とは違い、fopen関数は「fopen('./report_300000.csv', 'r')」で取り込むファイルを指定していますが、このタイミングではファイルの中身を取得せず、ファイルを開いた状態にしています。

そこから「$data = fgetcsv($fp)」で1レコードごとのデータを取得しています。つまり、サンプル1の変数「$data」にはCSVのデータ全てが格納されていましたが、今回のサンプルは1行分のデータしか格納されていないため、メモリ使用量を抑えることが可能になります。

[サンプル2]

/**** CSV読み込み処理 ****/
$fp      = fopen('./report_300000.csv', 'r');
$counter = 0;
while (($data = fgetcsv($fp)) !== FALSE) {
  // ループ回数をカウント
  $counter++;
  // 100,000ループごとメモリ使用量を確認
  if($counter%100000 === 0) {
    print " {$counter}ループ[メモリ使用量]:". memory_get_usage() / 1024 ."KB\n";
    print " {$counter}ループ[メモリ最大使用量]:". memory_get_peak_usage() / 1024 ."KB\n";

  }
}
print "処理2実行後[メモリ使用量]:". memory_get_usage() / 1024 ."KB\n";
print "処理2実行後[メモリ最大使用量]:". memory_get_peak_usage() / 1024 ."KB\n";
// メモリの解放
fclose($fp);
/*************************/

[結果]

実行開始[メモリ使用量]:229.6171875KB
実行開始[メモリ最大使用量]:242.1484375KB
100000ループ[メモリ使用量]:242.6484375KB
 100000ループ[メモリ最大使用量]:249.546875KB
 200000ループ[メモリ使用量]:242.6484375KB
 200000ループ[メモリ最大使用量]:249.546875KB
 300000ループ[メモリ使用量]:242.6484375KB
 300000ループ[メモリ最大使用量]:249.546875KB
処理2実行後[メモリ使用量]:238.953125KB
処理2実行後[メモリ最大使用量]:249.546875KB
実行終了[メモリ使用量]:230.3515625KB
実行終了[メモリ最大使用量]:249.546875KB
[経過時間]:1.4150228500366秒

結果として、サンプル1では1レコード当たりの使用量は84.4MB(84,848KB)で最大が119MB(119009.6328125KB)だったのに対し、サンプル2では1レコード当たり242.6KBで最大249.5KBと、割合として1/500まで使用量を抑えることができました

さらにサンプル2の場合は、取込み対象がどんなに大量のデータのCSVだとしても、メモリに読み込むデータサイズは必ず1レコードまでなので、バッファオーバーフローになる心配がありません。

これまでの内容を聞くとサンプル2の方が良いことづくめですが、1点注意したいポイントがあります。

サンプル1とサンプル2の経過時間に注目してください。前者に比べ後者は約10倍となる1.4秒も処理に時間がかかってしまっています

この原因として、サンプル1は扱うすべてのデータをメモリ内で一括取込しているため、とても高速に処理することができます。それに比べサンプル2は1レコードごとファイルにアクセスしている分、HDDに都度アクセスしていることになります。ストレージの造り上、メモリに比べてHDDの読み込み速度はとても遅くなっています。サンプル2では30万レコードのCSVを取り扱っており、30万回のディスクI/Oが発生しているため、激遅ぷんぷん丸になっているのです。

続いてSplFileObjectクラスを使ったサンプルで検証してみます。SplFileObjectクラスはファイルを操作する機能がいろいろ詰まった便利なクラスです。

setFlags関数でREAD_CSVを渡してあげることで、CSVを読み込むことができます。

[サンプル3]

/**** CSV読み込み処理 ****/
$file = new \SplFileObject('./report_300000.csv');
$file->setFlags(\SplFileObject::READ_CSV);
$file->setCsvControl(',');
$counter = 0;
foreach ($file as $row) {
  // ループ回数をカウント
  $counter++;
  // 100,000ループごとメモリ使用量を確認
  if($counter%100000 === 0) {
    print " {$counter}ループ[メモリ使用量]:". memory_get_usage() / 1024 ."KB\n";
    print " {$counter}ループ[メモリ最大使用量]:". memory_get_peak_usage() / 1024 ."KB\n";
//var_dump($row);
  }
}
print "処理2実行後[メモリ使用量]:". memory_get_usage() / 1024 ."KB\n";
print "処理2実行後[メモリ最大使用量]:". memory_get_peak_usage() / 1024 ."KB\n";
// メモリの解放
$file = NULL ;
/*************************/

[結果]

実行開始[メモリ使用量]:231.3203125KB
実行開始[メモリ最大使用量]:243.265625KB
 100000ループ[メモリ使用量]:251.140625KB
 100000ループ[メモリ最大使用量]:258.515625KB
 200000ループ[メモリ使用量]:251.140625KB
 200000ループ[メモリ最大使用量]:258.515625KB
 300000ループ[メモリ使用量]:251.140625KB
 300000ループ[メモリ最大使用量]:258.515625KB
処理2実行後[メモリ使用量]:245.3828125KB
処理2実行後[メモリ最大使用量]:258.515625KB
実行終了[メモリ使用量]:232.4140625KB
実行終了[メモリ最大使用量]:258.515625KB
[経過時間]:1.6545300483704秒

結果は、1ループ当たりのメモリ使用量が251.4KB、最大では258KB、処理時間は1.7秒とどれもサンプル2よりも劣る結果となりました。

ネットで調べたときは、SplFileObjectクラスはメモリ消費も処理速度の優れているとあったので、個人的には少し意外な結果です。

最後の例として、再度SplFileObjectクラスを使っていますが、CSVのポイント回すのにnext関数を使い、データのアクセスにはcurrent関数を使っています。

[サンプル4]

/**** CSV読み込み処理 ****/
$file = new \SplFileObject('./report_300000.csv');
$file->setFlags(\SplFileObject::READ_CSV);
$file->setCsvControl(',');

// 最初の行に巻き戻す
$file->rewind();

$counter = 0;
while ($file->valid()) {
//while (!$file->eof()) {
  // ループ回数をカウント
  $counter++;

  // 最初の行を出力する
  $file->current();
  // 100,000ループごとメモリ使用量を確認
  if($counter%100000 === 0) {
    print " {$counter}ループ[メモリ使用量]:". memory_get_usage() / 1024 ."KB\n";
    print " {$counter}ループ[メモリ最大使用量]:". memory_get_peak_usage() / 1024 ."KB\n";
  }
  // 次の行にポイントを移動
  $file->next();
}

print "処理2実行後[メモリ使用量]:". memory_get_usage() / 1024 ."KB\n";
print "処理2実行後[メモリ最大使用量]:". memory_get_peak_usage() / 1024 ."KB\n";
// メモリの解放
$file = NULL ;
/*************************/

[結果]

実行開始[メモリ使用量]:231.5KB
実行開始[メモリ最大使用量]:243.5KB
 100000ループ[メモリ使用量]:249KB
 100000ループ[メモリ最大使用量]:255.171875KB
 200000ループ[メモリ使用量]:249KB
 200000ループ[メモリ最大使用量]:255.171875KB
 300000ループ[メモリ使用量]:249KB
 300000ループ[メモリ最大使用量]:255.171875KB
処理2実行後[メモリ使用量]:245.1171875KB
処理2実行後[メモリ最大使用量]:255.171875KB
実行終了[メモリ使用量]:232.140625KB
実行終了[メモリ最大使用量]:255.171875KB
[経過時間]:1.6307351589203秒

結果は1ループ当たりのメモリ使用量が249KB、最大では255.1KB、処理時間は1.6秒とサンプル3よりも少しパフォーマンスが向上しました。

まとめ

今回PHPでCSV取込みのパフォーマンスについて検証し、最終的に以下のような結果になりました。

  サンプル1 サンプル2 サンプル3 サンプル4
1ループ時の
メモリ使用量
84.4MB 242.6KB 251.1KB 249KB
最大メモリ使用量 119MB 249.5KB 258.5KB 255.1KB
処理時間 0.16秒 1.4秒 1.7秒 1.6秒

今回は自分が個人的に実施した結果なので、他の方が別の角度から行った場合また違った結果になるかもしれませんが、メモリ消費量と処理時間の両方において最善の方法はサンプル2のfopen関数を使った例ということになりました。

参考までに、、、

-PHP