← 中文资源

简体中文版本

MySQL — ORM N+1 查询把一次页面加载变成查询风暴

用通用查询日志检测 ORM 的 N+1 查询模式,按请求计数确认,并用预加载与连接查询消除。

阅读英文原文

症状

  把通用查询日志落成表并按语句分组,N+1 的指纹一目了然:一条父 SELECT 加 N 条形状完全一致、只有主键值不同的子查询(SELECT * FROM users WHERE id = 42 / 43 / 44 … 各一条)。延迟的算术解释了规模症状:每条查询在服务器上亚毫秒,但各自一次网络往返——100 行配 1ms RTT 就是 100ms 纯等待,真实工作还没开始。页面延迟随行数线性增长(25 条很快、100 条要数秒)、模板或序列化层遍历解引用关系字段,都是同一形态。

诊断思路

  先开 general_log 按 argument 分组统计,“同一模板重复上百次不同 ID”就是 N+1 签名;再把触发点映射回代码——循环内访问映射到惰性关系的属性(.author、.tags、.cover)就是要改预加载的位置;最后量化修复效果——修复后每请求查询数坍缩为常数,必须测量而不是假设。

根因

  惰性加载在首次访问时用单独查询解析每个关系。遍历 N 个父对象并在每次迭代中触碰惰性关系,产生 1+N 条查询——数据库往返总数随数据量线性增长,即使每条查询本身都快且有索引。

修复与验证

  查询时点名关系批量预加载:Django 的 select_related/prefetch_related、TypeORM 的 relations、Prisma 的 include、Rails 的 includes;手工路径用显式 IN 批量或 JOIN;用查询数预算测试把不变量锁进 CI(django_assert_num_queries(4),N+1 回归即失败)。验证时给每请求查询数插桩,断言端点执行的查询数与结果规模无关——跨多个分页尺寸验证。注意 prefetch 式双查询会为整个集合取行,无界集合要先分页父对象、再对页内关系做预加载。

代码示例

SET GLOBAL general_log = 'ON';
SET GLOBAL log_output = 'TABLE';

SELECT argument, COUNT(*) c FROM mysql.general_log
WHERE command_type = 'Query' AND argument LIKE 'SELECT%'
GROUP BY argument ORDER BY c DESC LIMIT 5;

-- SELECT * FROM users WHERE id = 42         1
-- SELECT * FROM users WHERE id = 43         1
-- SELECT * FROM users WHERE id = 44         1
-- ...                                       98 more
-- SELECT * FROM posts;                       1
# Django Posts.objects.select_related("author").prefetch_related("tags")

# TypeORM
repo.find({ relations: { author: true, tags: true } })

# Prisma
prisma.post.findMany({ include: { author: true, tags: true } })

# Rails
Post.includes(:author, :tags).all
SELECT * FROM users WHERE id IN (41, 42, 43, ...);  -- one round-trip
def test_list_endpoint_query_budget(django_assert_num_queries):
    with django_assert_num_queries(4):   # fails when N+1 returns
        client.get("/posts?page=2")