Laravel(Eloquent): chunk() vs cursor()

## TL;DR

* cursor(): High Speed
* chunk(): Constant Memory Usage

#### 10,000 records

||Time(sec)|Memory(MB)|
|:-:|:-:|:-:|
|get()|0.17|22|
|chunk(100)|0.38|10|
|chunk(1000)|0.17|12|
|cursor()|0.16|14|

#### 100,000 records

||Time(sec)|Memory(MB)|
|:-:|:-:|:-:|
|get()|0.8|132|
|chunk(100)|19.9|10|
|chunk(1000)|2.3|12|
|chunk(10000)|1.1|34|
|cursor()|0.5|45|

* TestData: users table of Laravel default migration
* Homestead 0.5.0
* PHP 7.0.12
* MySQL 5.7.16
* Laravel 5.3.22

<hr>

[Laravel Advent Calendar 2016](http://qiita.com/advent-calendar/2016/laravel) 12日目の記事です。

## 背景

LaravelのORMであるEqoluentで、特定テーブルのデータを全件取得する際の最も基本的な方法は、モデルの[all()](https://laravel.com/api/5.3/Illuminate/Database/Eloquent/Model.html#method_all)メソッドの使用です。

```php
foreach (User::all() as $user) {
    // Userオブジェクトに対する処理
}
```

このメソッドは一度に全てのレコードを取得し、それらをオブジェクト化するため、取得する件数に応じてメモリ使用量が増大します。
なお、all()は内部でクエリビルダ(`Illuminate\Database\Eloquent\Builder`)の[get()](https://laravel.com/api/5.3/Illuminate/Database/Eloquent/Builder.html#method_get)を呼び出しており、ほぼ同等の処理を以下のように書くことができます。

```php
foreach (User::query()->get() as $user) {
    // Userオブジェクトに対する処理
}
```

### chunk()

メモリ使用量を抑えつつ、大量のレコードを取得する方法として、クエリビルダの[chunk()](https://laravel.com/api/5.3/Illuminate/Database/Eloquent/Builder.html#method_chunk)メソッドが用意されています。以下のような形で利用できます。

```php
$chunkSize = 100;
User::query()->chunk($chunkSize, function ($users) {
    // $usersには最大100件のUserオブジェクトが入っている
    foreach ($users as $user) {
        // Userオブジェクトに対する処理
    }
});
```

chunk()はなかなか良さそうですが、第2引数のコールバック関数の中で処理を行う必要があり、やや窮屈さがあります。

### cursor()

Laravel 5.2で導入された[cursor()](https://laravel.com/api/5.3/Illuminate/Database/Eloquent/Builder.html#method_cursor)は、内部で[PDOStatement::fetch()](http://php.net/manual/ja/pdostatement.fetch.php)を使用することで、結果セットを1行ずつ取得します。取得した結果はジェネレータで返すので、get()に近い感覚で使うことができます。

```php
foreach (User::query()->cursor() as $user) {
    // Userオブジェクトに対する処理
}
```

## パフォーマンステスト

以下の条件で、テストを行いました。

* テストデータ1: 10,000件のusersテーブルのデータ(Laravelに付属のマイグレーションを使用)
* テストデータ2: 100,000件のusersテーブルのデータ(Laravelに付属のマイグレーションを使用)
* Homestead 0.5.0(VM Ubuntu 16.04)
* PHP 7.0.12
* MySQL 5.7.16
* Laravel 5.3.22

### ダミーデータの作成方法

ダミーデータはSeederを使って作成します。ランダムなメールアドレスは、Fakerだと50,000件くらいが上限だったので、mt_rand()で作成しています（そのため、以下のSeederはたまに失敗します。。。）。

```php
<?php

// database/seeds/UsersSeeder.php

use Illuminate\Database\Seeder;

class UsersSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        DB::table('users')->delete();

        $faker = Faker\Factory::create('ja_JP');
        $total = 100000;
        $batchSize = 100;

        for ($i = 0, $limit = $total / $batchSize; $i < $limit; $i++) {
            DB::table('users')->insert($this->makeData($faker, $batchSize));
            printf("%d/%d\n", $i * $batchSize, $total);
        }
    }

    private function makeData(\Faker\Generator $faker, $batchSize)
    {
        $data = [];

        for ($i = 0; $i < $batchSize; $i++) {
            $data[] = [
                'name'     => $faker->userName,
                'email'    => mt_rand() . $faker->email,
                'password' => $faker->password(6),
            ];
        }

        return $data;
    }
}
```

```php
<?php

// database/seeds/DatabaseSeeder.php

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        $this->call(UsersSeeder::class);
    }
}
```

`php artisan db:seed`でデータが入ります。

### 検証用スクリプト

以下のコマンドを使用します。

```php
<?php

namespace App\Console\Commands;

use App\User;
use Illuminate\Console\Command;

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

    /**
     * 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()
    {
        $start = microtime(true);

//        $this->all();
//        $this->get();
//        $this->chunk(10000);
//        $this->chunk(1000);
//        $this->chunk(100);
//        $this->cursor();

        $time = microtime(true) - $start;
        $memory = memory_get_peak_usage(true) / 1024 / 1024;

        $this->output->writeln(sprintf('time: %f memory: %f MB', $time, $memory));
    }

    private function all()
    {
        foreach (User::all() as $user) {
            // do nothing
        }
    }
    
    private function get()
    {
        foreach (User::query()->get() as $user) {
            // do nothing
        }
    }

    private function chunk($count)
    {
        User::query()->chunk(
            $count,
            function ($users) {
                foreach ($users as $user) {
                    // do nothing
                }
            }
        );
    }

    private function cursor()
    {
        foreach (User::query()->cursor() as $user) {
            // do nothing
        }
    }
}
```

コマンドはapp/Console/Kernel.phpに登録しておきます。

```
// app/Console/Kernel.php L16
    protected $commands = [
        PerformanceTestCommand::class,
    ];
```

実行は`php artisan performance:test`です。
また、初回実行はキャッシュが効かないので計測対象外とし、2回目〜11回目の実行時間とメモリ使用量の平均値を取っています。

### 結果

冒頭の表を再掲します。

#### 10,000 records

||Time(sec)|Memory(MB)|
|:-:|:-:|:-:|
|get()|0.17|22|
|chunk(100)|0.38|10|
|chunk(1000)|0.17|12|
|cursor()|0.16|14|

#### 100,000 records

||Time(sec)|Memory(MB)|
|:-:|:-:|:-:|
|get()|0.8|132|
|chunk(100)|19.9|10|
|chunk(1000)|2.3|12|
|chunk(10000)|1.1|34|
|cursor()|0.5|45|

### 考察

データ数が多い場合に、実行時間が最も短いのはcursor()です。しかし、メモリ使用量はchunk(10000)よりも多く、かつ、総データ件数の増加に従ってメモリ使用量が増加します。cursor()はデータを1件ずつ取得するはずなので、メモリ使用量は最小かつ一定だと思っていましたが、そうではないようです。。。

chunk()は、チャンクサイズを大きくすると実行時間が短くなりますが、メモリ使用量が増えます。chunk()の重要な特性は、総データ件数に関わらず、メモリ使用量がチャンクサイズに応じて一定になる点です。

したがって、大量のデータを処理する際には、速度を優先する場合はcursor()、メモリ使用量が一定値を超えないようにしたい場合はchunk()を使うのが良さそうです。