iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Software Development

Kotlin Ktor 實戰 101系列 第 22 篇

Kotlin Ktor 實戰 101 Day 22 Repository 邊界、Connection Pool 與阻塞 I/O

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

InMemoryTodoRepository 從 day 19 抽出 TodoRepository 這個介面之後就一路撐到現在,資料放在一個 MutableList 裡,程式關掉就沒了,day 20 把 Todos 這張表跟連線池建好,day 21 把 transaction { } 的行為量過一遍,材料到齊了,這篇就把記憶體版整個刪掉,換成真的打資料庫的 ExposedTodoRepository

換掉之後有一串跟著要決定的事,介面 5 個方法要不要加 suspend,那是 day 19 欠的,update 回筆數還是回物件、交易邊界畫在 repository 還是留給呼叫端,是 repository 這一層的形狀,而 Exposed 的 transaction { } 是阻塞的,包進 suspend 裡要配上 withContext(Dispatchers.IO),day 21 那組把 event loop 卡住的實驗就能原封不動重跑一次,看那一行到底改變了什麼,連線池要開多大、Dispatchers.IO 自己的上限在哪、排隊排不到連線的時候誰來喊停,都用同一批數字回答

repository 長出 update 跟 delete 之後,PUT 跟 DELETE 2 個端點也就順手補上,day 12 那個一直撐著的 405 測試等到了 PUT 變成真端點

這篇要完成什麼

  • InMemoryTodoRepository 整個刪掉,ExposedTodoRepository 接手
  • 介面加上 suspend,day 19 欠的那個簽章決定
  • repository 的邊界,update 回什麼、transaction 包在哪一層
  • withContext(Dispatchers.IO) 那一行,跟 day 21 同一組實驗的前後對照,它修好了延遲、沒有修好吞吐
  • connection pool 從 5 開到 100,還有 Dispatchers.IO 預設 64 那道天花板
  • connectionTimeout 決定排隊排多久才放棄
  • PUT 跟 DELETE 端點補上,day 12 那個 405 改寫
  • DAO 那條路長什麼樣,為什麼這個系列不走

ExposedTodoRepository 接手

day 20 用一個試跑的方式先驗過一次,那時候的結論是「測試零修改全過」,但實作沒有留下來,day 21 又講了一次為什麼要拖到現在,原話是「要開 PUT /todos/{id} 得先讓 TodoRepository 這個介面長出 update 跟 delete 2 個方法,然後在 InMemoryTodoRepository 上實作一次,而 day 22 的題目就是把這個實作換成 Exposed 版,也就是說今天寫的那 2 個記憶體版方法,明天就會被丟掉」

所以這次不是把記憶體版擴充成 5 個方法再換掉,是直接刪掉整個 class。src/main/kotlin/com/cashwu/todo/TodoRepository.kt 剩下介面,實作搬到新檔案 src/main/kotlin/com/cashwu/todo/ExposedTodoRepository.kt

interface TodoRepository {
    suspend fun findAll(limit: Int?): List<Todo>
    suspend fun findById(id: Int): Todo?
    suspend fun create(title: String, done: Boolean): Todo
    suspend fun update(id: Int, title: String, done: Boolean): Todo?
    suspend fun delete(id: Int): Boolean
}

實作長這樣,5 個方法共用一個 private 的 query helper

class ExposedTodoRepository(
    private val clock: Clock,
    private val database: Database,
) : TodoRepository {

    private suspend fun <T> query(block: JdbcTransaction.() -> T): T =
        withContext(Dispatchers.IO) { transaction(database, statement = block) }

    override suspend fun findAll(limit: Int?): List<Todo> = query {
        val query = Todos.selectAll().orderBy(Todos.id)
        if (limit != null) query.limit(limit)
        query.map { it.toTodo() }
    }

    override suspend fun findById(id: Int): Todo? = query {
        Todos.selectAll().where { Todos.id eq id }.singleOrNull()?.toTodo()
    }

    override suspend fun create(title: String, done: Boolean): Todo = query {
        Todos.insert {
            it[Todos.title] = title
            it[Todos.done] = done
            it[createdAt] = Instant.now(clock).atOffset(ZoneOffset.UTC)
        }.resultedValues!!.single().toTodo()
    }

    override suspend fun update(id: Int, title: String, done: Boolean): Todo? = query {
        val changed = Todos.update({ Todos.id eq id }) {
            it[Todos.title] = title
            it[Todos.done] = done
        }
        if (changed == 0) {
            null
        } else {
            Todos.selectAll().where { Todos.id eq id }.single().toTodo()
        }
    }

    override suspend fun delete(id: Int): Boolean = query {
        Todos.deleteWhere { Todos.id eq id } == 1
    }
}

transaction(database, statement = block) 這個具名參數的寫法是因為中間還有 transactionIsolation 跟 readOnly 2 個參數,Todos、toTodo()、Todos.insert 那幾個都是 day 20 跟 day 21 建好的東西,Todos 這張表跟 ResultRow.toTodo() 在 TodoDatabase.kt

