iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Software Development

Kotlin Ktor 實戰 101系列 第 23 篇

Kotlin Ktor 實戰 101 Day 23 Flyway Migration 與 PostgreSQL

  • 分享至 

  • xImage
  •  

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

day 22 結尾說「H2 撐到這裡差不多了」,然後列了幾件要在這篇補上的事

這篇把建表從 SchemaUtils.create 換成一個 Flyway 的 migration 檔案,資料庫從 H2 換成跑在 Docker 裡的 PostgreSQL,換完之後,day 20 那幾筆寫著「等 PostgreSQL 再看」的預測全部有對照組可以驗,varchar(100) 數的到底是什麼、timestamptz 會不會把 offset 存下來、奈秒碰到微秒精度是截斷還是四捨五入,還有 day 21 那個 H2 不理的 readOnly,換一套資料庫之後會怎樣

這篇要完成什麼

  • SchemaUtils.create 為什麼撐不到正式環境
  • H2 換成 PostgreSQL,Docker Compose 加上 application.yaml 的預設值,還有 day 22 留下來的 H2 專屬語法
  • 第 1 個 migration 檔案,內容從哪裡來、怎麼命名、放哪裡、改了會怎樣
  • Flyway 跟 Exposed 兩邊都在描述 schema,誰是真相來源
  • 既有的資料庫怎麼接上 Flyway,baseline 那條路
  • day 20 的 varchar(100) 預測,長度規則真的反轉了
  • day 20 的 timestamptz,offset 沒有存進去
  • day 20 的奈秒,四捨五入到微秒
  • day 21 那個 readOnly = true,PostgreSQL 這邊會擋
  • day 22 欠的 poolSize,對著真的資料庫量出反折
  • 測試現在跑在哪個資料庫上,為什麼完整解法要等 day 25
  • 跟 Relix 的對照,設定與快速失敗
  • 這樣的資料庫層能不能上線

SchemaUtils.create 撐不到正式環境

day 20 建表用的是 SchemaUtils.create(Todos),那篇自己就寫了為什麼它是暫時的,原話是「它會建不存在的表,但它不會處理『表已經存在,而且欄位跟現在的定義不一樣』這件事,加一個欄位、改一個型別、改長度,它都不管,schema 的演進要版本化,每一次變更是一個可以往前套用的檔案,這是 Flyway 那類工具在做的事,跟 day 23 排在同一篇」

SchemaUtils 其實有比 create 聰明一點的東西,createMissingTablesAndColumns 會補缺的欄位,statementsRequiredToActualizeScheme 會把「要怎麼把資料庫調成跟 Kotlin 定義一樣」列成一串 SQL,但那跟版本化是 2 件事,它算的是「現在的定義」跟「現在的資料庫」之間的差,沒有任何地方記錄過去套用過什麼,也沒有人能回答「上禮拜那台機器跑到哪一版了」

再往下想一層,Table 這個定義只描述得出欄位跟索引。實際會遇到的變更不只這些,把一欄的資料搬到另一欄、把舊資料補一個預設值、加一個 partial index、建一個 view,全部都是 SQL 而不是欄位宣告,Exposed 產不出來

所以 schema 的變更要變成檔案,一個檔案一個版本,資料庫自己記住跑到哪裡

先把資料庫換成 PostgreSQL

3 個相依進來,build.gradle.kts 的 dependencies 區塊裡加

implementation("org.postgresql:postgresql:42.7.13")
implementation("org.flywaydb:flyway-core:13.4.0")
implementation("org.flywaydb:flyway-database-postgresql:13.4.0")

Flyway 從 10 開始把各家資料庫的支援拆成獨立的 artifact,PostgreSQL 要多裝 flyway-database-postgresql 才認得,H2 不用,Maven Central 上根本沒有 flyway-database-h2 這個東西,它還留在 flyway-core 裡面

資料庫用 Docker 起,compose.yaml 放在專案根目錄

services:
  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: todo
      POSTGRES_PASSWORD: todo
      POSTGRES_DB: todo
    ports:
      - '5432:5432'
    volumes:
      - todo-data:/var/lib/postgresql
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U todo -d todo']
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  todo-data:

volume 掛在 /var/lib/postgresql 而不是網路上抄得到的 /var/lib/postgresql/data,postgres 18 的 image 換了資料目錄的擺法,掛在舊路徑上容器會啟動失敗,log 裡會叫你把單一個 mount 放在 /var/lib/postgresql

src/main/resources/application.yaml 的 todo.database 換成 PostgreSQL 的預設值

todo:
  requestIdHeader: '$REQUEST_ID_HEADER:X-Request-Id'
  responseTimeHeader: '$RESPONSE_TIME_HEADER:X-Response-Time'
  database:
    url: '$DB_URL:jdbc:postgresql://localhost:5432/todo'
    driver: '$DB_DRIVER:org.postgresql.Driver'
    user: '$DB_USER:todo'
    password: '$DB_PASSWORD'
    poolSize: '$DB_POOL_SIZE:10'
    connectionTimeout: '$DB_CONNECTION_TIMEOUT:5000'

基本上只是把 day 20 那 5 個欄位的值換掉而已,密碼照 day 17 定的規則不給預設值,漏設就讓 server 啟動失敗,本機的 Compose 雖然用 todo 當示範密碼,啟動應用程式之前還是要明確設定 DB_PASSWORD=todo,這是那時候把連線參數丟進設定檔的用處,連線這一層換資料庫沒有動到任何一行 Kotlin

換掉這個檔案的當下測試就全倒了,./gradlew test 一跑是 40 幾個 ExceptionInInitializerError

io.ktor.server.config.ApplicationConfigurationException: Required environment variable "DB_PASSWORD" not found and no default value is present

TestApp.kt 有一個 top-level 的 val todoTestConfig = ApplicationConfig("application.yaml").property("todo").getAs(),它在 class 初始化的時候就把設定檔讀完,密碼沒有預設值、測試的 JVM 又沒有那個環境變數,TestAppKt 初始化失敗,用到它的測試一起倒

測試那邊給它一個值就好,build.gradle.kts 補在 tasks.test 裡面

tasks.test {
    useJUnitPlatform()
    environment("DB_PASSWORD", "")

    // ...
}

再來是測試會照著 application.yaml 去打 localhost:5432。處理方式是在 src/test/kotlin/com/cashwu/todo/TestApp.kt 一個地方把資料庫那 4 個設定蓋回 H2

val h2Database: MutableMap<String, String>.() -> Unit = {
    put("todo.database.url", "jdbc:h2:mem:todo")
    put("todo.database.driver", "org.h2.Driver")
    put("todo.database.user", "sa")
    put("todo.database.password", "")
}

todoApplication() 裡那 2 個 configure() 都掛上它,configure 的 overrides 本來就是給蓋設定用的,day 18 拿它關過模組

