大規模環境における
マニアックなキャッシュ利用術

YAPC::Asia 2011 - 2011/10/15
Yuji Shimada (xaicron)

自己紹介

仕事

CPAN Modules

etc...

あと、gihyo.jp に 「高速なWeb APIの実装とテスト―Mobage APIを支えるノウハウ」という記事が上がっているので、興味があったら見てみてください!

http://gihyo.jp/dev/serial/01/perl-hackers-hub/000901

はい

はじめに

Mobage オープンプラットフォームではここ半年ぐらいで、トラフィックが以前よりふえて、負荷が高まって来ました。

はじめに

そこで、負荷を軽減するために、いろんな所にキャッシュを使うことにしました。

はじめに

どういう問題が起きて、どのようにキャッシュを利用することで解決したのか。

というような話を漫然とします。

はじめに

キャッシュと一口に言ってもいろいろありますが、基本的には Mobage では memcached を幅広く利用しているので
そのへんの話が主になります。

はじめに

基本的にイケてない泥臭い話がメインになります

Agenda

第一部

あれ、私のキャッシュ
更新されてない...?

みなさん、Cache::Memcached::Fast の set とか delete の戻り値見てますか?

POD には

Return: boolean, true for positive server reply, false for negative server reply, or undef in case of some error.

とか書かれてますね。

つまり、set とか delete がよくわからん理由で失敗すると、undef が返ってくるんですよ!

実は出来てないキャッシュの更新

実は出来てないキャッシュの更新

キャッシュ更新するのに3秒待つとか悲しい
(しかも更新されてない)

Cache::Memcached::Fast のデフォルトの timeout

オプション 時間 説明
connect_timeout 250ms コネクションを貼る時間
io_timeout 100ms データを取ってくる時間

Cache::Memcached::Fast のデフォルトの timeout

オプション 時間 説明
connect_timeout 250ms コネクションを貼る時間
io_timeout 100ms データを取ってくる時間

connect_timeout に対する対策

connect_timeout に対する対策

永続接続をする

connect_timeout に対する対策

永続接続をする

大事なことなので2回言いました

永続接続したいなぁ

ちなみに、io_timeout は超大量のデータをとってきたりすると発生するかもしれません。

永続接続とはいえ

永続接続とはいえ

なんだか悲しいですね。

そこでリトライですよ!

リトライをする

リトライをする

大体こんな感じ

package MyApp::Cache::Memcached;

use strict;
use warnings;
use Sub::Retry 0.03 qw(retry);
use parent 'Cache::Memcached::Fast';

our $MAX_RETRY      = 3;
our $RETRY_INTERVAL = 0.05;

for my $method (qw(set delete)) {
    no strict 'refs';
    *$method = sub {
        use strict 'refs';
        my $self = shift;
        # 失敗したら50ms待ち、3回までリトライする
        my $ret  = retry $MAX_RETRY, $RETRY_INTERVAL, sub {
            $self->SUPER::$method(@_);
        }, sub {
            my $ret = shift;
            defined $ret ? 0 : 1;
        };
        return $ret;
    };
}

1;

必要に応じて、add や incr などを追加すればいいでしょう。

リトライする

ので、3回ぐらいのリトライで実用上は問題ないんじゃないかと思います

リトライする

ちなみに get の場合は、本当にデータがない場合も undef が返ってくるので判定できませんが、
おんなじようにリトライしといてもいいんじゃないかと思います。

というかしてます。

まとめ

キャッシュを
workerで作成

キャッシュをworkerで作成

Mobage はいろんなコンポーネントがあって、いろんな人が見るキャッシュとかがあります

キャッシュをworkerで作成

で、その人達がみんなで「キャッシュなかったら生成するー」とかを書くのはめんどいよねってことがあるので、キャッシュ生成専用の worker を立てやってるところがあります。

キャッシュをworkerで作成

負荷対策の本筋とは直接は関係ありませんが、この仕組があることで、あとあといろいろ便利だったのでちょろっと紹介します。

キャッシュの作成

