一个页面发了两百条 SQL:N+1 查询的四种形态、批量改写与防复发

问题不是「优化 SQL」,是「一页发了几百条 SQL」

很多人把 N+1 查询理解成一个性能名词,觉得离自己很远。其实它是最容易在个人站上长出来的问题,而且长得毫无声响:页面能打开,只是慢;数据库 CPU 不高,只是连接数上去了;加个索引不管用,因为问题不在单条 SQL 有多慢,而在同样的 SQL 被问了几百遍。

典型的形态是这样的:你要在首页列出二十篇文章,每篇都要显示作者名和分类名。逻辑写起来很自然——先查文章列表,然后 foreach 循环里对每一篇再查一次作者、查一次分类。一次页面请求 = 1 条主查询 + 20×2 条附加查询 = 41 条 SQL。如果页面上还有评论区、标签、相关推荐,数字迅速变成两三百。

单条 SQL 走主键索引只要 0.1 毫秒,看起来无害。但每一条都要经过 PHP 到 MySQL 的网络往返、协议解析、权限检查、执行、返回。真实开销的大头不是查询本身,是「往返次数」。在本地回环上每次往返 0.05 毫秒,等你用了云数据库、或者干脆是跨可用区的 RDS,单次往返变成 1 到 3 毫秒,三百条就是接近一秒。而这段时间里,PHP-FPM 的进程是被占住的,并发一上来,进程池迅速耗尽,整站开始排队。

所以判断 N+1 的核心指标不是「SQL 慢不慢」,而是「一个页面发了几条 SQL」。这个数字跟流量相乘,就是你数据库实际要承受的 QPS。

第一步:先把「每页几条 SQL」量出来

在没有监控的情况下,最直接的办法是让数据库自己说。打开 MySQL 的通用查询日志,跑一次页面,数日志行数:

-- 会话级打开,只影响当前连接,别动全局
SET GLOBAL general_log_file = '/var/log/mysql/general.log';
SET GLOBAL general_log = 'ON';
-- 访问一次页面,然后立刻关掉
SET GLOBAL general_log = 'OFF';

通用日志会记录每一条语句,包括连接和断开。数一下访问页面那几秒的 Query 行数,就是这个页面的 SQL 条数。这个方法很粗糙,但足够定位问题——如果首页是 200 条,你不需要任何更精细的工具就知道该动手了。

更省事的做法是在应用层埋一个计数器。Typecho 的 Db 类、WordPress 的 $wpdb、或者你自己封装的 PDO 包装类,都只有一个执行入口,加一个静态计数即可:

// 放在数据库封装类的 query 方法最前面
if (defined('DEBUG_SQL_COUNT')) {
    self::$count++;
    if (self::$count > 50) {
        error_log('SQL-COUNT-WARN ' . $_SERVER['REQUEST_URI'] . ' = ' . self::$count);
    }
}

阈值定在 50 是个务实的数字。内容型站点的正常页面(列表页、文章页)应该在 20 条以内,超过 50 条就说明有循环查询。这个日志很便宜,可以长期开着,等积累一周,按 URI 聚合一下,问题页面自己就跳出来了。

更进一步,如果设置了 long_query_time,还可以把「一条 SQL 本身很快但被调用了三百次」的场景抓出来——MySQL 的 performance_schema 里有 events_statements_summary_by_digest,按 COUNT_STAR 排序就能看到谁被问得最频繁:

SELECT DIGEST_TEXT, COUNT_STAR,
       ROUND(SUM_TIMER_WAIT/1000000000, 1) AS total_ms,
       ROUND(AVG_TIMER_WAIT/1000000, 2) AS avg_ms
FROM performance_schema.events_statements_summary_by_digest
ORDER BY COUNT_STAR DESC LIMIT 15;

这条 SQL 的价值在于:它按「语句模板」聚合(把具体参数归一化),所以三百次同样的查询会合并成一行,COUNT_STAR 直接暴露调用次数。如果某条 SELECT 的 COUNT_STAR 是十万级而 avg_ms 只有 0.1,那就是标准的 N+1。