還有一個蓋不到的地方,ConfigurationTest 的 the todo node maps onto the data class 比對的是 application.yaml 的原始內容,不走 overrides,那筆期望值要跟著換成 PostgreSQL 的值

至於為什麼不乾脆讓測試也跑 PostgreSQL,後面「測試現在跑在哪個資料庫上」那節講

還有幾行 day 22 留下來的東西是 H2 專屬的。Application.kt 裡那句 CREATE ALIAS IF NOT EXISTS SLEEP FOR "com.cashwu.todo.SlowSql.sleep",CREATE ALIAS 是 H2 註冊 Java 函式的語法,PostgreSQL 沒有這個東西,不拿掉 server 就起不來

Exception in thread "main" org.jetbrains.exposed.v1.exceptions.ExposedSQLException: org.postgresql.util.PSQLException: ERROR: syntax error at or near "ALIAS"
SQL: [CREATE ALIAS IF NOT EXISTS SLEEP FOR "com.cashwu.todo.SlowSql.sleep"]
	at com.cashwu.todo.ApplicationKt.module$lambda$4(Application.kt:54)

那句 exec、SlowSql.kt,還有 /slow 跟 /slow-io 2 個路由裡的 SELECT SLEEP(500),一起刪掉,PostgreSQL 內建 pg_sleep,後面量 poolSize 那節會用它把那 2 個路由加回來

啟動資料庫,再把同一組本機密碼明確交給應用程式

docker compose up -d
DB_PASSWORD=todo ./gradlew run

這時候建表的還是 day 20 那個 SchemaUtils.create(Todos),它會對著空的 PostgreSQL 把 todos 建起來,而 logback.xml 裡 Exposed 的 level 開在 DEBUG,它下的那句 DDL 就會印在啟動的 log 裡

DEBUG [no-call-id] Exposed -- CREATE TABLE IF NOT EXISTS todos (id SERIAL PRIMARY KEY, title VARCHAR(100) NOT NULL, done BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL)

這句就是下一節那個 migration 檔案的內容來源,順序是刻意的,migration 要寫給 PostgreSQL 看,內容當然得先問過 PostgreSQL

第一個 migration 檔案

檔案放在 src/main/resources/db/migration/V1__create_todos.sql,這是 Flyway 的預設位置,db/migration 這 2 層目錄專案裡還沒有,要自己建,Flyway 找不到它不會報錯,只會在 log 裡說 No migrations found on disk 然後什麼都不做,表沒建起來,接下來塞種子資料的交易才會炸

mkdir -p src/main/resources/db/migration

要自己開目錄、自己開檔案有點意外,那是因為 Flyway 沒有產生 migration 的指令,CLI 跟 Gradle plugin 有 migrate、info、validate、baseline、repair,就是沒有「幫我開一個新的 V2」。Rails 的 rails g migration、Django 的 makemigrations、EF Core 的 dotnet ef migrations add 之所以生得出來,是因為它們背後有 ORM,看得懂你的 model,Flyway 只認 SQL,不認 object Todos,從 diff 產 script 的 flyway generate 是 Red Gate 商業版的功能

命名規則是大寫 V 加版本號,2 個底線,再接描述,副檔名 .sql,2 個底線是規則的一部分,寫成 1 個底線 Flyway 就不認

內容就是上一節那句 DDL,排一下版,這是 src/main/resources/db/migration/V1__create_todos.sql 的全部

CREATE TABLE todos
(
    id         SERIAL PRIMARY KEY,
    title      VARCHAR(100)             NOT NULL,
    done       BOOLEAN DEFAULT FALSE    NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL
);

IF NOT EXISTS 拿掉了,Flyway 已經記得這個版本跑過沒有,再加一層「有就跳過」只會讓一個真正的衝突變成安靜的成功

src/main/kotlin/com/cashwu/todo/TodoDatabase.kt 裡,day 20 那個 connectAndSeed 改名成 connectAndMigrate,SchemaUtils.create(Todos) 那一行換成 Flyway,種子資料那段一個字沒動

fun DataSource.migrate(): MigrateResult =
    Flyway.configure()
        .dataSource(this)
        .locations("classpath:db/migration")
        .load()
        .migrate()

fun DataSource.connectAndMigrate(seed: List<Todo> = defaultTodos): Database {
    migrate()
    val database = Database.connect(this)
    transaction(database) {
        if (Todos.selectAll().empty()) {
            seed.forEach { todo ->
                Todos.insert {
                    it[title] = todo.title
                    it[done] = todo.done
                    it[createdAt] = todo.createdAt.atOffset(ZoneOffset.UTC)
                }
            }
        }
    }
    return database
}

locations 寫的就是預設值,明寫出來是因為這個路徑是約定而不是設定檔裡看得到的東西,寫一行讓它在程式碼裡有跡可循

種子資料為什麼不做成 V2__seed_todos.sql,因為測試會用不同的種子,withDatabase(name, seed = emptyList()) 這種呼叫在 day 20 就有了,塞進 migration 之後那個參數就沒地方去。真正的參考資料 (國家代號、稅率表那種) 放 migration 是對的,開發用的 3 筆假資料留在 Kotlin 這邊

改了名字,呼叫的地方就要跟著改,src/main/kotlin/com/cashwu/todo/Application.kt 的 dependencies 區塊裡,provide<Database> 那一行從 connectAndSeed() 換成 connectAndMigrate(),其他 4 行沒動

dependencies {
    provide<Database> { resolve<DataSource>().connectAndMigrate() }
    // ...
}

Database 這個 provider 會先 resolve<DataSource>(),所以 Flyway 跑 migration 用的跟後面所有查詢用的是同一個連線池

connectAndSeed 這個名字在測試裡還有 3 個呼叫點,不改就編不過

e: ExposedTodoRepositoryTest.kt:72:39 Unresolved reference 'connectAndSeed'.
e: TestApp.kt:45:34 Unresolved reference 'connectAndSeed'.
e: TransactionTest.kt:177:39 Unresolved reference 'connectAndSeed'.

TestApp.kt 的 withDatabase 直接換成 connectAndMigrate,另外 2 個換不過去,那是 day 21 跟 day 22 各留的「一條連線」測試,而 Flyway 13.4 在目前 H2 與 PostgreSQL 的執行路徑會同時使用 2 條連線,1 條操作 schema history、1 條跑 migration 本身,maximumPoolSize = 1 的池子連 migration 都跑不完,MigrationTest 裡用一個測試把它驗過

@Test
fun `a pool of one connection cannot run a migration`() {
    val oneConnection = todoDataSource(
        DatabaseConfig("jdbc:h2:mem:migrate-starved", "org.h2.Driver", "sa", "", 1, 250)
    )

    oneConnection.use {
        val failure = assertFailsWith<Exception> { it.migrate() }

        assertContains(failure.message.orEmpty(), "Connection is not available")
    }
}

