什么是 N+1 查询问题及如何解决?
什么是 N+1 查询问题?
N+1 查询问题的主要表现是应用中会执行大量查询,通常这是由于代码结构不合理导致:先执行一个查询获取记录列表,然后对这些记录中的每一个执行单独的查询。
尽管在表面上,小查询看起来应该更快,而大而复杂的查询可能会更慢,但实际上情况往往相反。多个查询需要与数据库进行多次交互,包括发送查询、数据库处理查询以及返回结果;而一个复杂的单一查询只需与数据库交互一次,并且可由数据库优化,通常比多个小查询快得多。
一个 N+1 查询的示例
以下是一个示例应用场景。假设我们有一个 PlanetScale 数据库,其中包含一个 items
表和一个 categories
表:
表结构如下:
categories 表:
id | name |
1 | Produce |
2 | Deli |
3 | Dairy |
items 表:
id | name | category_id |
1 | Apples | 1 |
2 | Cheese | 2 |
3 | Bread | NULL |
示例需求:
我们希望应用列出所有的商品,同时显示其所在的分类名称。
第一种实现方式是先查询分类列表,然后对每个分类的商品分别查询:
第一步:查询分类
$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,性能优化变得更加精准和高效。
关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台
除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接