mcpField
D
django-perf-review

django-perf-review

github.com/getsentryテスト・品質1.1k3.0k 回インストール5 日前に更新
リポジトリ

django-perf-review について

django-perf-review は、Django プロジェクトのコードレビュー向けスキルです。コードの状況を調べて裏付けた性能問題だけを報告し、該当なしの結果も認めます。

主な機能

  1. 01クエリとデータフローの追跡
  2. 02N+1 クエリリスクの検証
  3. 03無制限 QuerySet とページネーションの確認
  4. 04インデックスと書き込みループの評価
  5. 05未検証または影響の小さい問題の除外

活用シーン

  1. 01Django のビューやシリアライザーの N+1 クエリ確認
  2. 02リストエンドポイントの無制限なデータ読み込みの確認
  3. 03頻繁に使われる処理の遅い書き込みの調査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/getsentry/skills --skill django-perf-review

インストール後、Claude Code で/django-perf-reviewを入力して読み込まれたか確認します。

同梱ファイルLICENSE

詳細

Django パフォーマンスレビュー

Django のコードをレビューし、検証済みのパフォーマンス問題を報告します。報告する前にコードベースを調査して問題を確認してください。証明できる内容だけを報告してください。

レビューの進め方

  1. まず調査する - データの流れを追い、既存の最適化を確認し、データ量を検証します
  2. 報告前に検証する - パターンに当てはまるだけでは検証になりません
  3. 指摘ゼロでも問題ありません - 網羅的に見せるために問題を作り出さないでください
  4. 深刻度を影響に合わせる - CRITICAL の指摘で「軽微」と書きそうになったら、それは重大ではありません。深刻度を下げるか、指摘を見送ってください。

影響カテゴリ

問題は影響度別に分類されています。実運用で問題を引き起こす CRITICAL と HIGH を重視してください。

優先度カテゴリ影響
1N+1 クエリCRITICAL - データ量に比例して増加し、タイムアウトを引き起こします
2上限のない QuerySetCRITICAL - メモリ枯渇や OOM によるプロセス強制終了を引き起こします
3インデックスの不足HIGH - 大規模テーブルで全件スキャンが発生します
4書き込みループHIGH - ロック競合やリクエストの遅延を引き起こします
5非効率なパターンLOW - 報告する価値があるケースはほとんどありません

優先度 1: N+1 クエリ(CRITICAL)

影響: N+1 はそれぞれ O(n) のデータベース往復を追加します。100 行では追加クエリが 100 回、10,000 行ではタイムアウトにつながります。

ルール: ループ内でアクセスする関連データは事前に取得する

検証では、View → QuerySet → Template/Serializer → ループ内でのアクセスの順に追跡します。

# PROBLEM: N+1 - each iteration queries profile
def user_list(request):
    users = User.objects.all()
    return render(request, 'users.html', {'users': users})

# Template:
# {% for user in users %}
#     {{ user.profile.bio }}  ← triggers query per user
# {% endfor %}

# SOLUTION: Prefetch in view
def user_list(request):
    users = User.objects.select_related('profile')
    return render(request, 'users.html', {'users': users})
ルール: View だけでなく Serializer でも事前取得する

QuerySet が最適化されていないと、DRF Serializer が関連フィールドにアクセスした際に N+1 が発生します。

# PROBLEM: SerializerMethodField queries per object
class UserSerializer(serializers.ModelSerializer):
    order_count = serializers.SerializerMethodField()

    def get_order_count(self, obj):
        return obj.orders.count()  # ← query per user

# SOLUTION: Annotate in viewset, access in serializer
class UserViewSet(viewsets.ModelViewSet):
    def get_queryset(self):
        return User.objects.annotate(order_count=Count('orders'))

class UserSerializer(serializers.ModelSerializer):
    order_count = serializers.IntegerField(read_only=True)
ルール: クエリを実行する Model プロパティをループ内で使うのは危険です
# PROBLEM: Property triggers query when accessed
class User(models.Model):
    @property
    def recent_orders(self):
        return self.orders.filter(created__gte=last_week)[:5]

# Used in template loop = N+1

# SOLUTION: Use Prefetch with custom queryset, or annotate
N+1 の検証チェックリスト
  • View から Template/Serializer までのデータの流れを追跡した
  • ループ内で関連フィールドにアクセスしていることを確認した
  • コードベースで既存の select_related/prefetch_related を検索した
  • テーブルに十分な行数(1000 行以上)があることを確認した
  • ホットパスであることを確認した(管理画面ではなく、まれな操作でもない)

優先度 2: 上限のない QuerySet(CRITICAL)

影響: テーブル全体を読み込むとメモリを使い果たします。大規模テーブルでは OOM によるプロセス強制終了やワーカーの再起動が発生します。

ルール: リストエンドポイントには必ずページネーションを適用する
# PROBLEM: No pagination - loads all rows
class UserListView(ListView):
    model = User
    template_name = 'users.html'

# SOLUTION: Add pagination
class UserListView(ListView):
    model = User
    template_name = 'users.html'
    paginate_by = 25
ルール: 大規模なバッチ処理では iterator() を使う
# PROBLEM: Loads all objects into memory at once
for user in User.objects.all():
    process(user)

# SOLUTION: Stream with iterator()
for user in User.objects.iterator(chunk_size=1000):
    process(user)
ルール: 上限のない QuerySet に list() を呼び出さない
# PROBLEM: Forces full evaluation into memory
all_users = list(User.objects.all())

# SOLUTION: Keep as queryset, slice if needed
users = User.objects.all()[:100]
上限のない QuerySet の検証チェックリスト
  • テーブルが大規模(10k 行以上)、または上限なく増加する
  • ページネーションクラス、paginate_by、スライスのいずれも使われていない
  • ユーザー向けリクエストで実行される(チャンク処理を伴うバックグラウンドジョブではない)

優先度 3: インデックスの不足(HIGH)

影響: 全件スキャンが発生します。小規模なテーブルでは影響はごくわずかですが、大規模なテーブルでは致命的です。

ルール: 大規模なテーブルで WHERE 句に使うフィールドにインデックスを付ける
# PROBLEM: Filtering on unindexed field
# User.objects.filter(email=email)  # full scan if no index

class User(models.Model):
    email = models.EmailField()  # ← no db_index

# SOLUTION: Add index
class User(models.Model):
    email = models.EmailField(db_index=True)
ルール: 大規模なテーブルで ORDER BY に使うフィールドにインデックスを付ける
# PROBLEM: Sorting requires full scan without index
Order.objects.order_by('-created')

# SOLUTION: Index the sort field
class Order(models.Model):
    created = models.DateTimeField(db_index=True)
ルール: よく使うクエリパターンには複合インデックスを使う
class Order(models.Model):
    user = models.ForeignKey(User)
    status = models.CharField(max_length=20)
    created = models.DateTimeField()

    class Meta:
        indexes = [
            models.Index(fields=['user', 'status']),  # for filter(user=x, status=y)
            models.Index(fields=['status', '-created']),  # for filter(status=x).order_by('-created')
        ]
インデックス不足の検証チェックリスト
  • テーブルに 10k 行以上ある
  • ホットパスでフィールドが filter() または order_by() に使われている
  • Model を確認し、db_index=True も Meta.indexes のエントリもない
  • 外部キーではない(外部キーには自動的にインデックスが作成されます)

優先度 4: 書き込みループ(HIGH)

影響: 書き込みが 1 回ではなく N 回発生します。ロック競合が起き、リクエストが遅くなります。

ルール: ループ内では create() の代わりに bulk_create を使う
# PROBLEM: N inserts, N round trips
for item in items:
    Model.objects.create(name=item['name'])

# SOLUTION: Single bulk insert
Model.objects.bulk_create([
    Model(name=item['name']) for item in items
])
ルール: ループ内では save() の代わりに update() または bulk_update を使う
# PROBLEM: N updates
for obj in queryset:
    obj.status = 'done'
    obj.save()

# SOLUTION A: Single UPDATE statement (same value for all)
queryset.update(status='done')

# SOLUTION B: bulk_update (different values)
for obj in objects:
    obj.status = compute_status(obj)
Model.objects.bulk_update(objects, ['status'], batch_size=500)
ルール: ループ内で削除するのではなく、QuerySet に delete() を使う
# PROBLEM: N deletes
for obj in queryset:
    obj.delete()

# SOLUTION: Single DELETE
queryset.delete()
書き込みループの検証チェックリスト
  • ループが 100 件以上(または上限のない件数)の項目を処理する
  • 各反復で create()、save()、または delete() を呼び出す
  • ユーザー向けリクエストで実行される(単発のマイグレーションスクリプトではない)

