2012年11月アーカイブ

perlでデータベースを使う時に誰もが必ず使うDBI。その接続時に使うconnectメソッドの第4引数に設定しているオプションがサービスによりまちまちなんだけど、だれか鉄板設定を教えてください。

僕が使うのが、

my $dbh = DBI->connect($dsn, $user, $password, {
    AutoCommit => 1,
    PrintError => 0,
    RaiseError => 1,
    ShowErrorStatement => 1,
    AutoInactiveDestroy => 1,
});

これ。

加えて、mysqlであれば

mysql_enable_utf8 => 1
mysql_auto_reconnect => 0,

SQLiteだと

sqlite_unicode => 1
sqlite_use_immediate_transaction => 1

を追加しています。

自分が理解している範囲で軽く説明

AutoCommit => 1

トランザクションに関する設定。DBIのデフォルトが有効です

PrintError => 0

クエリ実行時などにエラーが発生すると、warnを使って出力するオプション。DBIのデフォルトは有効ですが、このあとのRaiseErrorを使うのでここでは無効

RaiseError => 1

クエリ実行時などにエラーが発生した際に、例外を投げます。DBIのデフォルトは無効で、エラーがないかどうかを自分で各メソッドの戻り値、errやerrstrを使って処理する必要があります

ShowErrorStatement => 1

RaiseErrorやPrintErrorのエラーメッセージに、エラーの起きたクエリ・ステートメントを追加してくれます

AutoInactiveDestroy => 1

DBI >= 1.6.14 でサポート。forkした際に安全に$dbhを破棄してくれる。詳しくはここ

他にも鉄板設定あったら教えて!

memcachedのキャッシュキーにsha1やmd5のhash値を使うとtcpdumpや解析ツールで覗きにくくなるので、cacheには出来るだけわかりやすい名前をつけて下さい。というハナシ

下のようなコードのように

sub cache {
    state $cache = Cache::Memcached::Fast->new({
        namespace => 'db:',
    });
}

sub key {
    my ($self,$key) = @_;
    sha1_hex($key);
}

sub cached_member {
    my ($self, $member_id) = @_;
    my $key = $self->key('member:'.$member_id);
    my $member = $self->cache->get($key);
    if ( !$member ) {
        #DBから取得
    }
    $member;
}

sub cached_foo {
    my ($self, $member_id) = @_;
    my $key = $self->key('foo:'.$member_id);
    my $foo = $self->cache->get($key);
    if ( !$foo ) {
        #DBから取得
    }
    $fooo;
}

全てのキーをsha1_hexでhash化してしまうと、全てのキーが

[0-9a-f]{40}

に統一されてしまい、解析ツールでみてもどれがどのキャッシュだか分からなくなってしまいます

例えば tcpdumpとpt-query-digestをつかった解析

$ sudo tcpdump -s 65535 -x -nn -q -tttt -i any -c 50000 port 11211 > /tmp/tcpdump.txt
$ perl ./pt-query-digest --type memcached /tmp/tcpdump.txt

以下がその一部

# Query 1: 17.17 QPS, 0.02x concurrency, ID 0x47FBFB3C523D96F9 at byte 4709526
# This item is included in the report because it matches --limit.
# Scores: Apdex = 1.00 [1.0], V/M = 0.00
# Query_time sparkline: | .^-_   |
# Time range: 2012-11-27 00:24:21.435633 to 00:24:32.733516
# Attribute    pct   total     min     max     avg     95%  stddev  median
# ============ === ======= ======= ======= ======= ======= ======= =======
# Count         28     194
# Exec time     56   218ms    30us    23ms     1ms     4ms     2ms   490us
# Query size    67   3.04M       0  50.26k  16.06k  40.32k  14.86k  15.96k
# Boolean:
# Memc error     1% yes,  98% no
# Memc get     100% yes,   0% no
# Memc miss      1% yes,  98% no
# String:
# Hosts        10.x.x.x (80/41%), 10.x.x.x (30/15%)... 5 more
# key print    db:?
# Query_time distribution
#   1us
#  10us  ############################
# 100us  ################################################################
#   1ms  #########################################
#  10ms  #
# 100ms
#    1s
#  10s+
get db:237c9527d4fae433391649ee6bdc92c8bfdb2624

注目するのは「key print」の行。全部、「db:?」でまとめられてしまうので、何のキャッシュを取得しようとしているのか分からなくなってしまい、キャッシュの取得に無駄がないかなど調査が不能になります。

memcachedのkeyにhash値を使うのはおそらく、memcached injectionを避けるためだと予想するのですが、それならば、URI escapeをするだけで十分です。その上でescape後のkeyの長さが250文字を超えるようであれば、仕方なくhash値を使うという実装の方がオススメです

use URI::Escape;
sub key {
    my ($self,$key) = @_;
    $key = uri_escape($key,"\x00-\x20\x7f-\xff");
    if ( length($key) > 200 ) { #namespace分の余裕
        $key = sha1_hex($key);
    }
    $key
}

