【PHP】CSVファイル分割+ジェネレーター関数でCSVの読み込み速度を改善する

PHPでCSV読み込みを高速化するための備忘録。

やってみて遅かったこと

個人的に英文の全文検索エンジンを運用し始めたのですが,レコード数20万件ほどのCSVを読み込むのにおよそ2.5秒かかっていました。一般的にこの数値は悪くないとも言えるのですが,検索エンジンで結果が表示されるのに2.5秒待たされるのはストレスを感じるレベルだと言えるでしょう。

他サイトで推奨されている方法も試してみましたが,残念ながら私が扱っているデータではまったく改善が見られませんでした。

そこで,色々と原因を追究して検索スピードの高速化に取り組んでみることにしました。ここで紹介する方法は,扱うデータの性質によって結果が大きく異なるかもしれません。他サイトで推奨されている方法が私のケースでうまくいかなかったのと同様,それぞれのケースによってここで紹介する方法は意味がない可能性があります。

CSVファイルを読み込むには,ファイル全体を一度に読み込む方法とレコードを1行ずつ読み込む方法があります。

CSVファイルのサイズが大きくなりレコードが数万件レベルになると,メモリの許容範囲を超えてエラーに遭遇することになります。このとき,一般的にはfgetcsv()を用いてCSVを1行ずつ読み込む方法が推奨されています。

ところが,fgetcsv()は読み込みが遅い。この原因は,ファイルを1行ずつ読み込むことに問題があるのではないかと推測しました。そこで,file()を用いて,データを一度に読み込むことにしました。しかしながら,これではメモリの許容範囲を超えることになるので,CSVをいくつかのファイルに分割することにしました。

こうして,分割したCSVをfile()で順番に読み込んでみました。file()はそれぞれの行を配列として返します。また,配列に格納されるのは文字列なので,それをCSVデータとしてさらに配列に分割して格納する必要があります。

ここで,文字列をCSVとして処理するのにstr_getcsv()を用いることができます。str_getcsv()は文字列をCSVとして認識し,それぞれの要素を配列に変換して返します。

ところが,この方法でも処理速度はまったく改善しませんでした。データを一度に読み込むか,1行ずつ読み込むかは全体の処理速度にほとんど影響しないようです。

結局何が問題なの?

以上の検証から言えることは,処理の遅延の原因はfgetcsv()str_getcsv()にあるということです。データの読み込み自体の問題ではなく,読み込んだデータをCSVとして解釈するプロセスに時間がかかるようです。

改善策

改善策として,データをCSVとして解釈するのを止めて,explode()単純に文字列を分割することにしました。ただし私が扱っているデータは英文だったのでデータ内にコンマを含みます。そこで,データをタブ区切りのCSVとして用意しました。あとは,読み込んだデータをタブを区切り文字として分割して配列に格納しました。

結果,読み込み時間が2.5秒→0.2秒に改善しました。

ここでは,ファイルを一度に読み込む方法でコードの例を示しますが,おそらく一行ずつ読み込む方法でも結果は同じではないかと思います。

結論として,fgetcsv()str_getcsv()のような関数は確かに便利なのですが,一方で文字列をCSVとして解釈するのに多くの処理時間を必要とするので,explode()で単純に分割するほうが確実です。

最後にサンプルコードを示します。この関数はジェネレーター関数であり,関数を呼び出すごとにタブ区切りのCSVを1行ずつ配列として返します。

function generator() {
  $files = glob('./*.csv');  //ファイル名のリストを取得
  foreach($files as $file) {  //配列の各要素に対する処理
    $lines = file($file);       //データを各行ごとの配列として読み込み
    $lines = array_filter($lines);  //空白行を削除
    foreach($lines as $line) {  //各行に対する処理
      $data = explode("\t", $line);  //タブ文字を区切文字として文字列を分割
      yield $data;   //1行のデータを配列として返す
    }
  }
}