新聞中心
N+1問(wèn)題:N+1問(wèn)題是指在使用關(guān)系型數(shù)據(jù)庫(kù)時(shí),在獲取一組對(duì)象及其關(guān)聯(lián)對(duì)象時(shí),產(chǎn)生額外的數(shù)據(jù)庫(kù)查詢的問(wèn)題。其中N表示要獲取的主對(duì)象的數(shù)量,而在獲取每個(gè)主對(duì)象的關(guān)聯(lián)對(duì)象時(shí),會(huì)產(chǎn)生額外的1次查詢。

為北安等地區(qū)用戶提供了全套網(wǎng)頁(yè)設(shè)計(jì)制作服務(wù),及北安網(wǎng)站建設(shè)行業(yè)解決方案。主營(yíng)業(yè)務(wù)為做網(wǎng)站、成都網(wǎng)站設(shè)計(jì)、北安網(wǎng)站設(shè)計(jì),以傳統(tǒng)方式定制建設(shè)網(wǎng)站,并提供域名空間備案等一條龍服務(wù),秉承以專業(yè)、用心的態(tài)度為用戶提供真誠(chéng)的服務(wù)。我們深信只要達(dá)到每一位用戶的要求,就會(huì)得到認(rèn)可,從而選擇與我們長(zhǎng)期合作。這樣,我們也可以走得更遠(yuǎn)!
N+1問(wèn)題是很多項(xiàng)目中的通病。遺憾的是,直到數(shù)據(jù)量變得龐大時(shí),我們才注意到它。不幸的是,當(dāng)處理 N + 1 問(wèn)題成為一項(xiàng)難以承受的任務(wù)時(shí),代碼可能會(huì)達(dá)到了一定規(guī)模。
在這篇文章中,我們將開始關(guān)注以下幾點(diǎn)問(wèn)題:
- 如何自動(dòng)跟蹤N+1問(wèn)題?
- 如何編寫測(cè)試來(lái)檢查查詢計(jì)數(shù)是否超過(guò)預(yù)期值?
N + 1 問(wèn)題的一個(gè)例子
假設(shè)我們正在開發(fā)管理動(dòng)物園的應(yīng)用程序。在這種情況下,有兩個(gè)核心實(shí)體:Zoo和Animal。請(qǐng)看下面的代碼片段:
@Entity
@Table(name = "zoo")
public class Zoo {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "zoo", cascade = PERSIST)
private List animals = new ArrayList<>();
}
@Entity
@Table(name = "animal")
public class Animal {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "zoo_id")
private Zoo zoo;
private String name;
} 現(xiàn)在我們想要檢索所有現(xiàn)有的動(dòng)物園及其動(dòng)物??纯碯ooService下面的代碼。
@Service
@RequiredArgsConstructor
public class ZooService {
private final ZooRepository zooRepository;
@Transactional(readOnly = true)
public List findAllZoos() {
final var zoos = zooRepository.findAll();
return zoos.stream()
.map(ZooResponse::new)
.toList();
}
} 此外,我們要檢查一切是否順利進(jìn)行。簡(jiǎn)單的集成測(cè)試:
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
@Transactional(propagation = NOT_SUPPORTED)
@Testcontainers
@Import(ZooService.class
class ZooServiceTest {
@Container
static final PostgreSQLContainer> POSTGRES = new PostgreSQLContainer<>("postgres:13");
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
@Autowired
private ZooService zooService;
@Autowired
private ZooRepository zooRepository;
@Test
void shouldReturnAllZoos() {
/* data initialization... */
zooRepository.saveAll(List.of(zoo1, zoo2));
final var allZoos = assertQueryCount(
() -> zooService.findAllZoos(),
ofSelects(1)
);
/* assertions... */
assertThat(
...
);
}
}測(cè)試成功通過(guò)。但是,如果記錄 SQL 語(yǔ)句,會(huì)注意到以下幾點(diǎn):
-- selecting all zoos
select z1_0.id,z1_0.name from zoo z1_0
-- selecting animals for the first zoo
select a1_0.zoo_id,a1_0.id,a1_0.name from animal a1_0 where a1_0.zoo_id=?
-- selecting animals for the second zoo
select a1_0.zoo_id,a1_0.id,a1_0.name from animal a1_0 where a1_0.zoo_id=?如所見,我們select對(duì)每個(gè) present 都有一個(gè)單獨(dú)的查詢Zoo。查詢總數(shù)等于所選動(dòng)物園的數(shù)量+1。因此,這是N+1問(wèn)題。
這可能會(huì)導(dǎo)致嚴(yán)重的性能損失。尤其是在大規(guī)模數(shù)據(jù)上。
自動(dòng)跟蹤 N+1 問(wèn)題
當(dāng)然,我們可以自行運(yùn)行測(cè)試、查看日志和計(jì)算查詢次數(shù),以確定可行的性能問(wèn)題。無(wú)論如何,這效率很低。。
有一個(gè)非常高效的庫(kù),叫做datasource-proxy。它提供了一個(gè)方便的 API 來(lái)javax.sql.DataSource使用包含特定邏輯的代理來(lái)包裝接口。例如,我們可以注冊(cè)在查詢執(zhí)行之前和之后調(diào)用的回調(diào)。該庫(kù)還包含開箱即用的解決方案來(lái)計(jì)算已執(zhí)行的查詢。我們將對(duì)其進(jìn)行一些改動(dòng)以滿足我們的需要。
查詢計(jì)數(shù)服務(wù)
首先,將庫(kù)添加到依賴項(xiàng)中:
implementation "net.ttddyy:datasource-proxy:1.8"現(xiàn)在創(chuàng)建QueryCountService. 它是保存當(dāng)前已執(zhí)行查詢計(jì)數(shù)并允許您清理它的單例。請(qǐng)看下面的代碼片段。
@UtilityClass
public class QueryCountService {
static final SingleQueryCountHolder QUERY_COUNT_HOLDER = new SingleQueryCountHolder();
public static void clear() {
final var map = QUERY_COUNT_HOLDER.getQueryCountMap();
map.putIfAbsent(keyName(map), new QueryCount());
}
public static QueryCount get() {
final var map = QUERY_COUNT_HOLDER.getQueryCountMap();
return ofNullable(map.get(keyName(map))).orElseThrow();
}
private static String keyName(Map map) {
if (map.size() == 1) {
return map.entrySet()
.stream()
.findFirst()
.orElseThrow()
.getKey();
}
throw new IllegalArgumentException("Query counts map should consists of one key: " + map);
}
} 在那種情況下,我們假設(shè)_DataSource_我們的應(yīng)用程序中有一個(gè)。這就是_keyName_函數(shù)否則會(huì)拋出異常的原因。但是,代碼不會(huì)因使用多個(gè)數(shù)據(jù)源而有太大差異。
將SingleQueryCountHolder所有QueryCount對(duì)象存儲(chǔ)在常規(guī)ConcurrentHashMap.
相反,_ThreadQueryCountHolder_將值存儲(chǔ)在_ThreadLocal_對(duì)象中。但是_SingleQueryCountHolder_對(duì)于我們的情況來(lái)說(shuō)已經(jīng)足夠了。
API 提供了兩種方法。該get方法返回當(dāng)前執(zhí)行的查詢數(shù)量,同時(shí)clear將計(jì)數(shù)設(shè)置為零。
BeanPostProccessor 和 DataSource 代理
現(xiàn)在我們需要注冊(cè)QueryCountService以使其從 收集數(shù)據(jù)DataSource。在這種情況下,BeanPostProcessor 接口就派上用場(chǎng)了。請(qǐng)看下面的代碼示例。
@TestComponent
public class DatasourceProxyBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof DataSource dataSource) {
return ProxyDataSourceBuilder.create(dataSource)
.countQuery(QUERY_COUNT_HOLDER)
.build();
}
return bean;
}
}我用注釋標(biāo)記類_@TestComponent_并將其放入_src/test_目錄,因?yàn)槲也恍枰獙?duì)測(cè)試范圍之外的查詢進(jìn)行計(jì)數(shù)。
如您所見,這個(gè)想法很簡(jiǎn)單。如果一個(gè) bean 是DataSource,則將其包裹起來(lái)ProxyDataSourceBuilder并將QUERY_COUNT_HOLDER值作為QueryCountStrategy.
最后,我們要斷言特定方法的已執(zhí)行查詢量??纯聪旅娴拇a實(shí)現(xiàn):
@UtilityClass
public class QueryCountAssertions {
@SneakyThrows
public static T assertQueryCount(Supplier supplier, Expectation expectation) {
QueryCountService.clear();
final var result = supplier.get();
final var queryCount = QueryCountService.get();
assertAll(
() -> {
if (expectation.selects >= 0) {
assertEquals(expectation.selects, queryCount.getSelect(), "Unexpected selects count");
}
},
() -> {
if (expectation.inserts >= 0) {
assertEquals(expectation.inserts, queryCount.getInsert(), "Unexpected inserts count");
}
},
() -> {
if (expectation.deletes >= 0) {
assertEquals(expectation.deletes, queryCount.getDelete(), "Unexpected deletes count");
}
},
() -> {
if (expectation.updates >= 0) {
assertEquals(expectation.updates, queryCount.getUpdate(), "Unexpected updates count");
}
}
);
return result;
}
} 該代碼很簡(jiǎn)單:
- 將當(dāng)前查詢計(jì)數(shù)設(shè)置為零。
- 執(zhí)行提供的 lambda。
- 將查詢計(jì)數(shù)給定的Expectation對(duì)象。
- 如果一切順利,返回執(zhí)行結(jié)果。
此外,您還注意到了一個(gè)附加條件。如果提供的計(jì)數(shù)類型小于零,則跳過(guò)斷言。不關(guān)心其他查詢計(jì)數(shù)時(shí),這很方便。
該類Expectation只是一個(gè)常規(guī)數(shù)據(jù)結(jié)構(gòu)??聪旅嫠穆暶鳎?/p>
@With
@AllArgsConstructor
@NoArgsConstructor
public static class Expectation {
private int selects = -1;
private int inserts = -1;
private int deletes = -1;
private int updates = -1;
public static Expectation ofSelects(int selects) {
return new Expectation().withSelects(selects);
}
public static Expectation ofInserts(int inserts) {
return new Expectation().withInserts(inserts);
}
public static Expectation ofDeletes(int deletes) {
return new Expectation().withDeletes(deletes);
}
public static Expectation ofUpdates(int updates) {
return new Expectation().withUpdates(updates);
}
}最后的例子
讓我們看看它是如何工作的。首先,我在之前的 N+1 問(wèn)題案例中添加了查詢斷言??聪旅娴拇a塊:
final var allZoos = assertQueryCount(
() -> zooService.findAllZoos(),
ofSelects(1)
);不要忘記
_DatasourceProxyBeanPostProcessor_在測(cè)試中作為 Spring bean 導(dǎo)入。
如果我們重新運(yùn)行測(cè)試,我們將得到下面的輸出。
Multiple Failures (1 failure)
org.opentest4j.AssertionFailedError: Unexpected selects count ==> expected: <1> but was: <3>
Expected :1
Actual :3所以,確實(shí)有效。我們?cè)O(shè)法自動(dòng)跟蹤 N+1 問(wèn)題。是時(shí)候用 替換常規(guī)選擇了JOIN FETCH。請(qǐng)看下面的代碼片段。
public interface ZooRepository extends JpaRepository {
@Query("FROM Zoo z LEFT JOIN FETCH z.animals")
List findAllWithAnimalsJoined();
}
@Service
@RequiredArgsConstructor
public class ZooService {
private final ZooRepository zooRepository;
@Transactional(readOnly = true)
public List findAllZoos() {
final var zoos = zooRepository.findAllWithAnimalsJoined();
return zoos.stream()
.map(ZooResponse::new)
.toList();
}
} 讓我們?cè)俅芜\(yùn)行測(cè)試并查看結(jié)果:
這意味著正確地跟蹤了 N + 1 個(gè)問(wèn)題。此外,如果查詢數(shù)量等于預(yù)期數(shù)量,則它會(huì)成功通過(guò)。
結(jié)論
事實(shí)上,定期測(cè)試可以防止 N+1 問(wèn)題。這是一個(gè)很好的機(jī)會(huì),可以保護(hù)那些對(duì)性能至關(guān)重要的代碼部分。
當(dāng)前名稱:Java中N+1問(wèn)題的集成測(cè)試
網(wǎng)頁(yè)網(wǎng)址:http://www.dlmjj.cn/article/djcjhcg.html


咨詢
建站咨詢
