この記事は、 PLEX Advent Calendar 2024の11日目の記事です。
はじめに
2024年に株式会社プレックスにエンジニアとして新卒入社した佐藤祐飛(@yuhi_junior)と申します。
ISUCON14にチーム「黒酢唐揚げサン丼」として出場し、初出場で30位入賞することができました。
順位: 30位 / 834チーム 得点: 24464点
ISUCONはつよつよエンジニアが多く参加するので準備なしに好成績を収めることがとても難しいコンテストです。しかし、私たちはチームで入念に準備することで初出場の新卒チームであっても入賞することができました。
本番のチューニング記録は他の上位のチームに任せて、本ブログでは、ISUCON本番に向けてチームで取り組んだことについて紹介したいと思います。ISUCONを勉強し始めた方や、入賞を狙っている方に読んでいただけたら嬉しいです!
自分やチームについて
2023年の8月からインターンを開始しました。webエンジニアとしての経験年数は1年4ヶ月になります。
ISUCON13にテックリードの@naro143さんが参加していたことをきっかけにISUCONの存在を知りました。当時、ISUCON本を買って読んでみましたが何も理解できなかったことを鮮明に覚えています笑。
新卒入社した後、日本CTO協会主催のISUCON研修に参加させていただくことが決まりました。ISUCONに興味があったのと本気で勝ちたかったので入念に準備をしました。その結果、見事優勝することができました。この時にISUCONの基礎体力をつけることができました。
今回のチームは、ISUCON研修で1位と2位だったメンバーで出場しました。しかし、全員がISUCON初出場の新卒1年目のチームでした。
チームで取り組んだこと
ISUCONに参加する上でチーム方針としてやりたいことがざっくり3つありました。
- 高い基準値(継続的にモチベーションを保つため)
- 知見の共有(学習コスト軽減 & 学んだ知識をISUCON以外にも活かすため)
- コミュニケーションを積極的に取る
以下に具体的に取り組んだことを紹介します。
現在地点の把握と目標設定
この研修で自信がついていたのでチーム全体に「100位くらいならいけるっしょ」というムードが漂っていました。しかし、本番2ヶ月前にISUCON13の問題を解いたところまさかの本番377位相当のスコアで「このままではヤバイ」という危機感を感じました。ここから僕たちのISUCONが始まります。
初めに100位以内を目標(本番1週間前に目標を50位に上方修正しました)として設定し、100位以内を目指す上で自分たちに足りないものを入念に話し合いました。目標が明確化されているからこそ、チーム全体で勉強するべき内容や本番で取り組むチューニングに優先順位をつけることができました。
例えば、以下の勉強するべき内容があるとします。
前者の実装を行うと点数が伸びるのですが、ISUCON11予選のブログを読む限りTOP10位レベルのチームのみが実装しています。逆に後者は必ずと言って良いほど出題される実装です。100位を達成するためには前者を練習するべきかと言えばNoで、後者の実装を正確に短くできるように反復練習するべきと言えます。ISUCONにおいて入賞したいのであれば、目標から逆算して練習することが何よりも大事です。
余談ですが、前者の実装はISUCON14のPOST /api/chair/coordinate
においてもできたはずなのですが、チームメンバーで過去問の実装共有が出来てなかったことにより改善ができなかったです泣。目標設定からの逆算は突き抜けた上振れを引けなくなるというデメリットもあるのだと痛感しました。
ISUCON練習問題の作成
ISUCON過去問から部分的に練習問題を抽出するという形で練習問題をいくつか作成しました。これを行った理由は3点あります。
- 練習のサイクルを素早く回すことができる
- 改善を思いつく思考の再現性をもたせる
- チームメンバーが実装することができる基準値を作る
本番中は基本的に私がチューニング箇所を割り振っていく形で進めていたのですが、3つ目の基準値があったおかげで実装の成功確率を大まかに見積もることができました。
では、実際に利用した問題を紹介したいと思います。
ISUCON9予選 練習問題
getTransactions関数にて5N+1が発生している。この関数のチューニングを行いたい。以下の問の改善を入れて、ベンチが通るか検証してください。 https://github.com/isucon/isucon9-qualify/blob/00ee4c7f793ace5f4615cf9fb5570d2156c7e6a1/webapp/go/main.go#L853
問1 getUserSimpleByID, getCategoryByIDをキャッシュによって負荷を減らしてください
問2 transactionEvidenceとshippingのクエリをjoinで一つにまとめて、itemsループの外にてIN句でフェッチしてください。
問1解答例
var ( // 各リクエスト(スレッド)にまたがるデータ競合が発生するのでsync.Mapを利用 userSimpleMapByID sync.Map ) func getUserSimpleByID(q sqlx.Queryer, userID int64) (userSimple UserSimple, err error) { user := User{} if v, ok := userSimpleMapByID.Load(userID); ok { // キャッシュがあれば利用する user = v.(User) } else { // キャッシュがなければクエリを叩く err = sqlx.Get(q, &user, "SELECT * FROM `users` WHERE `id` = ?", userID) if err != nil { return userSimple, err } // キャッシュをセットする userSimpleMapByID.Store(userID, user) } userSimple.ID = user.ID userSimple.AccountName = user.AccountName userSimple.NumSellItems = user.NumSellItems return userSimple, err } // userについて挿入、更新、削除している箇所全てでキャッシュを削除する処理を入れる userSimpleMapByID.Delete(userID) var ( // categoryについて挿入、更新、削除が存在しないのでデータ競合は発生しない categoryByID map[int64]*Category ) // categoryについて挿入、更新、削除が存在しないので初期データを全て反映させるだけで良い func postInitialize(w http.ResponseWriter, r *http.Request) { ... categories := []Category{} err = dbx.Select(&categories, "SELECT * FROM `categories`") if err != nil { log.Print(err) outputErrorMsg(w, http.StatusInternalServerError, "db error") return } for _, category := range categories { categoryMapByID[category.ID] = &category } for _, category := range categories { if category.ParentID != 0 { categoryMapByID[category.ID].ParentCategoryName = categoryMapByID[category.ParentID].CategoryName } } ... } // キャッシュの値を返す func getCategoryByID(q sqlx.Queryer, categoryID int) (category Category, err error) { return *categoryMapByID[categoryID], err }
問2解答例
// transactionEvidenceとshippingのjoin用に構造体を定義 type TransactionEvidenceShipping struct { ItemID int64 `db:"item_id"` TransactionEvidenceID int64 `db:"transaction_evidence_id"` TransactionEvidenceStatus string `db:"transaction_evidence_status"` ShippingStatus string `db:"shipping_status"` } func getTransactions(w http.ResponseWriter, r *http.Request) { ... // IN句用にitemIDのスライスを作成 itemIDs := make([]int64, len(items)) for _, item := range items { itemIDs = append(itemIDs, item.ID) } // transaction_evidencesの各レコードの存在は保証したいのでLEFT JOINにしている q, params, err := sqlx.In("SELECT te.item_id AS item_id, te.id AS transaction_evidence_id, te.status AS transaction_evidence_status, s.status AS shipping_status FROM transaction_evidences te LEFT JOIN shippings s ON te.id = s.transaction_evidence_id WHERE te.item_id IN (?)", itemIDs) q := dbx.Rebind(q) transactionEvidenceShippings := []TransactionEvidenceShipping{} err = tx.Select(&transactionEvidenceShippings, q, params...) if err != nil { log.Print(err) outputErrorMsg(w, http.StatusInternalServerError, "db error") return } // itemのループで対応するtransactionEvidenceShippingを取得するためにmapを作成 transactionEvidenceShippingMapByItemID := make(map[int64]TransactionEvidenceShipping) for _, transactionEvidenceShipping := range transactionEvidenceShippings { transactionEvidenceShippingMapByItemID[transactionEvidenceShipping.ItemID] = transactionEvidenceShipping } itemDetails := []ItemDetail{} for _, item := range items { ... transactionEvidenceShipping := transactionEvidenceShippingMapByItemID[item.ID] if transactionEvidenceShipping.ItemID > 0 { itemDetail.TransactionEvidenceID = transactionEvidenceShipping.TransactionEvidenceID itemDetail.TransactionEvidenceStatus = transactionEvidenceShipping.TransactionEvidenceStatus itemDetail.ShippingStatus = transactionEvidenceShipping.ShippingStatus } itemDetails = append(itemDetails, itemDetail) } ... }
ISUCON11予選 練習問題
GET /api/condition/:jia_isu_uuid
エンドポイントがalpの結果より重いことがわかった。 またスロークエリとして 以下のクエリが上位に来ていた。以上の情報をもとにgetIsuConditions関数のチューニングを行なってください。https://github.com/isucon/isucon11-qualify/blob/1011682c2d5afcc563f4ebf0e4c88a5124f63614/webapp/go/main.go#L941
SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'aaaf8165-11eb-4903-b371-77b4d4512c66' AND `timestamp` < '2021-08-24 09:42:20' ORDER BY `timestamp` DESC
解答例
getIsuConditions内で呼ばれているgetIsuConditionsFromDBのコードを読むと、クエリパラメータとして受け取ったcondition_levelに対応するISUのコンディションを取得していることがわかる。
ここで以下の箇所に注目すると、limit分だけのコンディションを取得すれば良く、スロークエリは無駄なレコードを取得していることがわかる。
if len(conditionsResponse) > limit { conditionsResponse = conditionsResponse[:limit] }
しかし、condition_levelがDBテーブルに存在しないため、スロークエリにlimit句をつけることができない。
そこで、isu_conditionsテーブルにcondition_levelカラムを追加し、スロークエリ時点で必要なコンディションでWHERE条件をかけてlimitすることを考える。
コード例 github.com
知見をドキュメント化
各メンバーが学んだ知見をCosense(旧ScrapBox)にまとめました。CosenseはNotionなどの階層ベースのドキュメントツールとは異なり、相互リンクで繋がるので知識が定着しやすいです。チーム全体の学習時間を減らすことができたのと、本番中でも何度も参照したのでとても良かったです。
また、普段の業務でもISUCON練習で作成したページを振り返ることがあります。ISUCONの知識は業務でもよく役立ちます。例えば、実際にISUCON14で出題されたLAG関数、OVER ~ PARTITION BYを用いたSQLクエリを業務で書く機会がありました。
もくもく会
週3日でISUCONの勉強をするもくもく会を実施していました。勉強する時間を確保できたこと以外にも、チームで話し合いをする時間を多く取れたことがとても良かったです。
実装の境界についてチームで合意を取ることができたり、各メンバーが何を得意不得意としているのかを把握することができました。
もくもく会で勉強したことについてチームメンバーがブログを書いているのでこちらも参考にしてみてください
ペアプログラミング
本番では優先度が高い改善については部分的に僕とメンバー一人でペアプログラミングを導入していました。理由は僕の弱点として凡ミスをしやすいということがあるので、それを補うためです。
実際に本番中でも僕のミスによって沼っていた改善があるのですが、一度落ち着いてメンバーとペアプログラミングを始めることで改善することができました。本当にメンバーには感謝しかないです。
timesで思考を垂れ流す
他のチームのレポジトリやブログで学んだことをtimesにアウトプットしていました。メンバーに「知らないということ」を知ってもらい、モチベーションを刺激できたかなと思います。準備期間が長いとチーム全体で中弛みしがちなのでモチベーション維持する施策は大事だと思います。
輪読会を実施
日本CTO協会の新卒メンバーで輪読会を開催しているのですが、ISUCONに向けて@soudai1025さんの「RDBの正しい歩き方」を輪読しました。本の内容をさらに深ぼりしてチーム全体のDBへの理解を深めました。
インデックスの知識やトランザクション分離レベルについて普段の業務に生きる知識を身につけることができました。
輪読会についてのブログはこちら
最後に
ISUCON14に向けてチームで取り組んだことを紹介しました。
社会人の中で一番時間を使って練習したという自負があります(誕生日でさえもチームで一日練習して、ケーキすら食べてません笑笑)。エンジニア歴1年4ヶ月で入賞できたのでとても自信がつきました。
本番はチームメンバーにとても助けられたので本当に感謝の気持ちで一杯です。チームで掴み取った入賞だと思います。
最後になりますが、ISUCON14の運営の皆様、素晴らしいISUCON体験をありがとうございました。こんなにも熱くなれることは久しぶりでとても良い経験になりました。この結果に満足することなく打倒takonomura、NaruseJunを目指して日々精進していきたいと思います!