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

  • 25
    いいね
  • 0
    コメント

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


Laravel Advent Calendar 2016 12日目の記事です。

背景

LaravelのORMであるEqoluentで、特定テーブルのデータを全件取得する際の最も基本的な方法は、モデルのall()メソッドの使用です。

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

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

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

chunk()

メモリ使用量を抑えつつ、大量のレコードを取得する方法として、クエリビルダのchunk()メソッドが用意されています。以下のような形で利用できます。

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

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

cursor()

Laravel 5.2で導入されたcursor()は、内部でPDOStatement::fetch()を使用することで、結果セットを1行ずつ取得します。取得した結果はジェネレータで返すので、get()に近い感覚で使うことができます。

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

// 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

// 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

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()を使うのが良さそうです。

この投稿は Laravel Advent Calendar 201612日目の記事です。