src/main/kotlin/com/cashwu/todo/Application.kt 現在只換一行

 dependencies {
     provide<Clock> { Clock.systemUTC() }
     provide<DataSource> { todoDataSource(resolve<TodoConfig>().database) }
     provide<Database> { resolve<DataSource>().connectAndSeed() }
-    provide<TodoRepository> { InMemoryTodoRepository(resolve()) }
+    provide<TodoRepository> { ExposedTodoRepository(resolve(), resolve()) }
 }

 val config: TodoConfig by dependencies
 val repository: TodoRepository by dependencies
 val database: Database by dependencies

 transaction(database) {
     exec("""CREATE ALIAS IF NOT EXISTS SLEEP FOR "com.cashwu.todo.SlowSql.sleep"""")
 }

2 個 resolve() 分別是 Clock 跟 Database,型別由 ExposedTodoRepository 建構子的參數順序推出來

下面那個 val database 跟掛 SLEEP 的 transaction 區塊是 day 21 壓測留下的,這篇後面還要用同一個 SLEEP 重跑一次實驗,所以先留著,量完再連 SlowSql.kt 一起清掉。清掉的時候 val database 也會一起走,因為它在 day 20 的用途是讓啟動驗證涵蓋到資料庫,那時候沒有人真的用到 Database,DI 容器只會在啟動階段解析 by dependencies 宣告的 key 跟從那些 key 順著 provider 走得到的東西,所以要手動掛一個變數上去,現在 provide<TodoRepository> 的 provider 裡面就會 resolve<Database>(),而 repository 本來就是 by dependencies 宣告的,那條相依鏈從 repository 走到 Database 再走到 DataSource,自己就走到底了,這個變數拿掉之後啟動驗證的行為不變

findAll 這裡有一個 day 20 那次試跑沒處理的細節,orderBy(Todos.id),沒有 ORDER BY 的 SELECT 在 SQL 規格上不保證順序,H2 現在剛好照插入順序回來,換一個資料庫或加了索引之後就未必,API 的第 1 個測試斷言的是 id 1、2、3 這個順序,這個順序要在 SQL 裡明確寫出來,不能靠資料庫剛好這樣回

SELECT TODOS.ID, TODOS.TITLE, TODOS.DONE, TODOS.CREATED_AT FROM TODOS ORDER BY TODOS.ID ASC LIMIT 2

介面加上 suspend,day 19 欠的那個決定

day 19 抽出 TodoRepository 的時候試過把 3 個方法都加上 suspend,然後把實驗撤掉了,原話是「現在標上 suspend 只是在騙自己,方法裡面沒有任何一個地方會真的掛起,而且 day 22 要處理的正是「Exposed 的 transaction { } 是 blocking 的,包在 suspend 裡面反而危險」這件事,那時候會需要 withContext(Dispatchers.IO) 或 newSuspendedTransaction,簽名怎麼定要連著那個決定一起做」

那個決定現在做了,suspend 這次不是裝的,ExposedTodoRepository 每個方法都經過 withContext,它本身就是 suspend 函式,切換 dispatcher 的那一刻協程會真的掛起,介面上的 suspend 對得上實作裡實際發生的事

這次調整後編譯就出現多個錯誤,主要在 DependencyInjectionTest 和 TodoRepositoryTest,錯誤分成 2 種,第 1 種是測試裡那 2 個假 repository,介面變了它們就得跟著變

e: DependencyInjectionTest.kt:50:18 Non-suspend function 'findAll' cannot override suspend function 'suspend fun findAll(limit: Int?): List<Todo>' defined in 'com.cashwu.todo.TodoRepository'.

第 2 種是 day 19 沒有的,叫得出 InMemoryTodoRepository 這個名字的地方全部斷掉

e: TodoRepositoryTest.kt:11:26 Unresolved reference 'InMemoryTodoRepository'.

DependencyInjectionTest 裡那 2 個假實作 RecordingTodoRepository 跟 BrokenTodoRepository 各補上 suspend 跟 2 個新方法,它們本來就是為了測 DI 而寫的替身,麻煩的是 RecordingTodoRepository 原本包著一個真的 InMemoryTodoRepository 當 delegate,同一個檔案還有 2 個測試直接拿 InMemoryTodoRepository 當 DI 的 key

處理方式是讓記憶體版降級,它不再是正式程式碼的一種選項,而是搬進測試原始碼當替身,src/test/kotlin/com/cashwu/todo/TestApp.kt 裡多一個 FakeTodoRepository

class FakeTodoRepository(initial: List<Todo> = defaultTodos) : TodoRepository {
    private val todos = initial.toMutableList()
    private var nextId = (initial.maxOfOrNull { it.id } ?: 0) + 1

    override suspend fun findAll(limit: Int?): List<Todo> =
        if (limit == null) todos.toList() else todos.take(limit)

    override suspend fun findById(id: Int): Todo? = todos.find { it.id == id }

    override suspend fun create(title: String, done: Boolean): Todo =
        Todo(nextId++, title, done, FIXED_NOW).also { todos.add(it) }

    override suspend fun update(id: Int, title: String, done: Boolean): Todo? {
        val index = todos.indexOfFirst { it.id == id }
        if (index == -1) return null
        return todos[index].copy(title = title, done = done).also { todos[index] = it }
    }

    override suspend fun delete(id: Int): Boolean = todos.removeIf { it.id == id }
}

跟 day 19 那版比,synchronized 沒了、Clock 也沒了,用它的 3 個測試都是單執行緒,時間固定成 FIXED_NOW 就夠,替身不需要為了跟正式實作長得像而背上正式實作的複雜度,FIXED_NOW 是 day 18 放進 TestApp.kt 的那個 2026-10-10T12:00:00Z

有了 FakeTodoRepository,DependencyInjectionTest.kt 那 2 個假實作就能改了,5 個方法全部加 suspend,update 跟 delete 照原本的模式各補一個,RecordingTodoRepository 繼續轉給 delegate

private class RecordingTodoRepository(private val delegate: TodoRepository) : TodoRepository {
    val limits = mutableListOf<Int?>()

    override suspend fun findAll(limit: Int?): List<Todo> {
        limits += limit
        return delegate.findAll(limit)
    }

    override suspend fun findById(id: Int): Todo? = delegate.findById(id)

    override suspend fun create(title: String, done: Boolean): Todo = delegate.create(title, done)

    override suspend fun update(id: Int, title: String, done: Boolean): Todo? =
        delegate.update(id, title, done)

    override suspend fun delete(id: Int): Boolean = delegate.delete(id)
}

BrokenTodoRepository 更單純,同樣的 5 個簽章,每一個的 body 都是 throw IllegalStateException("儲存體壞掉了"),跟 day 19 那版只差 suspend 跟多出來的 2 個方法

delegate 那個問題在測試裡解決,day 19 那個測試建 RecordingTodoRepository 的那一行換掉 delegate 就好,其餘斷言不動

-    val recording = RecordingTodoRepository(InMemoryTodoRepository(fixedClock(FIXED_NOW)))
+    val recording = RecordingTodoRepository(FakeTodoRepository())

另外 2 個測試是 day 19 拿來驗 DI key 的,a provided implementation resolves through its interface 用 provide { } 註冊實作、分別以實作型別跟介面型別 resolve,斷言拿到同一個物件。它測的是容器怎麼配 key,跟 repository 裡面裝什麼無關,所以換成 FakeTodoRepository 就好

-    var asImplementation: InMemoryTodoRepository? = null
+    var asImplementation: FakeTodoRepository? = null
-        dependencies.provide { InMemoryTodoRepository(fixedClock(FIXED_NOW)) }
-        asImplementation = dependencies.resolve<InMemoryTodoRepository>()
+        dependencies.provide { FakeTodoRepository() }
+        asImplementation = dependencies.resolve<FakeTodoRepository>()

the module registers the repository under the interface key only 就不能這樣換。它驗的是正式的 module() 只用 TodoRepository 這個 key 註冊,拿實作型別去要會得到 MissingDependencyException,正式的實作現在是 ExposedTodoRepository,要的就得是它,換成 FakeTodoRepository 的話這個測試會因為根本沒人註冊過那個型別而永遠通過,驗不到任何東西

-        failure = runCatching { dependencies.resolve<InMemoryTodoRepository>() }.exceptionOrNull()
+        failure = runCatching { dependencies.resolve<ExposedTodoRepository>() }.exceptionOrNull()

原本的 TodoRepositoryTest.kt 整個刪掉,包括 day 19 那個 sixty concurrent creates keep every todo and every id unique,那個測試用 CyclicBarrier 讓 60 條 thread 一起 create,驗的是 InMemoryTodoRepository 裡那個 synchronized,鎖跟 class 一起沒了,測試自然沒有東西可驗

Exposed 版的 id 唯一是資料庫 AUTO_INCREMENT 的事,併發寫入的答案 day 21 已經在 Todos.update 上量過,day 20 也用 60 個併發 POST 打過一次拿到 63 筆,不需要在 repository 這層再擠一次 thread,day 19 說過那個測試是哨兵不是證明,哨兵要盯的對象消失了就一起退場

同一個檔案裡另外 4 個測試,id 接在種子資料後面跟 findById 回 null 這 2 個改寫進新檔案,快照跟「空的 repository 從 1 號開始」那 2 個不搬,Exposed 版每次 findAll 都是新的 SELECT、種子資料又是 connectAndSeed 一定會塞的,這 2 個情境在它身上不存在

新檔案是 src/test/kotlin/com/cashwu/todo/ExposedTodoRepositoryTest.kt,測的是真的打資料庫的那個實作,withDatabase 是 day 21 從 TodoDatabaseTest.kt 搬到 TestApp.kt 的 helper,開一個名字不同的 in-memory 資料庫、塞種子資料、結束就關掉

class ExposedTodoRepositoryTest {

    @Test
    fun `update hands back the stored row and null when it matched nothing`() {
        withDatabase("repo-update") { database ->
            val repository = ExposedTodoRepository(fixedClock(FIXED_NOW), database)

            runBlocking {
                val updated = repository.update(2, "繳水費", true)

                assertEquals(Todo(2, "繳水費", true, defaultTodos[1].createdAt), updated)
                assertEquals(updated, repository.findById(2))
                assertNull(repository.update(999, "沒這筆", true))
            }
        }
    }

}

這個 class 其他的測試照 repository 的 5 個方法排,每個方法一組,findAll 驗回來的順序是 id 1、2、3 而且帶 limit 只回前幾筆,findById 驗找得到跟找不到回 null,create 驗 id 接在種子資料的 3 後面而且 createdAt 來自傳進去的 Clock,delete 驗刪到回 true、再刪一次回 false,都是同一個 withDatabase 加 runBlocking 的形狀,就不一個一個貼了。還有 2 個測的不是 CRUD 而是 withContext 那一行跟連線池的行為,放在後面對應的章節

runBlocking 是 suspend 介面帶來的成本,每個測試都要多包一層,介面上的 suspend 說的是真話,代價就是直接呼叫它的地方都得先進到協程裡,測試也不例外

那 day 20 說的「零修改全過」還算數嗎 ? route 那一層算數,測試那一層不算,TodoRoutesTest 原本測試的斷言一個字都沒改就全部通過 (有一個後來因為名字會騙人而改了名,那是後面 PUT 那節的事),包括 POST 之後拿到 id 4、列表變 4 筆那幾個,因為 AUTO_INCREMENT 剛好接在種子資料的 3 後面

會不用改還靠 day 20 講過的那個條件,testApplication 結束時連線池關掉、in-memory 資料庫跟著消失,下一個測試拿到全新的資料庫,換成 PostgreSQL 之後資料不會自己消失,測試之間的隔離要到 day 25 Testcontainers 才處理

repository 的邊界畫在哪裡

3 個決定,每個都有代價

update 回什麼,day 21 證明了 Todos.update 回傳的筆數就是併發的答案,changed == 1 代表這條 thread 搶到了,但那是 SQL 層的答案,端點要的是「改完之後長什麼樣」,要回 200 加上完整物件。中間隔著一次 SELECT

把 logback.xml 的 Exposed logger 臨時開到 DEBUG 就看得到,一次 update(2, ...) 送出去 2 句

UPDATE TODOS SET TITLE='繳水費', DONE=TRUE WHERE TODOS.ID = 2
SELECT TODOS.ID, TODOS.TITLE, TODOS.DONE, TODOS.CREATED_AT FROM TODOS WHERE TODOS.ID = 2

改不到的時候只送 1 句,changed == 0 就直接回 null,不用再查一次

UPDATE TODOS SET TITLE='沒這筆', DONE=TRUE WHERE TODOS.ID = 999

介面上的型別是 Todo?,null 代表沒這筆,另一個選擇是回 Int 讓呼叫端自己再查一次,那樣 repository 比較薄,代價是「改」跟「查」變成 2 個交易,中間有縫,多送一句 SELECT 換掉那個縫,這篇選擇回 Todo?

transaction 包在哪一層,這裡是一個方法一個 transaction { },所以那個 UPDATE 加 SELECT 是同一個交易,中間別人插不進來,反過來也可以讓 repository 只提供語句,交易由 service 那一層開,好處是跨多個 repository 的操作能綁成一個交易

代價是介面會漏,transaction { } 的 block 帶著 JdbcTransaction 這個 receiver,要讓呼叫端組合交易,介面上就得出現 Exposed 的型別,或者自己包一層 UnitOfWork 之類的抽象,TodoRepository 現在 5 個方法的參數跟回傳值全部是 Int、String、Boolean、Todo,換掉底下的實作不會影響任何一個呼叫端,這件事在 day 19 定介面的時候就是重點。todo-api 現在沒有跨表操作,付那個代價買不到東西,所以維持一個方法一個交易

真的長出「建立訂單同時扣庫存」這種需求的時候再改,那時候的判斷依據是有沒有 2 個寫入必須一起成功或一起失敗,不是覺得哪種比較漂亮

delete 回 Boolean 而不是筆數。Todos.deleteWhere 回的是 Int,但這個介面上 id 是主鍵,刪到的只會是 0 或 1,回 Boolean 讓端點那邊直接 if (!repository.delete(id)),如果哪天要開「刪掉所有已完成」這種端點,那才需要筆數,那也會是另一個方法

findAll(limit) 維持 day 19 的形狀,limit 下推到資料庫變成 SQL 的 LIMIT,不是撈全表再 take。這是當初把 limit 放進介面而不是放在 route 的原因

withContext(Dispatchers.IO) 那一行

先確認它真的換了 thread,ExposedTodoRepositoryTest.kt 裡有一個 Clock 的實作,它唯一的工作是在被問時間的時候記下自己在哪條 thread 上

private class ThreadNamingClock(private val delegate: Clock) : Clock() {
    var threadName: String? = null

    override fun instant(): Instant {
        threadName = Thread.currentThread().name
        return delegate.instant()
    }

    override fun getZone(): ZoneId = delegate.zone

    override fun withZone(zone: ZoneId): Clock = delegate.withZone(zone)
}

create 裡面那句 Instant.now(clock) 跑在 transaction { } 內部,所以這個 clock 記到的就是資料庫工作實際發生的地方,測試跟這個 class 放在同一個檔案 ExposedTodoRepositoryTest.kt,前面說的 2 個非 CRUD 測試這是第 1 個

@Test
fun `the query runs on an IO thread instead of the caller thread`() {
    withDatabase("repo-thread") { database ->
        val clock = ThreadNamingClock(fixedClock(FIXED_NOW))
        val repository = ExposedTodoRepository(clock, database)

        val caller = onOneThread {
            repository.create("倒垃圾", false)
            Thread.currentThread().name
        }

        assertTrue(caller.startsWith("只有這條"), caller)
        assertTrue(
            clock.threadName!!.startsWith("DefaultDispatcher-worker"),
            "查詢跑在 ${clock.threadName}",
        )
    }
}

onOneThread 是 day 21 寫在 TransactionTest.kt 的 helper,這篇 2 個檔案都要用所以搬到 TestApp.kt,呼叫端在「只有這條」上,查詢在 DefaultDispatcher-worker 上,day 21 那個 /thread 路由印出 3 個一模一樣名字的情況變成 2 個不一樣。真的跑起來的 Netty 上也對得上,臨時加一個 /threads 路由把 handler 跟 withContext(Dispatchers.IO) 裡面的 thread 名字一起印出來

outer=eventLoopGroupProxy-4-2
inIo=DefaultDispatcher-worker-3

day 21 那組輸出裡 outer、inTx、inSuspend 全部是 eventLoopGroupProxy-4-2,差別就在這裡,/threads 這個路由後面看 Dispatchers.IO 上限的時候還要用一次,那時候量完再刪

接著重跑 day 21 那組實驗

/slow-io 是 day 21 那個 /slow 外面包一層 withContext(Dispatchers.IO),其餘一模一樣,SLEEP alias、ping.sh、slow.sh 全部沿用 day 21 留下的,slow.sh 的路徑改成參數好在 2 個路由之間切換,連線池還是 poolSize: 5,閒置時 GET / 10 次都是幾毫秒的等級,跟 day 21 一樣,然後一個 shell 跑 bash slow.sh slow,另一個 shell 同時跑 bash ping.sh,兩邊的輸出擺在一起

batch wall = 8215 ms
2.025569 3.525113 0.003489 0.956324 0.004059 0.464438 0.003148 0.002089 0.003404 0.002696

跟 day 21 量到的形狀一樣,再跑 2 輪 batch wall 是 8617 跟 8270 毫秒,GET / 最慢的一次分別是 3.0 跟 3.5 秒,換成 bash slow.sh slow-io

batch wall = 8152 ms
0.004611 0.004076 0.003781 0.001728 0.001357 0.001283 0.001189 0.001046 0.001032 0.000878

10 次全部在 5 毫秒以內,再跑 2 輪 batch wall 是 8167 跟 8155 毫秒,30 次裡最慢的一次就是上面那個 4.6 毫秒,前面那組最慢的一次是 3.5 秒,差了 3 個數量級,數字每次跑都不一樣,重點是形狀,前面那組總是幾次卡住、其餘毫秒級,這一組沒有卡住的那幾次

原因很直接,沒有 withContext 的時候,handler 在 event loop thread 上呼叫 getConnection(),池子空了就整條 thread 卡在那裡,那條 thread 上排隊的其他連線也跟著不動,加了 withContext 之後,卡住的是 DefaultDispatcher-worker,event loop thread 交出工作就回去處理下一個請求

但它修好的是延遲,不是吞吐。回頭看 batch wall 那一行,8215 對 8152、8617 對 8167、8270 對 8155,加那一行沒有讓 80 個請求整批跑快,這個數字本來就跟 dispatcher 無關,80 個請求各佔住一條連線 500 毫秒,池子只有 5 條,80 乘 500 除以 5 等於 8000 毫秒,withContext 換的是「誰在等」,不是「等多久」。所以 Dispatchers.IO 買到的是隔離而不是速度,慢的端點還是一樣慢,但它不再拖累不相干的端點,一個查詢寫壞的報表頁面拖垮整個服務跟只拖垮那一頁,是 2 種完全不同的事故。要讓那 8 秒變短得動別的東西

connection pool 要開多大

那就把池子開大試試,同一個 /slow-io 路由、同樣 80 個請求 40 併發,DB_POOL_SIZE 從 5 換到 100,每個尺寸跑兩輪

poolSize 第 1 輪 第 2 輪
5 8134 ms 8155 ms
12 3613 ms 3580 ms
20 2134 ms 2088 ms
40 1168 ms 1168 ms
64 1173 ms 1157 ms
100 1198 ms 1160 ms

前面 4 列照著 80 乘 500 除以 poolSize 走,40 之後就不動了,原因在打的那一端,xargs -P 40 最多只有 40 個 curl 同時在跑,池子再大也沒有第 41 個請求可以喂進去,80 個請求分兩批、每批 500 毫秒,1168 毫秒就是這麼來的

這張表最實用的部分不是那幾個數字,是它證明了瓶頸會搬家,從 5 開到 40 的時候瓶頸是連線池,過了 40 之後瓶頸換成請求量,繼續加大 poolSize 只是多開一堆閒著的連線

那正式環境要填多少 ? HikariCP 的 wiki 有一條起始估算公式

connections = ((core_count * 2) + effective_spindle_count)

文件把這條公式當成測量的起點,不是答案,core_count 是資料庫那台機器的核心數,不是跑 Ktor 這台的,很多人抄的時候會搞錯,effective_spindle_count 是資料集放不進快取時的有效磁碟數,全在快取裡就是 0。這篇的環境套不上這條公式,H2 是 in-process 的,資料庫跟應用程式是同一批核心,那個 500 毫秒又是 Thread.sleep 做出來的,完全不吃 CPU,真的資料庫上連線開太多會讓資料庫端的 context switch 跟鎖競爭變嚴重,這張表「越大越快」的線性關係到某個點就會反折

day 20 講過「poolSize 的 5 是隨手填的,真正的數字要看資料庫端的連線上限跟服務的並行量,不是越大越好」,那句話現在還是對的,這篇沒有換掉那個 5,要等 day 23 換成 PostgreSQL 之後才有一台真的資料庫可以量

不過有 2 個上限是現在就成立的

Dispatchers.IO 的 64 是第二道天花板

一個來自 Dispatchers.IO 自己,它的並行度由系統屬性 kotlinx.coroutines.io.parallelism 決定,預設是 64 跟核心數取大的那一個,這台 12 核心的機器上就是 64

那表示資料庫工作全部交給 Dispatchers.IO 時,同一時間最多只有 64 個協程能真的執行 getConnection(),池子開到 65 條以上,多出來的連線不會增加同一時間的資料庫並行量。前面那張表看不出來,因為打的併發只有 40,把併發、請求數、池子全部拉到 128 再跑 3 輪,batch wall 是 1208、1144、1166 毫秒,128 個 500 毫秒的請求如果真的一起跑應該是 500 多毫秒,實際是 1150 上下,剛好跑了兩輪。同一份程式碼加一個 JVM 參數 -Dkotlinx.coroutines.io.parallelism=128 再跑 3 輪,變成 737、737、824 毫秒,掉到一輪的量級了,順便看那個 /threads 路由回的名字,預設跑出來最大是 DefaultDispatcher-worker-59,調成 128 之後看得到 DefaultDispatcher-worker-127

所以 poolSize 開得比 Dispatchers.IO 的並行度大,無法提高同一時間的資料庫並行量,另一條路是用 limitedParallelism 切一個專用 view,「能不能上線」那節會再提,另一個上限是資料庫端 max_connections 那類設定,超過了直接被拒絕連線,比較容易發現,這 2 個上限加上前面那條公式的起始值,才是決定池子大小時要一起看的依據

connectionTimeout 決定排隊排多久才放棄

池子滿了之後,getConnection() 不會永遠等下去,HikariCP 的 connectionTimeout 決定等多久之後放棄,預設是 30 秒

30 秒對一個 HTTP 請求來說太長了,把前面那個 poolSize = 5 的情況加大到 80 個併發打 80 個請求,最後排到的那幾個要等 7 秒多,但 80 個全部回 200、1 個 500 都沒有,這種「成功」沒有意義,上游的 load balancer 或瀏覽器早就自己 timeout 了,server 這邊還在幫一個沒人要的回應算資料,day 20 說過「要補的是幾個現在沒設的參數,connectionTimeout、maxLifetime、leakDetectionThreshold 這些」,這篇先補第 1 個

src/main/kotlin/com/cashwu/todo/TodoConfig.kt 的 DatabaseConfig 多一個欄位 val connectionTimeout: Long = 5_000,todoDataSource 的 HikariConfig 裡多一行 connectionTimeout = config.connectionTimeout,application.yaml 的 todo.database 底下多一行

connectionTimeout: '$DB_CONNECTION_TIMEOUT:5000'

5000 這個數字不是算出來的,它是「這個服務願意讓一個請求花多久」這個決定的第 1 版,真正該做的是先定出請求的時間預算,再切一塊給等連線,預設值 30 秒比任何合理的請求預算都大,等於這個參數沒有在保護任何東西

ExposedTodoRepositoryTest.kt 裡開一個只有 1 條連線、timeout 250 毫秒的池子,先讓一個協程佔住那條連線 1.5 秒,再叫 repository 去要

@Test
fun `a saturated pool gives up after the connection timeout`() {
    val source = todoDataSource(
        DatabaseConfig(
            url = "jdbc:h2:mem:repo-timeout",
            driver = "org.h2.Driver",
            user = "sa",
            password = "",
            poolSize = 1,
            connectionTimeout = 250,
        )
    )

    source.use { dataSource ->
        val database = dataSource.connectAndSeed()
        val repository = ExposedTodoRepository(fixedClock(FIXED_NOW), database)

        runBlocking {
            val holder = launch(Dispatchers.IO) {
                transaction(database) {
                    Todos.selectAll().count()
                    Thread.sleep(1_500)
                }
            }
            delay(200)

            val started = System.nanoTime()
            val outcome = runCatching { repository.findAll(null) }
            val elapsed = (System.nanoTime() - started) / 1_000_000

            assertTrue(outcome.isFailure, "findAll 等了 $elapsed ms 之後正常回傳,connectionTimeout 沒有生效")
            val failure = assertIs<ExposedSQLException>(outcome.exceptionOrNull())
            assertContains(failure.message.orEmpty(), "Connection is not available")
            holder.join()
        }
    }
}

拿到的訊息是

java.sql.SQLTransientConnectionException: todo-pool - Connection is not available, request timed out after 253ms (total=1, active=1, idle=0, waiting=0)

total=1, active=1, idle=0 這 3 個數字說明得很清楚,池子裡就 1 條連線、正在用、沒有閒的,這一輪實際等到的是 253 毫秒,設定值是 250,connectAndSeed 是 day 20 寫在 TodoDatabase.kt 的擴充函式,連上去建表塞種子資料

沒有直接用 assertFailsWith 而是先 runCatching 再量時間,是防這個測試自己騙人,todoDataSource 那行 connectionTimeout = config.connectionTimeout 要是漏掉,HikariCP 用預設的 30 秒,findAll 會等到 holder 睡完把連線還回來,然後正常回傳,assertFailsWith 只會報「completed successfully」,看不出是哪裡沒接上,把那行拿掉跑一次,訊息是

findAll 等了 1305 ms 之後正常回傳,connectionTimeout 沒有生效

1305 就是 holder 那 1.5 秒扣掉前面先等的 200 毫秒,訊息直接把原因講出來

ExposedSQLException 不是 ApiException,所以在真的服務上它會落到 day 15 那個 exception<Throwable> 的 handler,回 500。這是好事,池子被占滿、拿不到連線的時候快速失敗,比讓請求排在那裡慢慢死掉好處理,至少監控看得到 500 的數量在跳

PUT 跟 DELETE 補上了

repository 有 update 跟 delete 之後,端點要動 3 個檔案。先是請求的 body,src/main/kotlin/com/cashwu/todo/Todo.kt 在 CreateTodoRequest 後面加一個 UpdateTodoRequest

@Serializable
data class UpdateTodoRequest(
    val title: String,
    val done: Boolean = false,
)

跟 CreateTodoRequest 欄位一模一樣但是不同型別,因為 RequestValidation 的 validate<T> 是綁型別的,共用同一個 class 會讓「新增」跟「更新」以後想分開驗證的時候動不了,2 個型別的驗證規則現在一樣,所以 src/main/kotlin/com/cashwu/todo/TodoValidation.kt 把原本寫在 validate<CreateTodoRequest> 裡的那段 body 原封不動搬進 private fun validateTitle(title: String): ValidationResult,兩邊各叫一次

fun RequestValidationConfig.todoValidation() {
    validate<CreateTodoRequest> { request -> validateTitle(request.title) }
    validate<UpdateTodoRequest> { request -> validateTitle(request.title) }
}

然後是路由,src/main/kotlin/com/cashwu/todo/TodoRoutes.kt,3 個帶 id 的端點都要把 {id} 轉成 Int、轉不了回 400,這段抽成 ApplicationCall.todoId(),放在檔案最下面、todoRoutes 函式外面,標 private,GET {id} 裡原本那 2 行也換成它

         get("{id}") {
-            val id = call.parameters["id"]?.toIntOrNull()
-                ?: throw ApiException(HttpStatusCode.BadRequest, "id 要是數字")
+            val id = call.todoId()
             val todo = repository.findById(id)
                 ?: throw ApiException(HttpStatusCode.NotFound, "找不到 id $id 的待辦")
             call.respond(todo)
         }

         post {
             val request = call.receive<CreateTodoRequest>()
             val todo = repository.create(request.title, request.done)
             call.respond(HttpStatusCode.Created, todo)
         }
+
+        put("{id}") {
+            val id = call.todoId()
+            val request = call.receive<UpdateTodoRequest>()
+            val todo = repository.update(id, request.title, request.done)
+                ?: throw ApiException(HttpStatusCode.NotFound, "找不到 id $id 的待辦")
+            call.respond(todo)
+        }
+        delete("{id}") {
+            val id = call.todoId()
+            if (!repository.delete(id)) {
+                throw ApiException(HttpStatusCode.NotFound, "找不到 id $id 的待辦")
+            }
+            call.respond(HttpStatusCode.NoContent)
+        }
     }
 }
