こんにちは、株式会社カミナシのエンジニア @imu です。
はじめに
我々のサービス『カミナシ』をローンチしてから、今年の夏で丸2年を迎えます。
私は『カミナシ』のプロダクト開発初期メンバーの1人でした。当時会社の残りランウェイ(残資金で運営できる期間)が極端に短いという厳しい時間的制約のなかで高速にプロダクトを作り上げたことを誇りに思う一方、その代償としての技術的負債は今も解消しきれずに残っています。
継続的に寄せられるお客様からの機能開発要望や、ユーザー数が増えた結果発生しはじめた不具合への対処に時間をとられ、それら技術的負債の根本的な解消には、これまで会社として注力することができていませんでした。今年に入ってから既存ユーザーにも影響が出てしまうような問題の発生が徐々に目立ちはじめています。
この状況をカイゼンすべく、私たちはバックエンドの技術的負債を解消する第一歩を踏み出しました。
今回は具体的に何をどうやって解消したのか、お話出来ればと思います!
開発環境
- Golang: 1.16
- Amazon Aurora MySQL 5.x系
- Gorm: v1.9.16
- Datadog APM
要約
- パフォーマンス劣化しているエンドポイントの全体を見直しました
- 処理フロー
- for文で毎回Insert処理していないかの再確認
- 処理順番が適切かどうかの再確認(機能追加でサッと追加してしまっていないか、まとめて処理できないかなど)
- 処理フロー
- Gorm(ORM)の見直し
- データ構造
- 以前の開発者ブログをご覧ください。
原因調査
1. 仮設を立てる
2. 再現性のある環境を構築
- ここで注意が必要なのはローカル環境ではなく、本番と同じ環境を用意することです。
- 本番のデータ量でしか再現しない可能性もあるため、スナップショットで復元して別環境を立てて劣化している確認しました。
3. ユーザー目線からの違和感を見つける
- ユーザーが遅いと言う時間は3秒以上掛かっているという話でした。が、これはユーザーがあるボタンをタップしてから、画面にデータが表示されるまでの体感時間です。そのため、ユーザーと同じ動作をしてボトルネックが他にないか確認しました。
- 結果、APIのリクエスト時に認証処理をしており、認証自体に数百ミリ秒掛かっていることが分かりました。これはユーザー目線から考えることが出来たので見つかった点です。
4. ORMのPreloadは便利だけど
- GormはPreloadを使うと関連テーブルまで検索してくれて、指定したstructに入れてくれる!とっても便利!
- だけど実際のコードを見るとやりすぎ感が…。
- 各Preloadで呼ばれるSQL自体は数十ミリ秒でしたが、
50ms x 40 = 2000ms
となり2秒
掛かってしまいます。
- 各Preloadで呼ばれるSQL自体は数十ミリ秒でしたが、
// 記録した結果を取得するためにPreloadが40個書かれていました…。 db. Preload("Hoge1"). Preload("Hoge2"). Preload("Hoge3"). ... Preload("Hoge40"). First(context, fuga, "id = ?", fuga.ID)
- 上記のPreloadを分解して
goroutine
を使い並行処理にすればカイゼンされると思い対応しました!
Preloadを並行処理化する
Preloadは単に順番通りに実行されるものなので、待たなくて良いケースがありますよね。 例えばユーザー情報を取得するケースを例に実際のコードを書いてみます。
ctx := context.Background() u := &orm.User{} db. Preload("Department"). // 部署 Preload("Authority"). // 権限 Preload("Members"). // チームメンバー Preload("Members.Department"). // チームメンバーの部署 First(ctx, u, "id = ?", userID)
これは Department
と Authority
と Members / Members.Department
は関係性がないため、分けて取得しても問題ないところです。まずは単純に分解してみましょう。
ctx := context.Background() u := &orm.User{} db.First(ctx, u, "id = ?", userID) // ユーザー情報を取得して、ユーザーIDから検索する d := &orm.Department{} db.First(ctx, d, "user_id = ?", u.ID) a := &orm.Authority{} db.First(ctx, a, "user_id = ?", u.ID) m := &orm.Members{} db.Preload("Department").First(ctx, m, "user_id = ?", u.ID) // 最後にUserオブジェクトにセットする u.Department = d u.Authority = a u.Members = m
あとは goroutine
を使って並行処理にしていきましょう。ここで大事なのは最初に実装していたPreloadの順番にしなくても良いということです。処理に時間が掛かる場合は最初に実行して、少しでも短くするように心がけました。
ctx := context.Background() // 先にユーザー情報を取得する u := &orm.User{} db.First(ctx, u, "id = ?", userID) // goroutine eg, egctx := errgroup.WithContext(ctx) defer egctx.Done() // ここは Members / Departmentの2つSQLが発行されるため、最初に実行して少しでも処理時間を短くする m := &orm.Members{} eg.Go(func() error { return db.Preload("Department").First(egctx, m, "user_id = ?", u.ID) }) d := &orm.Department{} eg.Go(func() error { return db.First(egctx, d, "user_id = ?", u.ID).Error }) a := &orm.Authority{} eg.Go(func() error { return db.First(egctx, a, "user_id = ?", u.ID).Error }) if err := eg.Wait(); err != nil { return err } u.Department = d u.Authority = a u.Members = m return nil
以上になります!
実際に修正した箇所はテストコードがなかったので、cmp.Diff
を使ってカイゼン前とカイゼン後に差分がないか検証し、テストコードを追加しました。0=>1フェーズでテストコードがなく、初期から実装していないとこういったリファクタリングが容易に出来ないのは辛いですね。次0=>1の経験が出来るときは初期からテストコードは実装しておきたいと思いました。
最後にApache Benchで性能テストを行い完了です!
カイゼン結果
- リリース前
- p90: 9.47s
- p99: 26.76s
- リリース後
- p90: 179ms
- p99: 1.26s
- max値はネットワーク問題で処理が開始されるまでの時間も入っていて、大幅に掛かるケースがあるようです。
学び
- ORMは便利だけど、用途を理解した上で実装しないと後から大変になる。
- 可能であればデータ数を想定した設計が予め出来ていると良かった。
- 技術的負債は必ず起きるので、フェーズごとに早めにカイゼンしないと後で辛くなる。
- 過去の失敗を責めず、今起きている課題に対して実直に取り組むこと。
- 数百ミリのSQLでも何か問題になっていないか?と疑うことでカイゼンされるケースもある。
- テストコードがない箇所の修正は大変なので、100%じゃなくて良いのであると破壊的な変更がしやすい。
- 技術的負債を解消するのは行き詰まったりするので、出来ないときはさっさと寝る!意外と翌日すんなり解決したり閃くことが多いです(笑)
goroutine
のアプローチ方法が増えたので他メンバーでも実装方法の共有ができた。
おわりに
現在も課題は多く、今回対応した箇所は氷山の一角といったところです。リファクタリングついでにテストコードを拡充して少しずつではありますが、堅牢なシステムにして、素早く価値のあるプロダクトを提供できるようにしていきたいです!
バックエンド編なので、フロントエンド編も解消する箇所があるので、共有出来ればいいなと思っています。
カミナシのプロダクトに興味がありましたら、気軽にTwitterDMや以下の採用ページからご連絡ください!
最後まで読んでいただき、ありがとうございました!