7、数据库集成(Room):Room在服务端的使用、DAO模式、数据库迁移、性能优化

说实话,很多人一听到 Room,第一反应就是「这不是 Android 上的本地数据库吗?」。没错,它确实是 Google 为 Android 打造的 ORM 框架。但你想过没有——Ktor 服务端和 Android 客户端共用同一套数据层,这有多香?

我在一个跨平台项目中试过这种方案。客户端和服务端都用 Room,数据模型完全一致,DAO 接口直接复用。改一个字段,两端同步更新,别提多省心了。今天我们就聊聊 Room 在 Ktor 服务端怎么落地。

7.1 Room 在服务端的定位

Room 本质上是对 SQLite 的封装。它帮你处理了对象映射、SQL 生成、类型转换这些脏活累活。在服务端,它同样能干这些事。

但有一点要清楚:Room 不是为高并发设计的。它底层用的是 SQLite,而 SQLite 是单写者数据库。如果你的 API 每秒几千次写入,Room 可能不是最佳选择。不过对于中小型应用、内部工具、原型验证,它完全够用。

适用场景:
  • 中小型服务端应用(日活 < 10 万)
  • 客户端与服务端共享数据层代码
  • 快速原型开发,减少样板代码
  • 嵌入式设备或边缘计算场景

7.2 项目依赖配置

要在 Ktor 项目里用 Room,首先得加依赖。注意 Room 的 KSP 注解处理器是必须的。

// build.gradle.kts
plugins {
    id("com.google.devtools.ksp") version "1.9.0-1.0.13"
}

dependencies {
    // Room 核心库
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    
    // KSP 注解处理器
    ksp("androidx.room:room-compiler:2.6.1")
    
    // SQLite JDBC 驱动(服务端需要)
    implementation("org.xerial:sqlite-jdbc:3.43.0.0")
}

这里有个坑:Room 默认依赖 Android 的 SQLite 实现。在服务端跑,你得换成 JDBC 驱动。不然启动就会报错。

注意: 服务端环境没有 Android 框架。Room 的 Context 参数需要自己模拟。我一般用 Room.databaseBuilder 传入一个空的 ApplicationContext 实现。

7.3 定义实体与 DAO

实体类就是数据库表的映射。DAO 是数据访问对象,定义你所有的数据库操作。

// 实体类
@Entity(tableName = "users")
data class User(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val name: String,
    val email: String,
    val createdAt: Long = System.currentTimeMillis()
)

// DAO 接口
@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE id = :id")
    suspend fun getUserById(id: Long): User?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: User): Long

    @Update
    suspend fun updateUser(user: User)

    @Delete
    suspend fun deleteUser(user: User)

    @Query("SELECT * FROM users ORDER BY createdAt DESC")
    fun getAllUsers(): Flow<List<User>>
}

我个人习惯把 DAO 设计成接口,这样方便测试时 mock。另外注意 suspend 关键字——Room 在 Kotlin 协程下跑得最顺。

7.4 构建数据库实例

服务端没有 Android 的 Context,我们需要自己创建一个伪上下文。代码很简单:

// 模拟 Android Context
class ServerContext : Context() {
    override fun getDatabasePath(name: String?): File {
        return File("data", name ?: "default.db")
    }
    // 其他抽象方法返回空实现即可
}

// 构建 Room 数据库
val db = Room.databaseBuilder(
    ServerContext(),
    AppDatabase::class.java,
    "app.db"
).build()

// 获取 DAO
val userDao = db.userDao()

嗯,这里要注意:ServerContext 需要实现 Context 的所有抽象方法。大部分返回 null 或空值就行,只有 getDatabasePath 是真正用到的。

7.5 DAO 模式在服务端的实践

DAO 模式说白了就是一层抽象。你的业务逻辑不直接操作 SQL,而是调用 DAO 接口。这样做的好处很明显:

  • 解耦: 换数据库实现时,只改 DAO 实现类
  • 可测试: 单元测试时 mock DAO 接口就行
  • 类型安全: 编译时就能发现 SQL 错误

我在项目中通常这样组织代码:

// 服务层调用 DAO
class UserService(private val userDao: UserDao) {
    suspend fun createUser(name: String, email: String): User {
        val user = User(name = name, email = email)
        val id = userDao.insertUser(user)
        return user.copy(id = id)
    }

    suspend fun getUser(id: Long): User? {
        return userDao.getUserById(id)
    }
}

// Ktor 路由中注入
fun Application.configureUserModule() {
    val db = get<AppDatabase>()
    val userService = UserService(db.userDao())

    routing {
        post("/users") {
            val request = call.receive<CreateUserRequest>()
            val user = userService.createUser(request.name, request.email)
            call.respond(HttpStatusCode.Created, user)
        }
        get("/users/{id}") {
            val id = call.parameters["id"]?.toLongOrNull()
            if (id == null) {
                call.respond(HttpStatusCode.BadRequest)
                return@get
            }
            val user = userService.getUser(id)
            if (user != null) {
                call.respond(user)
            } else {
                call.respond(HttpStatusCode.NotFound)
            }
        }
    }
}

