N+1問題:N+1問題是指在使用關系型數據庫時,在獲取一組對象及其關聯對象時,產生額外的數據庫查詢的問題。其中N表示要獲取的主對象的數量,而在獲取每個主對象的關聯對象時,會產生額外的1次查詢。
N+1問題是很多項目中的通病。遺憾的是,直到數據量變得龐大時,我們才注意到它。不幸的是,當處理 N + 1 問題成為一項難以承受的任務時,代碼可能會達到了一定規模。
在這篇文章中,我們將開始關注以下幾點問題:
- 如何自動跟蹤N+1問題?
- 如何編寫測試來檢查查詢計數是否超過預期值?
N + 1 問題的一個例子
假設我們正在開發管理動物園的應用程序。在這種情況下,有兩個核心實體:Zoo和Animal。請看下面的代碼片段:
@Entity
@Table(name = "zoo")
public class Zoo {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
private String name;
@.NEToMany(mAppedBy = "zoo", cascade = PERSIST)
private List<Animal> 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;
}
現在我們想要檢索所有現有的動物園及其動物。看看ZooService下面的代碼。
@Service
@RequiredArgsConstructor
public class ZooService {
private final ZooRepository zooRepository;
@Transactional(readOnly = true)
public List<ZooResponse> findAllZoos() {
final var zoos = zooRepository.findAll();
return zoos.stream()
.map(ZooResponse::new)
.toList();
}
}
此外,我們要檢查一切是否順利進行。簡單的集成測試:
@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(
...
);
}
}
測試成功通過。但是,如果記錄 SQL 語句,會注意到以下幾點:
-- 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對每個 present 都有一個單獨的查詢Zoo。查詢總數等于所選動物園的數量+1。因此,這是N+1問題。
這可能會導致嚴重的性能損失。尤其是在大規模數據上。
自動跟蹤 N+1 問題
當然,我們可以自行運行測試、查看日志和計算查詢次數,以確定可行的性能問題。無論如何,這效率很低。。
有一個非常高效的庫,叫做datasource-proxy。它提供了一個方便的 API 來JAVAx.sql.DataSource使用包含特定邏輯的代理來包裝接口。例如,我們可以注冊在查詢執行之前和之后調用的回調。該庫還包含開箱即用的解決方案來計算已執行的查詢。我們將對其進行一些改動以滿足我們的需要。
查詢計數服務
首先,將庫添加到依賴項中:
implementation "net.ttddyy:datasource-proxy:1.8"
現在創建QueryCountService. 它是保存當前已執行查詢計數并允許您清理它的單例。請看下面的代碼片段。
@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<String, QueryCount> map) {
if (map.size() == 1) {
return map.entrySet()
.stream()
.findFirst()
.orElseThrow()
.getKey();
}
throw new IllegalArgumentException("Query counts map should consists of one key: " + map);
}
}
在那種情況下,我們假設_DataSource_我們的應用程序中有一個。這就是_keyName_函數否則會拋出異常的原因。但是,代碼不會因使用多個數據源而有太大差異。
將SingleQueryCountHolder所有QueryCount對象存儲在常規ConcurrentHashMap.
相反,_ThreadQueryCountHolder_將值存儲在_ThreadLocal_對象中。但是_SingleQueryCountHolder_對于我們的情況來說已經足夠了。
API 提供了兩種方法。該get方法返回當前執行的查詢數量,同時clear將計數設置為零。
BeanPostProccessor 和 DataSource 代理
現在我們需要注冊QueryCountService以使其從 收集數據DataSource。在這種情況下,BeanPostProcessor 接口就派上用場了。請看下面的代碼示例。
@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;
}
}
我用注釋標記類_@TestComponent_并將其放入_src/test_目錄,因為我不需要對測試范圍之外的查詢進行計數。
如您所見,這個想法很簡單。如果一個 bean 是DataSource,則將其包裹起來ProxyDataSourceBuilder并將QUERY_COUNT_HOLDER值作為QueryCountStrategy.
最后,我們要斷言特定方法的已執行查詢量。看看下面的代碼實現:
@UtilityClass
public class QueryCountAssertions {
@SneakyThrows
public static <T> T assertQueryCount(Supplier<T> 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;
}
}
該代碼很簡單:
- 將當前查詢計數設置為零。
- 執行提供的 lambda。
- 將查詢計數給定的Expectation對象。
- 如果一切順利,返回執行結果。
此外,您還注意到了一個附加條件。如果提供的計數類型小于零,則跳過斷言。不關心其他查詢計數時,這很方便。
該類Expectation只是一個常規數據結構。看下面它的聲明:
@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 問題案例中添加了查詢斷言。看下面的代碼塊:
final var allZoos = assertQueryCount(
() -> zooService.findAllZoos(),
ofSelects(1)
);
不要忘記
_DatasourceProxyBeanPostProcessor_在測試中作為 Spring bean 導入。
如果我們重新運行測試,我們將得到下面的輸出。
Multiple Failures (1 failure)
org.opentest4j.AssertionFailedError: Unexpected selects count ==> expected: <1> but was: <3>
Expected :1
Actual :3
所以,確實有效。我們設法自動跟蹤 N+1 問題。是時候用 替換常規選擇了JOIN FETCH。請看下面的代碼片段。
public interface ZooRepository extends JpaRepository<Zoo, Long> {
@Query("FROM Zoo z LEFT JOIN FETCH z.animals")
List<Zoo> findAllWithAnimalsJoined();
}
@Service
@RequiredArgsConstructor
public class ZooService {
private final ZooRepository zooRepository;
@Transactional(readOnly = true)
public List<ZooResponse> findAllZoos() {
final var zoos = zooRepository.findAllWithAnimalsJoined();
return zoos.stream()
.map(ZooResponse::new)
.toList();
}
}
讓我們再次運行測試并查看結果:
這意味著正確地跟蹤了 N + 1 個問題。此外,如果查詢數量等于預期數量,則它會成功通過。
結論
事實上,定期測試可以防止 N+1 問題。這是一個很好的機會,可以保護那些對性能至關重要的代碼部分。