跳到主要内容

ORM N+1 查询问题

问题

什么是 ORM 的 N+1 查询问题?如何在 SQLAlchemy 和 Django ORM 中排查和解决?

答案

N+1 问题示意

# 1 次查询获取用户列表
SELECT * FROM users; -- 返回 100 个用户

# 对每个用户再查 1 次订单 → 100 次额外查询
SELECT * FROM orders WHERE user_id = 1;
SELECT * FROM orders WHERE user_id = 2;
...
SELECT * FROM orders WHERE user_id = 100;

# 总共 1 + 100 = 101 次 SQL

SQLAlchemy 排查

debug/detect_n_plus_1.py
import logging
from sqlalchemy import event

# 开启 SQL 日志
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)

# 统计查询次数
query_count = 0

@event.listens_for(Engine, "before_cursor_execute")
def count_queries(conn, cursor, statement, parameters, context, executemany):
global query_count
query_count += 1

SQLAlchemy 解决方案

solutions/sqlalchemy_fix.py
from sqlalchemy.orm import relationship, joinedload, selectinload, subqueryload

class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String)
orders = relationship("Order", back_populates="user")

class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"))
user = relationship("User", back_populates="orders")

# ❌ N+1:延迟加载(默认)
users = session.query(User).all()
for user in users:
print(user.orders) # 每次触发 SQL

# ✅ 方案 1:joinedload(LEFT JOIN 一次查完)
users = session.query(User).options(joinedload(User.orders)).all()
# 生成: SELECT users.*, orders.* FROM users LEFT JOIN orders ON ...

# ✅ 方案 2:selectinload(2 次查询,大数据推荐)
users = session.query(User).options(selectinload(User.orders)).all()
# 生成: SELECT * FROM users; SELECT * FROM orders WHERE user_id IN (1,2,3...);

# ✅ 方案 3:subqueryload(子查询)
users = session.query(User).options(subqueryload(User.orders)).all()

# 多级预加载
users = session.query(User).options(
selectinload(User.orders).selectinload(Order.items)
).all()

Django ORM 解决方案

solutions/django_fix.py
# ❌ N+1
users = User.objects.all()
for user in users:
print(user.order_set.all())

# ✅ select_related(外键,JOIN)
users = User.objects.select_related("profile").all()

# ✅ prefetch_related(反向关系,IN 查询)
users = User.objects.prefetch_related("order_set").all()

# 自定义预取
from django.db.models import Prefetch
users = User.objects.prefetch_related(
Prefetch("order_set", queryset=Order.objects.filter(status="paid"))
).all()

自动检测工具

tools/auto_detect.py
# Django: django-debug-toolbar 或 nplusone
# pip install nplusone
INSTALLED_APPS = ["nplusone.ext.django", ...]
NPLUSONE_RAISE = True # 开发环境直接报错

# SQLAlchemy: 自定义中间件检测
class NPlusOneDetector:
def __init__(self):
self.queries: list[str] = []

def on_query(self, statement: str):
self.queries.append(statement)

def check(self):
# 检测重复模式的查询
from collections import Counter
patterns = Counter()
for q in self.queries:
# 简化 SQL,去掉具体参数
pattern = re.sub(r"= \d+", "= ?", q)
patterns[pattern] += 1

for pattern, count in patterns.most_common(5):
if count > 5:
logging.warning(f"可能的 N+1: {pattern} 执行了 {count} 次")

常见面试问题

Q1: joinedload vs selectinload?

答案

策略SQL适用场景
joinedloadLEFT JOIN一对一/少量一对多
selectinloadIN 查询大量一对多
subqueryload子查询复杂条件
lazy按需加载确定不需要关联数据

Q2: 如何在生产环境防止 N+1?

答案

  1. Code Review 关注 ORM 查询
  2. SQL 日志监控:统计每个接口的查询次数
  3. 自动检测:CI 中跑 nplusone 检查
  4. 默认加载策略:在 Model 层设置默认的 lazy="selectin"

相关链接