当前位置:首页 > 文章列表 > 文章 > 常见问题 > 接口列表页越查越慢怎么办:N+1 查询从 120 次降到 3 次

接口列表页越查越慢怎么办:N+1 查询从 120 次降到 3 次

来源:17golang原创 2026-06-29 19:49:08 0浏览 收藏

列表接口刚上线时很快,数据量一涨就开始变慢:第一页还能接受,翻到后面或者筛选条件复杂一点,接口耗时就从几十毫秒升到几百毫秒。很多人第一反应是“数据库慢了”,但真实问题常常是 N+1 查询。

所谓 N+1,不是某条 SQL 特别慢,而是一次列表请求先查出 N 条主数据,再为每条数据单独查用户、商品、标签或统计信息。单次看每条查询都很快,合起来就把接口拖慢。下面用一个订单列表接口做案例,按指标驱动的方式把问题拆开。

目录
  • 基线数据:先看一次请求到底查了多少次
  • 假设验证:为什么不是单条 SQL 慢
  • 改动点:把逐条查询改成批量加载
  • 压测方法:固定条件再比较结果
  • 结果对比:查询次数和 P95 都降下来
  • 边界条件:什么时候还要继续治理

基线数据:先看一次请求到底查了多少次

问题接口是一个订单列表,返回 40 条订单,每条订单还要带用户昵称、商品标题和最近一次支付记录。业务代码一开始写得很直观:

function listOrders(PDO $db): array
{
    $orders = $db->query(
        "select id, user_id, product_id, amount, created_at
         from orders
         order by id desc
         limit 40"
    )->fetchAll(PDO::FETCH_ASSOC);

    foreach ($orders as &$order) {
        $order['user'] = findUser($db, (int) $order['user_id']);
        $order['product'] = findProduct($db, (int) $order['product_id']);
        $order['pay'] = findLastPayment($db, (int) $order['id']);
    }

    return $orders;
}

40 条订单,每条补 3 类信息,就会出现:

1 次订单列表查询
40 次用户查询
40 次商品查询
40 次支付查询
合计约 121 次 SQL

在本地或测试库里,每条小 SQL 只要 1 到 3 毫秒,看起来没有问题。线上并发上来后,连接池、网络往返、数据库 CPU 和锁等待会把这些碎片成本放大。

列表接口 N+1 查询基线证据:一次请求产生大量小 SQL

假设验证:为什么不是单条 SQL 慢

判断 N+1 的关键是看“查询次数”和“相同 SQL 模板重复次数”。如果只看慢 SQL 日志,可能没有任何一条 SQL 超过阈值。建议在调试环境先加一层轻量计数,记录本次请求内 SQL 次数和模板摘要。

final class SqlCounter
{
    public int $count = 0;
    public array $samples = [];

    public function mark(string $sql): void
    {
        $this->count++;
        $key = preg_replace('/\\b\\d+\\b/', '?', $sql);
        $this->samples[$key] = ($this->samples[$key] ?? 0) + 1;
    }
}

一次请求结束后打印摘要:

SQL count: 121
select id,nickname from users where id = ?          40
select id,title from products where id = ?          40
select * from payments where order_id = ? limit 1   40
select ... from orders limit 40                      1

这组数据说明,问题不是某条 SQL 特别慢,而是同类查询重复了太多次。此时继续只加索引,收益有限;更有效的方向是把循环里的单条查询移到循环外,改成批量加载。

改动点:把逐条查询改成批量加载

改造目标很明确:主表查一次,用户批量查一次,商品批量查一次,支付记录批量查一次。先收集 ID,再用 where id in (...) 或聚合子查询拿到补充信息,最后在内存里按 ID 组装。

