前のエントリーで書いた Thundering Herd 問題への対策案 で、重いクエリを排他制御すればいいのではないかというご意見も頂いたので、それをmemcachedで実現するようなモジュールを書いてみた。
下のモジュールではmemcachedのaddを使って制御します。addが成功したときだけ渡されたコールバックを実行し、ロックを得ることができない場合はaddに失敗するので、その場合はsleepして処理をやりなおす。他の排他制御するモジュールと違い、キャッシュ専用なので、排他制御の前にキャッシュにgetを行い、sleep中に既にキャッシュができていないかを確認するようになっている。
コードはなんの確認もしてないのであしからず
package Cache::ExclusiveControl;
use Try::Tiny;
use Time::HiRes;
use Class::Accessor::Lite (
rw => [ qw(cache poll_time max_wait parallel) ],
);
sub new {
my $class = shift;
my %args = (
poll_time => 0.1,
max_wait => 10,
parallel => 1,
@_;
);
bless \%args, $class;
}
sub get_or_set {
my ($self, $key, $cb, $expires ) = @_;
my $value;
my $loop = 0;
while ( 1 ) {
my $lockkey = $key . "::lock::" . int(rand($self->parallel));
$value = $memcached->get($key);
last if $value;
my $locked = $memcached->add($lockkey, 1, $self->max_wait );
if ( $locked ) {
try {
$value = $cb->();
$memcached->set( $key, $value, $expires );
}
catch {
die $_;
}
finally {
$memcached->delete( $lockkey );
};
}
$loop++;
die "timeout" if $loop >= $self->max_wait / $self->poll_time;
Time::HiRes::sleep( $self->poll_time );
}
return $value;
}
1;
package main;
my $c_ec = Cache::ExclusiveControl->new(
max_wait => 10, #最大10秒待つ。10秒超えたらdie
poll_time => 0.05, #lockを確認する間隔
parallel => 1, #callbackが並行動作できる数
cache => Cache::Memcached::Fast->new(...)
);
# get_or_set( key, callback, expire );
my $value = $c_ec->get_or_set(
'cache_may_thunder',
sub {
my $sth = $dbh->prepare("selet super heavy sql");
$sth->execute(..);
my $row = $sth->fetchrow_hashref;
return $row;
},
3600, #expirer
);
おそらく、addをサポートするキャッシュシステム全てで使えるはず。あと、Cache::Memcached::Semaphore というモジュールはあるのだが、これは排他制御の部分しか提供してくれない。
block動作をするような排他制御システムを使う場合は、lockを獲得した後すぐに重いクエリを動かすのではなく、まずキャッシュへgetを行うのが必須だと思われます。