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