+
+private fun ApplicationCall.todoId(): Int =
+    parameters["id"]?.toIntOrNull()
+        ?: throw ApiException(HttpStatusCode.BadRequest, "id 要是數字")

ApplicationCall 要 import io.ktor.server.application.ApplicationCall。todoId() 不能寫成 route("/todos") { } 裡面的 local function,Kotlin 的 local function 要先宣告才能用,放在區塊最後面的話上面 3 個端點都會 Unresolved reference,放在檔案層級就沒有這個順序問題

ApiException 是 day 15 定的,StatusPages 把它翻成對應的狀態碼跟 JSON,update 回 null 跟 delete 回 false 都對到 404,訊息跟 GET {id} 找不到的時候同一句。跑起來是這樣

curl -i -X PUT -H "Content-Type: application/json" \
  -d '{"title":"繳水費","done":true}' localhost:8080/todos/2
HTTP/1.1 200 OK
X-Request-Id: lulgce9zuwio
X-Response-Time: 13ms
Content-Length: 76
Content-Type: application/json

{"id":2,"title":"繳水費","done":true,"created_at":"2026-08-28T09:30:00Z"}

created_at 沒變,PUT 只換 title 跟 done。改一筆不存在的回 404,訊息走的是 day 15 那個錯誤格式

curl -i -X PUT -H "Content-Type: application/json" \
  -d '{"title":"沒這筆","done":true}' localhost:8080/todos/999