キャッシュ生成はだいたいこの二種類

on the fly で作る

アプリ側でキャッシュがなかったらDBとかから引いてきて作るのこと

以下のような method を生やしておくと便利

sub get_fallback {
    my ($self, $key, $callback, $expires) = @_;
    my $value = $self->get($key);
    return $value if defined $value;
    unless (defined $value) {
        $res = $callback->($key);
    }
    $self->set($key, $value, $expires) if defined $value;
    return $value;
}
# 使用例
my $value = $memd->get_fallback($key, sub {
    # キャッシュにヒットしなかった場合の処理
}, $expires);

on the fly で作る

あとは普通のことなので省略

キャッシュを worker で作成

というようなデータを worker で定期的に作成 + 変更があった場合は enqueue することで更新する

キャッシュを worker で作成

static/img/cache_from_worker.png

キャッシュを worker で作成

メリット

キャッシュを worker で作成

デメリット

キャッシュを worker で作成

Mobage では、各ゲームの情報なんかを worker で生成してます。

キャッシュを worker で作成

あとでこの仕組みを色々と使った例を紹介します。

第一部・

第二部

memcached への
トラフィックを削減する

key の偏り

先ほど例に上げた、config を memcached に入れる運用はよくやっていると思いますが、
よく利用される key へのアクセスが偏ってしまうという問題があります。

key の偏り

例えば、key の偏りをなくすために、以下のように、key に suffix をつけて保存先を分散する方法があります。

sub set {
    my ($self, $key, $value, $expires) = @_;
    for my $i (1..30 ) {
        $memd->set("$key:$i", $value, $expires);
    }
}

sub get {
    my ($self, $key) = @_;
    $memd->get(sprintf '%s:%s', $key, int(rand(30)+1));
}

key を変えて分散する方法の問題点

わりと効率が悪いです

そこで、全部のアプリサーバーに
memcached を立てることにしました

すいません、常識かもしれませんが、今年までローカルに立ってなかったんです...

ローカルに memcached を立てる

メリット

ローカルに memcached を立てる

static/img/local_memcached.png

  1. まず、アプリはローカルから get する
  2. ローカルにヒットしなかったらリモートから get する
  3. リモートから引いてきたデータをローカルに set する

ローカルに memcached を立てる

ローカル memcached を利用することで、リモートの memcached へのトラフィックが激減し、さらにローカルに永続接続することで、スループットも向上しました。

ローカルに memcached を立てる

デメリット

ローカルに memcached を立てる

とりわけ重要なのは、キャッシュの不整合が起きる可能性。
これはキャッシュの時間を極端に短くすることで対応。
数秒とか。それだけでもかなりの効果あり。

CPU 使用率は実際にはほとんど上がらない。

ローカルに memcached を立てる

「いまんところ使わないけど、とりあえずアプリサーバーに memcached 立てておくかー」ぐらいのゆるふわな感じでやっとくと、後々便利かもしれません。

はい

大体半分ぐらい来ました

って書いとくといいらしいです

DNS のキャッシュ

DNS のキャッシュ

の2種類のお話

内部向け DNS のキャッシュ

きのう @riywo さんが話して若干被ってるかもしれませんが、僕の方が圧倒的に早くスライドを書き終えていたので、パクりはあちらです。

あと、オープンプラットフォームは実装が、怪盗なんたらとかとは全く別なので、違う方法でやってるので多分へいき。

内部向けのDNSのキャッシュ

内部向けのDNSのキャッシュ

ちなみに、LVS とか MyDNS のいいところは

基本的に、アプリの改修なくかつ無停止に変更できるのがいい

内部向けのDNSのキャッシュ

fujiwara 組長のが昨日はなしていた、HAproxy とかは再起動が必要なので、こんかいのケースには使えない

内部向けのDNSのキャッシュ

しかし、既存の負荷分散だと厳しくなってきたので、なんとかしないといけない。

真っ先に思いたのは dnscache や、unbound などの DNS キャッシュをローカルに立てること

内部向けのDNSのキャッシュ

