【Laravel11】CSV書き込み処理のメモリ使用量と処理時間の検証

# はじめに
初記事なので、言葉遣いが変だったり読みにくかったらぜひご意見お待ちしてます！
今後につなげていきます！
## 自己紹介
初記事なのでここで自己紹介書かせていただきます！
PHP（Laravel）を書いてるエンジニアです。
最近はReactを触り始めてます。
まだLaravelを触り始めて半年のぺーぺーです。

技術記事なので自己紹介はこのぐらいで．．．。
# 本題
## なぜこの検証を行ったのか
大量のデータ（1万件以上）をcsvでダウンロードするとメモリ不足に陥りました。
## なぜこの記事を書いたのか
csv書き込み処理のメモリ使用量や処理時間の検証を行ってる記事が少なかったので、自分のアウトプットと、同じ悩みを抱えてる人の参考になれば良いなと思い、書くことにしました。
```terminal
local.ERROR: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes) 
{"exception":"[object] (Symfony\\Component\\ErrorHandler\\Error\\FatalError(code: 0): Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes) at
/var/www/html/vendor/maximebf/debugbar/src/DebugBar/DataFormatter/HasXdebugLinks.php:76) [stacktrace] #0 {main} "} 

```
## 動作環境
- Laravel：11.30.0
- OS：macOS Sequoia 15.1.1
## 事前説明
`fopen`、`fwrite`、`fputcsv`、`fclose`という関数がありますが、長いので全てを指すときはまとめて`f`シリーズと呼ばせていただきます。
## 計測方法



### 書き込みパターンの比較
ロジックを4つのパターンに分け、データ件数も4つで比べてみました。

以下の表は、4つの書き込み処理パターンをまとめたものです。
| パターン | 書き込みタイミング  | 使用メソッド |
|:-:|:-:|:-:|
| パターン1️⃣ | 全てのデータを一度に書き込む | `f`シリーズのみ |
| パターン2️⃣ | 全てのデータを一度に書き込む | `f`シリーズ + `lazy` (`chunk`) メソッド |
| パターン3️⃣ | データを繰り返し書き込む | `f`シリーズのみ |   |
| パターン4️⃣ | データを繰り返し書き込む | `f`シリーズ + `lazy` (`chunk`) メソッド |

以下の表は比較したデータの件数と、その時のパターン2️⃣、4️⃣のchunk数をまとめたものです。
| データ件数 | chunk数 |
|:-:|:-:|
| 500 | 100,1000,10000 |
| 1000 | 100,1000,10000 |
| 10000 | 100,1000,10000 |
| 100000 | 100,1000,10000,100000 |
#### パターン1️⃣の例
```php5:PatternOne.php
$filePath = // ファイルパス
$file = fopen($filePath, 'w');

fwrite($file, "\xEF\xBB\xBF");

$csvData = [];
$csvData[] = [
    // ヘッダー
];

$users = User::get();
foreach ($users as $user) {
    $csvData[] = [
        // $user
    ];
}

fputcsv($file, $csvData);
fclose($file);
```
#### パターン4️⃣の例
```php5:PatternFour.php
$file = fopen($filePath, 'w');

fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, [
    // ヘッダー
]);

User::chunk(1000, function ($users) use ($file) {
    foreach ($users as $user) {
        fputcsv($file, [
             // データ
        ]);
    }
});

fclose($file);
```
### メモリ使用量や処理時間の測定
メモリは`memory_get_usage()`、時間は`microtime(true)`で、それぞれの差分で出すことができます。
```php5
// メモリ測定開始
$startMemory = memory_get_usage();

// 開始時刻を記録
$startTime = microtime(true);

// ファイル書き込み処理

// メモリ使用量の記録
$endMemory = memory_get_usage();
$memoryUsed = ($endMemory - $startMemory) / (1024 * 1024);
Log::info("メモリ: {$memoryUsed} 【MB】");

// 処理時間の記録
$endTime = microtime(true);
$timeTaken = $endTime - $startTime;
Log::info("処理時間: {$timeTaken} 秒");
```

#### 参考
https://qiita.com/dublog/items/0647c306afc835efbeb5
## 計測結果
### まとめ
- **パターン1️⃣** はシンプルですが、大量のデータを扱うとメモリ不足に陥る可能性があります。
- **パターン2️⃣** は `lazy` (`chunk`) メソッドを使用することでメモリ使用量を抑えつつ、一度に書き込む利便性を保持します。
- **パターン3️⃣** はデータを繰り返し処理するため、メモリ効率が向上しますが、処理時間が増加する可能性があります。
- **パターン4️⃣** は `chunk` メソッドと繰り返し処理を組み合わせることで、最もメモリ効率が良く、安定した処理が可能です。






### パターン1️⃣
データが1万件を超えるとメモリー不足
- - -
### パターン2️⃣
1万件超えでメモリ不足
#### データ1000件時のログ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3760692/3971d064-b521-1fb0-3392-67d6cbed6c17.png)
- - -
### パターン3️⃣
10万件超えるとメモリ不足
#### データ1000件
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3760692/1a0f121e-264a-32da-402c-7b3d964aa0bb.png)
#### データ10000件
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3760692/7dc10574-22b5-ec8f-54c5-0a2d34bbdd17.png)
- - -
### パターン4️⃣
4つのパターンで処理速度最速、メモリ使用量最低。
データが1万件の場合、メモリが`2.30~2.54M`、処理速度が`0.08~0.26`秒でした。
## その後の変更点
```ini:php.ini
memory_limit = 1024M
```
### 変更した結果
データが1万件超えでも全てのパターン処理されるようになりました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3760692/bd78c262-5857-b94d-b5d7-f47a7648ff18.png)

が．．．
データが10万件超えるとパターン4️⃣以外はメモリ不足になるのは変わらずです。
## 結果
10万件を超える場合は、パターン4️⃣の中でもchunk(10000)メソッドが処理速度が早いし、メモリ使用量も少なかったです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3760692/01311f73-9311-e2ef-9c0b-dccb909453f3.png)

パターン4️⃣でデータ40万件を試してみると、時間こそかかりましたがメモリ不足に陥ることはなかったです。
## まとめ
csvファイルにデータを書き込む処理では、`fopen`,`fwrite`,`fputcsv`,`fclose`を使いつつ、`chunk(lazy)`（できれば`chunk(lazy)ById`）メソッドを使っていくのがおすすめ！