HTTP/1.1 404 Not Found
X-Response-Time: 12ms
Content-Length: 66
Content-Type: application/json

{"status":404,"message":"找不到 id 999 的待辦","details":[]}

DELETE 成功回 204 沒有 body,同一個指令再打一次就是 404

curl -i -X DELETE localhost:8080/todos/2
HTTP/1.1 204 No Content
X-Response-Time: 2ms

HTTP/1.1 404 Not Found
X-Response-Time: 1ms
Content-Length: 64
Content-Type: application/json

{"status":404,"message":"找不到 id 2 的待辦","details":[]}

TodoRoutesTest.kt 補 7 個測試,形狀都跟 day 12 那批 POST 的測試一樣

PUT 成功的那個最後多一句 GET 回來比對,確認回應的東西跟資料庫裡的是同一份,不是 handler 自己組的

@Test
fun `put todo replaces title and done and responds ok`() = testApplication {
    todoApplication()

    val response = client.put("/todos/2") {
        header(HttpHeaders.ContentType, ContentType.Application.Json)
        setBody("""{"title":"繳水費","done":true}""")
    }

    assertEquals(HttpStatusCode.OK, response.status)
    assertEquals(
        """{"id":2,"title":"繳水費","done":true,"created_at":"2026-08-28T09:30:00Z"}""",
        response.bodyAsText(),
    )
    assertEquals(response.bodyAsText(), client.get("/todos/2").bodyAsText())
}