しかし、どちらも重み付けされたラウンドロビンができそうにない

\(^o^)/

内部向けのDNSのキャッシュ

結局アプリ側で自力で DB への負荷分散をやることに

イケてない話ばかりですいません

実装

  1. アプリで必要な DNS の一覧を取得する
  2. DNS に紐づいているエントリーと重みを MyDNS の DB から取得する
  3. fqdn を key にして memcached にぶっこむ

というバッチを作成し、数秒おきに実行する

実装

  1. アプリは memcached から対象のエントリーを取得する
    1. この時に、ローカルの memcached にも set しておく
    2. 更に、プロセス内にも同じキャッシュを持っておく
  2. 拙作、Data::WeightedRoundRobin で重み付けのラウンドロビンをして IP を取得
  3. 対象の DB へアクセス!

実装

static/img/internal_dns_cache.png

図にするとこんな感じ

実装

既存のコードを以下のような感じに書き換える

package MyAPP::DB;
use DBI;
use Data::WeightedRoundRobin;

sub connect {
    my ($self, $connect_info) = @_;
    my ($scheme, $driver, $driver_dsn) =
        (DBI->parse_dsn($connect_info->{dsn}))[0,1,4];
    my $driver_hash = {
        map { split '=', $_, 2 } split ';', $driver_dsn
    };
    # fqdn を ip に書き換える
    my $ip = $self->resolve($driver_hash->{host});
    $driver_hash->{host} = $ip;

    # dsn を再構築
    $driver_dsn = join ';', map {
        "$_=$driver_hash->{$_}"
    } keys %$driver_hash;
    $connect_info->{dsn} =
        sprintf '%s:%s:%s', $scheme, $driver, $driver_dsn;

    # 置き換えられた ip へ接続
    DBI->connect(@$connect_info{qw/dsn user password attr/});
}

sub resolve {
    my ($self, $fqdn) = @_;
    do {
        $self->{wrr}{$fqdn} ||= Data::WeightedRoundRobin->new(
            $self->fetch_dns_entry($fqdn)
        );
    }->next || $fqdn; # 取れなかった場合、fqdn を返す
}

sub fetch_dns_entry {
    my ($self, $fqdn) = @_;
    # 実際には プロセス内キャッシュ -> ローカルキャッシュ
    # -> リモートキャッシュの順にフォールバックする
    my $entry = $cache->get($fqdn);
}

効果

PV x 数回発生していた
DNS ルックアップが 0 になりました

内部向けのDNSのキャッシュ

簡単にやってることをまとめると

  1. 何かしらの key を決めて
  2. DB などのストレージにエントリーの一覧を記録しておき
  3. そいつのキャッシュを予め作っておいて
  4. アプリでキャッシュを取得して
  5. 対象の ip にアクセスする

っていうだけです。簡単ですね。

内部向けのDNSのキャッシュ

内部向けのDNSのキャッシュ

デメリット

内部向けのDNSのキャッシュ

普通は LVS で事足りるんですが

外向けのDNSのキャッシュ

今まで、内部トラフィックの話をしてきましたが、
Mobage オープンプラットフォームには、Gadget Sever という docomo ゲートウェイみたいなサーバーがあります。

この人は、外部のサーバーに対してリクエストを行います。

1分で分かる Gadget Server

static/img/gadget_server.png

外部DNSをキャッシュする

外部DNSをキャッシュする

そうだ、Furl にしよう!

それが悲劇の始まりであった...

いっぱいパッチ送ったよ...

外部DNSをキャッシュする

外部DNSをキャッシュする

DNS のキャッシュは Net::DNS::Lite + Cache::LRU を使えば良い感じに

外部DNSをキャッシュする

以下のようにやるだけ

use Furl;
use Net::DNS::Lite;
use Cache::LRU;

# Net::DNS::Lite のキャッシュを有効に
$Net::DNS::Lite::CACHE = Cache::LRU->new(size => 1024);

my $furl = Furl->new(
    # DNS ルックアップに Net::DNS::Lite を使う
    inet_aton => \&Net::DNS::Lite::inet_aton, 
);