function listOrdersFast(PDO $db): array
{
    $orders = $db->query(
        "select id, user_id, product_id, amount, created_at
         from orders
         order by id desc
         limit 40"
    )->fetchAll(PDO::FETCH_ASSOC);

    $userIds = array_values(array_unique(array_column($orders, 'user_id')));
    $productIds = array_values(array_unique(array_column($orders, 'product_id')));
    $orderIds = array_values(array_column($orders, 'id'));

    $users = fetchUsersByIds($db, $userIds);
    $products = fetchProductsByIds($db, $productIds);
    $payments = fetchLastPaymentsByOrderIds($db, $orderIds);

    foreach ($orders as &$order) {
        $order['user'] = $users[$order['user_id']] ?? null;
        $order['product'] = $products[$order['product_id']] ?? null;
        $order['pay'] = $payments[$order['id']] ?? null;
    }

    return $orders;
}

批量查询函数可以统一把结果转成以主键为 key 的数组:

function fetchUsersByIds(PDO $db, array $ids): array
{
    if (!$ids) {
        return [];
    }

    $ids = array_values(array_unique(array_map('intval', $ids)));
    $idList = implode(',', $ids);
    $rows = $db->query(
        "select id, nickname
         from users
         where id in ($idList)"
    )->fetchAll(PDO::FETCH_ASSOC);

    $map = [];
    foreach ($rows as $row) {
        $map[(int) $row['id']] = $row;
    }
    return $map;
}

支付记录如果要取每个订单最近一条,不能简单用 where order_id in (...) 后全量返回。可以用子查询先找每个订单的最大支付记录 ID,再回表取详情:

select p.*
from payments p
join (
  select order_id, max(id) as max_id
  from payments
  where order_id in (?,?,?)
  group by order_id
) t on p.order_id = t.order_id and p.id = t.max_id;

N+1 查询改造:收集 ID 后批量加载并按映射组装响应

压测方法:固定条件再比较结果

优化前后要固定相同条件,否则结果容易误判。建议固定这几项:

  • 相同页大小,例如每页 40 条。
  • 相同筛选条件,例如同一状态、同一时间范围。
  • 相同数据库环境,不要一边连测试库一边连线上从库。
  • 相同并发和持续时间,例如并发 20、持续 2 分钟。

可以用任意压测工具,这里只展示指标表。关键是记录请求耗时和 SQL 次数,而不是只看平均值。

场景:订单列表 limit 40
并发:20
持续:2 分钟
记录:SQL 次数、平均耗时、P95、数据库 QPS

结果对比:查询次数和 P95 都降下来

下面是一次典型对比结果。数字不是为了证明某个固定收益,而是说明判断方向:

版本 单请求 SQL 次数 平均耗时 P95 数据库 QPS
逐条查询 121 380ms 720ms 2400+
批量加载 4 92ms 160ms 310
支付聚合合并后 3 76ms 130ms 260

这个结果说明两件事:第一,N+1 的核心成本是大量小查询和网络往返;第二,接口 P95 比平均值更能反映用户感受,因为并发下偶发等待会被放大。

边界条件:什么时候还要继续治理

批量加载不是终点。下面这些情况还要继续拆:

  • 页大小过大:一次返回 500 条,即使只有 3 次 SQL,也可能在传输和组装阶段变慢。
  • in 条件过长:可以限制页大小,或者按业务维度预聚合。
  • 关联数据变化频繁:缓存要谨慎,避免列表展示旧状态。
  • 补充字段很多:考虑列表接口只返回摘要,详情页再查完整信息。
  • 统计字段昂贵:把实时统计拆成异步汇总表或定时刷新字段。

总结一下:列表接口越查越慢时,先别急着给每条 SQL 加索引。把一次请求内的 SQL 次数、重复模板和 P95 指标拉出来。如果发现同一类查询重复几十次,就优先治理 N+1:收集 ID、批量查询、映射组装,再用固定压测条件确认收益。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
PHP-FPM 慢请求报警运行手册:从 slowlog 到进程池参数调整PHP-FPM 慢请求报警运行手册:从 slowlog 到进程池参数调整
上一篇
PHP-FPM 慢请求报警运行手册:从 slowlog 到进程池参数调整
接口按钮隐藏了,为什么用户还能越权操作?后端权限校验这样补齐
下一篇
接口按钮隐藏了,为什么用户还能越权操作?后端权限校验这样补齐
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    256次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    298次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    275次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    253次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    61次使用