iT邦幫忙

2021 iThome 鐵人賽

DAY 12
1
Modern Web

Kotlin 怎麼操作資料庫?談談 Kotlin Exposed 框架系列 第 12

[Day 12] N+1 問題的解決方式:eager loading

前面我們介紹了透過 DAO 取出資料的許多方式,包含了一對多關聯,多對多關聯,甚至包含到 Parent-Child reference 的做法。

今天我們來介紹使用 DAO 有時會遇到的 N+1 問題,以及在 Exposed 框架下的解決方式。

什麼是 N+1 問題

比方說我們有一個一對多關聯如下

object Cities : IntIdTable() {  
    val name = varchar("name", 50)  
}  
  
class City(id: EntityID<Int>) : IntEntity(id) {  
    companion object : IntEntityClass<City>(Cities)  
    var name by Cities.name  
    val users by User referrersOn Users.city  
}  
  
object Users : IntIdTable() {  
    val city = reference("city", Cities)  
    val name = varchar("name", 50)  
}  
  
class User(id: EntityID<Int>) : IntEntity(id) {  
    companion object : IntEntityClass<User>(Users)  
    var city by City referencedOn Users.city  
 	var name by Users.name  
}

然後我們寫入資料如下

SchemaUtils.create(Users)  
SchemaUtils.create(Cities)  
val paris = City.new {  
 	name = "Paris"  
}  
val moscow = City.new {  
 	name = "Moscow"  
}  
val helsinki = City.new {  
 	name = "Helsinki"  
}
val taipei = City.new {  
	name = "Taipei"  
}
val alice = User.new {  
 	name = "Alice"  
 	city = paris  
}  
val bob = User.new {  
 	name = "Bob"  
 	city = paris  
}  
val carol = User.new {  
 	name = "Carol"  
 	city = moscow  
}

這時候,我們想要拿出所有 city 對應的所有 user 時,
我們可以這樣寫

City  
    .all()  
    .forEach {  
 		it
		.users  
        .forEach{  
 			println(it.name)  
        }  
 }

這樣寫邏輯沒有問題,是可以印出我們想要的資料的

Alice
Bob
Carol

不過,如果我們透過 StdOutSqlLogger 看看底層的 query 長怎樣

addLogger(StdOutSqlLogger)  
City  
    .all()  
    .forEach {  
 		it
		.users  
 		.forEach{  
 			println(it.name)  
        }  
 }

我們會看到

SQL: SELECT CITIES.ID, CITIES."NAME" FROM CITIES
SQL: SELECT USERS.ID, USERS.CITY, USERS."NAME" FROM USERS WHERE USERS.CITY = 1
SQL: SELECT USERS.ID, USERS.CITY, USERS."NAME" FROM USERS WHERE USERS.CITY = 2
SQL: SELECT USERS.ID, USERS.CITY, USERS."NAME" FROM USERS WHERE USERS.CITY = 3
SQL: SELECT USERS.ID, USERS.CITY, USERS."NAME" FROM USERS WHERE USERS.CITY = 4

由於存取 city.users 的邏輯撰寫在 forEach() 內,所以 Exposed 在第一次透過 SELECT 取出所有的 city 之後,必須對個別 city 都執行一次 SELECT 語法,來取出對應的 user

如果今天 city 的個數越來越多,那麼可以想到這段程式的 query 數量就會越來越多,運行時間也就會越來越長。

這就是我們所說的 N+1 問題,框架先透過一次 query ,取出了 N 個物件,然後針對每個物件都個別執行 query,導致再執行了 N 次 query,才能取出關聯的物件。所以總計需要 N+1 個 query 才能達成我們需要的結果。

那麼,要怎麼改善這段程式呢?

with()

要改善的邏輯其實很單純,就是我們要讓 Exposed 在 city.all() 之後,就知道我們之後的程式會需要 city.users 的內容,並且事先取出所有 city 對應的 city.users

我們透過 with() 函數,來改寫前面這段邏輯

addLogger(StdOutSqlLogger)  
City  
    .all()
	.with(City::users)
    .forEach {  
 		it
		.users  
 		.forEach{  
 			println(it.name)  
        }  
 }

這樣撰寫之後,我們取出的內容是一樣的。不過我們的 query 會變成

SQL: SELECT CITIES.ID, CITIES."NAME" FROM CITIES
SQL: SELECT USERS.ID, USERS.CITY, USERS."NAME" FROM USERS WHERE USERS.CITY IN (1, 2, 3, 4)

由於事先就知道我們後面的邏輯會用到 city.users 的內容,所以 Exposed 就先用一個 query,取出所有的 city.users 了。

這樣不管我們的 city 有幾筆資料,這段程式之後執行時都會是兩次 query 完成,不會有隨著資料成長導致 query 數目增加的問題,N+1 問題也就解決了。


上一篇
[Day 11] 多對多關聯的變形:Parent-Child reference
下一篇
[Day 13] 非同步的操作資料庫?談 suspendedTransactionAsync
系列文
Kotlin 怎麼操作資料庫?談談 Kotlin Exposed 框架30

尚未有邦友留言

立即登入留言