Skip to content

バックエンドのN + 1問題の解消 #247

@taminororo

Description

@taminororo

開発概要

目的

  • バックエンド(Go)における「N+1問題」を特定し、パフォーマンスを最適化する
  • シフト一覧取得処理で発生しているN+1問題を解消し、レスポンス速度を大幅に改善する
  • 特に、モバイルアプリのシフト一覧表示に使用される ShiftCard 関連の取得処理を最適化する
  • GORMのJOINクエリを使用して、ループ内での個別クエリ発行を排除する

開発期間

  • 開始日:
  • 締切日:

考えられる開発内容

1. 問題箇所の特定と分析

1.1 シフト一覧取得メソッドのN+1問題

GetShifts メソッド

ファイル: api/lib/usecase/shift_usecase.go:77-184

  • 問題箇所を確認: rows.Next() ループ内で各シフトに対して7つのクエリを発行
    • taskRep.Find() - タスク情報
    • placeRep.Find() - 場所情報
    • userRep.Find() - ユーザー情報
    • yearRep.Find() - 年情報
    • dateRep.Find() - 日付情報
    • timeRep.Find() - 時間情報
    • weatherRep.Find() - 天気情報
  • クエリ数: N件のシフトがある場合、1 + N×7 クエリが発行されることを確認

GetShiftsByUser メソッド

ファイル: api/lib/usecase/shift_usecase.go:284-393

  • 問題箇所を確認: 同様にループ内で7つのクエリを発行
  • クエリ数: 1 + N×7 クエリが発行されることを確認

GetShiftsByUserAndDateAndWeather メソッド

ファイル: api/lib/usecase/shift_usecase.go:395-504

  • 問題箇所を確認: 同様にループ内で7つのクエリを発行
  • クエリ数: 1 + N×7 クエリが発行されることを確認

GetShiftByID メソッド

ファイル: api/lib/usecase/shift_usecase.go:186-282

  • 問題箇所を確認: 1件取得だが、7つのクエリを発行(N+1ではないが最適化可能)
  • クエリ数: 7 クエリが発行されることを確認

1.2 ShiftCard関連のメンバー取得メソッドのN+1問題

GetUsersByShift メソッド

ファイル: api/lib/usecase/shift_usecase.go:506-583

  • 問題箇所を確認: rows.Next() ループ内で各ユーザーを個別に取得
  • クエリ数: 1 + N クエリ(Nはユーザー数)が発行されることを確認

getShiftMembersForTime メソッド

ファイル: api/lib/usecase/shift_usecase.go:1025-1073

  • 問題箇所を確認: GetUsersByShift で取得した各ユーザーに対して、grade と bureau を個別に取得
  • クエリ数: 1 + M×2 クエリ(Mはユーザー数、各ユーザーに対してgradeとbureauの2クエリ)が発行されることを確認

getBeforeMembers メソッド

ファイル: api/lib/usecase/shift_usecase.go:1075-1147

  • 問題箇所を確認: 同様に各ユーザーに対して grade と bureau を個別取得
  • クエリ数: 1 + M×2 クエリが発行されることを確認

getAfterMembers メソッド

ファイル: api/lib/usecase/shift_usecase.go:1149-1226

  • 問題箇所を確認: 同様に各ユーザーに対して grade と bureau を個別取得
  • クエリ数: 1 + M×2 クエリが発行されることを確認

2. 新しいエンティティ型の追加

2.1 ShiftJoinResult エンティティの作成

