前面我們介紹了透過 DAO 取出資料的許多方式,包含了一對多關聯,多對多關聯,甚至包含到 Parent-Child reference 的做法。
今天我們來介紹使用 DAO 有時會遇到的 N+1 問題,以及在 Exposed 框架下的解決方式。
比方說我們有一個一對多關聯如下
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 問題也就解決了。