ISUCON本選に出場して9位でした。最高スコアは3位

ISUCON6本選行ってきました

f:id:bluerabbit:20161022094351j:plain

恒例のレゴ

f:id:bluerabbit:20161022091337j:plain

戦ってきましたー

f:id:bluerabbit:20161022091544j:plain

最終スコア

f:id:bluerabbit:20161031215248p:plain

チームにるぽの結果は9位でした。

スコアの推移

f:id:bluerabbit:20161031215240p:plain

最高スコアは31,776点で最高スコアだけなら3位でした。 このスコア出たときはいけるぞ!まだ入賞狙える可能性あるぞ!という感じでしたが、まだ16時だったので、もう少し改善すれば・・という感じでしたが、結果的にはその後バグを混入してしまい1万点台で終わって結果9位でした。

むむむ、惜しかった。

ココまでこれたのもメンバーのおかげ、najeiraが事前に問題予想でチャットとかくるんじゃないかな?ってことで事前に参考実装をgithubにコミットしてて、実際にチャットの実装が使える内容が来てビンゴ!だったのとsoundTrickerがdockerに詳しかったのでインフラ周りはsoundTrickerに完全にお任せという感じになった。

サーバ構成

  • isu01
    • nginx
      • go
      • react
  • isu02
  • isu03
  • isu04
    • go
    • react
  • isu05

isu01のサーバにてnginxでhttpsを受けて、isu01-04のサーバにhttpで振り分けて処理させる構成にし、isu05はmysqlとredisを入れる形にしました。

感想

賞金は取れなかったけど、ISUCON最高のメンバーで最高に楽しかったなー。 ISUCON6裏話Nightするかもしれないらしいので行こう。

ISUCON6予選突破しました

ISUCON6予選突破

ISUCONというWebアプリケーションのパフォーマンス改善コンテストに参加し、予選突破しました。 やっとですよ。やっと。ISUCON4, ISUCON5と予選敗退していてプライドがズタズタになっていたのでやっと突破できて本当に嬉しいです。

スコアは124,271点(ベストスコアは13万点台)でした。

チーム:にるぽ

appengine ja night(現gcp ja night)つながりでチームを組みました。 @najeira がGo推しだったので言語はGoを選択。私は2年ぶりくらいにgolangを触ることにw

出場チーム数

317チーム中11位でした。

ちょっと気になってコレまでのチーム数を調べたら下記の通り

  • ISUCON1 20チーム
  • ISUCON2 25チーム
  • ISUCON3 74チーム
  • ISUCON4 185チーム
  • ISUCON5 271チーム
  • ISUCON6 317チーム

年々参加チーム数が増えてて強豪揃いの中、ナントカ予選突破できて良かったです。

予選問題

予選の問題は、はてなダイアリーはてなスターを模したisudaとisutarのhttpサーバが立っていてマイクロサービスアーキテクチャな構成でした。

isudaはキーワードと本文を投稿でき、キーワードが投稿されると全ての投稿本文にあるキーワードがリンクされます。まさにはてなダイアリーみたいな感じです。

予選でやったこと

ざっと変更点を箇条書きすると下記になります。

  • isudaとisutarの統合
  • インメモリーキャッシュ
  • strings.NewReplacerを使うように変更
  • 正規表現ライブラリをrubexに変更
  • /initializeで最初の1ページはHTML, Regexp, Starのキャッシュを生成
  • nginxで静的ファイル配信
  • テーブルにindex追加
    • entry#updated_at
    • star#keyword

ボトルネック

今回の問題はボトルネックがDBではなくキーワードリンクしたHTMLを生成するhtmlifyでした。 DBはuserが500件程度, entryが7000件程度と件数が少なかったためクソな検索でもボトルネックになっていませんでした。 フルスキャンしてるQueryはありましたが最後までそのまま放置し、ボトルネックに集中する戦略にしました。

改修

まずは目に付くマイクロサービスになっているisudaとisutarの統合。httpをやめて直接SQLを発行かつインメモリキャッシュできるように変更。ココはサクッと @soundTricker が行いました。振り返ってみるとあとはひたすらインメモリーキャッシュですね。htmlifyしたHTML、Regexpインスタンス、Star、Spam、Userはテーブル全件をキャッシュしました。

並行に作業を進めていたのでどれが劇的にスコアに影響を与えたのか言い難いですが、終盤スコアがあがっていったのは下記の変更を入れ始めてからだったと思います。

  • htmlifyの中で正規表現オブジェクトを毎回作るのではなくhtmlifyの外で正規表現オブジェクトを作る
  • HTMLと正規表現オブジェクトをキャッシュする
  • strings.NewReplacerの利用
  • 正規表現ライブラリをrubexに変更
    • 終盤は正規表現ライブラリを変更したら2万点くらい上がりました。

