こちらからは以上です。
Fitbit charge 5を買った
Fitbit charge 4のバンドが留められなくなったので、やむなく後続モデルを買うことになった。1年以上使えたのでいいのではないか。
画面がカラーになっているけどそんなに見てないのでどっちでもいい。腕に何も巻いてないのが逆に気になっていたのか最近寝付きがよくなかった。これでよくなるといいですね。
ISUCON12 チーム :old_noto_innocent: で予選突破した #isucon
今年も id:nonylene id:wass80 と出場しました。最終スコアは50,696点で予選突破できました。分析基盤 (Kibanaやプロファイラ) に流れてきたデータを見つつ作戦を立てて実装する、という流れがうまくはまって順調にチューニングを進められたのがよかったと思います。
最終的なサーバー構成
- isuports-1
- App (Go)
- nginx
- isuports-2
- DB (MySQL, player_scoreのみ)
- isuports-3
- DB (MySQL)
やったこと
リポジトリはこちらです。
分析基盤まわりの話を id:nonylene が書いてくれたので、こちらもあわせてどうぞ。
id:wass80 視点の記事もあるので、こちらもあわせてどうぞ。
テナントごとのDBをMySQLに統合する
MySQLの様子を見ていて、あれ、競技のデータはどこ? と初期化スクリプトを見にいったらなにやらファイルをコピーしている、SQLiteだ!! となったのが一番の衝撃でした。 SQLiteもインデックスを貼るとかはできるけど、これは慣れた土俵に持っていくのがよい、ということでテナントごとのDBをMySQLに統合することにしました。
マルチテナントなのに各テーブルに tenant_id
カラムがあり、さらにsqlite-to-mysqlというスクリプトが用意されていたので、これは移行しろというメッセージなのか? と思いながら試行錯誤しつつデータ移行ロジックを完成させました。
初期化がギリギリで、素朴にsqlite-to-mysqlスクリプトでテナントDBの初期データから持ってくるのでは全く間に合わないため、移行済の初期データのmysqldumpを取って流し込むことでなんとか30秒以内に収めていました。
完全にMySQLに統合するのに2時間ぐらいかかりましたが、なんとか統合できました。ここで統合を完遂できたのが一番のバリューだったと思います。
テナントごとのDBをMySQLに統合するにあたっては、各DBをマージした1つのDBファイルを用意する→sqlite-to-mysqlでMySQLに流し込む という流れを取りました。以下のStackoverflowの回答を参考にしてマージしました。
player_score
をUPSERTできるようにする
player_score
は最新のデータしか参照していないので、履歴を持たないようにできます。(tenant_id, competition_id, player_id)
の複合主キーにすることでUPSERTできるようになりました。
visit_history
をUPSERTできるようにする
visit_history
は履歴データになっていますが、アプリケーションではユーザーが最初に訪れた履歴しか参照していないため、そのようなデータ構造になるように書き換えました。
このあたりで初期データの行数がぐっと減ったことで、初期化でmysqldumpを流し込むのが3秒ぐらいで終わるようになりました。
(2022/7/24 23:33 追記) UPSERTって書いてるけど最初の履歴だけ残したらいいのでINSERT IGNOREでいいですね。
player
のN+1を解決する
GET /api/player/competition/:competition_id/ranking
で player
テーブルに対するN+1クエリが発行されていたので、IN句を使うように書き換えました。
billing
(課金レポート) のデータ移行とN+1を解決する
課金レポートを都度生成するのではなく billing
というテーブルに保存しておく実装を id:wass80 にやってもらい、初期データから課金レポートを書き出しておくところを実装しました。その過程で狂ったインタフェースの関数が生えておもしろかったです。
そのあと課金レポートを参照する箇所にN+1クエリがあったので解消しました。 ISUCONでは基本的にINクエリで解決する方針を取ることが多いのですが、ここはJOINで書いたほうが楽だろう、ということで久しぶりにLEFT JOINを書きました。
SELECT c.id, c.title, IFNULL(b.player_count, 0) AS player_count, IFNULL(b.visitor_count, 0) AS visitor_count FROM competition c LEFT JOIN billing b ON c.id = b.competition_id WHERE c.id IN (?)
インデックスを貼る
スローログを見つつ、明らかに足りないインデックスを足してまわりました。
CREATE INDEX tenant_id ON competition (tenant_id); CREATE INDEX tenant_id_created_at_desc ON player (tenant_id, created_at DESC);
player_score
用のDBを参照する
MySQLの負荷がまだ高いということでDBを分割することになりました。このあたりはサーバーを手際よく準備してもらっていたので *sqlx.DB
のインスタンスをもう1つ用意して差し替えるぐらいでサクッと参照を分けられました。
DSNに interpolateParams=true
を足す
SQLのプレースホルダ置換を有効にするアレです。スコアが10000点ぐらい上がってウケました。
分析用グッズを剥がす
再起動試験の前に分析用の仕組みを剥がしてまわりました。これでさらにスコアが13000点ぐらい上がってまたウケました。
やってないこと
flockを剥がす
テナント単位でflockを取っているのをなんとか剥がせないか、とアプリケーションロックを使う実装を用意していましたが、参加者やコンテストが増えすぎるのか減点が大きくなって導入を断念しました。429を返してコンテストが増えすぎないようにするのも試したけどあまりうまくいきませんでした。キャッシュの導入ができているともうちょっとマシになったのかも?
ID採番ロジックを変える
採番用のテーブルにREPLACE文を発行しまくるおもしろ採番ロジックを剥がそう、と単なる乱数にしてみましたが整合性チェックに落ちるようになったので差し戻しました。uuidとか使えばよかったのかも? あるいは何もしてないRedisが立っていたのでkatsubushiを導入してほしいというメッセージだったのかもしれません。
キャッシュを入れる
今回はとくにどこにもキャッシュを入れることなく終わりました。先述したようにflockを剥がすにあたってはキャッシュでいくらか負荷を低減できているとよかったのかもしれません。
感想
インフラ周りの調整や分析基盤の用意は id:nonylene に、分析や作戦立ては id:wass80 にだいたいやってもらっていたので、自分はひたすら手を動かすことに専念できました。手が速いことが自分のバリューだと思っているので、今回はかなり本領を発揮できたのではないでしょうか。
今回は初手でSQLiteのデータをMySQLに統合しましたが、SQLiteのままスケールさせるにはどうするのが正解だったのでしょうか。SQLiteのまま予選を突破したチームがいれば話を聞いてみたいです。
(2022/7/24 21:51 追記) Discordを見ている限りだとSQLiteを残したままスコアを上げたチームのほうが多そうでした。みんなMySQLに移行しているものだと思っていたけど意外と少数派だったのか……。
去年の反省をもとに、初期化スクリプトをちゃんと読む・いきなり必殺技に手を出さない、を心がけて進められたのもよかったです。
今回の予選問題もかなりクオリティが高く、ベンチマークもスムーズだったように思います。運営の皆様ありがとうございました。今年こそは100万円をゲットしたいですね。
ベンチマーク履歴
■
知り合いに校正を頼んで直していったあとに筆が乗って付録が生えた……。
表紙のサイズがどれくらい必要なのか、形式は何なのか、とかよく分かっていないまま雰囲気でやっている。そろそろ形式を確認しよう。
input type="text" にpatternを指定してもrequiredでないなら空のとき検証成功する
CSSで検証成功したときにinputの背景色を変えるようにしています。
.input-20220720:valid { background-color: green; }
<form action="#"> <input type="text" class="input-20220720" pattern="[a-zA-Z0-9]{4}" maxlength="4"> </form> <form action="#"> <input type="text" class="input-20220720" required pattern="[a-zA-Z0-9]{4}" maxlength="4"> </form>
制約検証 - 開発者ガイド | MDN をちょっと読んだけどこうなる理由が分からなかったので規格を読んできます。
追記
MDNを見返すとこのように書いてありました。つまり、空の値のときはpattern
属性による検証がスキップされるということでしょうか?
pattern 属性が有効で、空でない値がパターンと一致しない場合、制約の検証によりフォームの送信ができなくなります。
<input>: 入力欄 (フォーム入力) 要素 - HTML: HyperText Markup Language | MDN
実質的に無料
同人誌印刷会社の仮注文を済ませて、予算の感覚がつかめてきた。スケジュール感も分かったので早割に間に合わせてみようとしてみているけど、早割を狙うと意外とそんなに余裕がない。とはいえ原稿はだいたいできているので、この1週間で仕上げて誰かに校正してもらおう……。
GitHub ActionsでPDFをビルドするworkflowを書いた。プライベートリポジトリでworkflowを走らせる時間には上限がある*1けど、コミケ直前の数週間走らせるぐらいなら実質的に無料だろうし気にすることはなかろう。workflowを整備するのは楽しくて、どうやると日々の作業の負荷が低減されるのか……などを考えながら進めたくなる。
GitHub Actions runner内にTeX Liveの環境を整えるところからやるのか、気が滅入るな……と思ったけど、Re:VIEWプロジェクト向けのDockerイメージが用意されていたので、コンテナ上でworkflowを動かすだけで済んで助かった。だいたい↓のブログ記事を参考にしました。
*1:Proだと3,000分/月, Freeでも2,000分/月 https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions