
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 欠的那個簽章決定update 回什麼、transaction 包在哪一層withContext(Dispatchers.IO) 那一行,跟 day 21 同一組實驗的前後對照,它修好了延遲、沒有修好吞吐Dispatchers.IO 預設 64 那道天花板connectionTimeout 決定排隊排多久才放棄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
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 才處理
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 的原因
先確認它真的換了 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 秒變短得動別的東西
那就把池子開大試試,同一個 /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 自己,它的並行度由系統屬性 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 個上限加上前面那條公式的起始值,才是決定池子大小時要一起看的依據
池子滿了之後,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 的數量在跳
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 次說法,但一直沒有斷過
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,這筆交換在這裡不划算
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 那一層可以,剩下的還有幾個洞
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 產生