ブログのアクセス解析用に使うストレージを選定するのに先だって,こんな条件下での速度比較をしてみた。

  • 主な処理は,ブログ毎,あるいはページ毎のアクセス数のカウントアップ。つまり,ブログid & 記事id をキーとして,memcached なら set/incr, sqlなら insert 〜 on duplicate key update 〜 文の発行をする。
  • 200個のプロセスが,それぞれ平行して「あるキーのカウントアップ(書き込み) を行い,続いて別のキーの読み出しを行う」という処理を500回繰り返す。(合計10万回のカウントアップと10万回の読み出しに相当。)
  • ストレージは,ベンチマークを行うスクリプトとは別のサーバ上にあり,必ずネットワーク経由で api を叩いて読み書きをする必要がある。
  • 実際に使用状況に即したデータになるように,データのシリアライズ/デシリアライズやネットワークのコストも含めて集計する。(なので,ストレージエンジン自体の性能よりも,perl のクライアントライブラリの性能に左右される部分が大きいかもしれない。)

これを,各種ストレージ/キューに対して行ってみた結果:

保存対象wallclock secs 
gearman (Gearman::Client + dispatch_background メソッド) *174.6
gearman (Gearman::Client + do_task メソッド)50.5
mysql (engine = innodb)40.7
gearman (Gearman::Client::Async) *38.3
memcached (Cache::Memcached)34.1
memcached (Cache::Memcached::Managed + Cache::Memcached::Fast)31.1
mysql (engine = myisam)30.1
mysql (engine = memory, hash インデックス)24.2
mysql (engine = blackhole) *20.3
tokyo tyrant (memory)13.4
tokyo tyrant (file)12.5
memcached (Cache::Memcached::Fast)8.5

...ところで Benchmark の cmpthese メソッドって,実行時間 (wallclock seconds) じゃなくて CPU占有時間で計測するようになってるので,mysql の応答を待ってぼーっとしてる時間とかが考慮されないよねぇ。wallclock seconds 使ってくれるようにするオプションはないんだっけか...

表中のエントリのうち,* がついているもの (gearman の dispatch_background版とGearman::Client::Async版,それと mysql の blackhole エンジン版) は,キューに投げるだけのバックグラウンド型の処理。カウントアップはあとで別のプロセスがやることになるので,カウントアップ処理に必要な時間は含まれない。ベンチマークとしては不公平だけど,データ入力ルーチンが仕事を終えて次のデータに取りかかれるまでの時間という意味で,この数値は重要だ。


memcached :

Cache::Memcached::Fast 経由がやはり無敵。
ただ,今回のような使用目的では,キーの一覧やグループ管理ができないと使い勝手が悪すぎるので,Cache::Memcached::Managed によるグループ管理を追加した状態でもベンチをとってみた。
以前のエントリにも書いたけど,Managed を使うとグループ単位でまとめてキーの取得/削除などができるようになる。グループ管理のために平均4倍弱の余計な入力が発生するけど,Cache::Memcached::Fast がもともと Cache::Memcached の4倍くらい高速なので,差し引きで丁度 Cache::Memcached単体版と Managed + Fast版が同じくらいの速度になった。

tokyo tyrant :

ファイルへの永続的な書き込みでこの速度は素晴らしい。ただ memcached と同様,今回のような使い方では,ただの key-value ストレージでは機能的に不足ではある。

mysql :

データをあとで集計することを考えると,key-value ストレージよりも RDB の方が断然使いでがある。その分パフォーマンスが劣る面はあるので,その犠牲を最小限におさえつついかに RDB のメリットをキープするか,というのが鍵になる。

myisam の方が innodb よりやや高速なように見えるけど,複雑な集計用 sql と大量のインサートとが重なるとテーブルロックによるパフォーマンス劣化が著しいので,実際にはなかなか気軽には使えない。memory エンジンは容量一杯になるとそれ以上の追加が出来なくなるのでチューニングに注意が必要。

blackhole エンジンはこれ単体だと何もデータを保存してくれないし何も結果を返してくれないので,まるで無意味だ。ただ binlog だけは書き出されるので,これをマスターにし,後段に通常エンジンのスレーブをつないでやると,入力バッファとしての役割を果たしてくれる。(詳しくはこのあたりを参照)
key-value ストレージに匹敵する入力パフォーマンスと,データが RDB に保存されるという使い勝手の良さとが共存している点に注目したい。

gearman:

もともと,「worker を並列にたくさん立ち上げておいて,処理の数が増えても処理時間が単調増加しないようにする」というケースで真価を発揮するものなので,今回のような使い方には合っていない気はした。が,キューの一種だし,スパムちゃんぷるーで安定稼働の実績があったので一応数値をとってみた。
思ったよりもパフォーマンスが悪かったのは,gearman 自体よりクライアントライブラリの性能やシリアライズの処理によるかもしれない。

(あと,dispatch_background メソッドは基本的には通常のメソッドより高速なんだけど,なぜか時々応答が帰ってこなくなることがあってトータルの数字が悪くなってしまっている。今回はもともと採用予定がなかったのであまり深く原因は調査せず。)