UpdateTodoRequest 的 done 有預設值 false,所以省略它不是 400,而是把原本 true 的 id 1 改回 false,這是 PUT 整筆取代的語意

@Test
fun `put todo without done resets it to false`() = testApplication {
    todoApplication()

    val response = client.put("/todos/1") {
        header(HttpHeaders.ContentType, ContentType.Application.Json)
        setBody("""{"title":"買牛奶"}""")
    }

    assertEquals(HttpStatusCode.OK, response.status)
    assertEquals(false, Json.decodeFromString<Todo>(response.bodyAsText()).done)
}

打不存在的 id,update 回 null,handler 丟 ApiException,body 是 day 15 那個格式

@Test
fun `put todo by unknown id responds not found`() = testApplication {
    todoApplication()

    val response = client.put("/todos/999") {
        header(HttpHeaders.ContentType, ContentType.Application.Json)
        setBody("""{"title":"沒這筆","done":true}""")
    }

    assertEquals(HttpStatusCode.NotFound, response.status)
    assertEquals(
        """{"status":404,"message":"找不到 id 999 的待辦","details":[]}""",
        response.bodyAsText(),
    )
}

空白標題那個要順便斷言原本的 繳電費 還在,RequestValidation 擋在 handler 前面,update 根本沒被叫到