這是目前版本與這 2 個資料庫的行為,不是所有 Flyway 資料庫實作都不能改變的規則

那 2 個測試的做法是先用一個 2 條連線的 DataSource 把 schema 建好、而且不關掉它 (in-memory H2 要有連線活著才不會消失),再另外開那個只有 1 條連線的池子,helper 放進 TestApp.kt

fun <T> withMigratedSchema(name: String, block: () -> T): T {
    val migrator = todoDataSource(
        DatabaseConfig("jdbc:h2:mem:$name", "org.h2.Driver", "sa", "", poolSize = 2)
    )
    return migrator.use {
        it.connectAndMigrate()
        block()
    }
}

2 個測試的改法一樣,原本的 body 整個包進 withMigratedSchema,裡面那句 val database = dataSource.connectAndSeed() 換成 Database.connect(dataSource)

@Test
fun `a saturated pool gives up after the connection timeout`() {
    withMigratedSchema("repo-timeout") {
        // 原本的 body 原樣搬進來,只有取得 database 那一行變了
        val database = Database.connect(dataSource)
    }
}

傳給 withMigratedSchema 的名字要跟池子那個 JDBC URL 裡的名字一樣,都是 repo-timeout,不然建好 schema 的是另一個 in-memory 資料庫,TransactionTest 那個名字是 deadlock

a saturated pool gives up after the connection timeout 的例外型別也因此變了。以前 connectAndSeed 會先在同一個 Database 上跑一個建表加種子資料的交易,讀 metadata、解 dialect 這些一次性的事那時就做完,後面要不到連線是發生在送 statement 的時候,被包成 ExposedSQLException;現在 schema 由另一個 DataSource 建好,這個 Database 上還沒有任何交易跑完過,findAll 第 1 件事就是拿連線去解 dialect,池子已經被佔住,丟出來的是底層的 SQLTransientConnectionException,還沒走到 Exposed 包裝例外的地方。訊息一個字沒變,還是 Connection is not available, request timed out after ...ms。TransactionTest 那個 deadlock 測試沒有這個問題,它第 1 個交易跑得完,接住的還是 ExposedSQLException

上一節那張 todos 是 SchemaUtils.create 建的,Flyway 沒有它的記錄,直接啟動會停在這裡

Found non-empty schema(s) "public" but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.

那是後面 baseline 那節的題目,這裡要的是從零跑一次,把表 drop 掉再啟動

docker compose exec db psql -U todo -d todo -c 'DROP TABLE todos'

第 1 次啟動的 log 長這樣

INFO  [no-call-id] c.z.h.HikariDataSource -- todo-pool - Start completed.
INFO  [no-call-id] o.f.c.FlywayExecutor -- Database: ******** (PostgreSQL 18.6)
INFO  [no-call-id] o.f.c.i.c.DbValidate -- Successfully validated 1 migration (execution time 00:00.028s)
INFO  [no-call-id] o.f.c.i.c.DbMigrate -- Current version of schema "public": << Empty Schema >>
INFO  [no-call-id] o.f.c.i.c.DbMigrate -- Migrating schema "public" to version "1 - create todos"
INFO  [no-call-id] o.f.c.i.c.DbMigrate -- Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.031s)
DEBUG [no-call-id] Exposed -- SELECT todos.id, todos.title, todos.done, todos.created_at FROM todos LIMIT 1
DEBUG [no-call-id] Exposed -- INSERT INTO todos (title, done, created_at) VALUES ('買牛奶', TRUE, '2026-08-27T08:00:00Z')
DEBUG [no-call-id] Exposed -- INSERT INTO todos (title, done, created_at) VALUES ('繳電費', FALSE, '2026-08-28T09:30:00Z')
DEBUG [no-call-id] Exposed -- INSERT INTO todos (title, done, created_at) VALUES ('寫 day 05 的文章', FALSE, '2026-08-29T21:15:00Z')
INFO  [no-call-id] Application -- Responding at http://0.0.0.0:8080

Flyway 建好表,接著就是 connectAndMigrate 裡那段種子資料,SELECT ... LIMIT 1 是 Todos.selectAll().empty() 那句在問表裡有沒有東西

第 2 次啟動 DbMigrate 就只剩這 2 行

INFO  [no-call-id] o.f.c.i.c.DbValidate -- Successfully validated 1 migration (execution time 00:00.028s)
INFO  [no-call-id] o.f.c.i.c.DbMigrate -- Current version of schema "public": 1
INFO  [no-call-id] o.f.c.i.c.DbMigrate -- Schema "public" is up to date. No migration necessary.
DEBUG [no-call-id] Exposed -- SELECT todos.id, todos.title, todos.done, todos.created_at FROM todos LIMIT 1

那 3 句 INSERT 不見了,種子資料只塞第 1 次,因為表裡已經有東西

checksum 對不上就不讓你過

那張 flyway_schema_history 表裡面是這樣一列,欄位多,用 psql -x 展開比較好讀

docker compose exec db psql -U todo -d todo -x -c 'SELECT * FROM flyway_schema_history'
-[ RECORD 1 ]--+---------------------------
installed_rank | 1
version        | 1
description    | create todos
type           | SQL
script         | V1__create_todos.sql
checksum       | 457275436
installed_by   | todo
installed_on   | 2026-09-03 17:35:10.047348
execution_time | 31
success        | t

checksum 那一欄是重點,Flyway 每次啟動都會拿本機檔案的 checksum 跟資料庫記錄的比對,對不上就不讓你過

這件事要用測試驗過才算數,新增 src/test/kotlin/com/cashwu/todo/MigrationTest.kt,再放一份動過手腳的 migration 在 src/test/resources/db/tampered/V1__create_todos.sql,版本號一樣是 1,title 改成 VARCHAR(200)

@Test
fun `editing an applied migration fails the checksum check`() {
    source("migrate-tampered").use { dataSource ->
        dataSource.migrate()

        val failure = assertFailsWith<FlywayValidateException> {
            Flyway.configure()
                .dataSource(dataSource)
                .locations("classpath:db/tampered")
                .load()
                .migrate()
        }

        assertContains(failure.message.orEmpty(), "Migration checksum mismatch for migration version 1")
    }
}

source(name) 是同一個檔案裡的 private helper,開一個名字不同、2 條連線的 in-memory H2 池

private fun source(name: String) = todoDataSource(
    DatabaseConfig("jdbc:h2:mem:$name", "org.h2.Driver", "sa", "", poolSize = 2)
)

沒有先寫它的話,IDE 會在 source 這個名字上提示 import Exposed ColumnSet 的同名成員,那是另一個東西

上面那個測試只比對訊息裡的一個片段,跑起來看到的就是一個 PASSED,訊息全文是寫測試的時候印出來看的,在 assertContains 那行前面暫時插一句 println(failure.message) 就會看到,它把兩邊的數字都列出來

Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1
-> Applied to database : 457275436
-> Resolved locally    : 1073995070
Either revert the changes to the migration, or run repair to update the schema history.

Applied to database 那個 457275436 就是上面那張表裡的值,真的把已經套用過的 migration 改掉再啟動 server,Ktor 那邊會直接掛掉,訊息跟上面那段一樣,要看的是底下那幾個 frame

Exception in thread "main" org.flywaydb.core.api.exception.FlywayValidateException: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1
	...
	at com.cashwu.todo.TodoDatabaseKt.migrate(TodoDatabase.kt:44)
	at com.cashwu.todo.TodoDatabaseKt.connectAndMigrate(TodoDatabase.kt:47)
	at com.cashwu.todo.TodoDatabaseKt.connectAndMigrate$default(TodoDatabase.kt:46)
	at com.cashwu.todo.ApplicationKt$module$1$4.invokeSuspend(Application.kt:31)

這條路徑跟 day 20 那個「資料庫連不上就不要啟動」是同一條,provide<Database> 的 provider 丟例外,啟動驗證接住,server 起不來,已經跑過的 migration 就是歷史,要改 schema 就寫 V2

誰是真相來源

現在 schema 被描述了 2 次,TodoDatabase.kt 裡的 object Todos 是 1 次,V1__create_todos.sql 是 1 次,TITLE_MAX_LENGTH 這個常數還在 TodoValidation.kt,那是第 3 次

分工是這樣,資料庫真正長什麼樣,由 migration 決定,那是唯一會被套用到資料庫上的東西,object Todos 是 Kotlin 這邊的讀法,它負責把欄位對應到型別,讓 Todos.title 這種寫法編得過,兩邊講的是同一件事,但只有一邊有執行力

問題是它們會漂,改了 migration 忘了改 object Todos,程式要跑到那一句查詢才會炸,所以要有一個測試站在中間,測試放到 src/test/kotlin/com/cashwu/todo/MigrationTest.kt

做這個檢查的函式在 Exposed 1.5.0 搬家了,SchemaUtils.statementsRequiredToActualizeScheme 還在但標了 deprecated

'fun statementsRequiredToActualizeScheme(vararg tables: Table, withLogs: Boolean = ...): List<String>' is deprecated.
This function will be removed in future releases. Please use `MigrationUtils.statementsRequiredForDatabaseMigration()` instead.
`MigrationUtils` is accessible with a dependency on `exposed-migration-jdbc`.

照它說的多裝一個相依,只有測試用得到,build.gradle.kts

testImplementation("org.jetbrains.exposed:exposed-migration-jdbc:1.5.0")
@Test
fun `the only gap between the migration and the table definition is the h2 precision`() {
    source("migrate-drift").use { dataSource ->
        dataSource.migrate()

        val missing = transaction(Database.connect(dataSource)) {
            MigrationUtils.statementsRequiredForDatabaseMigration(Todos)
        }

        assertEquals(
            listOf("ALTER TABLE TODOS ALTER COLUMN CREATED_AT TIMESTAMP(9) WITH TIME ZONE NOT NULL"),
            missing,
        )
    }
}

statementsRequiredForDatabaseMigration 回的是「Exposed 覺得還要下哪些 SQL,資料庫才會跟 object Todos 一樣」,理想狀態是空的,這裡卻有 1 句

那一句是 H2 專屬的雜訊,Exposed 的 timestampWithTimeZone() 沒有精度參數,它在 H2 上一律產生 TIMESTAMP(9),而 migration 寫的是不帶精度的 TIMESTAMP WITH TIME ZONE,H2 就用了預設的 6,同一個檢查對著 PostgreSQL 跑是空的,因為 Exposed 在 PostgreSQL 上產生的本來就是不帶精度的版本

這件事本身就說明了為什麼這個檢查不能只跑在 H2 上,它回報的是工具之間的落差,跟真正的設計漂移混在一起

剛才那個 exposed-migration-jdbc 裡還有另一個函式,順著這個分工往下看就是另一條路

MigrationUtils.generateMigrationScript 會比對 object Todos 跟現在的資料庫,把差異寫成一個 .sql 檔,假設要幫待辦加一個 priority 欄位,Kotlin 這邊先改

object Todos : Table("todos") {
    // 前面四欄沒動
    val priority = integer("priority").default(0)
}

然後對著現在的資料庫產生 migration,下面這段一樣是臨時寫的 scratch,跑完就刪

@OptIn(ExperimentalDatabaseMigrationApi::class)
@Test
fun `generate the next migration`() {
    transaction(Database.connect(dataSource)) {
        MigrationUtils.generateMigrationScript(
            Todos,
            scriptDirectory = "src/main/resources/db/migration",
            scriptName = "V2__add_priority",
        )
    }
}

generateMigrationScript 標了 @ExperimentalDatabaseMigrationApi,不 opt-in 編不過,前面那個 statementsRequiredForDatabaseMigration 反而不用,另一個是 scriptDirectory 不存在它不會幫忙建,直接丟 java.io.IOException: No such file or directory

跑完 src/main/resources/db/migration/V2__add_priority.sql 就出現了,內容是這一行

ALTER TABLE todos ADD priority INT DEFAULT 0 NOT NULL;

手寫 V2 的步驟就變成 1 行呼叫

但選它就是選了另一邊,migration 檔變成 object Todos 的輸出,Kotlin 改了就重新產生一次,真相來源是 Kotlin,這條路走得通,只是跟這篇的選擇相反,這篇讓 SQL 那份檔案說了算,理由前面講過,Table 描述不出把一欄的資料搬到另一欄、補預設值、partial index、view,migration 一旦只能是 Exposed 的輸出,那些東西就沒地方寫

既有的資料庫怎麼接上

前面那句 DROP TABLE 是為了讓 Flyway 從空的 schema 開始,真實情況多半沒有這個選項,資料庫已經在跑了,表裡有資料,drop 不得。這時候直接 migrate() 拿到的就是那句 Found non-empty schema(s) ... but no schema history table

Flyway 不敢在一個看不懂的 schema 上動手,處理方式是告訴它「現在這個狀態就當作第 1 版,別再跑 V1 了」,下面這段是臨時寫的 scratch 測試,跑完就刪,不在 todo-api 的正式程式碼裡

Flyway.configure()
    .dataSource(dataSource)
    .baselineOnMigrate(true)
    .baselineVersion("1")
    .load()
    .migrate()

跑完之後 migrationsExecuted 是 0,把 info().all() 的版本、描述、類型、狀態印出來是這樣

1 create todos SQL BASELINE_IGNORED
1 << Flyway Baseline >> BASELINE BASELINE

V1 被標成 BASELINE_IGNORED,也就是「我知道有這個檔案,但我不會執行它」,從第 2 版開始才會真的跑,todo-api 用不到這個開關,所以正式程式碼裡沒有它,但接手一個舊專案的時候第 1 件事就是它

