第 5 章 · ORM 进阶与查询优化
本章目标:深入 Django ORM 的关系型建模(ForeignKey、ManyToManyField);掌握 select_related 与 prefetch_related 解决 N+1 查询;使用 transaction.atomic 保证数据一致性;了解数据库索引与 Meta.indexes;能在 shop-demo 中建模「商品–分类–标签–订单行」并完成高效查询。
学时建议:5~6 小时(含 2 小时跟练与 SQL 观察)
前置:完成 django-web ch01~ch04(Product、Category 模型、CBV 列表)。
5.1 场景说明:商品关联越来越复杂
shop-demo 电商后台需要表达更多业务关系:
| 关系 | 类型 | 示例 |
|---|---|---|
| 商品 → 分类 | 多对一(N:1) | 每个商品属于一个分类 |
| 商品 ↔ 标签 | 多对多(M:N) | 「热销」「新品」可同时打在多个商品上 |
| 订单 → 订单行 → 商品 | 一对多链式 | 下单时扣减库存需事务 |
| 列表页展示分类名 + 标签 | 联表查询 | 避免 N+1 性能问题 |
本章在本地 SQLite 上演练;生产环境常用 PostgreSQL,ORM API 一致。联调 API 域名 api.example.com 仅为占位。
5.2 关系字段详解
5.2.1 ForeignKey(多对一)
已在 ch01 使用:
class Product(models.Model):
category = models.ForeignKey(
Category,
on_delete=models.PROTECT, # 有商品时禁止删分类
related_name="products", # 反向:category.products.all()
)
on_delete | 行为 | 适用 |
|---|---|---|
CASCADE | 父删子删 | 强依赖、子无独立意义 |
PROTECT | 有子记录则禁止删父 | 分类下有商品 |
SET_NULL | 父删子字段置 NULL | 可选关联,需 null=True |
SET_DEFAULT | 父删子字段置默认值 | 有默认外键时 |
反向查询:
cat = Category.objects.get(slug="digital")
cat.products.filter(is_active=True)
product = Product.objects.first()
product.category.name
5.2.2 ManyToManyField(多对多)
catalog/models.py 扩展:
class Tag(models.Model):
name = models.CharField("标签名", max_length=32, unique=True)
slug = models.SlugField(max_length=32, unique=True)
class Meta:
verbose_name = "商品标签"
def __str__(self):
return self.name
class Product(models.Model):
# ... 原有字段 ...
tags = models.ManyToManyField(Tag, blank=True, related_name="products")
Django 自动创建中间表 catalog_product_tags:
catalog_product catalog_product_tags catalog_tag
┌────┬─────────┐ ┌────────────┬────────┐ ┌────┬──────┐
│ id │ name │ │ product_id │ tag_id │ │ id │ name │
├────┼─────────┤ ├────────────┼────────┤ ├────┼──────┤
│ 1 │ 鼠标 │◄───────►│ 1 │ 2 │◄────►│ 2 │ 热销 │
└────┴─────────┘ └────────────┴────────┘ └────┴──────┘
操作示例:
hot = Tag.objects.create(name="热销", slug="hot")
product.tags.add(hot)
product.tags.remove(hot)
product.tags.set([tag1, tag2])
Tag.objects.filter(products__sku="SKU-001")
5.2.3 显式 through 模型(预习)
中间表需额外字段(如打标时间)时,用 through="ProductTag" 自定义中间模型(详见官方文档)。
5.3 订单模型与一对多链
orders/models.py(新建 App orders):
from django.db import models
from catalog.models import Product
class Order(models.Model):
order_no = models.CharField("订单号", max_length=32, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
status = models.CharField(
max_length=16,
choices=[("pending", "待支付"), ("paid", "已支付"), ("cancelled", "已取消")],
default="pending",
)
def __str__(self):
return self.order_no
class OrderLine(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="lines")
product = models.ForeignKey(Product, on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(default=1)
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
class Meta:
verbose_name = "订单明细"
注册 App 并迁移:
python manage.py startapp orders
# settings.INSTALLED_APPS 加入 "orders"
python manage.py makemigrations
python manage.py migrate
链式查询:
Order.objects.filter(lines__product__category__slug="digital").distinct()
5.4 N+1 问题与 select_related
5.4.1 问题演示
# 坏:每条商品额外查一次 category
for p in Product.objects.all():
print(p.category.name) # 每次访问触发 SQL
若 100 个商品 → 1 + 100 = 101 次查询(N+1)。
5.4.2 select_related:SQL JOIN
适用于 ForeignKey、OneToOne(单值关系):
products = Product.objects.select_related("category").filter(is_active=True)
生成近似 SQL:
SELECT catalog_product.*, catalog_category.*
FROM catalog_product
INNER JOIN catalog_category ON (catalog_product.category_id = catalog_category.id)
WHERE catalog_product.is_active = 1;
在 ProductListView 中:
def get_queryset(self):
return (
Product.objects.filter(is_active=True)
.select_related("category")
.order_by("-created_at")
)
| 方法 | 关系类型 | SQL 策略 |
|---|---|---|
select_related | FK、O2O | JOIN |
prefetch_related | M2M、反向外键 | 分两次查询 + Python 合并 |