最近全然書いてなかったけど、そろそろ書かないと復帰できない気がしたので。
タイトル通り、今年もLAPRASのメンバーでISUCON予選に参加してきました。チームメンバーは去年から一人入れ替わり、チーム真人間として参加しました。メンバーは以下でした。
@showwin @KawamataRyo @denzowill
結果は92087点で32位。使用言語はPython。だってPythonの会社なんです。LAPRASは。
結局Pythonは1組も決勝には行けておらず、Goばっかりという形でした。逆にPythonを選択したチームでは1番、少なくともかなり上位の結果だったのかなと思うと悔しさもあります。
当日のリポジトリはこちらです。
なお、修正ごとの点数を記録するのを忘れたためどれくらい効果があったかは記憶と当事者の主観です。
当日の進め方
準備
11時くらいまでは各々準備をしながらレギュレーションの読み込み等をしていました。何が加点になって何が減点になるのかは大事です。 今年も3台のインスタンスが各チームに割り当てられる形式だったため、それぞれが1台ずつを専有し @showwin がインフラ(nginx等)を確認し残りの二名は各々アプリケーションの改善に取り組む方針としました。
また、この時点でDatadog APMを設定し、ボトルネックはほぼすべてAPMで確認するようにしておきました。
最初にやったこと
@KawamataRyoとともにコードリーディングを行い、コメントを付与していきました。この時点で明らかなN1や改善が即座に思いつくところはTODOとして置いておき後で処理しやすいようにしました。
さらに、app.py
1ファイルのままだと二人での作業がぶつかりやすい上に見通しも悪くなるため雑にファイル分割を行い、特に重いエンドポイント等はロジックを別ファイルに切り出しました。
改善開始
DBにIndexがほとんどない状態だったので @KawamataRyo にざっくりとIndexを追加してもらいつつ /api/trend
はレギュレーション上加点箇所ではないのに激重だったため、他の改善点をAPMから見えるようにするため雑にキャッシュを設けました。
これだけでも若干点数はあがりました。
続いて、 isu_condition.condition
のデータ改善を行いました。該当列には hoge=true,foo=false,bar=false
といった形式で警告データがいれられているものの、実際には=true
の数を数えているだけだったためデータ投入時点で数を数えておきwarn_count
カラムを追加することでIndexが利用できるようにしました。
この修正自体は公式の講評でも触れられており効果的でした。ちなみにこのときに初期データも修正する必要があったためダンプファイルを編集した結果、画像データのバイナリが中途半端に壊れベンチ-マーカのバリデーションに失敗するトラブルに見舞われ右往左往しています。
アイコンの改善
アイコンデータがDBに格納されており、アプリケーション経由で戻していたのでnginxで戻そうとしています。
しかし、ログイン状態等に応じて404を戻す必要があるなど、nginxだけでは制御しきれなさそうと後に判明したため、ファイルを外に書き出して戻すのみにしました。これだけでもisu
テーブルのサイズを抑えることができました。効果はあったかな・・・?
get_isu_list のN+1改善
isu
の一覧を取った上で、最新のisu_condition
を取得する処理でN+1が発生していたのでSQLで結合して回避するようにしました。
理論上効果があるはずでしたが、この時点では大きなスコアの変化はありませんでした。どうも結合のあたりで十分にIndexを効かせてパフォーマンスを出せてないようだったため、クエリを2回発行する形に後に変更しています。
post_isu_conditionの改善
外部のサーバからISUの状態がどんどん飛んでくるのでそれを永続化するpost_isu_conditionがかなり負荷が高くなっています。初期では1/10だけを処理して他は無視する実装になっていますがそれでも重い。
そこで、雑にグローバル変数でバッファを設けて100件ごとにBulkInsertするように変更しました。これだと各スレッドごとにバッファができてしまう上に、100件に満たない値が最終的に無視されてしまい整合性が保てないのですが、元々の実装でも90%のリクエストは無視してるくせにレスポンスコード200を返しているため整合性はほぼ気にしなくていいだろうと判断して簡易実装しています。
ただ、この実装だと100件ごとのBulkInset時にAPIがタイムアウトし得るため、後にBulkInsert時は別スレッドを生成してそちらにたくす実装にしています。
redisの投入
DBの負荷を少しでも下げるため、isu
やuser
といったあまり変化がない情報はRedisでキャッシュをもたせるようにしました。
これは地味ながら効果はありました。
スコアの伸び悩み
レギュレーション上、初期実装で1/10に落とされているISUの状態情報を多く受け入れられるようにすることで点が伸びる想定でしたが1/2程度まで受け入れをしてもいまいち点が伸びない状態でした。この間に@showwinに3台構成にするためnginxやMariaDBの設定を調整してもらいました。
api/trendと向き合う
api/trend
は初手で雑なキャッシュをして以降特に触ってなかったのですがさすがに向き合うべきと思い着手しました。ここは以下のような実装でクエリの三重ループになっていました。
isu
をcharacter
ごとにgroup by
し存在するcharacter
の一覧を取得- 手に入れた上で
character
毎にisu
のリストを取得 isu
毎にisu_condition
の最新のレコードを取得し状態レベルに応じて集計
仕方ないので強引に1クエリにまとめました。
with latest_condition as ( select jia_isu_uuid, timestamp, warn_count from isu_condition where (jia_isu_uuid, timestamp) in ( select jia_isu_uuid, max(timestamp) from isu_condition group BY jia_isu_uuid ) ) select i.character, ic.warn_count, group_concat( i.id ORDER BY ic.timestamp desc ) as isu_id_list, group_concat( ic.timestamp ORDER BY ic.timestamp desc ) as timestamp_list from isu i left outer join latest_condition ic on i.jia_isu_uuid = ic.jia_isu_uuid group by i.character, ic.warn_count order by i.character, ic.warn_count
group_councat
を用いて必要な結果をカンマ区切りで戻すことで一気に取得できるようにしています。ここは書いている本人もあってるか全く自信がなかったので元々の実装の結果と突き合わせしながら延々と作業をしていました。
これはそれなりに効果があったはずですが、講評ではここの更新頻度に応じてユーザ数が増える実装だったらしいので、キャッシュ保持期間の5秒はもう少しチューニングの余地があったと思います。
また、キャッシュがなかった場合はこのクエリを実行するようにしていましたが、クエリ自体もそれなりに重いのでキャッシュが切れた際はとりあえずキャッシュを返しつつスレッドを生やしてキャッシュの再生成をするほうが良かったかもしれません。
isu graphで過度に情報を取得している問題の改善
ISUの状態を示す日次グラフを表示する機能があったのですが、コードがやたら込み入っており @denzow @KawamataRyoは読むのを後回しにしていたのですがインフラ周りをやり終えた@showwinが一瞬で時間範囲が広すぎる問題に気が付き、修正してくれました。
最後
ここまでで目立った修正はしたつもりだったものの、予選通過ラインとして予測した10万点には届いておらず、最終的にpost_isu_conditionを全開放してもサーバの余裕がある割に点が伸びない状態でした。
とりあえず思いつく限り細かい修正を行い、最後にAPMの無効化のロギングの停止を行いベンチマーカーを何回か回してる最中に時間切れとなりました。
振り返り
去年からメンバーが一人入れ替わり、またフルリモートでの参加でしたが予選通過こそできなかったもののそれなりに手応えのある会でした。特に序盤にレギュレーションの読み込みとファイル分割は後半に結構効いていたため来年もまずはこのあたりから固めつつ、来年こそはPythonでの決勝進出を目指します。