換過去之後端點沒有變

端點的行為跟 H2 版一模一樣

curl -i localhost:8080/todos
HTTP/1.1 200 OK
X-Request-Id: jp8p/rrr7c61
X-Response-Time: 10ms
Content-Length: 245
Content-Type: application/json

[{"id":1,"title":"買牛奶","done":true,"created_at":"2026-08-27T08:00:00Z"},{"id":2,"title":"繳電費","done":false,"created_at":"2026-08-28T09:30:00Z"},{"id":3,"title":"寫 day 05 的文章","done":false,"created_at":"2026-08-29T21:15:00Z"}]

POST 一筆進去

curl -i -X POST -H "Content-Type: application/json" \
  -d '{"title":"倒垃圾"}' localhost:8080/todos
HTTP/1.1 201 Created
X-Request-Id: vqdjmu679s2/
X-Response-Time: 19ms
Content-Length: 84
Content-Type: application/json

{"id":4,"title":"倒垃圾","done":false,"created_at":"2026-08-31T07:27:05.515876Z"}

created_at 那個 .515876 只有 6 位,這是後面奈秒那節的伏筆,這一輪的 X-Response-Time 是 10ms 跟 19ms,每次都不一樣

真正的差別在把 server 關掉再開一次,列表還是 4 筆,id 4 那筆還在,Flyway 的 log 說 Schema "public" is up to date,資料庫裡的東西不會再因為 process 結束就消失了,day 20 到 day 22 靠著這個特性的所有東西都要重新想過

用 psql 直接看那張表,\d 是 psql 的 meta-command,不是 SQL

docker compose exec db psql -U todo -d todo -c '\d todos'
                                       Table "public.todos"
   Column   |           Type           | Collation | Nullable |              Default
------------+--------------------------+-----------+----------+-----------------------------------
 id         | integer                  |           | not null | nextval('todos_id_seq'::regclass)
 title      | character varying(100)   |           | not null |
 done       | boolean                  |           | not null | false
 created_at | timestamp with time zone |           | not null |
Indexes:
    "todos_pkey" PRIMARY KEY, btree (id)

SERIAL 在 PostgreSQL 裡是個縮寫,展開之後就是這裡看到的 integer 加一個 sequence 加一個 default

varchar(100) 的長度規則真的反轉了

day 20 講長度規則的時候留了一段預測,原話是「PostgreSQL 的 varchar(100) 數的是字元,也就是 code point,換過去之後 100 個 emoji 在資料庫那邊是合法的,我們的 validation 卻會先擋掉,兩邊的嚴格程度就對調了,要不要改成 code point 計數,等 day 23 真的換上 PostgreSQL 再看實際行為」

先打 API,一個 100 個 🎉 的標題,字串用 python3 生比較不會數錯

curl -i -X POST -H "Content-Type: application/json" \
  -d "{\"title\":\"$(python3 -c 'print("🎉"*100)')\"}" localhost:8080/todos
HTTP/1.1 400 Bad Request
X-Request-Id: ga39m9s-9hpv
X-Response-Time: 23ms
Content-Length: 98
Content-Type: application/json

{"status":400,"message":"欄位不符合規則","details":["title 長度不能超過 100 個字"]}

day 14 那個 validation 擋下來了,因為 String.length 數的是 UTF-16 code unit,100 個 🎉 是 200 個,然後繞過 API,直接對 PostgreSQL 塞同一個字串

docker compose exec db psql -U todo -d todo -c \
  "INSERT INTO todos (title, done, created_at)
   VALUES (repeat('🎉', 100), false, now())
   RETURNING id, char_length(title), octet_length(title);"
 id | char_length | octet_length
----+-------------+--------------
  5 |         100 |          400
(1 row)

INSERT 0 1

進去了,char_length 是 100、octet_length 是 400,PostgreSQL 認的是 100 個字元,同一句換成 repeat('🎉', 101) 就不行

ERROR:  value too long for type character varying(100)

預測完全命中,PostgreSQL 的文件寫得很直白,character varying(n) 是「can store strings up to n characters (not bytes) in length」,day 20 在 H2 上的結論是「3 層裡面最嚴的那一層剛好排在最前面」,所以 day 14 那筆待辦不用改,那個「剛好」現在變成「明顯比資料庫嚴」

那要不要把 validation 改成數 code point ? 這篇不改,但理由換了,以前不改是因為它剛好跟 H2 一樣,現在不改是因為改了以後沒有測試能證明它是對的,測試跑在 H2 上,H2 的 varchar(100) 還是數 code unit,把 validation 放寬到 100 個 code point,測試環境裡那個請求會通過 validation 然後被 H2 打回來變成 500,一個在正式環境正確,在測試環境會壞的改動,不應該在測試環境還是 H2 的時候送出去

代價說清楚,現在有一批 PostgreSQL 收得下的標題會被 API 擋掉,方向是偏嚴,不會弄髒資料

timestamptz 沒有把 offset 存進去

day 20 換成 timestampWithTimeZone 之後量到 H2 是真的把 offset 存進欄位裡,寫 +08:00 進去讀出來就是 +08,那篇的預測是「PostgreSQL 的 timestamptz 依文件說的是另一套做法,值正規化成 UTC 存、不留 offset,跨時區讀出來一樣不會跑掉,結論不變,只是機制不同,day 23 接上 PostgreSQL 的時候會實際看一次」

實驗是塞 2 筆同一個瞬間,但 offset 不同的資料進去,1 筆 2026-08-27T13:30:00+05:30,1 筆 2026-08-27T16:00:00+08:00。用原始 JDBC 把欄位當字串讀回來

下面這段是臨時寫的測試,跑完就刪,它自己建一張 tz_probe,最後也自己 drop 掉,不碰 todos

@Test
fun `timestamptz keeps the instant and drops the offset`() {
    val source = todoDataSource(
        DatabaseConfig("jdbc:postgresql://localhost:5432/todo", "org.postgresql.Driver", "todo", "todo", 2, 5000)
    )

    source.use { dataSource ->
        dataSource.connection.use { connection ->
            connection.createStatement().use { statement ->
                statement.execute("DROP TABLE IF EXISTS tz_probe")
                statement.execute("CREATE TABLE tz_probe (ord int, who text, at timestamptz)")
                statement.executeQuery("SHOW TimeZone").use {
                    it.next()
                    println(">>> session TimeZone = ${it.getString(1)}")
                }
            }

            connection.prepareStatement("INSERT INTO tz_probe VALUES (?, ?, ?::timestamptz)").use {
                it.setInt(1, 1); it.setString(2, "德里寫的"); it.setString(3, "2026-08-27T13:30:00+05:30"); it.executeUpdate()
                it.setInt(1, 2); it.setString(2, "台北寫的"); it.setString(3, "2026-08-27T16:00:00+08:00"); it.executeUpdate()
            }

            connection.createStatement().use { statement ->
                statement.executeQuery("SELECT who, at::text FROM tz_probe ORDER BY ord").use {
                    while (it.next()) println(">>> raw ${it.getString(1)} = ${it.getString(2)}")
                }
                statement.executeQuery("SELECT who, at FROM tz_probe ORDER BY ord").use {
                    while (it.next()) {
                        println(">>> back ${it.getString(1)} = ${it.getObject(2, OffsetDateTime::class.java).toInstant()}")
                    }
                }
                statement.execute("DROP TABLE tz_probe")
            }
        }
    }
}

