Laravelのメモリ消費についての気付き

Laravelでバッチ処理を始めて書いた際にLaravelのメモリについて困ったことを書いていきます。
ちなみに解決できなかったので、必要なレコードをコマンドの引数で年月指定させ、年月で絞って実装しました。(本文では日付の条件を省略しています。)

## 環境

```terminal
$ docker --version
Docker version 20.10.6, build 370c289

$ nginx -version
nginx version: nginx/1.21.0

$ php --version
PHP 7.2.34 (cli) (built: Dec 11 2020 10:56:30) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.2.0, Copyright (c) 1998-2018 Zend Technologies

$ php artisan --version
Laravel Framework 6.20.32
```

## 準備

バッチ処理作成
テーブル(310,000 + αレコード)とモデルは作成済み。レコードはたまたま私のローカルで380,000行あったというだけです。

```terminal
$ php artisan command:make testMemory
Console command created successfully.
```

とりあえず何も考えず実行。
`TestModel`から全レコードを取得し、`hoge`カラムの値を`fuga`に書き換えるという内容です。

```php
<?php

namespace App\Console\Commands;
use App\Models\TestModel;

use Illuminate\Console\Command;

class TestMemory extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'command:test-memory';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        try {
            $models = TestModel::get();
            foreach($models as $model) {
                $model->hoge = 'fuga';
                $model->update();
            }
            var_dump($model);
        } catch (\Exception $e) {
            echo $e."\n";
            echo 'メモリが足りないよぉ';
        }
    }
}
```





## 実行
```terminal
$ php artisan command:test-memory
$
```
うんともすんともいわずにメモリリークし、処理が終了。これは想定どおり。
Exceptionにもかからないのは知りませんでした。
次はchunkを使ってメモリの使用量を減らしてみます。


長ったらしいので`try`部分だけ書きます。(他部分変更無し)


```php
$counter = 0;

try {
    TestModel::select('id', 'hoge')
       ->orderBy('id', 'DESC')
       ->chunk(1000, function($arrs) use (&$counter){
           foreach($arrs as $arr) {
                 $arr->hoge = 'fuga';
                 $arr->update();
                 $counter++;
           }
           echo memory_get_usage() / (1024 * 1024) . "MB\n";
           echo $counter . " rows affected.\n";
       });
}
```

```terminal
$ php artisan command:test-memory
~
~
120.81422424316MB
82000 rows affected.
122.03582000732MB
83000 rows affected.
123.25741577148MB
84000 rows affected.
```

バッチ処理が進むに毎にメモリ使用量が増えていき、84,000行を超えたところで処理落ちしてしまいました。
`hoge`カラムに`fuga`が入っていないレコードは約240,000件ありました。
310,000 - 240,000だからまぁ正しいし、メモリ開放されていないからまぁそうか。

次は`$counter++`以外のforeachの処理を外だししてメモリ開放してみる。

```php
$counter = 0;

try {
   TestModel::select('id', 'hoge')
       ->orderBy('id', 'DESC')
       ->chunk(1000, function($arrs) use (&$counter){
           foreach($arrs as $arr) {
                 $this->sotoDashi($arr);
                 $counter++;
           }
           echo memory_get_usage() / (1024 * 1024) . "MB\n";
           echo $counter . " rows affected.\n";
       });
}
public function sotoDashi(TestModel $model)
{
    $model->hoge = 'fuga';
    $model->update();
    unset($model);
}
```

勝利を確信、完全に理解した( )とおもったらだめでした。

```terminal
$ php artisan command:test-memory
16.927040100098MB
1000 rows affected.
18.179916381836MB
2000 rows affected.
19.496086120605MB
3000 rows affected.
20.717681884766MB
4000 rows affected.
22.064277648926MB
5000 rows affected.
23.285873413086MB
6000 rows affected.
^C
```

1000行ごとに1.2~1.3MBメモリ使用量が増えていっているのが見てわかる。さっきとなにも変わっていない。
メモリ開放していないのは`$counter`だけだからそんなまさかと思い、`$counter`消してもほとんど変わらない(そりゃそうだ)。

どこでメモリを食っているのか検討が付かなかったのでてきとうにコメントアウトして実行してみると、
`$model->update();`をコメントアウトしたらこいつが原因であることが判明

```terminal
$ php artisan command:test-memory
15.732566833496MB
82000 rows affected.
15.733581542969MB
83000 rows affected.
15.734596252441MB
84000 rows affected.
```

## おわり
ここで完全に敗北が決定しました。
他にもcursorとか使ってもダメでした。(むしろchunkの方がメモリ効率いいみたいです)
ループ内でモデルオブジェクトに対して`save()`や`update()`をすると`unset()`を使ってもメモリが開放されないということを知りました。
メモリの上限を増やせば動くとは思いますが、今回はメモリが開放されないことを書きたかったため敢えて書きませんでした。
そもそもLaravelで書いているのは取得したレコードにあるカラムの値を新しく作ったカラムに加工して入れるためにやってます。SQLでそれをできるかは知らないので、そっちのアプローチができれば解決するかもしれません。

良い方法知っていたり、こういう場合はそもそも〜とかいったようなアドバイスいただけたら嬉しいです。

## 参考記事

https://qiita.com/ryo511/items/ebcd1c1b2ad5addc5c9d