ISUCON 12とっても楽しかったですね。
今年はISUCON 6以降で久しぶりの登場となったJavaで参加してきました。
ISUCON 12における698チームのうち、利用言語が把握されているのが568チーム。 そのうちJavaを利用していたのはわずか5組(0.9%)ということで、なかなかレアな戦いになったようです。
予選問題
今年の予選問題についてはさまざまなところで語られていますが、マルチテナント型のSaaSがテーマでした。 どういう問題だったのかをここで逐一記述するよりも、こちらのエントリに詳しく纏まっているので、そちらをご参照いただく方がわかりやすいです。
当日にSQLiteがTwitterのトレンドに入ったとかそういう話もありましたが、個々のテナントに関するデータがSQLiteのファイルに格納されていることが特徴的であり、面食らった点でもあります。
もちろん、そういった情報は実装から読み取らなければなりません。 以下が予選中に競技者であるぼくたちへ公開された情報であり、あとは実装から読み取ります。
Java実装
ではJava実装がどうだったのかというと、こちらが実装でした。
Spring Frameworkベースで構築されていました。
APIエンドポイントはApplication.java
にあるので、このファイルを眺めるとおおよその実装は把握できる構成です。
実装上で特徴的なのは、DBトランザクションを使う代わりに、排他制御がロックで実行されているということでしょう。他言語ではflock
が使われていたようですが、Javaではsynchronized
が使われていました。
なぜか初期実装はトランザクションという概念を知らない人が書いた(という想定)ので、アプリケーションの整合性を保つために排他ロックをしている
(中略)
排他ロックの実装は、大昔のWebアプリケーションを知っている方には懐かしの flock です。(Javaのみ、実装上の理由で synchronized でした)
Webアプリケーションはdocker-composeで動作し、systemdから呼び出される構成です。
戦い
昨年度と同様、@kkasaiさん、@hondaYoshitakaさんと参戦しました。 当日は、@kkasaiさんがnginxやMySQLのログ解析やチューニング、@hondaYoshitakaさんとぼくが主としてアプリケーションのチューニングを担当しました。
目についたのがsynchronized
でのロックやN+1問題です。
ソースを読んだ結果として、そもそもこの排他制御はロックじゃなくてトランザクションで守るべき問題やろ、ということは明確でした。
その手段として、SQLiteからMySQLへのマイグレーションを選択し、ぼくがそれを担当することにしました。参加チームの半分程度が同様の戦略をとったようです。
そこで、「あまり馴染みがないSQLiteではなく、MySQLに取り込んでからチューニングを行おう」と考えたチームも多かったようです。アンケートの結果では、半分ほどのチームが移行を検討したようです。
マイグレーション
順序としては以下のように計画を立てました。
- SQLiteに対するデータアクセスを、MySQLに対するデータアクセスにするようソース修正していく
- SQLite上のスキーマと初期データを、MySQLへ移行する
- あとは友情と汗と努力
ソース修正
Java実装では、SQLiteに対するクエリ発行は以下のような形で実装されていました。
PreparedStatement ps = tenantDb.prepareStatement("SELECT * FROM player WHERE id = ?"); ps.setQueryTimeout(SQLite_BUSY_TIMEOUT); ps.setString(1, id); ResultSet rs = ps.executeQuery(); if (!rs.isBeforeFirst()) { return null; } return new PlayerRow( rs.getLong("tenant_id"), rs.getString("id"), rs.getString("display_name"), rs.getBoolean("is_disqualified"), new Date(rs.getLong("created_at")), new Date(rs.getLong("updated_at")));
こういった実装をセコセコとMySQL用の実装に修正します。
SqlParameterSource source = new MapSqlParameterSource().addValue("playerId", id); RowMapper<PlayerRow> mapper = (rs, i) -> new PlayerRow( rs.getLong("tenant_id"), rs.getString("id"), rs.getString("display_name"), rs.getBoolean("is_disqualified"), new Date(rs.getLong("created_at")), new Date(rs.getLong("updated_at"))); PlayerRow row = this.adminDb.queryForObject("SELECT * FROM player WHERE id = :playerId", source, mapper);
結構量はありましたが、とりあえずは完了し、次のステップに向かいました。
スキーマと初期データ移行
ここが厄介でした。 親切にも、SQLiteからSQLを取り出すスクリプトが用意されており、正直ここで苦戦するのは計算外でした。
何に苦戦したかというと、以下の点です。
- SQLiteの中のDBに格納されている初期データは数百万レコード(=数百万のINSERT文)であること
- データの初期化処理は30秒以内に完了しなければならないというレギュレーションがあること
ベンチマーカーによる負荷走行は以下のように実施されます。
- 初期化処理の実行 POST /initialize(30 秒でタイムアウト)
- アプリケーション互換性チェック(数秒~数十秒)
- 負荷走行(60 秒)
初期化処理もしくはアプリケーション互換性チェックに失敗すると、負荷走行は即時失敗(fail)になります。
これはすなわち、馬鹿正直に考えるとMySQLに対する数百万のINSERT文を30秒以内に完了しなければならないことを意味します。
INSERT文を分割しての並列でのINSERT等さまざまな方法を試しましたが、なかなかうまくいかず、ここでタイムアップを迎えました。
戦いを終えて
ぼくがMySQLへのマイグレーションで苦しんでいる間に、チームメイトの二人は着々と高速化を行い、上位1/4くらいに入る感じで終わりました。
強い課題感を感じたのは、やはり実装から業務仕様を読み取るスピードです。
例えば、ぼくが解決できなかった初期データの件は、ISUCON12 予選問題の解説と講評 : ISUCON公式Blogにて以下のような解決法が示されています。
さて、ここで player_score の処理と初期状態のデータをよく観察すると、アップロードされてくるCSVには無駄な行(同一の参加者の複数のスコア)が大量に含まれていることが分かります。
アプリケーションの仕様上、各参加者ごとにCSV内で出現する最後の行がリーダーボードで有効になるため、その1行のみを保存すれば十分です。
アプリケーションは、CSVを読みながら各参加者の最後の1行だけを覚えておき、INSERTするように変更できます。
初期データでは、各参加者について row_num が一番最大の行のみを抽出して、player_score テーブルを作り直すことができます。
きちんと実装から仕様を読み取れれば「なるほどな」となるのですが、限られた時間の中で高速に仕様を読み解き、その仕様からチューニング方法を思いつくスキル・スピードが全く足りない。 大きな課題を見つけて、今年のぼくのISUCONは終了したのでした。
課題はあれど、振り返ってみてもISUCONに参加したのはめちゃくちゃ楽しかったです。 自分の足りないところをなんとかすべく、1年間またやっていこうと思います。