什么是 N+1 查询问题?

N+1 查询问题的主要表现是应用中会执行大量查询,通常这是由于代码结构不合理导致:先执行一个查询获取记录列表,然后对这些记录中的每一个执行单独的查询。
尽管在表面上,小查询看起来应该更快,而大而复杂的查询可能会更慢,但实际上情况往往相反。多个查询需要与数据库进行多次交互,包括发送查询、数据库处理查询以及返回结果;而一个复杂的单一查询只需与数据库交互一次,并且可由数据库优化,通常比多个小查询快得多。


一个 N+1 查询的示例

以下是一个示例应用场景。假设我们有一个 PlanetScale 数据库,其中包含一个 items 表和一个 categories 表:

表结构如下:

categories 表

idname
1Produce
2Deli
3Dairy

items 表

idnamecategory_id
1Apples1
2Cheese2
3BreadNULL

示例需求:

我们希望应用列出所有的商品,同时显示其所在的分类名称。
第一种实现方式是先查询分类列表,然后对每个分类的商品分别查询:
第一步:查询分类

$dbh = new Dbh();
$conn = $dbh->connect();
$sql = "SELECT * FROM categories;";
$stmt = $conn->prepare($sql);
$stmt->execute();

第二步:对每个分类查询商品

while ($row = $stmt->fetch()) {
    // 显示分类名称
    echo $row['name'];

    // 查询该分类的商品
    $sql = "
        SELECT id, name FROM items
        WHERE category_id = :category_id
        ORDER BY name;
    ";
    $stmt2 = $conn->prepare($sql);
    $stmt2->bindParam(':category_id', $row['id']);
    $stmt2->execute();

    while ($row2 = $stmt2->fetch()) {
        // 显示商品 ID 和名称
        echo $row2['id'];
        echo $row2['name'];
    }
}

这种方法简单、直观,每个查询逻辑清晰。然而,它的最大问题就是会执行许多查询,导致性能不佳。这种查询方式被称为 **N+1 查询问题**,即一个初始查询(查分类列表)加上每 N 个分类单独执行一个查询。

性能对比:

假设数据库有 800 个商品分布在 17 个分类中,通过上述方法运行了 18 次查询,总耗时超过 1 秒。而通过单次复杂查询,我们可以将时间降低到 0.16 秒。


使用 JOIN 解决 N+1 查询问题

我们可以进行代码重构,将原本的多次查询(N+1)优化为单次复杂查询:

$dbh = new Dbh();
$conn = $dbh->connect();

$sql = "
    SELECT
        c.id AS category_id,
        c.name AS category_name,
        i.id AS item_id,
        i.name AS item_name
    FROM categories c
    LEFT JOIN items i ON c.id = i.category_id
    ORDER BY c.name, i.name;
";
$stmt = $conn->prepare($sql);
$stmt->execute();

$lastCategoryId = null;

while ($row = $stmt->fetch()) {
    // 每个分类渲染一次标题
    if ($row['category_id'] != $lastCategoryId) {
        echo $row['category_name'];
    }

    // 显示每个商品
    if (!is_null($row['item_id'])) {
        echo $row['item_id'];
        echo $row['item_name'];
    }

    $lastCategoryId = $row['category_id'];
}

通过上述代码,我们将逻辑简化为单次查询,同时响应时间从 1.4 秒降低到 0.16 秒。


数据结构优化:更复杂的查询

如果需求更复杂,比如需要展示分类及其商品数量,可以使用 GROUP BY 进行聚合查询:

SELECT c.id, c.name, count(i.id) AS item_count FROM categories c
LEFT JOIN items i ON c.id = i.category_id
GROUP BY c.id, c.name
ORDER BY c.name;

但如果既要获取商品数量,又要获取商品列表,可以在服务器端通过程序计算商品数量。例如:

$dbh = new Dbh();
$conn = $dbh->connect();

$sql = "
    SELECT
        c.id AS category_id,
        c.name AS category_name,
        i.id AS item_id,
        i.name AS item_name
    FROM categories c
    LEFT JOIN items i ON c.id = i.category_id
    ORDER BY c.name, i.name;
";
$stmt = $conn->prepare($sql);
$stmt->execute();

$categories = [];
$categoryItems = [];

while ($row = $stmt->fetch()) {
    if (!is_null($lastCategoryId) && $row['category_id'] != $lastCategoryId) {
        $categories[$lastCategoryName] = $categoryItems;
        $categoryItems = array();
    }

    if (!is_null($row['item_id'])) $categoryItems[$row['item_id']] = $row['item_name'];

    $lastCategoryId = $row['category_id'];
    $lastCategoryName = $row['category_name'];
}
$categories[$lastCategoryName] = $categoryItems;

// 渲染数据
foreach ($categories as $categoryName => $items) {
    echo $categoryName;
    echo count($items) . ' items';

    foreach($items as $itemId => $itemName) {
        echo $itemId;
        echo $itemName;
    }
}

这种方式不仅高效,还可以生成更适合应用的数据结构。例如,通过分类 ID 索引商品列表,方便快捷地访问特定分类的商品。


如何检测 N+1 查询?

如果应用较复杂,N+1 查询问题可能隐藏较深。以下方法可以帮助检测和优化:

Laravel Debug Bar

对于 Laravel 应用,可使用 Debug Bar 或通过以下代码完全禁用 N+1 查询:

Model::preventLazyLoading(!app()->isProduction());

PlanetScale Insights

PlanetScale 提供 Insights 监控工具来检测数据库查询性能,包括 N+1 查询和运行时间较长的查询。您可以在此工具中找到详细的查询统计,识别性能瓶颈并优化查询。


总结

N+1 查询问题典型地通过多个小查询引发性能问题,而这种问题完全可以被重构为单次复杂查询解决。通过优化数据库和应用代码设计,您不仅可以显著提高速度,还能为应用开发提供更高效的数据结构。借助工具如 Laravel Debug Bar 和 PlanetScale Insights,性能优化变得更加精准和高效。



什么是 N+1 查询问题及如何解决?插图

关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台

除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接

本文链接:http://choupangxia.cn/2025/09/11/mysql-n1-query/