...というわけで,こうやって並べてみると,今までイロモノだとばかり思っていた blackhole エンジンが,意外に機能とパフォーマンスのバランスのとれたソリューションに見えて来るのでした。


ちなみに,アクセス解析に要求されるスペックがだいたいどんなものかというと,ライブドアブログすべて(PC,モバイル含む) に設置されているアクセスカウンタへのアクセスが,最もアクセスの激しい 0時台において
  • 1秒あたり最大瞬間風速は400〜500アクセス (blog単位でのユニーク数でいうと約300。これを10台のwebサーバ,計200プロセスぐらいでさばいている。)
  • 10秒あたりの最大瞬間風速は約4000アクセス。(blog単位でのユニーク数は約2000)
ぐらい。

つまり,上の例でいうと10万カウントアップを250秒でこなせる性能があれば,一番重い時間帯でも乗り切れる計算になるので,それだけでいうと上の表のどの方式を使っても問題はないようだ。

なので実は,入力段よりも,当日のデータを集計して合計やランキングを出すとか,いらなくなったデータを効率的に消すとかいった,後段の解析処理の部分の実装の方が勝負どころかもしれない。その点でも,保存したデータを RDB で扱える blackholeエンジン方式のメリットは大きい。

(from intra blog 2009-02-02)


↓ベンチマークに使用したコードは以下に
#/usr/local/bin/perl
#/usr/local/bin/perl

use strict;
use DBI;
use DBD::mysql;
use TokyoTyrant;
use Cache::Memcached;
use Cache::Memcached::Fast;
use Cache::Memcached::Managed;
use Gearman::Client;
use Gearman::Client::Async;
use Storable qw(freeze);

use Benchmark qw(:all :hireswallclock);


# bench

my ($fork, $iterations) = (200, 500);

my $results = timethese(1, {
    gearman => sub { forker(\&gearman_exec, $fork, $iterations) },
    gearman_bg => sub { forker(\&gearman_bg_exec, $fork, $iterations) },
    gearman_async => sub { forker(\&gearman_async_exec, $fork, $iterations) },
    mem => sub { forker(\&mem_exec, $fork, $iterations) },
    innodb => sub { forker(\&innodb_exec, $fork, $iterations) },
    myisam => sub { forker(\&myisam_exec, $fork, $iterations) },
    blackhole => sub { forker(\&blackhole_exec, $fork, $iterations) },
    tt => sub { forker(\&tt_exec, $fork, $iterations) },
    tt_mem => sub { forker(\&tt_mem_exec, $fork, $iterations) },
    memcached => sub { forker(\&memcached_exec, $fork, $iterations) },
    memcached_fast => sub { forker(\&memcached_fast_exec, $fork, $iterations) },
    memcached_managed => sub { forker(\&memcached_mf_exec, $fork, $iterations) },
});
#cmpthese($results);


sub generate_data {
    my $blog_id = int(rand(100000));
    my $article_id = int(rand(100));

    my $date = '2009-01-01';
    my $key = join('::', $date, $blog_id, $article_id);

    return ($date, $blog_id, $article_id, $key);
}

sub forker {
    my ($code, $n_fork, $iterations) = @_;

    my @pid;
    for my $i (1..$n_fork) {
        if (my $pid = fork) {
            push @pid, $pid;
        } else {
            &$code($iterations);
            exit;
        }
    }
    waitpid($_, 0) for @pid;
}


##

sub gearman_async_exec {
    my $i = shift;

    my $client = Gearman::Client::Async->new (
        job_servers => ['***']
    );


    for (1..$i) {
        my ($date, $blog_id, $article_id, $key) = &generate_data;
        my $datapack = freeze({blog_id => $blog_id, article_id => $article_id, date => $date});
        my $task = Gearman::Task->new('TotalPV::count', \$datapack, {});
        $client->add_task($task);

        Danga::Socket->SetPostLoopCallback( sub { 0 } );
        Danga::Socket->EventLoop;
    }

}

sub gearman_bg_exec {
    my $i = shift;

    my $client = Gearman::Client->new;
    $client->job_servers('***');

    for (1..$i) {
        my ($date, $blog_id, $article_id, $key) = &generate_data;
        my $datapack = freeze({blog_id => $blog_id, article_id => $article_id, date => $date});
        my $task = Gearman::Task->new('TotalPV::count', \$datapack, {});
        $client->dispatch_background($task);
    }
}

sub gearman_exec {
    my $i = shift;

    my $client = Gearman::Client->new;
    $client->job_servers('***');

    for (1..$i) {
        my ($date, $blog_id, $article_id, $key) = &generate_data;
        $client->do_task('TotalPV::count', freeze({blog_id => $blog_id, article_id => $article_id, date => $date}));
    }
}

sub mem_exec {
    my $i = shift;

    my $dbh = DBI->connect('***', '***', '***');
    my $sth_1 = $dbh->prepare(q{select count from count_mem where date = ? and blog_id = ? and article_id = ?});
    my $sth_2 = $dbh->prepare(q{insert into count_mem (date, blog_id, article_id, count) values (?, ?, ?, 1) on duplicate key update count = count + 1});

    for (1..$i) {
        my ($date, $blog_id, $article_id, $key) = &generate_data;
        $sth_1->execute($date, $blog_id, $article_id) or warn $DBI::errstr;
        my ($count) = $sth_1->fetchrow_array;

        my ($date, $blog_id, $article_id, $key) = &generate_data;
        $sth_2->execute($date, $blog_id, $article_id) or warn $DBI::errstr;
    }
}