第二步:最常见的四类 N+1 及其修法

类型一:列表页取关联字段

就是开头说的那种。文章列表要显示作者、分类、封面图信息。修法是把「循环里查」改成「先收集主键,一次查回来」。也就是把 N+1 变成 1+1:

// 反例:循环里查
foreach ($posts as $p) {
    $author = $db->fetch("SELECT name FROM users WHERE id = ?", [$p['user_id']]);
}

// 正例:先收集再批量查
$uids = array_unique(array_column($posts, 'user_id'));
$in   = implode(',', array_fill(0, count($uids), '?'));
$rows = $db->fetchAll("SELECT id, name FROM users WHERE id IN ($in)", $uids);
$map  = array_column($rows, 'name', 'id');
foreach ($posts as $p) {
    $author = $map[$p['user_id']] ?? '';
}

这个改写看着朴素,但它把 20 次往返压成 1 次。分类、标签、封面这些一对一的关联都可以照抄这个模式。

注意 IN 的参数个数要控制。MySQL 对超长 IN 列表的解析开销会上升,一般建议单个 IN 不超过 1000 个值。如果主键数组超过这个量级,就分块查后合并。

类型二:一对多,才是真正麻烦的

文章和标签是一对多,一篇可以有多个标签;分类和子分类也是。用 IN 查回来的是「标签表的所有相关行」,但你需要把它们按文章归组。做法是在 PHP 里做分组,而不是在 SQL 里拼字符串:

SELECT article_id, tag_name
FROM article_tags at
JOIN tags t ON t.id = at.tag_id
WHERE at.article_id IN (?, ?, ?);   -- 20 个占位符

// 结果按 article_id 分组
$grouped = [];
foreach ($rows as $r) {
    $grouped[$r['article_id']][] = $r['tag_name'];
}

千万不要用 GROUP_CONCAT 然后在 PHP 里拆字符串——标签名里一旦出现逗号就会切错,而且 GROUP_CONCAT 有默认 1024 字节的截断长度(group_concat_max_len),标签一多就静默丢数据。这类错误非常隐蔽,页面上只是少显示几个标签,你很久都不会发现。

类型三:评论区的嵌套回复

这是个人博客最容易失控的地方。一级评论查回来,每条评论再查它的子回复,子回复里还要查回复者的头像和昵称。一篇热文有 80 条评论、200 条回复,就是 280 次查询。

正确做法是把「树形结构」的取法改掉:一次性把这个页面的所有评论(不分层级)按 article_id 和 parent_id 全查回来,然后在内存里用父指针组装成树。

SELECT id, parent_id, user_id, content, created_at
FROM comments
WHERE article_id = ? AND status = 1
ORDER BY created_at ASC;
// 一次查完(假设一页 300 条,也就 300 行),
// 然后 $tree[$parent_id][] = $row 组装,深度在内存里递归

300 行数据在 PHP 内存里毫无压力。很多人担心「一次查太多会不会慢」——恰恰相反,一次查 300 行比三百次查 1 行快一个数量级,因为往返次数才是主导成本。

至于评论者的昵称头像,同样用 IN 批量取,不要随着评论循环查。如果用户量很大,考虑把昵称和头像 URL 冗余存到评论表里(评论发出时的快照),这样连这次批量查都省了。冗余字段在这个场景是合理的,因为评论显示的是「当时的昵称」,用户后来改名本来也不该改变历史评论的署名。

类型四:后台列表的「计数列」

后台文章列表往往要显示「评论数」「浏览量」这类聚合列。天真写法是每行查一次 SELECT COUNT(*) FROM comments WHERE article_id = ?。一页 20 行就是 20 次 COUNT。

对于这类场景,维护一个冗余计数字段(在评论表插入/删除时同步更新文章表的 comment_count)是最简单也最快的方案。代价是有一致性风险——如果某次更新失败,计数会偏差。解决办法是配一个每天凌晨的对账任务:

UPDATE articles a
SET a.comment_count = (
  SELECT COUNT(*) FROM comments c
  WHERE c.article_id = a.id AND c.status = 1
)
WHERE a.comment_count <> (
  SELECT COUNT(*) FROM comments c
  WHERE c.article_id = a.id AND c.status = 1
);

