ORM(对象关系映射)中的“N+1选择问题”是什么?
-
01-07-2019 - |
题
“N+1 选择问题”通常被认为是对象关系映射(ORM)讨论中的一个问题,我知道它与必须对对象中看似简单的东西进行大量数据库查询有关世界。
有人对这个问题有更详细的解释吗?
解决方案
假设您有一个集合 Car
对象(数据库行),以及每个 Car
有一个集合 Wheel
对象(也是行)。换句话说, Car
-> Wheel
是一对多的关系。
现在,假设您需要迭代所有汽车,并为每一辆汽车打印出车轮列表。简单的 O/R 实现将执行以下操作:
SELECT * FROM Cars;
进而 对于每个 Car
:
SELECT * FROM Wheel WHERE CarId = ?
换句话说,您有一个汽车选择,然后还有 N 个附加选择,其中 N 是汽车总数。
或者,可以获取所有轮子并在内存中执行查找:
SELECT * FROM Wheel
这将数据库的往返次数从 N+1 减少到 2。大多数 ORM 工具都提供了多种方法来防止 N+1 选择。
参考: Java 与 Hibernate 的持久性, ,第 13 章。
其他提示
SELECT
table1.*
, table2.*
INNER JOIN table2 ON table2.SomeFkId = table1.SomeId
通过为 table2 中的每个子行返回 table1 结果,您将得到一个结果集,其中 table2 中的子行导致重复。O/R 映射器应根据唯一键字段区分 table1 实例,然后使用 table2 的所有列来填充子实例。
SELECT table1.*
SELECT table2.* WHERE SomeFkId = #
N+1 是第一个查询填充主对象,第二个查询填充返回的每个唯一主对象的所有子对象。
考虑:
class House
{
int Id { get; set; }
string Address { get; set; }
Person[] Inhabitants { get; set; }
}
class Person
{
string Name { get; set; }
int HouseId { get; set; }
}
以及具有类似结构的表。对地址“22 Valley St”的单个查询可能会返回:
Id Address Name HouseId
1 22 Valley St Dave 1
1 22 Valley St John 1
1 22 Valley St Mike 1
O/RM 应使用 ID=1、Address="22 Valley St" 填充 Home 实例,然后仅通过一个查询将 Dave、John 和 Mike 的 People 实例填充到 Inhabitants 数组中。
对上面使用的同一地址进行 N+1 查询将导致:
Id Address
1 22 Valley St
与一个单独的查询像
SELECT * FROM Person WHERE HouseId = 1
并产生一个单独的数据集,例如
Name HouseId
Dave 1
John 1
Mike 1
最终结果与上面单个查询的结果相同。
单选的优点是您可以预先获得所有数据,这可能是您最终想要的。N+1 的优点是降低了查询复杂性,并且您可以使用延迟加载,其中仅在第一次请求时加载子结果集。
与产品具有一对多关系的供应商。一位供应商拥有(供应)多种产品。
***** Table: Supplier *****
+-----+-------------------+
| ID | NAME |
+-----+-------------------+
| 1 | Supplier Name 1 |
| 2 | Supplier Name 2 |
| 3 | Supplier Name 3 |
| 4 | Supplier Name 4 |
+-----+-------------------+
***** Table: Product *****
+-----+-----------+--------------------+-------+------------+
| ID | NAME | DESCRIPTION | PRICE | SUPPLIERID |
+-----+-----------+--------------------+-------+------------+
|1 | Product 1 | Name for Product 1 | 2.0 | 1 |
|2 | Product 2 | Name for Product 2 | 22.0 | 1 |
|3 | Product 3 | Name for Product 3 | 30.0 | 2 |
|4 | Product 4 | Name for Product 4 | 7.0 | 3 |
+-----+-----------+--------------------+-------+------------+
因素:
供应商的惰性模式设置为“true”(默认)
用于查询 Product 的 Fetch 模式为 Select
获取模式(默认):供应商信息被访问
第一次缓存没有发挥作用
供应商已被访问
获取模式为“选择获取”(默认)
// It takes Select fetch mode as a default
Query query = session.createQuery( "from Product p");
List list = query.list();
// Supplier is being accessed
displayProductsListWithSupplierName(results);
select ... various field names ... from PRODUCT
select ... various field names ... from SUPPLIER where SUPPLIER.id=?
select ... various field names ... from SUPPLIER where SUPPLIER.id=?
select ... various field names ... from SUPPLIER where SUPPLIER.id=?
结果:
- 1 个产品选择语句
- 供应商的 N 选择语句
这是N+1选择问题!
我无法直接评论其他答案,因为我没有足够的声誉。但值得注意的是,这个问题本质上只是因为从历史上看,很多 dbms 在处理连接方面表现得很差(MySQL 是一个特别值得注意的例子)。因此,n+1 通常比连接要快得多。然后有一些方法可以改进 n+1 但仍然不需要连接,这就是原始问题所涉及的。
然而,MySQL 现在在连接方面比以前好多了。当我第一次学习MySQL时,我经常使用连接。然后我发现它们有多慢,并在代码中改用n+1。但是,最近,我又重新开始使用连接,因为 MySQL 现在在处理连接方面比我第一次开始使用它时要好得多。
如今,就性能而言,对一组正确索引的表进行简单联接很少会出现问题。如果确实会影响性能,那么使用索引提示通常可以解决这些问题。
MySQL 开发团队之一对此进行了讨论:
http://jorgenloland.blogspot.co.uk/2013/02/dbt-3-q3-6-x-performance-in-mysql-5610.html
所以总结就是:如果您过去因为 MySQL 的糟糕性能而避免使用连接,那么请在最新版本上重试。您可能会感到惊喜。
由于这个问题,我们放弃了 Django 中的 ORM。基本上,如果你尝试做
for p in person:
print p.car.colour
ORM 会很高兴地返回所有人员(通常作为 Person 对象的实例),但随后它需要查询每个 Person 的 car 表。
我称之为“一个简单且非常有效的方法”折叠”,这避免了这样一个荒谬的想法:关系数据库的查询结果应该映射回组成查询的原始表。
步骤1:广泛选择
select * from people_car_colour; # this is a view or sql function
这将返回类似的东西
p.id | p.name | p.telno | car.id | car.type | car.colour
-----+--------+---------+--------+----------+-----------
2 | jones | 2145 | 77 | ford | red
2 | jones | 2145 | 1012 | toyota | blue
16 | ashby | 124 | 99 | bmw | yellow
第2步:客观化
将结果吸入通用对象创建器中,并在第三个项目之后使用一个参数进行拆分。这意味着“jones”对象不会被多次创建。
步骤3:使成为
for p in people:
print p.car.colour # no more car queries
看 这个网页 为了实施 折叠 对于蟒蛇。
假设您有公司和员工。公司有许多员工(即EMPLOYEE 有一个字段 COMPANY_ID)。
在某些 O/R 配置中,当您拥有映射的 Company 对象并访问其 Employee 对象时,O/R 工具将为每个员工执行一次选择,而如果您只是使用直接 SQL 执行操作,则可以 select * from employees where company_id = XX
. 。因此 N(员工数量)加 1(公司)
这就是 EJB 实体 Bean 的初始版本的工作方式。我相信像 Hibernate 这样的东西已经消除了这个问题,但我不太确定。大多数工具通常包含有关其映射策略的信息。
这是对问题的一个很好的描述 - https://web.archive.org/web/20160310145416/http://www.realsolve.co.uk/site/tech/hib-tip-pitfall.php?name=why-lazy
现在您已经了解了该问题,通常可以通过在查询中执行联接获取来避免该问题。这基本上会强制获取延迟加载的对象,以便在一个查询中而不是 n+1 个查询中检索数据。希望这可以帮助。
我认为这篇文章写在 休眠陷阱:为什么人际关系应该是懒惰的 与真正的N+1问题完全相反。
如果您需要正确的解释请参考 休眠 - 第 19 章:提高性能 - 获取策略
选择获取(默认值)极易受到n+1选择问题的影响,因此我们可能需要启用加入获取
查看 Ayende 关于该主题的帖子: 解决 NHibernate 中的 Select N+1 问题
基本上,当使用像 NHibernate 或 EntityFramework 这样的 ORM 时,如果您有一对多(主从)关系,并且想要列出每个主记录的所有详细信息,则必须对数据库,“N”是主记录的数量:1 个查询获取所有主记录,N 个查询(每个主记录一个)获取每个主记录的所有详细信息。
更多数据库查询调用 --> 更多延迟时间 --> 应用程序/数据库性能下降。
然而,ORM 可以选择避免这个问题,主要是使用“连接”。
当您忘记获取关联然后需要访问它时,就会发生 N+1 查询问题:
List<PostComment> comments = entityManager.createQuery(
"select pc " +
"from PostComment pc " +
"where pc.review = :review", PostComment.class)
.setParameter("review", review)
.getResultList();
LOGGER.info("Loaded {} comments", comments.size());
for(PostComment comment : comments) {
LOGGER.info("The post title is '{}'", comment.getPost().getTitle());
}
它会生成以下 SQL 语句:
SELECT pc.id AS id1_1_, pc.post_id AS post_id3_1_, pc.review AS review2_1_
FROM post_comment pc
WHERE pc.review = 'Excellent!'
INFO - Loaded 3 comments
SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM post pc
WHERE pc.id = 1
INFO - The post title is 'Post nr. 1'
SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM post pc
WHERE pc.id = 2
INFO - The post title is 'Post nr. 2'
SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM post pc
WHERE pc.id = 3
INFO - The post title is 'Post nr. 3'
首先,Hibernate 执行 JPQL 查询,并生成一个列表 PostComment
实体被获取。
然后,对于每个 PostComment
, ,相关联的 post
属性用于生成包含以下内容的日志消息 Post
标题。
因为 post
关联未初始化,Hibernate 必须获取 Post
带有次要查询的实体,n PostComment
实体,将执行 N 个以上的查询(因此出现 N+1 查询问题)。
首先,你需要 适当的 SQL 日志记录和监控 这样您就可以发现这个问题。
其次,这种问题最好通过集成测试来发现。您可以使用 自动 JUnit 断言以验证生成的 SQL 语句的预期计数. 。这 数据库单元项目 已经提供了此功能,并且它是开源的。
当您发现 N+1 查询问题时, 您需要使用 JOIN FETCH 以便在一个查询中获取子关联,而不是 N. 。如果您需要获取多个子关联,最好在初始查询中获取一个集合,并使用辅助 SQL 查询获取第二个集合。
提供的链接有一个非常简单的 n + 1 问题示例。如果你把它应用到 Hibernate 上,它基本上是在谈论同样的事情。当您查询对象时,实体将被加载,但任何关联(除非另有配置)都将被延迟加载。因此,一个查询根对象,另一个查询加载每个根对象的关联。返回 100 个对象意味着一个初始查询,然后是 100 个附加查询以获得每个对象的关联,即 n + 1。
一位百万富翁拥有 N 辆汽车。您想要获得全部 (4) 个轮子。
一 (1) 个查询加载所有汽车,但对于每 (N) 个汽车,提交一个单独的查询来加载车轮。
费用:
假设索引适合内存。
1 + N 查询解析和规划 + 索引搜索 AND 1 + N + (N * 4) 装载负载的板访问。
假设索引不适合内存。
最坏情况下的额外成本 1 + N 板访问以加载索引。
概括
瓶颈是板访问(约。HDD上的每秒70次随机访问)急切的加入选择还将访问Plate 1 + N +(n * 4)以进行有效载荷。因此,如果索引适合 ram - 没问题,它足够快,因为只涉及 ram 操作。
发出 1 个返回 100 个结果的查询比发出 100 个每个返回 1 个结果的查询要快得多。
N+1 选择问题很痛苦,在单元测试中检测此类情况是有意义的。我开发了一个小型库,用于验证给定测试方法或任意代码块执行的查询数量 - JDBC嗅探器
只需向您的测试类添加一个特殊的 JUnit 规则,并在您的测试方法上放置带有预期查询数量的注释:
@Rule
public final QueryCounter queryCounter = new QueryCounter();
@Expectation(atMost = 3)
@Test
public void testInvokingDatabase() {
// your JDBC or JPA code
}
正如其他人更优雅地表述的那样,问题是您要么拥有 OneToMany 列的笛卡尔积,要么正在进行 N+1 选择。分别可能是巨大的结果集或与数据库的频繁交互。
我很惊讶这一点没有被提及,但这就是我解决这个问题的方法...... 我制作了一个半临时的 ids 表. 当你有的时候我也会这样做 IN ()
条款限制.
这并不适用于所有情况(可能甚至不是大多数),但如果您有很多子对象,这样笛卡尔积就会失控(即很多 OneToMany
列(结果的数量将是列的乘积),并且它更像是一个批处理作业。
首先,将父对象 ID 批量插入到 ids 表中。这个batch_id是我们在应用程序中生成并保留的。
INSERT INTO temp_ids
(product_id, batch_id)
(SELECT p.product_id, ?
FROM product p ORDER BY p.product_id
LIMIT ? OFFSET ?);
现在对于每个 OneToMany
专栏你只需做一个 SELECT
在 ids 表上 INNER JOIN
ing 子表 WHERE batch_id=
(或相反亦然)。您只想确保按 id 列排序,因为这将使合并结果列更容易(否则您将需要一个 HashMap/Table 来存储整个结果集,这可能还不错)。
然后你只需定期清理 ids 表即可。
如果用户选择 100 个左右的不同项目进行某种批量处理,这种方法也特别有效。将 100 个不同的 id 放入临时表中。
现在,您正在执行的查询数量取决于 OneToMany 列的数量。
以 Matt Solnit 为例,假设您将 Car 和 Wheels 之间的关联定义为 LAZY,并且需要一些 Wheels 字段。这意味着在第一次选择之后,hibernate 将为每辆车执行“Select * from Wheels where car_id = :id”。
这使得每N辆车都进行第一次选择和更多1次选择,这就是为什么它被称为n+1问题。
为了避免这种情况,请将关联获取设置为急切,以便 hibernate 通过连接加载数据。
但请注意,如果多次不访问关联的 Wheel,最好保持 LAZY 或使用 Criteria 更改获取类型。