ファイル: api/lib/entity/shift_join_result.go(新規作成)

  • ShiftJoinResult 構造体を定義
    • Shift基本情報(ShiftID, IsAttendance, CreatedAt, UpdatedAt)
    • Task情報(TaskID, TaskName, TaskColor, TaskURL, TaskRemark, MaxMember, TaskBureauID)
    • Place情報(PlaceID, PlaceName)
    • User情報(UserID, UserName, UserMail, UserBureauID, UserGradeID)
    • Year/Date/Time/Weather情報(YearID, YearValue, DateID, DateValue, TimeID, TimeValue, WeatherID, WeatherValue)
  • GORMタグを適切に設定(gorm:"column:..."

2.2 UserWithGradeAndBureau エンティティの作成

ファイル: api/lib/entity/user_with_grade_bureau.go(新規作成)

  • UserWithGradeAndBureau 構造体を定義
    • UserID, UserName, UserMail
    • GradeID, GradeName
    • BureauID, BureauName, BureauColor
  • GORMタグを適切に設定(gorm:"column:..."

3. リポジトリ層の拡張

3.1 ShiftRepository インターフェースの拡張

ファイル: api/lib/internals/repository/shift_repository.go

  • ShiftRepository インターフェースに新しいメソッドを追加:
    • GetShiftsWithJoins(context.Context) ([]entity.ShiftJoinResult, error)
    • GetShiftsByUserWithJoins(context.Context, string) ([]entity.ShiftJoinResult, error)
    • GetShiftsByUserAndDateAndWeatherWithJoins(context.Context, string, string, string) ([]entity.ShiftJoinResult, error)
    • GetShiftByIDWithJoins(context.Context, string) (entity.ShiftJoinResult, error)
    • GetUsersByShiftWithJoins(context.Context, string, string, string, string, string) ([]entity.UserWithGradeAndBureau, error)

3.2 ShiftRepository 実装の追加

ファイル: api/lib/internals/repository/shift_repository.go

  • GetShiftsWithJoins メソッドを実装
    • GORMのJOINクエリを使用して、shifts, tasks, places, users, years, dates, times, weathers を一括取得
    • 既存の shift_card_repository.go の実装パターンを参考にする
  • GetShiftsByUserWithJoins メソッドを実装
    • ユーザーIDでフィルタリングしたJOINクエリ
  • GetShiftsByUserAndDateAndWeatherWithJoins メソッドを実装
    • ユーザーID、日付ID、天気IDでフィルタリングしたJOINクエリ
  • GetShiftByIDWithJoins メソッドを実装
    • シフトIDでフィルタリングしたJOINクエリ
  • GetUsersByShiftWithJoins メソッドを実装
    • シフト条件でフィルタリングし、users, grades, bureaus をJOINで一括取得

4. UseCase層の修正

4.1 GetShifts メソッドの最適化

ファイル: api/lib/usecase/shift_usecase.go

  • GetShifts メソッドを修正
    • 新しい GetShiftsWithJoins リポジトリメソッドを使用
    • ShiftJoinResultentity.Shift に変換するロジックを追加
    • 既存の動作を維持することを確認

4.2 GetShiftsByUser メソッドの最適化

ファイル: api/lib/usecase/shift_usecase.go

  • GetShiftsByUser メソッドを修正
    • 新しい GetShiftsByUserWithJoins リポジトリメソッドを使用
    • ShiftJoinResultentity.Shift に変換するロジックを追加
    • 既存の動作を維持することを確認

4.3 GetShiftsByUserAndDateAndWeather メソッドの最適化

ファイル: api/lib/usecase/shift_usecase.go

  • GetShiftsByUserAndDateAndWeather メソッドを修正
    • 新しい GetShiftsByUserAndDateAndWeatherWithJoins リポジトリメソッドを使用
    • ShiftJoinResultentity.Shift に変換するロジックを追加
    • 既存の動作を維持することを確認

4.4 GetShiftByID メソッドの最適化

ファイル: api/lib/usecase/shift_usecase.go

  • GetShiftByID メソッドを修正
    • 新しい GetShiftByIDWithJoins リポジトリメソッドを使用
    • ShiftJoinResultentity.Shift に変換するロジックを追加
    • 既存の動作を維持することを確認

4.5 GetUsersByShift メソッドの最適化

ファイル: api/lib/usecase/shift_usecase.go

  • GetUsersByShift メソッドを修正
    • 新しい GetUsersByShiftWithJoins リポジトリメソッドを使用
    • UserWithGradeAndBureauentity.ShiftUsers に変換するロジックを追加
    • grade と bureau の情報も含めて取得することを確認
    • 既存の動作を維持することを確認

4.6 getShiftMembersForTime メソッドの改善

ファイル: api/lib/usecase/shift_usecase.go

  • getShiftMembersForTime メソッドを確認
    • 最適化された GetUsersByShift を使用することで、grade/bureau の個別取得が不要になることを確認
    • 必要に応じてコードを簡素化

4.7 getBeforeMembers メソッドの改善

ファイル: api/lib/usecase/shift_usecase.go

  • getBeforeMembers メソッドを確認
    • 最適化された GetUsersByShift を使用することで、grade/bureau の個別取得が不要になることを確認
    • 必要に応じてコードを簡素化

4.8 getAfterMembers メソッドの改善

ファイル: api/lib/usecase/shift_usecase.go

  • getAfterMembers メソッドを確認
    • 最適化された GetUsersByShift を使用することで、grade/bureau の個別取得が不要になることを確認
    • 必要に応じてコードを簡素化

5. 動作確認とテスト

5.1 単体テスト

  • 新しいリポジトリメソッドの動作確認
    • JOINクエリが正しく実行されることを確認
    • 結果が正しくマッピングされることを確認
  • UseCaseメソッドの動作確認
    • 既存の動作が維持されることを確認
    • エラーハンドリングが適切に実装されていることを確認

5.2 パフォーマンステスト

  • クエリ数の確認
    • 修正前: N件のシフト取得で 1 + N×7 クエリ
    • 修正後: N件のシフト取得で 1 クエリになることを確認
  • レスポンス時間の測定
    • 100件のシフト取得で約700倍の改善(701クエリ → 1クエリ)を確認
    • 10人のユーザー取得(grade/bureau含む)で約21倍の改善(21クエリ → 1クエリ)を確認

5.3 統合テスト

  • APIエンドポイントの動作確認
    • シフト一覧取得APIが正常に動作することを確認
    • ShiftCard取得APIが正常に動作することを確認
    • モバイルアプリからのリクエストが正常に処理されることを確認

備考

現在の問題コード例

GetShifts メソッドの問題箇所

for rows.Next() {
    // ... shift基本情報の取得 ...
    
    // ループ内で7つのクエリを発行(N+1問題)
    row, err := a.taskRep.Find(c, TaskID)
    row, err = a.placeRep.Find(c, PlaceID)
    row, err = a.userRep.Find(c, UserID)
    row, err = a.yearRep.Find(c, YearID)
    row, err = a.dateRep.Find(c, DateID)
    row, err = a.timeRep.Find(c, TimeID)
    row, err = a.weatherRep.Find(c, WeatherID)
    
    shifts = append(shifts, shift)
}

修正後のコード案(JOINクエリを使用)

// リポジトリ層で1回のJOINクエリで一括取得
results, err := a.rep.GetShiftsWithJoins(c)
if err != nil {
    return nil, err
}

// 結果をentity.Shiftに変換
var shifts []entity.Shift
for _, result := range results {
    shift := convertShiftJoinResultToShift(result)
    shifts = append(shifts, shift)
}

期待される効果

クエリ数の変化

修正前

  • GetShifts: N件のシフト → 1 + N×7 クエリ
  • GetShiftsByUser: N件のシフト → 1 + N×7 クエリ
  • GetShiftsByUserAndDateAndWeather: N件のシフト → 1 + N×7 クエリ
  • GetUsersByShift: M人のユーザー → 1 + M クエリ
  • getShiftMembersForTime: M人のユーザー → 1 + M×2 クエリ(grade + bureau)

修正後

  • GetShifts: N件のシフト → 1 クエリ(JOINで一括取得)
  • GetShiftsByUser: N件のシフト → 1 クエリ
  • GetShiftsByUserAndDateAndWeather: N件のシフト → 1 クエリ
  • GetUsersByShift: M人のユーザー → 1 クエリ(JOINでgrade/bureauも含めて取得)
  • getShiftMembersForTime: M人のユーザー → 1 クエリ(GetUsersByShift の最適化により)

パフォーマンス改善

  • 100件のシフト取得: 701クエリ → 1クエリ(約700倍の改善
  • 10人のユーザー取得(grade/bureau含む): 21クエリ → 1クエリ(約21倍の改善

実装上の注意事項

  • 既存の shift_card_repository.go の実装パターンを参考にする
  • GORMのDBインスタンスは db.Client.GormDB() で取得可能
  • 既存のメソッドは後方互換性のため残す(段階的な移行を想定)
  • エラーハンドリングを適切に実装する
  • JOINクエリの結果を既存のエンティティ型に変換する際は、データの整合性を確認する

変更ファイル

新規作成

  • api/lib/entity/shift_join_result.go - JOINクエリ結果用エンティティ(Shift)
  • api/lib/entity/user_with_grade_bureau.go - JOINクエリ結果用エンティティ(User + Grade + Bureau)

修正

  • api/lib/internals/repository/shift_repository.go - 新しいJOINクエリメソッドの追加
  • api/lib/usecase/shift_usecase.go - 既存メソッドの最適化

参考実装

既存の最適化済み実装を参考にします:

  • api/lib/internals/repository/shift_card_repository.go - GetOptimizedShiftData メソッド
    • GORMのJOINクエリを使用した実装例
    • Table(), Select(), Joins(), Where(), Scan() の使用方法

今後の拡張予定

  • 他のリポジトリメソッドでも同様のN+1問題がないか確認
  • パフォーマンスモニタリングの導入
  • クエリログの分析ツールの導入

参考

開発の流れ

  1. PMにIssue(タスク)をもらう
  2. 開発をする(↓の「リンク」の『開発のやり方』を見よう!)
  3. チェックボックスを押していこう
  4. ヤバい状況になったらIssueの右側にあるStatusを「Help」にしてPMにSlackで連絡しよう
  5. チェックボックスが全部押せたらプルリクを作ろう
  6. レビューを待とう
  7. 修正点があれば修正しよう。なければPMがマージします!お疲れ様!

SeeFTのタスク管理のルール

  1. タスクは全てGit-Hub Projectで管理する
  2. 全てのタスクに期日を決める
  3. 毎週タスクの進捗を確認する(MTに出られない人はSlackで報告)
  4. 毎週忙しさ(消化できるタスク量)を共有する
  5. Helpは余裕のある人がいれば巻き取る。いなければ期日を変更する

リンク

Metadata

Metadata

Assignees

Labels

Size-L開発時間の目安は20時間✨Backendバックエンドのタスク. 主にGo, TypeScriptを使用✨Databaseデータベースのタスク. 主にPostgreSQLを使用優先度1Better・なるべくここまでは実装したい枠

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions