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. 02验证 N+1 查询风险
  3. 03检查无界 QuerySet 与分页
  4. 04评估索引和写入循环
  5. 05排除未经验证或影响较小的问题

适用场景

  1. 01审查 Django 视图和序列化器中的 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 行 = 超时。

规则:预取循环中访问的关联数据

通过追踪以下流程进行验证:视图 → QuerySet → 模板/序列化器 → 循环访问

# 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})
规则:在序列化器中预取,而不只是视图中

如果 QuerySet 未优化,DRF 序列化器访问关联字段会导致 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)
规则:会执行查询的模型属性在循环中很危险
# 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 验证清单
  • 已追踪从视图到模板/序列化器的数据流
  • 已确认循环中访问了关联字段
  • 已在代码库中搜索现有的 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() 使用了该字段
  • 已检查模型——没有 db_index=True 或 Meta.indexes 条目
  • 该字段不是外键(外键已自动建立索引)

优先级 4:写入循环(HIGH)

影响: 进行 N 次数据库写入,而不是 1 次。造成锁竞争,使请求变慢。

规则:在循环中使用 bulk_create,而不是 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
])
规则:在循环中使用 update() 或 bulk_update,而不是 save()
# 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()

通常跳过——大多数情况下,差异不到 1 毫秒。

模式: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. 追踪数据流——跟踪 queryset 从创建到使用的过程
  2. 搜索现有优化——搜索 select_related、prefetch_related、分页
  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))

Queryset 是惰性的。将其赋值给变量不会执行任何操作。

单次查询模式不属于 N+1:

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

N+1 需要通过循环触发额外查询。一次 list() 调用没有问题。

在单个对象获取时缺少 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 需要循环。单个对象执行 2 次查询而非 1 次,如果相关,可报告为 LOW,但绝不能报告为 CRITICAL/HIGH。

风格偏好不属于性能问题: 如果你唯一的建议是“合并这两行”或“重命名这个变量”,那属于风格问题,而非性能问题。请勿报告。

评论

支持 Markdown