sub innodb_exec {
    my $i = shift;

    my $dbh = DBI->connect('***', '***', '***');
    my $sth_1 = $dbh->prepare(q{select count from count_innodb where date = ? and blog_id = ? and article_id = ?});
    my $sth_2 = $dbh->prepare(q{insert into count_innodb (date, blog_id, article_id, count) values (?, ?, ?, 1) on duplicate key update count = count + 1});

    for (1..$i) {
        my ($date, $blog_id, $article_id, $key) = &generate_data;
        $sth_1->execute($date, $blog_id, $article_id) or warn DBI::errstr;
        my ($count) = $sth_1->fetchrow_array;

        my ($date, $blog_id, $article_id, $key) = &generate_data;
        $sth_2->execute($date, $blog_id, $article_id) or warn DBI::errstr;
    }
}

sub blackhole_exec {
    my $i = shift;

    my $dbh = DBI->connect('***', '***', '***');
    my $sth_1 = $dbh->prepare(q{select count from count_blackhole where date = ? and blog_id = ? and article_id = ?});
    my $sth_2 = $dbh->prepare(q{insert into count_blackhole (date, blog_id, article_id, count) values (?, ?, ?, 1) on duplicate key update count = count + 1});

    for (1..$i) {
        my ($date, $blog_id, $article_id, $key) = &generate_data;
        $sth_1->execute($date, $blog_id, $article_id) or warn DBI::errstr;
        my ($count) = $sth_1->fetchrow_array;

        my ($date, $blog_id, $article_id, $key) = &generate_data;
        $sth_2->execute($date, $blog_id, $article_id) or warn DBI::errstr;
    }
}


sub myisam_exec {
    my $i = shift;

    my $dbh = DBI->connect('***', '***', '***');
    my $sth_1 = $dbh->prepare(q{select count from count_myisam where date = ? and blog_id = ? and article_id = ?});
    my $sth_2 = $dbh->prepare(q{insert into count_myisam (date, blog_id, article_id, count) values (?, ?, ?, 1) on duplicate key update count = count + 1});

    for (1..$i) {
        my ($date, $blog_id, $article_id, $key) = &generate_data;
        $sth_1->execute($date, $blog_id, $article_id) or warn DBI::errstr;
        my ($count) = $sth_1->fetchrow_array;

        my ($date, $blog_id, $article_id, $key) = &generate_data;
        $sth_2->execute($date, $blog_id, $article_id) or warn DBI::errstr;
    }
}

sub tt_exec {
    my $i = shift;

    my $tt = TokyoTyrant::RDB->new();
    $tt->open('***', ***);

    for (1..$i) {
        my ($date, $blog_id, $article_id, $key) = &generate_data;
        my $count = $tt->get($key);

        my ($date, $blog_id, $article_id, $key) = &generate_data;
        my $count = $tt->addint($key, 1);
    }
}

sub tt_mem_exec {
    my $i = shift;

    my $tt = TokyoTyrant::RDB->new();
    $tt->open('***', ***);

    for (1..$i) {
        my ($date, $blog_id, $article_id, $key) = &generate_data;
        my $count = $tt->get($key);

        my ($date, $blog_id, $article_id, $key) = &generate_data;
        my $count = $tt->addint($key, 1);
    }
}

sub memcached_exec {
    my $i = shift;

    my $mc = new Cache::Memcached { servers => [ '***' ] };
    for (1..$i) {
        my ($date, $blog_id, $article_id, $key) = &generate_data;
        my $count = $mc->get($key);

        my ($date, $blog_id, $article_id, $key) = &generate_data;
        $mc->set($key, 1) unless ($mc->incr($key, 1));
    }
}

sub memcached_fast_exec {
    my $i = shift;

    my $mc = new Cache::Memcached::Fast { servers => [ { address => '***', noreply => 1 } ] };
    for (1..$i) {
        my ($date, $blog_id, $article_id, $key) = &generate_data;
        my $count = $mc->get($key);

        my ($date, $blog_id, $article_id, $key) = &generate_data;
        $mc->set($key, 1) unless ($mc->incr($key, 1));
    }
}

sub memcached_mf_exec {
    my $i = shift;

    my $mc = Cache::Memcached::Managed->new(
        data => '***',
        namespace => '',
        expiration => '1D',
        version => 1,

        group_names => ['blog_id'],
        memcached_class => 'Cache::Memcached::Fast',
    );

    for (1..$i) {
        my ($date, $blog_id, $article_id, $key) = &generate_data;
        my $count = $mc->get(
            id => $key,
            key => 'count',
        );

        my ($date, $blog_id, $article_id, $key) = &generate_data;
        $mc->incr(
            value => 1,
            id => $key,
            key => 'count',
            blog_id => $blog_id,
        );
    }
}