...

外部DNSをキャッシュする

これで問題ないかに見えたが、キャッシュのヒット率が悪いことが判明

外部DNSをキャッシュする

外部DNSをキャッシュする

とはいえ、DNS のルックアップは大幅に減った

だが、まだいける!

外部DNSをキャッシュする 2.0

static/img/foreign_dns_cache.png

  1. キャッシュが全くない場合に、DNS を引く
  2. リモートとローカルの memcached にぶっこむ
  3. アプリ側で適当に rand() して実サーバーへリクエスト

外部DNSをキャッシュする 2.0

次回からは、

プロセス内キャッシュ -> ローカル -> リモート -> DNS ルックアップ

というかんじにフォールバック。

大体、DNS のルックアップが 1 / (サーバー x プロセス) ぐらいに。

外部DNSをキャッシュする 2.0

実装は以下のような感じ

package MyApp::UserAgent;

use Furl;
use Net::DNS::Lite;
use Socket;
use List::Util qw(min);

my $NET_DNS_LITE = Net::DNS::Lite->new;
my $RR_TTL_IDX     = 3;
my $RR_ADDRESS_IDX = 4;

sub request {
    my ($self, $req) = @_;
    my $furl = Furl->new(
        inet_aton => sub {
            my ($host, $timeout) = @_;
            return $self->my_inet_aton($host, $timeout);
        },
    );
}

sub my_inet_aton {
    my ($self, $host, $timeout) = @_;
    my $iaddr;

    my $ip_list = $self->fetch_ip_list($host, $timeout);
    while (@$ip_list) {
        # ランダムに ip を選択
        my $idx = int rand @$ip_list;
        $iaddr = Socket::inet_aton($ip_list->[$idx]);    
        last if defined $iaddr;
        splice @$ip_list, $idx, 1; # 繋がらなかった人を削除
    }

    return $iaddr;
}

sub fetch_ip_list {
    my ($self, $host, $timeout) = @_;

    # プロセス内キャッシュ -> ローカル -> リモートにフォールバック
    my ($ip_list, $ttl) = $self->cache->get($host);
    unless ($ip_list) {
        # A レコードを引く
        for my $rr (
            $NET_DNS_LITE->resolve($host, 'a', timeout => $timeout)
        ) {
            $ttl = min $ttl, $rr->{RR_TTL_IDX];
            push @$ip_list, $rr->[$RR_ADDRESS_IDX];
        }
        # キャッシュの生成
        $self->cache->set($ip_list, $ttl);
    }
    retrun $ip_list;
}

外部DNSをキャッシュする 2.0

本当はもっと色々やってますが、だいたいこんな感じで、外部のDNS をキャシュ

外部DNSをキャッシュする 2.0

する予定です

すいません、リリース間に合いませんでした

第二部・

おまけ

例えば、キャッシュを使わない

はい

いままでキャッシュの話してきて、なにいってんのこいつ?

例えば、キャッシュを使わない

キャッシュが必要とはいえ...

例えば、キャッシュを使わない

例えば、キャッシュを使わない

MySQL 5.6 の memcached プロトコルについてはよくわからないので省略

HandlerSocket について

HandlerSocket について

詳しくは

HandlerSocket について

置き換えやすいタイプのキャッシュ

HandlerSocket について

置き換えやすいタイプのキャッシュ


HandlerSocket について

とはいえ、HandlerSocket は

とかあるので、当然ながら全ての memcached を置き換えることはできませんが、マッチする所では強力ですね。

まぁオープンプラットフォームはつかってないんですけどね...

今日のまとめ

まとめ

トラフィックが増えてくると、よくわからないところでよくわからないキャッシュをいっぱい作らなきゃいけなくて大変なので、誰か助けてください。

俺達の戦いは
まだ始まったばかりだぜ!

ご清聴ありがとうございました

j or →: next

k or ←: prev

h or ↑: list

l or ↓: return

o or ↵: open

? or /: toggle this help