司令塔 najeira

@najeira が本当に素晴らしい仕事をしてくれてインフラとアプリの改修、ボトルネックの計測という全ての面で大活躍。「そこって遅いの?本当にボトルネックなの?」と問いかけどこを修正するべきかを指揮し、全てが的確でした。@najeira がhtmlifyの対応をしている時にじゃ他のところやるかーってなっていたのを「いや三人でココをナントカするべき」という判断を下したのが印象深いです。

また、事前に下記のgoライブラリを用意してました。

ボトルネックを調査するためにmeasureで計測

まず最初にmeasureを埋め込み/statsにアクセスすると計測結果を確認できるようにし、/stats_resetで計測結果をクリアできるように準備します。その後ベンチマークを走らせて/statsのurlを叩けば遅い箇所が一目瞭然になるようにしていました。計測結果はGoogle Spreadsheetで共有し @najeira が計測した結果を元に次にどこを修正すべきかの道筋が立てられたのは本当に大きいです。

この辺りは @najeira とは2回目のISUCON参加ということもあり、事前に考えて準備し工夫していた効果が出て良かったです。

debugとconv

私が普段golang触っていないこともあり、デバッグするためのライブラリdebugを作ってもらいました。とりかくdebug.Printしまくってポカミスを減らせました。debug.Printは実行時に実行されたファイル名とファイルの行数と引数の値を確認できるライブラリです。事前に即席で作ってもらいました。

golangに慣れてないせいで数値から文字列、文字列から数値変換など簡単な事もさっと出てこないためconvを作ってもらいました。Stringにしたかったらconv.Stringだし、Intにしたかったらconv.Int(s)すれば済みます。

また、/testというハンドラを用意しそこでデバッグするようにしてました。

r.HandleFunc("/test", myHandler(testHandler))
func testHandler(w http.ResponseWriter, r *http.Request) {
  debug.Print("== Call /test")
  responseText := conv.String("test")

  // ココでfuncのコードを動作確認

  w.WriteHeader(200)
  responseText = fmt.Sprintf("%s", responseText)
  fmt.Fprintf(w, responseText)
}

あとは起動してcurl http://localhost:5000/testする感じです

予習

事前に一度三人で集まってpixivの社内ISUCONの課題を解きました。素振り重要です!matsuuさんのおかげで初azureでもさっと課題に取り組めました。感謝。 matsuuさん毎年いつもありがとうございます。

環境構築

サーバ起動したらansibleで全員ssh入れるようにし主要なツールは入れられるようにしてました。

デプロイ

fabricを使いました。fabric使ったこと無かったけど便利ですね。簡単で軽量で。 これを使ってgoのビルド、デプロイやnginxの設定ファイルの配布やnginxの再起動など全てをfabコマンドで行えるようにしてました。

競技終了前のチェック

大事ですね。この時間ずっとドキドキしていました。17時までに修正をやめて再起動テストを行う時間を作ろうというのは最初から話をしていたので、大きな修正は16:50頃には終わっていました。そこからはずっと再起動テストを行いスコアがfailしないこと、レギュレーションを読み直して大丈夫だよね?ってのを確認してました。

最後に

本当にメンバーに救われました。@najeira, @soundTricker のおかげで本選に出場できました。メンバーには感謝です。 私はココをこうすればいいんじゃないの?とは言えてもgolangだと書き方がわからなくってみたいなのが多々あり、アイデアしか出せない場面もあったり本選は足をひっぱらないようにgo力高めておきます。

本選も優勝目指して頑張るぞい。

Amethystを使い始めてSpacesを見直してTotalSpaces2を入れた

MacでSpacesはVMWareWindowsを起動するときくらいしか使ってなかったんですが、Amethystというタイル型ウィンドウマネージャを入れたことでより良いSpaces環境が欲しくなってTotalSpaces2を導入した。

totalspaces.binaryage.com

TotalSpaces2の気に入ったところ

特定のアプリケーションは必ず同じスペースで起動できる

http://static.binaryage.com/a53f905c_images_showcase_app-assignments.png

Amethystでchromeは左右二画面で見たいけど、エディタやターミナルは常に最大化した一画面で見たかったので用途毎にスペースを変更し、スペース毎にAmethystのLayout設定を変更するようにした

スペース移動時に発生するアニメーション機能をオフにできる

http://static.binaryage.com/71b9f8c0_images_showcase_cube-transition.png