at::text 是叫 PostgreSQL 自己把欄位轉成字串,看到的就是它存進去之後的樣子

>>> session TimeZone = Asia/Taipei
>>> raw 德里寫的 = 2026-08-27 16:00:00+08
>>> raw 台北寫的 = 2026-08-27 16:00:00+08

2 筆長得一模一樣,+05:30 那筆完全不見了,而那個 +08 也不是寫進去的東西,它是連線的 session time zone,pgjdbc 連上去的時候會照 JVM 的預設時區送一句 SET TimeZone,這台機器是 Asia/Taipei 所以顯示成 +08,容器裡的 PostgreSQL 自己的 TimeZone 設定是 UTC,在容器裡用 psql 看同一批資料就會看到 +00

PostgreSQL 的文件把這件事寫得很完整,「the value is stored internally as UTC, and the originally stated or assumed time zone is not retained」(值在內部一律存成 UTC,輸入時寫明的、或是推斷出來的那個時區不會被保留)

輸出的時候「it is always converted from UTC to the current timezone zone, and displayed as local time in that zone」 (一律從 UTC 轉成當下 timezone 設定的那個時區,再用那個時區的當地時間顯示),存的是瞬間,顯示的是當下這條連線的時區

同一段 scratch 的第 2 個查詢改用 getObject(2, OffsetDateTime::class.java),讀回 Kotlin 這一端,2 筆都是同一個 Instant

>>> back 德里寫的 = 2026-08-27T08:00:00Z
>>> back 台北寫的 = 2026-08-27T08:00:00Z

結論跟 day 20 說的一樣,跨時區讀出來不會跑掉,差別在 H2 會把你寫進去的 offset 原封不動留著,PostgreSQL 只留瞬間,如果哪天真的需要「這筆資料是在哪個時區被建立的」,那要另外開一欄存時區名稱,不能指望 timestamptz

奈秒四捨五入到微秒

day 20 那個測試確認 H2 能保留 9 位小數,並把 PostgreSQL 會截斷還是四捨五入留到這篇實測。寫 2026-10-10T12:00:00.123456789Z 進去

一樣是臨時寫的測試,順便把另一個方向的 2026-10-10T12:00:00.1234564Z 也塞進去

@Test
fun `postgres rounds nanoseconds to microseconds`() {
    val source = todoDataSource(
        DatabaseConfig("jdbc:postgresql://localhost:5432/todo", "org.postgresql.Driver", "todo", "todo", 2, 5000)
    )

    source.use { dataSource ->
        dataSource.connection.use { connection ->
            connection.createStatement().use { statement ->
                statement.execute("DROP TABLE IF EXISTS ns_probe")
                statement.execute("CREATE TABLE ns_probe (who text, at timestamptz)")
            }

            connection.prepareStatement("INSERT INTO ns_probe VALUES (?, ?)").use {
                it.setString(1, "奈秒")
                it.setObject(2, Instant.parse("2026-10-10T12:00:00.123456789Z").atOffset(ZoneOffset.UTC))
                it.executeUpdate()
                it.setString(1, "進不上去")
                it.setObject(2, Instant.parse("2026-10-10T12:00:00.1234564Z").atOffset(ZoneOffset.UTC))
                it.executeUpdate()
            }

            connection.createStatement().use { statement ->
                statement.executeQuery("SELECT who, at::text, at FROM ns_probe ORDER BY who DESC").use {
                    while (it.next()) {
                        println(">>> raw ${it.getString(1)} = ${it.getString(2)}")
                        println(">>> back ${it.getString(1)} = ${it.getObject(3, OffsetDateTime::class.java).toInstant()}")
                    }
                }
                statement.execute("DROP TABLE ns_probe")
            }
        }
    }
}
>>> raw 進不上去 = 2026-10-10 20:00:00.123456+08
>>> back 進不上去 = 2026-10-10T12:00:00.123456Z
>>> raw 奈秒 = 2026-10-10 20:00:00.123457+08
>>> back 奈秒 = 2026-10-10T12:00:00.123457Z

.123456789 變成 .123457。截掉的話會是 .123456,這裡尾數的 789 把第 6 位的 6 進成了 7,PostgreSQL 的文件只寫「The allowed range of p is from 0 to 6」,允許的精度範圍是 0 到 6,沒有明講超過的部分怎麼處理,實際跑出來是四捨五入

2 個方向都會捨入,.1234564 那筆讀回來是 .123456,尾數的 4 進不上去

差幾百奈秒在絕大多數場景沒有意義,但 2 件事要注意

  • 一是「寫進去的物件」跟「讀出來的物件」不再相等,任何拿 Instant 直接做 assertEquals 的測試都會失敗
  • 二是這個誤差會往上跑到秒,2026-10-10T12:00:00.9999999Z 讀回來是 2026-10-10T12:00:01Z,TodoDatabaseTest 補一個測試驗這件事
@Test
fun `rounding can push an instant into the next second`() {
    val almost = Instant.parse("2026-10-10T12:00:00.9999999Z")

    withDatabase("rounding", seed = emptyList()) { database ->
        transaction(database) {
            Todos.insert {
                it[title] = "跨秒"
                it[done] = false
                it[createdAt] = almost.atOffset(ZoneOffset.UTC)
            }
        }

        assertEquals(
            Instant.parse("2026-10-10T12:00:01Z"),
            transaction(database) { Todos.selectAll().first().toTodo().createdAt },
        )
    }
}

有趣的是 H2 現在也一樣,information_schema 裡 created_at 的 datetime_precision 是 6,因為 migration 寫的是不帶精度的 TIMESTAMP WITH TIME ZONE,而 H2 的預設精度就是 6,day 20 那個「9 位數 1 位都沒掉」的測試因此要改,同一個形狀,名字換成 an instant is rounded to the microsecond the migration asked for,寫進去的還是 .123456789Z,斷言換成 .123457Z

這是把 schema 集中到一個 migration 檔案的第 1 個實際好處,以前 H2 拿 TIMESTAMP(9),PostgreSQL 拿 timestamptz,兩邊的行為差在測試看不到的地方,現在兩邊吃同一份 DDL,精度的落差被搬到測試裡了

