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 驱动。不然启动就会报错。
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()
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 在服务端的完整架构:
7.9 避坑指南
最后,分享几个我踩过的坑:
- 不要在生产环境用
allowMainThreadQueries()——服务端虽然没有主线程概念,但这个配置会绕过 Room 的线程检查,容易写出阻塞代码。 - 迁移脚本一定要先备份——我曾经在生产环境跑迁移,结果脚本有 bug,整个表数据丢了。从那以后,我每次迁移前都先备份数据库文件。
- SQLite 的 WAL 模式——在服务端,建议开启 WAL(Write-Ahead Logging)模式,能显著提升并发读性能。Room 默认不开启,需要手动配置。
// 开启 WAL 模式
db.openHelper.writableDatabase.enableWriteAheadLogging()
好了,Room 在服务端的集成就聊到这里。记住一个原则:Room 是工具,不是银弹。适合的场景用起来很爽,不适合的场景别硬上。选型时多想想你的业务场景,比什么都重要。