もっとかっこよく出来る機能があるのですが、完全にオフにする機能があってソレが快適です

スペースにショートカットキーを指定できる

http://i.gyazo.com/9c75bd439d0cd1baf7c240cae89f254b.png

Chromeでよくcommand+数字キーのショートカットを多用するのでそれと同じようにスペースも変更可能にした

rubyでArray#find, detectを使い分ける

ref.xaio.jp

detectメソッドは、findの別名です。

今までfindをよく使ってましたが、ActiveRecordを使っているところではActiveRecordのfindと区別するためにdetectを使おうってメンバーが話しててそうだなと思った。

jQuery#dataは値をconvertする

次のようなhtmlに$('#tel').data('tel')で取得すると300000000に変換されてしまう。

<span id='tel' data-tel='0300000000'>電話番号</span>

jQueryのバージョンによっては下記のようにjQuery#dataは数値っぽかったら数値型に変換した結果を返してくれるみたいです。余計なお世話だ。注意しましょう。

$('#tel').data('tel')         // 300000000
$('#tel').attr('data-tel') // "0300000000"

api.jquery.com

Hashから値を取り出す時にもコードに意思を埋め込む

何気ないところでもコードに性格でますよね。下記のようにHashからvalueを取得するの一つとっても

hash = {name: 'foo'}
hash[:name] # foo

下記のようにfetchを使う事でアクセスするキーが必ず存在することをコードで示せます。

動作が違うのはHash#fetchはキーが存在しない場合にKeyErrorがraiseされることです。

hash = {name: 'foo'}
hash.fetch(:name) # foo

特に気にもせず前者の方法を使っていたのですが、Hash#fetchを使う事でアクセスする際にキーが無ければnilではなくエラーにすることで必ずキーが存在するはずだという前提条件を示す事が出来て便利。

csvjsonを読み込んで特定のキーがある前提で処理をする時などはfetchを使う事でhash[:count].to_iみたいに数値型のキーが間違っていても0になって気付かないとかは開発中にすぐに気付けて良さそうですね。

class Hash

#SQLアンチパターン ファントムファイルに立ち向かう

SQLアンチパターン

SQLアンチパターン

SQLアンチパターン

ファントムファイル(幻のファイル)

  • 画像などのバイナリファイルをストレージにおいて、そのパスをDBに格納するのをアンチパターンとして紹介している
  • 本書でも絶対にストレージに置いてはダメだとは書いては無い、常に2つの設計を検討すると書いてある
  • 私はDBよりも安価になっていくS3などのストレージに保存した方がいいと思っていて、その場合にはどのようなテーブル構成がいいのかを記録しておこうと思って書く

まずはファントムファイルとは何かをざっとまとめとく

画像のファイルパスを保存する場合

下記のようなテーブルを作成してファイルパスを保存する

CREATE TABLE Screenshots (
  bug_id BIGINT UNSIGNED NOT NULL,
  image_id BIGINT UNSIGNED NOT NULL,
  screenshot_path VARCHAR(100),
  caption VARCHAR(100),
  PRIMARY KEY (bug_id, image_id),
  FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id)
);
  • screenshot_path VARCHAR(100)で画像のファイルパスを保存
  • この設計がアンチパターンと紹介されている

DBではなくストレージにファイルを格納する場合のリスク

  • DELETEしても実画像ファイルが一緒に削除されることが保証されていない(「孤児」となったファイルが蓄積されていく恐れ)
  • DBとファイルのトランザクションの問題
    • ファイルの内容を変更するとDBでコミットされる前に、他のクライアントがその変更されたファイルを参照する可能性がある
    • 削除する場合は、ファイルを削除してDBでコミットに失敗した場合にファイルは元に戻らない、あるいは先にコミットしてファイルの削除に失敗したら孤児ファイルになる
  • バックアップが難しい
    • DBのバックアップツールでファイルパスだとその時点のデータをexportすることが保証されない
    • ファイルのバックアップも別途実施する必要がある
    • 2つのバックアップを仮に同時に行ったとしてもトランザクション付きでバックアップツールを実行できる訳でもないので厳密にその時のスナップショットを取るのが難しい
  • RDBにあるアクセス権限制御が使えない(GRANT/REVOKE)
  • ファイルパスはSQLデータ型ではないのでDBで検証できない
    • 外部ファイルにはFKを付けられない
    • カラムをNot Nullにしても実データは本当にそのパスにあるかわからない

本に書いてあるアンチパターンを用いてもよい場合

  • データベースの容量を減らしたい
  • データベースのバックアップを短時間で終了したい
  • バックアップファイルの容量を抑えたい
    • 画像をデータベースに格納しないことで巨大なサイズのデータベースをバックアップするよりも管理がしやすくなる
  • 画像ファイルの加工が容易になる

