alucepsの日記

ソフトウェアエンジニアをしているおっさんが生きている中でメモしたいと思ったことを記録します。

CakePHPでバッチ処理を作成した時にメモリ不足でハマった

CakePHPで数十万件のデータを処理するバッチを作成した。そのバッチを実行したところ、いつのまにかバッチが終了していた。ずいぶんと終わるのが早いなと思ったら以下エラーで終了していた。

PHP Fatal error: Allowed memory size of xxx

原因を調査しながら色々と試したので順を追って記載する。

メモリはどのように食いつぶされていくのか

ループの適当なところに以下コードを埋めて、どのようにメモリが食いつぶされていくのかを観察した。とりあえず1000回ループするごとにログを出力した。

$this->log(array(
  'count'  => $count,
  'memory' => memory_get_usage(true),
), LOG_DEBUG);

すると次のようにメモリが食いつぶされていることがわかった。

回数 メモリ使用量(byte)
0 128,974,848
1000 133,431,296 4,456,448
2000 137,887,744 4,456,448
3000 142,344,192 4,456,448
4000 146,800,640 4,456,448

数十万件のデータを一度に取得した場合、その時点で 100MB 以上を消費していた。さらに前述のように 10MB 近いメモリが消費されていく。単純に計算しても終了する頃には数GBのメモリが…おそろしい。

対策1 unsetを試す

消費したメモリを解放したい。まずは unset を試してみた。

$data = $this->Model->query($some_query);
foreach ($data as $v) {
  ...
  unset($v);
}

結論としては、メモリ消費の様子に変化なし。

対策2 foreach -> for に変更

foreach$value にデータをコピーすることでメモリ消費量が増えるのかもしれないと予想して以下のコードを試した。

$data = $this->Model->query($some_query);
for ($i = 0; $i < count($data); $i++) {
  ...
  unset($data[$i]);
}

結論としては、これもメモリ消費の様子に変化なし。

対策3 数十万件のデータを分割して取得する

そもそも最初にドーンとメモリを消費することが防げたら最終的なメモリの消費量も抑えられると予想して、数十万件のデータを分割して取得してみた。

$count = $this->Model->query($count_query);
$limit = 1000;
$loop  = ceil($count / $limit);

for ($i = 0; $i < $loop; $i++) {
    $offset = $limit * $i;
    $some_query = '... limit {$limit} offset {$offset};';
    $data = $this->Model->query($some_query);

    $count = count($data);
    for ($j = 0; $j < $count; $j++) {
        unset($data[$j]);
    }
}

これは想定通り。ただ、回数が増えるごとに 5MB 近く増えてしまう。

回数 メモリ消費量(byte)
0 3,145,728
1000 7,864,320 4,718,592
2000 12,845,056 4,980,736
3000 17,825,792 4,980,736
4000 22,544,384 4,718,592

対策4 Model->query() のキャッシュを無効にする

これは盲点だったのだが、CakePHPModel->query() はデフォルトでクエリをキャッシュする仕組みになっているようだ。

CakePHP1.2 モデル | query

query() は、モデルの呼び出しとは本質的に分離した機能で、 $Model->cachequeries の状態に従いません。query を呼び出すにあたりキャッシングを無効にするには、query($query, $cachequeries = false) というように第2引数に false を設定します

ということで Model->query() をの第2引数に false を指定してみた。

回数 メモリ消費量(byte)
0 2,883,584
1000 3,145,728 262,144
2000 3,145,728 0
3000 3,145,728 0
4000 3,145,728 0

素晴らしい。あの謎のメモリ消費量の増加はクエリキャッシュだったのか。

以上、Model->query() を使うときにメモリが気になったら第2引数に false を指定しよう、というお話。