@Test
fun `put todo with blank title responds bad request`() = testApplication {
    todoApplication()

    val response = client.put("/todos/2") {
        header(HttpHeaders.ContentType, ContentType.Application.Json)
        setBody("""{"title":"   ","done":true}""")
    }

    assertEquals(HttpStatusCode.BadRequest, response.status)
    assertEquals("繳電費", Json.decodeFromString<Todo>(client.get("/todos/2").bodyAsText()).title)
}

DELETE 成功的那個刪完再 GET 一次斷言 404、列表剩 2 筆,跟 curl 那組是同一件事

@Test
fun `delete todo responds no content and removes it`() = testApplication {
    todoApplication()

    val response = client.delete("/todos/2")

    assertEquals(HttpStatusCode.NoContent, response.status)
    assertEquals("", response.bodyAsText())
    assertEquals(HttpStatusCode.NotFound, client.get("/todos/2").status)
    assertEquals(2, Json.decodeFromString<List<Todo>>(client.get("/todos").bodyAsText()).size)
}

DELETE 不存在的 id 走的是 delete 回 false 那條路,訊息跟 PUT 那個一樣

@Test
fun `delete todo by unknown id responds not found`() = testApplication {
    todoApplication()

    val response = client.delete("/todos/999")

    assertEquals(HttpStatusCode.NotFound, response.status)
    assertEquals(
        """{"status":404,"message":"找不到 id 999 的待辦","details":[]}""",
        response.bodyAsText(),
    )
}

