你的 ORM 正在偷偷吃掉你的性能——一个被低估了五年的问题
做后端开发这么多年,我见过无数个项目在数据库层面翻车。性能优化做了一圈,缓存加了、索引建了、服务器扩容了,结果还是慢。最后查到最后,90% 的问题都出在一个地方——ORM 的 N+1 查询。
这个问题老生常谈了,但今天我不讲概念,我讲实战。讲讲为什么你明明知道有这个问题,还是会踩坑,以及怎么彻底根治它。
先说个真实案例
去年接了一个电商系统重构的单子。商家反馈后台打开订单列表要 8-10 秒,严重影响工作效率。接手之前他们已经找过人做过「优化」——加了 Redis 缓存,数据库从单机换成了主从复制,甚至还上了 ES 做搜索。结果呢?还是慢。
我用 Django Debug Toolbar 看了一下请求,一个列表页发出了 247 次数据库查询。订单列表只显示了 20 条数据,但是 ORM 自动帮你查了每个订单的:用户信息、商品信息、支付信息、物流信息……
# 原代码大概长这样
orders = Order.objects.all()[:20]
for order in orders:
print(order.user.username)
print(order.items.count())
print(order.payment.method)
print(order.shipping.address)
20 条订单 x 4 个关联 = 80 次查询打底,再加上各种七七八八的统计查询,一次页面请求干出去 200+ 次数据库 roundtrip。MySQL 再快,一次 roundtrip 平均 1-2ms,200 次就是 200-400ms 起步,网络延迟一抖直接破秒。
为什么你总是中招?
因为你写代码的时候,脑子里想的是「一个订单有用户、有商品」,这是面向对象的思维。但数据库里,这些数据散落在不同的表里。你每访问一次关联属性,ORM 就执行一次查询——它不会猜到你接下来要用。
Django 的 ORM 文档写得很清楚:select_related 和 prefetch_related 是解决这个问题的标配。但现实是:
新来的同事不知道、赶工期的时候没空优化、代码传了几手没人敢动、注释里写着「TODO: 后续优化」然后就没有然后了……
历史遗留代码 + 工期压力 = 性能债务雪球。
正确姿势是什么?
1. 强制使用 select_related 和 prefetch_related
# 一对多关系用 select_related(JOIN)
orders = Order.objects.select_related("user", "payment", "shipping").all()[:20]
# 多对多或多对一关系用 prefetch_related(单独查询后组装)
orders = Order.objects.prefetch_related("items", "items__product").all()[:20]
select_related 会在 SQL 层面做 JOIN,一次查询把关联数据拉进来。prefetch_related 会先查主表,再查关联表,最后在 Python 里组装。两者适用场景不同,用错了等于没优化。
2. 把关联查询写成固定模式
我的经验是:在 Django 项目里,给每个 Model 写一个默认的 Manager 方法,封装好常用的查询预取逻辑。
class OrderManager(models.Manager):
def with_relations(self):
return self.select_related(
"user", "payment", "shipping"
).prefetch_related(
"items", "items__product"
)
# 调用方无脑用这个,再也不会漏
orders = Order.objects.with_relations()[:20]
这样新同事就算不懂 N+1,写出来的代码也是高效的。从工具层面规避人性弱点,比指望每个人记住优化规范靠谱一万倍。
3. 上工具监控
Django 项目装上 django-debug-toolbar,开发阶段就能看到每个请求多少次 SQL。生产环境可以用 django-silk 做自动记录。
# settings.py
MIDDLEWARE = [
...
"debug_toolbar.middleware.DebugToolbarMiddleware",
"silk.middleware.SilkyMiddleware",
]
# 设置 SQL 执行时间阈值,超过 50ms 的请求自动记录
SILKY_PYTHON_PROFILER = True
SILKY_INTERCEPT_PERCENT = 50
我习惯在项目里设定一个规矩:任何新增的 API endpoint,上线前必须检查 SQL 查询次数。这个卡点比任何代码 review 都有用。
说个反直觉的
有时候 prefetch 反而比 select_related 更高效。别杠,听我说完。
select_related 做 JOIN,关联表数据量大了之后,笛卡尔积会让结果集膨胀得很难看。假设订单表 1 万条,每条订单平均 3 个商品,JOIN 之后就是 3 万行,内存占用和传输量都翻倍。
而 prefetch_related 分两次查询:一次查订单(1 万行),一次查关联商品(3 万行),再在内存里组装。看起来多做了一次查询,但如果你的订单表本身不大、关联表数据量远大于主表,prefetch 的总数据传输量反而更小。
没有银弹,只有场景。学会分析执行计划(EXPLAIN),用数据说话,别背概念。
最后
N+1 查询是个老问题,但老问题之所以一直老,是因为它藏在代码细节里,不显山不露水,等你发现的时候已经欠了一屁股债。
我的建议是:与其等项目出问题了再救火,不如在开发流程里加一个强制环节——每次数据库查询都要过一眼是不是多打了。养成习惯,比任何框架和工具都管用。
当然,如果你现在手头就有个慢得要死的系统,先别急着重写,先装个 debug toolbar 看看——我赌你至少能砍掉 80% 的无效查询。
有问题欢迎留言,我看过都会回。