你想想看,这种分层结构是不是很清晰?路由只负责 HTTP 处理,业务逻辑在 Service 里,数据访问在 DAO 里。各司其职。

7.6 数据库迁移

项目迭代过程中,数据库结构肯定会变。Room 提供了迁移机制,不用手动删表重建。

// 定义迁移:从版本 1 到 版本 2
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // 添加 age 字段
        database.execSQL("ALTER TABLE users ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
    }
}

// 定义迁移:从版本 2 到 版本 3
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // 创建新表
        database.execSQL("""
            CREATE TABLE IF NOT EXISTS posts (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                content TEXT NOT NULL,
                userId INTEGER NOT NULL,
                FOREIGN KEY (userId) REFERENCES users(id)
            )
        """.trimIndent())
    }
}

// 构建时添加迁移
val db = Room.databaseBuilder(
    ServerContext(),
    AppDatabase::class.java,
    "app.db"
).addMigrations(MIGRATION_1_2, MIGRATION_2_3)
 .build()
小技巧: 如果迁移逻辑复杂,建议先用测试数据库跑一遍迁移脚本。我曾经在生产环境上因为 ALTER TABLE 语句写错,导致整个表锁死。从那以后,我每次迁移都先在 staging 环境验证。

7.7 性能优化

Room 在服务端跑,性能瓶颈主要来自 SQLite 本身。这里分享几个我踩过的坑和优化方案。

7.7.1 连接池管理

SQLite 是单写者,但你可以用连接池来分摊读压力。Room 默认只维护一个连接。我建议手动配置:

// 使用连接池
val db = Room.databaseBuilder(
    ServerContext(),
    AppDatabase::class.java,
    "app.db"
).setQueryExecutor(Executors.newFixedThreadPool(4))  // 读线程池
 .setTransactionExecutor(Executors.newSingleThreadExecutor())  // 写线程池
 .build()

读操作可以并发,写操作必须串行。这样设计既保证了数据一致性,又提升了读性能。

7.7.2 批量操作优化

循环插入单条数据,性能极差。我见过有人这么写:

// ❌ 错误做法:循环插入
users.forEach { user ->
    userDao.insertUser(user)
}

正确的做法是用事务批量处理:

// ✅ 正确做法:事务批量插入
db.withTransaction {
    users.forEach { user ->
        userDao.insertUser(user)
    }
}

// 或者用 Room 的批量插入
@Insert
suspend fun insertUsers(users: List<User>)

批量插入比循环插入快 10 倍以上。原因很简单——每次插入都涉及磁盘 I/O 和事务提交,批量操作把这些开销合并了。

7.7.3 索引优化

查询慢?八成是没加索引。Room 支持在实体上声明索引:

@Entity(
    tableName = "users",
    indices = [
        Index(value = ["email"], unique = true),
        Index(value = ["createdAt"])
    ]
)
data class User(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val name: String,
    val email: String,
    val createdAt: Long = System.currentTimeMillis()
)

加了索引后,按 email 查询从全表扫描变成 B+ 树查找,速度提升几个数量级。

7.7.4 查询优化

Room 的 Flow 在服务端可能不太合适。每次表数据变化,Flow 都会重新发射。如果监听者很多,性能会下降。

我建议服务端用挂起函数替代 Flow:

// 服务端用挂起函数
@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE id = :id")
    suspend fun getUserById(id: Long): User?  // 挂起函数,只查一次

    // 客户端用 Flow
    @Query("SELECT * FROM users ORDER BY createdAt DESC")
    fun getAllUsers(): Flow<List<User>>  // Flow,实时监听
}

说白了,服务端不需要实时监听数据库变化。客户端才需要。分开设计,各取所需。

7.8 知识体系总览

下面这张图概括了 Room 在服务端的完整架构:

Room 服务端架构总览 Ktor 路由层 Service 业务层 DAO 数据访问层 Room 数据库 SQLite 文件存储 优化策略 • 连接池管理 • 批量事务操作 • 索引优化 • 挂起函数替代 Flow • 迁移版本管理 • 读写线程分离 核心特性 • 编译时 SQL 校验 • 类型安全查询 • 自动对象映射 • 协程原生支持 • 迁移自动化 • 客户端复用代码

7.9 避坑指南

最后,分享几个我踩过的坑:

  • 不要在生产环境用 allowMainThreadQueries()——服务端虽然没有主线程概念,但这个配置会绕过 Room 的线程检查,容易写出阻塞代码。
  • 迁移脚本一定要先备份——我曾经在生产环境跑迁移,结果脚本有 bug,整个表数据丢了。从那以后,我每次迁移前都先备份数据库文件。
  • SQLite 的 WAL 模式——在服务端,建议开启 WAL(Write-Ahead Logging)模式,能显著提升并发读性能。Room 默认不开启,需要手动配置。
// 开启 WAL 模式
db.openHelper.writableDatabase.enableWriteAheadLogging()

好了,Room 在服务端的集成就聊到这里。记住一个原则:Room 是工具,不是银弹。适合的场景用起来很爽,不适合的场景别硬上。选型时多想想你的业务场景,比什么都重要。