readOnly 在 PostgreSQL 上會擋

day 21 量過 Exposed 的 readOnly = true,那篇的原話是「readOnly 最後落到 JDBC 的 Connection.setReadOnly(),那個方法在規格上就是給 driver 的最佳化提示,擋不擋是 driver 自己決定,H2 選擇不擋,PostgreSQL 這邊會擋,SQLSTATE 25006 的訊息是 cannot execute INSERT in a read-only transaction,day 23 換過去的時候再實際跑一次」

用這段測試先跑一個普通交易,再用 readOnly = true 的交易去寫

@Test
fun `readonly blocks the insert`() {
    pg().use { dataSource ->
        val database = Database.connect(dataSource)

        transaction(database) { Todos.selectAll().count() }

        try {
            transaction(database, readOnly = true) {
                Todos.insert {
                    it[title] = "唯讀"
                    it[done] = false
                    it[createdAt] = Instant.now().atOffset(ZoneOffset.UTC)
                }
            }
        } catch (e: Exception) {
            val psql = generateSequence(e as Throwable?) { it.cause }
                .filterIsInstance<PSQLException>()
                .first()
            println(">>> readOnly sqlstate=${psql.sqlState} ${psql.message?.lines()?.first()}")
        }
    }
}

pg() 是同一個檔案裡開 PostgreSQL 連線池的 helper,Exposed 把 PSQLException 包在 ExposedSQLException 裡面,所以要沿著 cause 找回去才拿得到 SQLSTATE

>>> readOnly sqlstate=25006 ERROR: cannot execute INSERT in a read-only transaction

pgjdbc 在 setReadOnly(true) 之後會對交易下 SET TRANSACTION READ ONLY,PostgreSQL 就真的擋,同一個 readOnly = true,H2 讓你寫,PostgreSQL 丟例外

不過過程中撞到一個沒預料到的東西,同樣的程式碼,換一個順序就不擋了,把前面那個普通交易拿掉,唯讀交易變成第 1 個

@Test
fun `readonly on the first transaction is swallowed`() {
    pg().use { dataSource ->
        val database = Database.connect(dataSource)

        transaction(database, readOnly = true) {
            println(">>> jdbc isReadOnly = ${connection.readOnly}")
            exec("SHOW transaction_read_only") { rows ->
                rows.next()
                println(">>> transaction_read_only = ${rows.getString(1)}")
            }
        }
    }
}
>>> jdbc isReadOnly = false
>>> transaction_read_only = off

那次的差別是 transaction(database, readOnly = true) 剛好是這個 Database 上的第 1 個交易,翻 Exposed 1.5.0 的 ThreadLocalTransaction 就看得懂,透過 DataSource 連線的時候,第 1 個交易會把要求的 isolation level 跟 readOnly 當成「這個 DataSource 本來的設定」快取起來,然後跳過 setter,後面的交易只有在值跟快取不一樣的時候才真的去設。第 1 個交易是唯讀的,那個唯讀就被當成基準值吃掉了

正式路徑上碰不到,因為 connectAndMigrate 一定先跑一個塞種子資料的交易,但這解釋了一件事,readOnly 這個參數在不同的接法下會有不同的效果,day 21 說它「當成給資料庫的最佳化提示可以,當成防呆機制不行」那句話,換到 PostgreSQL 之後還是成立

poolSize 那個 5 終於有東西可以量

day 22 收尾欠了一筆,「這篇量出了瓶頸怎麼搬家、量出了 2 道天花板,但真正的數字要等 day 23 換成 PostgreSQL 之後,對著一台真的資料庫、用真的查詢量才有意義」

先把 day 22 那組實驗原樣搬過來,臨時加一個路由,withContext(Dispatchers.IO) 包一句 SELECT pg_sleep(0.5),40 個併發打 80 個請求,每個尺寸 2 輪

poolSize 第 1 輪 第 2 輪
5 8121 ms 8150 ms
10 4090 ms 4092 ms
20 2324 ms 2370 ms
40 1620 ms 1653 ms

這幾個數字每次跑都不一樣,看的是形狀,跟 day 22 那張 H2 的表形狀一樣,越大越快

原因也一樣,pg_sleep 跟 Thread.sleep 都不吃 CPU,資料庫那邊沒有東西可以搶,連線開幾條就有幾條在睡

所以換一句真的花力氣的查詢,SELECT count(*) FROM generate_series(1, 3000000)。在這個容器裡單獨跑一次大約 230 毫秒,而且是實打實的 CPU,這次打 160 個請求、40 併發,每個尺寸跑 3 輪,跑之前先打 40 個當暖身

poolSize 第 1 輪 第 2 輪 第 3 輪
2 18904 ms 18895 ms 19728 ms
4 12840 ms 12828 ms 13077 ms
6 12756 ms 13311 ms 12949 ms
8 13202 ms 13317 ms 13046 ms
12 13506 ms 13762 ms 13522 ms
20 13719 ms 19447 ms 14744 ms
40 14177 ms 13679 ms 13629 ms

這就是 day 22 預測的那個反折,從 2 開到 4 少了 6 秒,4 到 6 之間到底,之後不但沒有更快還慢慢往上爬,20 那一列的 19447 是離群值,同一個尺寸另外兩輪是 13719 跟 14744,這幾個數字每一輪都不一樣,看的是整條曲線的形狀

底部那個數字對得上算術,容器有 3 顆核心,160 個請求各要 230 毫秒的 CPU,總共 36.8 秒的工作分給 3 顆核心是 12.3 秒,扣掉幾個離群的輪次,量到的谷底是 12.7 到 13.1 秒。poolSize 開到 2 的時候只有 2 顆核心在動,36.8 除以 2 是 18.4 秒,量到 18.9 到 19.7 秒。連線再多也生不出第 4 顆核心

day 22 引的 HikariCP 公式跟這次量到的結果落在同一個量級,((core_count * 2) + effective_spindle_count) 暫時把這個 Docker volume 算成一顆有效磁碟,容器的 3 顆核心會得到 7,落在量到的谷底附近。effective_spindle_count 仍然是依工作負載與儲存設備估出來的,不是這次實驗證明的常數,那篇特別提醒過 core_count 指的是資料庫那台機器,這次終於有一台分得開的資料庫可以量

第 2 個上限是資料庫端,這個容器的 max_connections 是預設的 100,poolSize 加上其他服務的連線超過它會直接被拒絕,加上 day 22 那個 Dispatchers.IO 的 64,3 個上限都在了

application.yaml 的預設值從 5 換成 10,10 已經過了這台機器的谷底,留一點餘裕給不吃 CPU 的查詢,同時離 100 還很遠,這個數字仍然只對這台機器有意義,它該從 DB_POOL_SIZE 進來,這也是 day 20 把它做成環境變數的原因