这条语句只在计数不一致的行上更新,正常情况下零写入。对个人站来说,这种「读时冗余 + 定时对账」的组合比实时关联查询划算得多。

第三步:一次 JOIN 解决是不是更好

很多人第一个念头是「那我把文章表和用户表 JOIN 起来不就完了」。一对一关联确实应该 JOIN,一条 SQL 拿全,没有任何理由分两次查。但一对多(文章 + 标签)JOIN 会产生笛卡尔积式的行数膨胀:20 篇文章每篇 5 个标签,JOIN 回来是 100 行,然后 PHP 还得去重合并。这种情况下「主查询 + 一次 IN 查询」反而更清晰。

判断标准很简单:如果 JOIN 之后行数不变(一对一),用 JOIN;如果行数会变(一对多),用两次查询 + 内存分组。混着用「一次 JOIN 全部字段然后去重」是最差的,既让结果集变大,又让代码变复杂。

还有一种情况值得单独说:分页查询里的 N+1。列表页需要 LIMIT 20 OFFSET 2000 这样的深分页时,MySQL 要先扫描前 2020 行再丢掉 2000 行。这个和 N+1 是两个独立问题,但如果两者叠加,页面会慢得无法解释。深分页的解法是改成键集分页(记住上一页最后一行的 id,用 WHERE id < ? ORDER BY id DESC LIMIT 20),这部分可以单独排期处理。

缓存不是解法,是止痛药

发现 N+1 之后,很多人的第一反应是「加个 Redis 缓存就好了」。单个查询的缓存确实能减少数据库压力,但它有两个致命问题。

第一,缓存解决的是「重复查询同一份数据」,而 N+1 的每次查询往往参数不同(不同文章 id),缓存命中率取决于页面被访问的频次。首页有缓存当然有用,但每篇文章详情页的评论区是低频的,缓存收益很小。第二,也是最要命的:加了缓存之后,「每页 200 条 SQL」的事实被掩盖了,下次流量翻倍时缓存集体失效,压力会在同一秒全部打到数据库上,直接雪崩。

所以顺序应该是:先消灭 N+1(减少查询条数),再考虑缓存(减少重复查询)。前者是结构优化,后者是性能优化。反过来做,是给自己埋雷。

真正有效的缓存层级是整页缓存:把渲染好的 HTML 存起来,命中时连 PHP 都不执行,一条 SQL 都不发。这个对内容型站点效果极佳,但它不解决「用户登录状态相关的部分」——所以通常的做法是整页缓存 + 局部片段缓存(评论、广告位单独缓存一小段 HTML)。

怎么防止 N+1 再长回来

改完之后,最大的风险是几个月后新写的一个功能又带进来一批循环查询,而且没人发现。三条防线。

第一,把那行 SQL 计数日志留在代码里,长期开启。它只有几行代码,开销可以忽略,但能在问题复发的第一时间报警。日志里按 URI 聚合,每天扫一次,超过阈值的页面自动列进待办。

第二,在代码审查时盯住 foreach / for / while 循环体里有没有数据库调用。这是一个可以在五分钟内肉眼扫完的检查,比任何静态分析工具都可靠。团队里有新人时,这是最重要的 review 要点。

第三,性能基线。每个月挑五个核心页面(首页、列表页、文章页、标签页、搜索页)各跑一次 SQL 计数,记到一个表格里。一旦某个页面的数字从 18 变成 60,说明有人加了新功能。这个动作只要十分钟,但它把「性能」从一次性工程变成了可持续的状态。

最后提醒一句:N+1 不是只有 ORM 才有的问题,手写 SQL 也一样会犯。ORM 只是让它更容易发生(因为「取关联」写起来太顺手了),但根源在于「把数据库当成一个可以被无限次调用的函数」。一旦你在脑子里建立了「往返次数是稀缺资源」这个模型,写法自然会改。

Last modification:November 13th, 2026 at 01:26 pm

Leave a Comment