この辺のモジュールを使うといろいろ面倒をみてくれます

Happy memcache:D

去年の第一回では、次の日に3時間の昼寝をしていたようですが、今年は開催中に体調を崩してしまい、参加者の皆様がハックをしている最中にクッションで横になっているという状態になってしました。セットアップの不手際と合わせて申し訳ないと思っています。また家の事情的なこともあり、準備段階であまり協力できなかった中、ibuchoさん、941さん、やぶたさん、tagomoris、sugyanには感謝しております。そして参加者、協力して下さった方みなさまに感謝しております

livedoor Techブログ : #isucon2 リアルタイムフォトレポート 更新終了

livedoor Techブログ : #isucon2 参加者・関連エントリまとめ

tagomoris/isucon2 - GitHub

ISUCON2では課題となったチケット販売サイトのアプリケーションの作成はsugyanが行い、ベンチマークツールはtagomorisが構築しています。お前は何をやっていたのかというと、PRE-ISUCON(自称)として課題アプリケーションの高速化に取り組んで、実際にどの程度まで数字がでるのか、またベンチマークツールやチェックスクリプトに抜けはないかなどなどのために日夜ごにょごにょしておりました。なによりこの期間にやっていたことをtwitterにつぶやけなかったのが厳しかったです^^

その結果として出したのが以下の数字。

isucon_kazeburo.png

優勝のfujiwara組(二連覇おめでとうございます!本当にすごい!)が83474なので、約5.6倍のスコアとなっています。7時間で達成したfujiwara組と異なり、この数字を出すまでに一週間程度掛けているので単純比較できるものではない点はご注意ください。

ベンチマークツールについて

さて、今回のISUCONのベンチマークツールの構成は1+3のツールに分かれています。

  • (1) データの初期化と数枚のチケット購入を行い、1秒後にチケット購入の順序確認とサイドバーの更新の確認を行う starter.js
  • (2) トップページからページをたどり、最終的にチケットを購入する buyer.js
  • (3) 各静的コンテンツのMD5値の確認と、チケット購入を行い、1秒後に座席表の更新を確認する checker.js
  • (4) http_load + patchで負荷を掛ける httpload.js

(1) のstarter.js が正常に終了したのを確認したあと、(2)から(4)が同時に起動します。buyerは25並列のプロセスが4つ、checkerは1並列1プロセス、httploadは50並列2プロセス、合計201並列でアプリケーションに対してリクエストを行います。

最終的なスコアは、この4プロセスのうち、(2)と(4)で決まります。

スコア = X - (Y * 0.01 + Z * 0.001)
  X. チケット購入の完売までの所要時間(ms)
  Y. 完売後の購入リクエスト処理数
  Z. 全体でのGETリクエスト処理数

X. のほとんどは(2)のbuyer.jsによるリクエスト、Zは(4)のhttp_loadによるリクエストになりますが、Zは1/1000されることから実質的にはチケット完売までの所用時間によって勝負が決まるようになっていました。

ここから僕のハックの話

静的ファイルへの書き出し

sugyanのアプリケーションを見た際に、まず最初に考えたのがたぶん多くのチーム同様最新n件を取得するクエリの改良です。しかしこれはいくつか試したあとすぐに諦めました。それよりも全体のHTMLを1秒以内に静的に書き出してしまう方が、圧倒的に高速だからです。今回のアプリケーションでは

  • トップページ
  • アーティストごとのページ * 2
  • 座席表ページ * 5

の合わせて9ページのみなので、全てを事前にファイルに書き出し、また購入後1秒以内にすべて更新しなおすことができれば表示時の負荷はなくなります。人の1秒は刹那でもコンピュータの1秒はかなりいろいろなことができます。この手法は数分でグッズを売り切るLINEシークレットセールでも使っています。 このHTML書き出しのスクリプトがこちら

事前gzip

次に取り組んだのが、コンテンツの圧縮です。今回のISUCONのコンテンツはどれもサイズが大きくなっているのでgzip圧縮が必須です。(なのでベンチマークツールにもAccept-Encodingヘッダをいれてます)

  • チケットページ - 300KB
  • jquery - 91KB
  • jquery-uri.js - 200KB
  • jquery-ui.css - 33KB
  • isucon_title.jpg - 31KB

これらのページを配信時にgzip圧縮していたのではCPUに負荷が掛かりすぎてしまいます。そこで事前にgzip圧縮を掛けて、WebサーバでリクエストのAccept-Encodingをみて、配信仕分ける方法とりました。nginxでは、HttpGzipStaticModuleを使います。HttpGzipStaticModuleは標準ではインストールされないので、configureで指定が必要です

./configure --with-http_gzip_static_module

設定では

gzip_static on;

とするだけです。

query-uri.js
query-uri.js.gz