最後是非數字的 id,這個驗的是 todoId() 那個 helper 在 DELETE 上也有掛到,GET 那邊本來就有同樣的測試

@Test
fun `delete todo by non numeric id responds bad request`() = testApplication {
    todoApplication()

    val response = client.delete("/todos/abc")

    assertEquals(HttpStatusCode.BadRequest, response.status)
}

然後是 day 12 那個一直撐著的測試,那篇的原話是「405 的語意本身還想看住,所以不是刪掉而是換一個還沒實作的方法,對 /todos 發 PUT 斷言 405,等哪天 PUT 也變成真端點再讓位一次」,測試的名字是 put todos responds method not allowed

PUT 變成真端點了,那個測試卻還是通過的

HTTP/1.1 405 Method Not Allowed
X-Response-Time: 0ms
Content-Length: 0

因為新的 PUT 掛在 /todos/{id},/todos 這個節點上註冊的方法還是只有 GET 跟 POST,這一次不用讓位,但測試的名字會騙人,put todos responds method not allowed 現在讀起來像「這個 API 不支援 PUT」,實際上它驗的是「集合路徑不吃 PUT」。所以改名字,改成 put todos without an id responds method not allowed,斷言一個字都沒動,day 06 建立的那個 405 語意到這裡換了第 2 次說法,但一直沒有斷過

DAO 那條路長什麼樣

day 20 挑 Table 而不是 IntIdTable 的時候留了一句「DAO 風格的比較留到 day 22」,這一節把 exposed-dao 裝起來寫一份對照,看完就移除了,todo-api 的相依清單沒有變

裝的方式是 build.gradle.kts 加一行,用 testImplementation 而不是 implementation,因為這份對照只會活在測試裡,正式程式碼一行都不碰

testImplementation("org.jetbrains.exposed:exposed-dao:1.5.0")

對照的程式碼放在 src/test/kotlin/com/cashwu/todo/DaoComparisonTest.kt,表、entity 跟測試都在這一個檔案,看完整個檔案刪掉、那行相依拿掉就回到原狀。DAO 那套要 3 個東西,一張 IntIdTable、一個繼承 IntEntity 的 class、一個繼承 IntEntityClass 的伴生物件

object DaoTodos : IntIdTable("dao_todos") {
    val title = varchar("title", 100)
    val done = bool("done").default(false)
    val createdAt = timestampWithTimeZone("created_at")
}

class TodoEntity(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<TodoEntity>(DaoTodos)

    var title by DaoTodos.title
    var done by DaoTodos.done
    var createdAt by DaoTodos.createdAt
}

IntIdTable 幫你宣告 id,欄位型別是 EntityID<Int>,IntIdTable 跟 EntityID 在 org.jetbrains.exposed.v1.core.dao.id,IntEntity、IntEntityClass 在 org.jetbrains.exposed.v1.dao,前者是 exposed-core 本來就有的,後者才是這次多裝的那個套件,屬性用 by 委派到欄位上,讀寫都是一般的 Kotlin 屬性語法,寫起來確實比 DSL 短

差別在什麼時候送 SQL,測試用 day 21 那個 withDatabase 開一個乾淨的資料庫,自己建 DaoTodos 這張表,然後在幾個位置插 println 印一個標記,logback.xml 的 Exposed logger 開到 DEBUG 之後,標記跟 SQL 會照實際發生的順序交錯印出來

@Test
fun `dao sends sql later than the kotlin line that asked for it`() {
    withDatabase("dao") { database ->
        transaction(database) { SchemaUtils.create(DaoTodos) }

        val entity = transaction(database) {
            println(">>> A 建立 3 筆")
            defaultTodos.forEach { todo ->
                TodoEntity.new {
                    title = todo.title
                    done = todo.done
                    createdAt = todo.createdAt.atOffset(ZoneOffset.UTC)
                }
            }
            println(">>> B new 之後、離開 transaction 之前")
            val found = TodoEntity.findById(2)!!
            println(">>> C findById 之後")
            found.done = true
            println(">>> D 指派 done = true 之後")
            println(">>> E 讀回來 done = ${found.done}")
            println(">>> F transaction 區塊的最後一行")
            found
        }

        println(">>> G 交易外讀 title 成功 = ${entity.title}")
        val failure = assertFailsWith<IllegalStateException> { TodoEntity.findById(1) }
        println(">>> H 交易外 findById 失敗 = ${failure::class.simpleName}: ${failure.message}")
    }
}

./gradlew test --tests 'com.cashwu.todo.DaoComparisonTest' -i 跑,-i 是為了讓 println 跟 DEBUG log 都印到 console 上。第 1 段交易的順序是這樣

>>> A 建立 3 筆
>>> B new 之後、離開 transaction 之前
INSERT INTO DAO_TODOS (TITLE, DONE, CREATED_AT) VALUES ('買牛奶', TRUE, '2026-08-27T08:00:00Z')
INSERT INTO DAO_TODOS (TITLE, DONE, CREATED_AT) VALUES ('繳電費', FALSE, '2026-08-28T09:30:00Z')
INSERT INTO DAO_TODOS (TITLE, DONE, CREATED_AT) VALUES ('寫 day 05 的文章', FALSE, '2026-08-29T21:15:00Z')
SELECT DAO_TODOS.ID, DAO_TODOS.TITLE, DAO_TODOS.DONE, DAO_TODOS.CREATED_AT FROM DAO_TODOS WHERE DAO_TODOS.ID = 2
>>> C findById 之後
>>> D 指派 done = true 之後
>>> E 讀回來 done = true
>>> F transaction 區塊的最後一行
UPDATE DAO_TODOS SET DONE=TRUE WHERE ID = 2

3 個 TodoEntity.new { } 呼叫完之後 (標記 B),1 句 INSERT 都還沒送,那 3 句是在下一個讀取動作逼它 flush 的時候才一起出去的,entity.done = true 那一行 (標記 D) 也一樣什麼都沒送,讀回來 (標記 E) 是 true,但 UPDATE 是交易 commit 的時候才發出去的

day 21 對 DSL 的描述是「一句 Kotlin 對一句 SQL,沒有隱藏的 N+1、沒有 lazy loading 的驚喜,這是 DSL 這條路相對 DAO 那條路最實際的好處,你寫的東西跟資料庫收到的東西長得一樣」,上面這段就是那句話的反面,你寫的東西跟資料庫收到的東西在時間上對不上