測試現在跑在哪個資料庫上

跑在 H2 上

day 20 就把問題講在前面了,原話是「換成 PostgreSQL 之後資料不會自己消失,測試之間的隔離就得自己想辦法,那是 day 25 Testcontainers 的題目」,day 22 又講了一次,every application gets its own todo list 那個測試會通過,靠的是 in-memory H2 在最後一條連線斷掉的時候整個消失

前面已經把機制交代過了,TestApp.kt 把 todo.database.* 4 個 key 蓋回 H2、build.gradle.kts 給測試一個 DB_PASSWORD、2 個「一條連線」的測試改用 withMigratedSchema

所以現在的狀態是,正式跑 PostgreSQL,測試跑 H2、兩邊吃同一份 migration,migration 這件事是真的共用,前面那個奈秒的改變就是證據,但這篇量到的 PostgreSQL 行為裡,只有四捨五入因為 H2 也吃同一份 DDL 而順便進了測試,其他 3 個 (varchar 數 code point、offset 不留、readOnly 會擋) 全部是手動跑出來的,./gradlew test 一個都碰不到

這就是為什麼 validation 那筆待辦不能現在改,手上沒有一個能對著 PostgreSQL 跑的測試環境,改對了也證明不了,day 25 的 Testcontainers 就是在補這個洞,在測試裡起一個真的 PostgreSQL 容器,每個測試拿到乾淨的資料庫

跟 Relix 的對照

Relix 沒有做到資料庫這一層,這節短,但設定那篇有 2 句話在這篇兌現了

Relix day 25 講 3 層覆蓋的時候寫,「為什麼環境變數優先級最高 ? 因為容器化部署時,改環境變數最方便,你不會想為了改 port 重新打包程式」,這篇換掉整個資料庫,Kotlin 1 行沒動,application.yaml 換了 5 個預設值,而測試環境是靠 todo.database.url 這幾個 key 被蓋掉才留在 H2 上,手刻框架那時候拿 port 舉例,換成 JDBC URL 是同一個機制

同一篇還有一句,「環境變數的值不是數字時,不能默默 fallback 到預設值,那會讓部署問題很難 debug,你以為設了 RELIX_PORT,結果根本沒生效」,Flyway 的 checksum 檢查是這句話在 schema 上的版本,已經套用過的 migration 被改掉,它可以選擇無視、可以選擇重跑、可以選擇假裝沒事,它選的是讓整個 server 起不來,2 件事的判斷一樣,寧可在啟動的時候大聲壞掉,也不要在半夜讓人查一個「設定明明改了卻沒效」的問題

這樣的資料庫層能不能上線

比 day 22 那個版本近了一大步,但還有幾個洞

migration 只有一個,而且是建表,Flyway 真正的考驗在第 2 個檔案,改欄位型別、搬資料、加索引,那些會需要考慮「這句 SQL 跑在一張有 1000 萬列的表上要多久」跟「舊版程式碼還在跑的時候這個變更安不安全」,一個能安全上線的 schema 變更多半要拆成 2、3 個版本配合程式碼的部署順序,那是 migration 這條路真正的難度

baselineOnMigrate 沒有進正式程式碼,這個決定是對的,它應該是一次性的操作而不是每次啟動都帶著的開關,帶著它等於允許 Flyway 在任何一個它看不懂的 schema 上自己開始記帳

day 22 列的 maxLifetime 跟 leakDetectionThreshold 還是沒有,到了真的 PostgreSQL 上,第 1 個變得更重要,它要設得比資料庫或中間 proxy 的連線期限短,讓 HikariCP 先主動汰換連線,它不是死連線的偵測機制,連線驗證與 keepaliveTime 是另外 2 件事

poolSize 有數字了,但那個數字是對著一台 3 核心的 Docker 容器量的,容器跟應用程式還在同一台筆電上搶 CPU,上線前要對著真的資料庫,用真的查詢重量一次,量的時候要看的是曲線的谷底在哪,而不是某一個絕對數字

validation 那筆待辦掛著,測試還在 H2 上,這 2 件事是同一件事,day 25 一起處理


小結

順序是先換資料庫再寫 migration,application.yaml 換成 PostgreSQL 之後,day 20 那個 SchemaUtils.create(Todos) 對著空的資料庫建表,Exposed 的 DEBUG log 印出來的那句 DDL 就是 V1__create_todos.sql 的內容。db/migration 這 2 層目錄要自己建,Flyway 找不到它不會報錯只會什麼都不做,connectAndSeed 改名 connectAndMigrate 之後 Application.kt 的 provide<Database> 跟測試那 3 個呼叫點都要跟著改,day 22 為了量連線池留下的 CREATE ALIAS 跟 2 個 /slow 路由是 H2 專屬語法,不刪掉 server 起不來

Flyway 真正在做的事是 checksum,對不上就丟 FlywayValidateException 讓 server 起不來,已經套用過的 migration 就是歷史,要改 schema 就寫 V2。既有的資料庫用 baselineOnMigrate 加 baselineVersion 接上,V1 會標成 BASELINE_IGNORED。schema 現在被描述 2 次,migration 有執行力、object Todos 是 Kotlin 這邊的讀法,MigrationUtils.statementsRequiredForDatabaseMigration 站在中間當漂移檢查

day 20 的 3 個預測全中。varchar(100) 數的是 code point,100 個 emoji 被 API 擋下卻進得了 PostgreSQL。timestamptz 只留瞬間不留 offset,2 筆同瞬間不同 offset 的資料讀回來一模一樣。奈秒是四捨五入不是截掉,.9999999 會跨到下一秒,而且 H2 現在也一樣,因為兩邊吃同一份不帶精度的 DDL。day 21 那個 readOnly = true 在 PostgreSQL 上真的擋,SQLSTATE 25006,但第 1 個交易的 readOnly 會被 Exposed 當成基準值快取掉。poolSize 要換成吃 CPU 的查詢才看得到反折,3 核心的容器上谷底在 4 到 6 之間,預設值從 5 改成 10

測試還是跑 H2,TestApp.kt 把 todo.database.* 蓋回去。這篇量到的 PostgreSQL 行為裡只有四捨五入因為 H2 吃同一份 DDL 而順便進了測試,其他都是手動跑的,那個洞要等 day 25 的 Testcontainers


下一篇

資料庫這段告一段落,接下來 2 篇講測試,下一篇把 testApplication 拆開,這個系列從 day 03 用到現在的東西其實一直沒有講清楚,它建的 application 跟 EngineMain 起的有什麼不一樣、client 那個 HttpClient 是怎麼接到 server 上的、application { } 跟 serverConfig { } 執行的時機差在哪,還有為什麼 configure(overrides = ...) 蓋得掉 application.yaml


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 22 Repository 邊界、Connection Pool 與阻塞 I/O
系列文
Kotlin Ktor 實戰 101 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言