と2つのファイルがあればAccept-Econdingにそって出し分けできます。これを使いjsやcssはすべて圧縮し、トップページなどもHTMLを書き出す際にgzip圧縮版も用意して配信時の負荷を削減しました

トランザクションレス => RDBMSレス

最期に残ったのが購入リクエストの処理です。元の購入トランザクションはわざとらしく複雑に書かれています

$dbh->begin_work;
$dbh->query('INSERT INTO order_request (member_id) VALUES (?)',$member_id);
my $order_id = $dbh->last_insert_id;
my $rows = $dbh->query(
    'UPDATE stock SET order_id = ? WHERE variation_id = ? AND order_id IS NULL ORDER BY RAND() LIMIT 1',
    $order_id, $variation_id,
);
if ($rows > 0) {
    my $seat_id = $dbh->select_one(
        'SELECT seat_id FROM stock WHERE order_id = ? LIMIT 1',
        $order_id,
    );
    $dbh->commit;
    render('complete.tx', { seat_id => $seat_id, member_id => $member_id });
} else {
    $dbh->rollback;
}

くせ者は「order_id」と席の順をランダムに決定する「ORDER BY RAND()」の2つです。後者についてはstarterでのチェック漏れがあったようですが。。

今回のベンチマークの中では「order_id」はシーケンシャルな番号が求められていないので、ユニークな番号があればそれで代替できます。そして席のランダム性はデータを作る段階でランダムな順を決めてしまえば購入時に選ぶ必要ありません。そこで以下のように席の種類ごとに1からその個数までのランダムな番号を振っていきました。

my $variations = $dbh->select_all(
    'SELECT id, name FROM variation ORDER BY id',
);
for my $variation (@$variations) {
    my $stock_count = $dbh->select_one('SELECT COUNT(*) FROM stock WHERE variation_id = ?',$variation->{id});
    my @rid = shuffle( 1..$stock_count );
    my $rows = $dbh->select_all('SELECT id FROM stock WHERE variation_id = ?', $variation->{id});
    for my $row ( @$rows ) {
        my $rid = shift @rid;
        $rid = $variation->{id} * 100_000 + $rid;
        $self->dbh->query('UPDATE stock SET rid = ? WHERE id=?', 
                          $rid, $row->{id});
    }
}

すると、購入は、席の種類ごとにシーケンシャルなIDが返せるミドルウェア(例えばmemcached)を導入し

my $max = $memcached->get('max_id:'.$variation_id);
my $rid = $memcached->incr('vari_id:'.$variation_id);
if ( $rid > $max ) {
    return $c->render('soldout.tx');
}
$rid = $variation_id * 100_000 + $rid;
my $rows = $dbh->query('UPDATE stock SET order_id = ? WHERE rid=?',$rid,$rid);

と書けるようになります。さらに、incrされた事で購入されたことが自明になるので、最期のUPDATEクエリも削除し

my $max = $memcached->get('max_id:'.$variation_id);
my $rid = $memcached->incr('vari_id:'.$variation_id);
if ( $rid > $max ) {
    return $c->render('soldout.tx');
}
$rid = $variation_id * 100_000 + $rid;
my $seat_id = $self->memcached->get('rid:'.$rid); #事前にcache

となりました。DBへの書き込みはHTMLに書き出すworkerで行えば1秒以内にDiskに書き込まれるのでまぁまぁ安心です。ここまでシンプルになればperlで実装する必要もなくなり、lua-nginx-moduleを使ってnginx上だけで購入までできるようにしました。それがこのXSS/SQL-injectionありまくりのソースコード

今回はlua-nginx-moduleやmemcached、mysqlへのアクセスを行うためこれらのパッケージがバンドルされているopenrestyを使いました

トラフィック対策

ここまできてのスコアは25000程度で、たまにFailする状態でした。かなり悩んで気付いたのが通信量で、トラフィック多すぎて購入リクエストが邪魔されたり、通信にCPUがとられHTML書き出しが遅くなっているんじゃないかということが分かりました。そこで、思い切ってhttp_load対策としてnginxに

    location ~ ^/(images|js|css)/ {
        rewrite_by_lua 'ngx.sleep(2.5)';
        root ../../../staticfiles;
    }

として、静的ファイルの配信にdelayをいれてみました。スコア算出でhttp_loadの割合が低い事、また購入buyer.jsではこれらのファイルにアクセスしないのでできた技です。

これでめでたく最終的なスコア「14787」となりました

さいごに

僕のコードはすべてgithubにて公開されています。

https://github.com/kazeburo/isucon2_hack/

ぜひみて頂ければと思います。

これからの楽しみ方としては、starter.jsやchecker.jsにさらに厳密なチェック、例えばorder_idがシーケンシャルになっていることや、サイドバーの出現順序の確認が加わったらどう実装するか、またbuyerでjsにアクセスしたら?などを考えるとあなたのマゾ度が試されて面白いかもしれません。