優先度 5: 非効率なパターン(LOW)

報告する価値があるケースはほとんどありません。 実際の問題をすでに報告する場合に限り、軽微な注記として含めてください。

パターン: count() と exists() の比較
# Slightly suboptimal
if queryset.count() > 0:
    do_thing()

# Marginally better
if queryset.exists():
    do_thing()

通常は見送ってください - 多くの場合、差は 1ms 未満です。

パターン: len(queryset) と count() の比較
# Fetches all rows to count
if len(queryset) > 0:  # bad if queryset not yet evaluated

# Single COUNT query
if queryset.count() > 0:

指摘するのは、QuerySet が大きく、まだ評価されていない場合に限ります。

パターン:小さなループ内の get()
# N queries, but if N is small (< 20), often fine
for id in ids:
    obj = Model.objects.get(id=id)

報告するのは、ループが大きい場合、または非常に頻繁に実行される処理の場合に限ります。


検証要件

問題を報告する前に、必ず次の手順を実施してください。

  1. データフローを追跡する - クエリセットの作成から使用までを追跡します
  2. 既存の最適化を検索する - select_related、prefetch_related、ページネーションを grep で検索します
  3. データ量を確認する - テーブルが実際に大きいかを確認します
  4. 頻繁に実行される処理か確認する - 呼び出し元を追跡し、頻繁に実行されることを確認します
  5. 軽減策がないか確認する - キャッシュやレート制限を確認します

すべての手順を検証できない場合は、報告しないでください。


出力形式

## Django Performance Review: [File/Component Name]

### Summary
Validated issues: X (Y Critical, Z High)

### Findings

#### [PERF-001] N+1 Query in UserListView (CRITICAL)
**Location:** `views.py:45`

**Issue:** Related field `profile` accessed in template loop without prefetch.

**Validation:**
- Traced: UserListView → users queryset → user_list.html → `{{ user.profile.bio }}` in loop
- Searched codebase: no select_related('profile') found
- User table: 50k+ rows (verified in admin)
- Hot path: linked from homepage navigation

**Evidence:**
```python
def get_queryset(self):
    return User.objects.filter(active=True)  # no select_related

修正:

def get_queryset(self):
    return User.objects.filter(active=True).select_related('profile')

If no issues found: "No performance issues identified after reviewing [files] and validating [what you checked]."

**Before submitting, sanity check each finding:**
- Does the severity match the actual impact? ("Minor inefficiency" ≠ CRITICAL)
- Is this a real performance issue or just a style preference?
- Would fixing this measurably improve performance?

If the answer to any is "no" - remove the finding.

---

## What NOT to Report

- Test files
- Admin-only views
- Management commands
- Migration files
- One-time scripts
- Code behind disabled feature flags
- Tables with <1000 rows that won't grow
- Patterns in cold paths (rarely executed code)
- Micro-optimizations (exists vs count, only/defer without evidence)

### False Positives to Avoid

**Queryset variable assignment is not an issue:**
```python
# This is FINE - no performance difference
projects_qs = Project.objects.filter(org=org)
projects = list(projects_qs)

# vs this - identical performance
projects = list(Project.objects.filter(org=org))

クエリセットは遅延評価されます。変数に代入しても、何も実行されません。

単一クエリのパターンは N+1 ではありません:

# This is ONE query, not N+1
projects = list(Project.objects.filter(org=org))

N+1 が発生するには、追加のクエリを実行するループが必要です。list() の呼び出しが 1 回だけなら問題ありません。

単一オブジェクトの取得で select_related がない場合も、N+1 ではありません:

# This is 2 queries, not N+1 - report as LOW at most
state = AutofixState.objects.filter(pr_id=pr_id).first()
project_id = state.request.project_id  # second query

N+1 が発生するにはループが必要です。1 つのオブジェクトでクエリが 1 回ではなく 2 回実行される場合、該当すれば LOW として報告できますが、CRITICAL/HIGH として報告してはいけません。

スタイル上の好みはパフォーマンスの問題ではありません: 提案が「この 2 行をまとめる」または「この変数名を変更する」だけの場合、それはスタイルの問題であり、パフォーマンスの問題ではありません。報告しないでください。

コメント