画像のバイナリデータをBLOBに保存する

下記のようなDDLを用いてバイナリデータを保存することで上述したリスクを軽減できる

CREATE TABLE Screenshots (
  bug_id BIGINT UNSIGNED NOT NULL,
  image_id SERIAL NOT NULL,
  screenshot_image BLOB,
  caption VARCHAR(100),
  PRIMARY KEY (bug_id, image_id),
  FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id)
);
  • screenshot_image BLOBで画像を保存

ここまでが本に記載してある内容

それでもファイルをストレージサーバに置く理由

ストレージと言ってもクラウドストレージを使う、大体の理由はクラウドストレージの利点を受けたいからですね

  • DBサーバは比較的高価でS3などのファイルストレージは安い、これからも安くなっていく事が見込まれる
  • サーバのファイル容量を気にしなくていい
  • 期限付きURLの発行などがクラウドストレージの機能で可能
  • Webサーバを経由せずに配信が可能になる
  • 認証をかけたい時はWebサーバで受けて、X-Reproxy-URL, X-Accel-Redirectヘッダーを付けてNginx経由で配信させる手段を取れる
    • (クラウドストレージの認証機能も要件にあえば使える)

ファイルパスを保存するときのテーブル設計

前振りが長かったのですが、ここからが本題です。

上記にあげたリスクの中で下記のDBとファイルのトランザクションの問題が一番嫌だなーと思っていて、その対応のためのテーブル設計案です。

  • ファイルの内容を変更するとDBでコミットされる前に、他のクライアントがその変更されたファイルを参照する可能性がある
  • 削除する場合は、ファイルを削除してDBでコミットに失敗した場合にファイルは元に戻らない、あるいは先にコミットしてファイルの削除に失敗したら孤児ファイルになる

例としてGmailの用にメールを送信するアプリで添付ファイルが一つしか付けられないという事にしましょう。問題をシンプルにするために添付ファイルは1対Nではなく1対1にします。ストレージはS3を例にします。

メールのリソースに対してパスを持つテーブル

単純に作ろうとすると下記のようになりますが、これをやってしまうとトランザクションの問題が顕著に影響を受けて困ります。

CREATE TABLE Emails (
  id BIGINT UNSIGNED NOT NULL,
  subject VARCHAR(255),
  body VARCHAR(255),
  to_address VARCHAR(255),
  file_path VARCHAR(255),
  PRIMARY KEY (id)
)
  • ブラウザでメールの削除ボタンを押したとして、削除しようとした時に一緒にS3のファイルも消す必要がありますが、S3のAPIを使って削除もしようとするとレスポンスが遅くなりますし、ファイルを削除してDBでコミットに失敗した場合にファイルは元に戻らないみたいなことが発生する可能性があります。

ファイルはファイル専用のテーブルを作る

CREATE TABLE Emails (
  id BIGINT UNSIGNED NOT NULL,
  subject VARCHAR(255),
  body VARCHAR(255),
  to_address VARCHAR(255),
  cloud_storage_id VARCHAR(255),
  PRIMARY KEY (id)
)
CREATE TABLE CloudStorages (
  id BIGINT UNSIGNED NOT NULL,
  file_path    VARCHAR(255),
  delete_flag SMALLINT(1) NOT NULL,
  PRIMARY KEY (id)
)

各機能のテーブルに直接FilePathを保存しないようにして、削除ボタンをクリックされた場合はEmailsレコードは単純にDELETEして、CloudStorageレコードはdelete_flagを更新するようにし、非同期で別途削除するようにします。

こうすることでファイルを削除してDBでコミットに失敗した場合にファイルは元に戻らないみたいなことが発生しないようになります。

ファイルの内容を変更するとDBでコミットされる前に、他のクライアントがその変更されたファイルを参照する可能性がある

この問題については、ファイルを更新する際にCloudStoragesテーブルにINSERTしてEmailsのcloud_storage_idをINSERTしたIDに変更し、元のCloudStoragesレコードはdelete_flagを更新します。

別解としてCloudStorageテーブルを作成しなくても、Emailsを削除するときに削除されたという削除イベントのテーブルを作成し、そこにfile_pathを持たせることで単純にパスを持つテーブルでも対応しようと思えばできます。

※論理削除フラグは〜って言いたくなったらstatusカラムに変更するかw CloudStorageテーブルを作成した場合でも削除イベントのCloudStorageテーブルを作って下さい。今回の話題とは直接関係ないので割愛します。