公平起見要補一句,這個實驗沒有量到 N+1,另外開一個交易跑 TodoEntity.all().toList() 只送一句 SELECT,接著把 3 筆的 title 全部讀一遍也沒有再送任何查詢,因為同一張表的欄位是一次撈齊的,N+1 那個問題要有 referencedOn 這種跨表的關聯才會出現,這個只有一張表的實驗碰不到

還有 2 個行為要看,交易結束之後,讀 entity 已經載入過的屬性是成功的

>>> G 交易外讀 title 成功 = 繳電費

因為那個值在 entity 的快取裡,但同一個交易外面呼叫 TodoEntity.findById(1)

>>> H 交易外 findById 失敗 = IllegalStateException: No transaction in context.

這 2 個行為合起來是最麻煩的地方,同一個 entity 物件在交易外面有時候能用有時候不能用,取決於那個屬性有沒有被載入過,DSL 加 data class 就沒有這個問題,toTodo() 出來的是一個純值,離開交易之後它就只是 3 個欄位加一個時間,跟資料庫沒有關係

還有一個跟這篇主題直接相關的限制,Exposed 官方文件在 DAO 那幾頁的相依說明上標得很清楚,DAO API 只跟 exposed-jdbc 相容、不能配 exposed-r2dbc,多半是因為快取跟 lazy loading 這套機制建立在阻塞式的 JDBC 上。也就是說如果哪天想走真正非阻塞的那條路,走 DAO 的專案得先把 DAO 拆掉

那 DAO 什麼時候合適 ? 表之間有大量關聯、程式碼裡經常要順著 order.customer.address 這種路徑走的時候,DAO 省下來的樣板程式碼很可觀,todo-api 只有 1 張表、4 個欄位、5 個方法,DSL 的樣板量本來就不多,換 DAO 買到的是更短的寫法跟更難預測的 SQL,這筆交換在這裡不划算

跟 Relix 的對照

day 31 那句話 day 20 跟 day 21 都引過,這次它終於被完整驗完,原話是「若要真正 non-blocking,engine API 必須以 coroutine 啟動 request、在完成時非阻塞地寫回 response,不能只替換 dispatcher,阻塞式資料庫 driver 仍可明確移到 Dispatchers.IO」

「不能只替換 dispatcher」那半句以前讀起來像是在講 Relix 自己那個 JDK HttpServer adapter,這篇量完之後它有了第 2 層意思,withContext(Dispatchers.IO) 換掉的是執行的地點,8215 毫秒對 8152 毫秒那組數字說的就是換地點不會讓事情變快,真正決定吞吐的是連線池大小跟資料庫本身,dispatcher 只決定誰在等

同一篇還有一句「Dispatchers.IO 讓阻塞 I/O 跑在另一組並行配額上,不佔用 Dispatchers.Default 的額度 (兩者共用同一個 thread pool,各自有上限,不是 2 個獨立的池子)」,括號裡那句在這篇變成一個要配的數字,那個上限預設 64,在目前直接用 Dispatchers.IO 的架構裡,poolSize 開超過它不會增加同一時間的資料庫並行量,128 併發那組 1150 上下對 740 上下是它的實測

還有一句是 day 20 引過的效能觀察方法,「如果 active count 長期等於 total count,表示 thread 都在忙 (或阻塞),需要加大 pool 或減少阻塞」,HikariCP 那個 timeout 訊息裡的 total=1, active=1, idle=0 就是同一組指標,只是主角從 thread pool 換成 connection pool,手刻框架那時候是靠原理推出來的觀察指標,這篇是 HikariCP 直接把它印在例外訊息裡

這樣的 repository 能不能上線

repository 那一層可以,剩下的還有幾個洞

ExposedTodoRepository 5 個方法的形狀就是正式專案在用的形狀,介面上沒有 Exposed 的型別、交易邊界固定在方法上、阻塞的部分明確標在 withContext 那一行,要補的是這篇沒碰到的操作,findAll 現在一次撈全表,資料一多就得配上分頁而不是只有 limit,還有「有就更新沒有就新增」那種 upsert 語意

Dispatchers.IO 這個選擇本身要複查,共用的 64 條 thread 給整個 process 用,如果同一個服務裡還有別的阻塞來源,資料庫的查詢會跟它們搶,正式一點的做法是用 Dispatchers.IO.limitedParallelism(n) 切一塊出來專門給資料庫,n 對齊 poolSize,這樣一個慢查詢塞爆的是自己那一格

connectionTimeout 補了,maxLifetime 跟 leakDetectionThreshold 還沒,前者讓連線池在基礎設施的連線期限之前主動汰換連線,後者是連線借出去沒還的告警,2 個都是上線前要補的設定

那 5 條連線也還沒有答案,這篇量出了瓶頸怎麼搬家,量出了 2 道天花板,但真正的數字要等 day 23 換成 PostgreSQL 之後,對著一台真的資料庫、用真的查詢量才有意義

最後是「一個方法一個交易」這個決定的另一面,每個 HTTP 請求現在都會借一次連線、開一次交易、還一次連線,連 GET /todos/999 這種必定找不到的請求也不例外,這個量級沒問題,但一個端點需要連續叫 3 次 repository 的時候就是 3 次借還,該做的是把那 3 次併成一個交易或者在前面加快取,那要等真的有那種端點再說


小結

InMemoryTodoRepository 刪掉,ExposedTodoRepository 接手,介面 5 個方法加上 suspend,記憶體版降級成測試裡的 FakeTodoRepository。repository 邊界定了 3 件事,update 回 Todo?、delete 回 Boolean、交易固定一個方法一個

withContext(Dispatchers.IO) 換的是誰在等,不是等多久,40 併發打滿時 GET / 從卡住幾秒變成毫秒級,整批 8 秒沒變。連線池開到 40 之後瓶頸換成打的那端,Dispatchers.IO 預設並行度 64 是第二道天花板,connectionTimeout 從預設 30 秒改成 5 秒,池子滿了快速失敗回 500

PUT 跟 DELETE 端點補上,day 12 那個 405 測試改名不讓位。DAO 試裝過一次,SQL 送出的時間跟 Kotlin 那一行對不上,交易外的 entity 有時能用有時不能,這個系列繼續走 DSL


下一篇

H2 撐到這裡差不多了,下一篇要換成 PostgreSQL,用 Docker Compose 把資料庫拉起來,SchemaUtils.create 換成 Flyway 的版本化 migration,然後把這篇量不到的東西補上,真的資料庫上連線池該開多大、timestampWithTimeZone 在 PostgreSQL 存進去長什麼樣、day 21 那個 readOnly = true 在 H2 上完全不擋,換到 PostgreSQL 會不會擋


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 21 Exposed CRUD 與 Transaction
下一篇
Kotlin Ktor 實戰 101 Day 23 Flyway Migration 與 PostgreSQL
系列文
Kotlin Ktor 實戰 101 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言