Realm 数据库的现状,迁移到 Room 的经验

Realm 数据库的现状,迁移到 Room 的经验

Realm 数据库的现状,迁移到 Room 的经验


Realm 数据库的现状,迁移到 Room 的经验


Realm 的收购与社区冷却


2020 年 MongoDB 宣布收购 Realm,这件事在当时看起来像是 NoSQL 移动数据库的一次胜利。Realm 的 GitHub 仓库 star 数一度接近一万,Android 开发者圈子里讨论它的热度不亚于现在讨论 Jetpack Compose。但收购之后的走向,很多早期用户没有预料到。


MongoDB 把 Realm 整合进了 MongoDB Atlas 的生态,推出了 MongoDB Realm(后来改名 Atlas Device SDK)。这个转变的代价是,原本独立运行的 Realm 数据库逐渐被边缘化。开源的 realm-java 仓库最后一次发布正式版本是 2020 年 11 月的 10.0.1,之后虽然有过几次维护更新,但核心团队明显把精力转向了 Swift 和 JavaScript 的 SDK。Android 开发者在 GitHub issue 里问 "Java SDK 还维护吗",得到的回复越来越敷衍。


我自己是从 2017 年开始在一个电商 App 里用 Realm 的。当时选择它的理由很直接:比 SQLite 快,API 设计得漂亮,不需要写样板代码的 ContentProvider 或者 Contract 类。一个 POJO 加上 @RealmClass 注解就能存取,查询用链式 API 写出来像 Java 8 Stream。那时候 Room 刚发布不久(1.0 是 2017 年 Google I/O 推出的),稳定性存疑,而 Realm 已经跑了几年生产环境。


问题出现在 2021 年前后。我们项目用的 Realm Java 7.0.x 版本,在 Android 11 上开始报 UnsatisfiedLinkError,原因是 so 库加载路径在 API 30 有变化。这个 issue 在 realm-java 的 GitHub 上挂了几个月,社区有人提了 PR,但官方迟迟不合并。最后我们不得不自己 fork 仓库打补丁,维护成本陡增。类似的情况还有:Kotlin 协程支持迟迟不到位,需要依赖第三方封装库比如 realm-coroutines;Android 12 的 SplashScreen API 引入后,Realm 的初始化逻辑和新的进程启动模型有冲突,导致冷启动偶现 ANR。


这些信号叠加起来,团队内部开始认真评估迁移方案。Room 2.4.0 在 2021 年底发布,带来了内置的数据库自动迁移支持和 Kotlin Symbol Processing(KSP),生态成熟度已经不可同日而语。我们最终在 2022 年 Q2 启动了全量迁移,历时四个月,把将近 80 张表、30 万行有效代码里的 Realm 调用全部替换掉。


迁移前的技术债务盘点


动手之前,我们花了两周做现状梳理。Realm 在我们的代码库里渗透得比想象中深,不只是数据层,业务逻辑里大量混入了 Realm 特有的概念。


最典型的是 RealmObject 的自动更新特性。Realm 的查询结果不是快照,而是 live object,底层数据变了,已经拿到的对象字段也会跟着变。这个设计在 UI 层省了不少事,配合 RealmRecyclerViewAdapter 可以实现自动刷新。但副作用是,对象的生命周期和 Realm 实例绑在一起,跨线程传递必须手动 copyFromRealm(),否则直接抛异常。我们的代码里到处都是这种防御性拷贝,有些路径漏掉了,线上就报 IllegalStateException: Realm access from incorrect thread


另一个深坑是 Realm 的异步事务。executeTransactionAsync 的回调在 Android 主线程执行,但事务本身在后台线程。这个线程模型和 Kotlin 协程的 Dispatchers.IO 混用时,很容易出现协程取消了但 Realm 事务还在跑,或者反过来事务完成了但协程作用域已经销毁。我们统计了一下,过去一年里 Realm 相关的 crash 占了总 crash 量的 12%,其中线程问题超过一半。


还有数据迁移的历史包袱。Realm 没有内置的 schema 版本管理机制,全靠开发者自己写 RealmMigration 接口。我们积累了 17 个版本的迁移脚本,最早的几个已经没人能看懂在干什么。有一次发版后发现某个迁移步骤在特定数据量下会 OOM,紧急热修复 rollback 了两次。


这些债务不清理掉,直接平替到 Room 只会把问题带过去。我们定了两条原则:迁移过程中不追求功能完全对等,Realm 的 live object 特性放弃掉,改用显式的 Flow 驱动 UI 刷新;数据模型彻底重构,不和旧 schema 兼容,通过一次性导出导入完成数据迁移。


Room 的选型与版本选择


当时 Room 的最新稳定版是 2.4.2,我们评估后决定直接上 2.5.0-alpha01。这个决策有风险,但几个新特性对我们很关键。


第一个是内置的自动迁移(Auto-Migration)。Room 2.4.0 引入了 @AutoMigration 注解,可以自动生成简单的 schema 变更迁移,比如添加列、删除列、重命名表。我们的迁移策略是:一次性从 Realm 导出 JSON,全新安装 Room 数据库,后续版本用自动迁移管理。这样就不需要在 Room 里重写那 17 个版本的 Realm 迁移脚本。


第二个是 KSP 支持。Room 2.5.0 开始正式支持 Kotlin Symbol Processing,替代之前的 KAPT。KAPT 的编译速度在我们项目里是个痛点,每次修改 Entity 类要重新生成大量代码,增量编译经常失效。KSP 的实测编译时间比 KAPT 快了 40% 左右,这个提升在迁移期间频繁改数据模型时特别明显。


第三个是 RoomDatabase.Builder 新增的 createFromAsset()createFromFile(),允许从预置文件初始化数据库。我们利用这个特性,把 Realm 导出的数据转换成 SQLite 格式,打包进 APK 作为初始数据库,用户升级时直接加载,避免首次启动的长时间迁移等待。


不过 alpha 版本确实踩了坑。2.5.0-alpha01 有个 bug,@Relation 注解在配合 PagingSource 使用时,关联数据偶尔加载不出来,翻页到特定位置就空指针。这个 issue 在 alpha03 修复,我们被迫每周跟进版本升级,持续了一个月。如果项目节奏不允许这种折腾,建议还是保守选稳定版,2.4.x 的功能对大多数场景已经足够。


数据模型的重构策略


Realm 和 Room 的模型设计哲学差异很大,直接翻译字段会留下隐患。


Realm 的 RealmList 对应的是对象引用关系,底层存储的是链接,查询时可以自动反序列化关联对象。Room 没有这种原生支持,关系要么用外键 + @Relation 注解做一对多查询,要么完全扁平化。我们选择了后者:把 Realm 里嵌套的对象树拆成独立的表,用显式的 ID 关联。这样写查询代码多一些,但性能可控,不会出现 Realm 那种深层嵌套对象意外触发大量磁盘 IO 的情况。


Realm 的字段类型也比 SQLite 丰富,有 RealmIntegerRealmByteRealmDate 等包装类型,支持 null 的策略也和 Java 原生类型不同。Room 直接映射到 SQLite 的存储类型,IntLongString 等,nullability 由 Kotlin 的类型系统保证(Int? vs Int)。迁移时我们遇到一个问题:Realm 里有些字段理论上是 non-null,但实际数据因为历史 bug 存了 null,Room 的 @NonNull 约束会直接触发插入失败。解决办法是先写数据清洗脚本,在导出阶段把脏数据修复掉,而不是放宽 Room 的约束。


主键策略也需要调整。Realm 支持复合主键,用 @PrimaryKey 配合 RealmObjectSchema.setRequired() 实现。Room 从 2.4.0 开始支持复合主键,但语法是 @Entity(primaryKeys = ["firstName", "lastName"]),和 Realm 的注解风格完全不同。我们有几张表原来用了复合主键,迁移时统一改成了自增的 rowid 做物理主键,业务唯一约束用 @Index + 应用层校验保证。这个改动是为了兼容 Room 的 OnConflictStrategy,复合主键的 upsert 逻辑在 SQLite 层面写起来比较啰嗦。


时间戳字段的处理也有坑。Realm 的 Date 类型精度到毫秒,SQLite 的 INTEGER 默认存 Unix 时间戳也是毫秒。但 Room 的 @TypeConverter 如果用 System.currentTimeMillis(),在 32 位设备上会有 2038 年问题。我们统一改用 Instant.now().toEpochMilli(),数据库字段用 Long,应用层用 Kotlin 的 java.time.Instant 处理,彻底放弃 Date 类。


查询层的重写与性能对比


Realm 的查询 API 是链式的、类型安全的,编译期检查字段名,运行时翻译成自己的查询引擎。Room 的查询写 SQL 字符串或者用 Kotlin DSL(Room 2.4.0 引入的 room-runtime 里的 Query 接口,但功能有限),类型安全靠 KSP 生成代码保证。


我们花了大量时间把 Realm 的查询翻译成 Room 的 @Dao 接口。一个典型的例子:原来的 Realm 查询是 realm.where(Order.class).equalTo("status", OrderStatus.PAID).greaterThan("createTime", lastWeek).findAllSorted("createTime", Sort.DESCENDING),改成 Room 就是 @Query("SELECT * FROM orders WHERE status = :status AND create_time > :since ORDER BY create_time DESC") suspend fun getPaidOrdersSince(status: String, since: Long): List<OrderEntity>


表面看 Room 的写法更底层,但实际体验下来,复杂的关联查询 Room 反而更清晰。Realm 的深层关联查询要用 beginGroup()/endGroup() 嵌套,超过三层就难读。Room 直接写 SQL JOIN,虽然字符串拼接容易出错,但 KSP 会在编译期检查表名和字段名拼写错误,报错信息也直接定位到 @Query 注解的位置。


性能方面我们做了对比测试,数据量 10 万条订单记录,查询条件返回 5000 条结果。


Realm 的首次查询耗时平均 180ms,因为要把磁盘数据加载到内存的 Realm 实例。Room 配合 enableMultiInstanceInvalidation() 的首次查询是 45ms 左右,但如果不加这个选项,多进程场景下数据一致性有问题。后续重复查询 Realm 有优势,因为对象已经在内存,平均 15ms;Room 每次都要走 SQLite 的查询计划,但配合 SimpleSQLiteQuery 的缓存和 WAL 模式,也能做到 20ms 以内。


真正拉开差距的是写入性能。Realm 的写入必须在事务里执行,而且写事务会阻塞同 Realm 实例的其他读写。我们有个批量导入场景,一次性插入 5000 条记录,Realm 的 executeTransaction 平均耗时 2.3 秒,UI 必须显示进度条。Room 用 @Insert(onConflict = OnConflictStrategy.REPLACE) 配合 List<Entity> 批量插入,同样数据量 380ms,而且因为 SQLite 的 WAL 模式,读操作不会被写阻塞。


不过 Room 也有吃亏的地方。Realm 的 RealmResults 是懒加载的,RecyclerView 滑动时按需反序列化对象。Room 的 @Query 返回 List<Entity> 是一次性全部加载到内存,大数据集必须用 PagingSource。我们迁移时把原来的列表页全部改成了 Paging 3 架构,PagingSource 的实现虽然样板代码多,但和 Room 的集成很顺畅, room-paging artifact 直接提供了 androidx.room.paging.LimitOffsetPagingSource 基类。


协程与响应式流的适配


Realm 对 Kotlin 协程的支持一直是个短板。官方直到被收购后才慢慢补上 realm-kotlin SDK,但那是 Multiplatform 版本,API 和原来的 realm-java 不兼容。我们在迁移前用的是社区库 realm-coroutines,作者已经停止维护,最后一个版本停留在 1.0.0,只支持到 Kotlin 1.4。


Room 从 2.1.0 开始就原生支持协程,@Dao 的方法标记 suspend 即可。更实用的是返回 Flow 的查询,数据库变化会自动发射新值。这个特性直接替代了 Realm 的 RealmChangeListener,但实现机制完全不同。


Realm 的变更监听是细粒度的,对象级别、字段级别都能监听,回调在创建 Realm 实例的线程执行。Room 的 Flow 实现基于 InvalidationTracker,是表级别的,任何对表的写入都会触发所有监听该表的 Flow 重新查询。这意味着如果一张表有多个高频更新的字段,监听整张表的 Flow 会频繁发射,有些发射的数据其实没变。


我们的解决办法是分表。把原来 Realm 里的大表按更新频率拆分,比如 user_profile 表拆成 user_basic(极少变)和 user_status(经常变),UI 只监听需要的表。另外 Room 2.5.0 引入了 @RewriteQueriesToDropUnusedColumns 优化,配合 Flow 使用时,KSP 生成的代码会自动 SELECT 最小必要的字段,减少无效查询的开销。


有一个隐蔽的坑:FlowdistinctUntilChanged() 不能乱用。Room 的 Flow 发射的是新查询的 List<Entity>,两个 List 即使内容相同也是不同对象,distinctUntilChanged() 默认的 === 比较永远返回 false。必须自定义比较逻辑,或者改用 Flow<QuerySnapshot> 模式。我们选择在 ViewModel 层用 stateIn 转成 StateFlow,配合 SharingStarted.WhileSubscribed(5000),避免配置变更时的重复查询。


迁移工具链与数据校验


从 Realm 导出数据我们用了官方提供的 realm-export 工具,但这个工具已经随 realm-java 一起停止维护,最后版本不支持 Realm 10.x 的新文件格式。最后是参考了 GitHub 上 realm/realm-java 仓库 issue #7668 里的讨论,用 JNI 直接调用 Realm Core 的 C++ API 写了个自定义导出工具,输出 JSON Lines 格式。


JSON 到 SQLite 的转换用了 sqlite-utils 命令行工具(Simon Willison 开源的 Python 工具,GitHub 仓库 simonw/sqlite-utils),配合自定义的 Python 脚本做数据清洗。这个工具链的好处是可以自动化验证,比如检查外键关联是否完整、枚举值是否在允许范围内。清洗脚本跑了三轮,修复了 1700 多条脏数据,主要是 Realm 时期没有约束导致的空字符串主键、越界的时间戳。


Room 数据库的初始化用 createFromFile() 加载预置的 SQLite 文件,但有个细节:预置文件的 schema 版本必须和代码里 @Database(version = 1) 一致,否则 Room 会尝试走迁移逻辑。我们的第一次集成测试就在这里翻车,预置文件是手动用 sqlite3 命令创建的,没写 room_master_table,Room 直接报 IllegalStateException: Pre-packaged database has an invalid schema


room_master_table 是 Room 内部用的 schema 校验表,不能手动构造。正确的做法是先让 Room 在空设备上生成一次数据库,拷贝出来作为预置模板,再把清洗后的数据 INSERT 进去。我们写了个 Gradle task 自动化这个流程:connectedCheck 跑一个专门的测试用例生成空数据库,pull 到 assets/ 目录,然后 Python 脚本合并数据。


数据校验是迁移最容易被忽视的环节。我们除了单元测试覆盖每个 DAO 方法,还做了端到端的一致性校验:同一批业务操作,分别在 Realm 和 Room 分支执行,对比最终数据库状态的 MD5。这个测试跑在 Firebase Test Lab 的物理设备矩阵上,覆盖 Android 8 到 13。发现了两个 Room 的隐式行为差异:一是 Room 的 @Delete 返回删除行数,Realm 的 deleteFromRealm() 是 void;二是 Room 的 @Update 只更新非 null 字段,Realm 的 copyToRealmOrUpdate() 是全量覆盖。这些差异在业务代码里有依赖,必须显式调整。


迁移后的维护成本与团队反馈


全部迁移完成后,我们统计了前后六个月的维护数据。Realm 时期平均每月 4.2 个数据库相关 bug,迁移后降到 0.7 个。crash 率从 0.15% 降到 0.03%,主要贡献是消除了线程相关的崩溃。


编译时间也有改善。KSP 替代 KAPT 后,clean build 从 4 分 30 秒降到 2 分 50 秒。增量编译更稳定,修改一个 Entity 类不再触发全量注解处理。


但团队的学习成本是真实存在的。Realm 的 API 更"高级",屏蔽了很多数据库细节,新成员上手快。Room 要求理解 SQL、事务隔离级别、索引优化这些概念,面试时我们发现很多候选人对 EXPLAIN QUERY PLAN 完全没概念。我们内部整理了一份《Room 最佳实践》文档,持续更新,现在已经有 40 多页。


还有一个意外的收益:和后台的数据格式对齐变容易了。Realm 的 BSON 格式和后台的 JSON 需要额外转换层,Room 直接用 SQLite 的文本存储,配合 Kotlin Serialization 的 Json.decodeFromString,前后台共用一套数据类定义。我们甚至实验过把 Room 的 SupportSQLiteDatabase 包装成 SqlDriver,给 KMM 共享模块用,虽然最后没投产,但技术可行性验证通了。


还在用 Realm 的人应该怎么办


如果你现在的项目还在用 Realm,我的建议是尽快评估迁移计划,但不要盲目动手。


先看你的 Realm 版本。如果是 10.x 且没有严重阻塞 bug,可以维持到下一个大版本重构周期。如果是 7.x 或更早,Android 12/13 的兼容性风险已经很高,优先升级 Realm 版本或者迁移。MongoDB 官方的 Atlas Device SDK(realm-kotlin)对 Android 的支持在加强,但 API 和 realm-java 不兼容,本质上也是迁移。


看团队技术栈。如果项目已经是 Kotlin 优先、协程深度使用、Paging 3 架构,Room 的契合度很高。如果还是 Java 为主、大量 RxJava 2 遗留,Realm 的 RxJava 支持反而比 Room 成熟(Room 的 RxJava 支持需要额外依赖 androidx.room:room-rxjava2),迁移的收益没那么大。


看数据复杂度。Realm 的 live object 和自动同步特性在某些场景确实省代码,比如实时协作的本地缓存。Room 要实现类似效果需要额外搭 LiveDataFlow 的管道,代码量多一些。但如果你的数据模型主要是 CRUD、列表展示、本地缓存,Room 更可控。


一个折中方案是渐进式迁移:新模块用 Room,旧模块维持 Realm,通过 Repository 模式封装,逐步替换。我们当时没选这个方案,是因为 Realm 的 so 库体积有 4MB 左右,两个库并存包体积受不了。但如果你的 App 本来体积就大,或者可以走 App Bundle 的动态交付,渐进式迁移的风险更低。


工具链推荐与具体配置


最后列一下我们迁移过程中实际用到的工具和版本,供参考。


Room 的版本我们现在跟到 2.6.1(稳定版),Gradle 配置:


dependencies {
    val roomVersion = "2.6.1"
    implementation("androidx.room:room-runtime:$roomVersion")
    implementation("androidx.room:room-ktx:$roomVersion")
    ksp("androidx.room:room-compiler:$roomVersion")
    implementation("androidx.room:room-paging:$roomVersion")
    testImplementation("androidx.room:room-testing:$roomVersion")
}

注意 room-testing 里的 MigrationTestHelper 在测试迁移时很有用,可以验证升级路径的 schema 一致性。


数据库调试用 Android Studio 的 Database Inspector,实时查看 Room 数据库内容,支持直接执行 SQL。这个工具在 Arctic Fox 版本引入,现在已经很稳定。Realm 时期我们用的是 Stetho(Facebook 开源,已归档)或者 realm-browser 库,体验都不如 Database Inspector。


性能分析用 Android Profiler 的 CPU 和 Memory 视图,配合 Trace.beginSection() 标记自定义区间。Room 查询的耗时可以用 androidx.room.RoomDatabase.QueryCallback 回调打日志,开发阶段开启,发布关闭:


Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .setQueryCallback({ sqlQuery, bindArgs ->
        Log.d("RoomQuery", "SQL: $sqlQuery, args: $bindArgs")
    }, Executors.newSingleThreadExecutor())
    .build()

数据迁移的 JSON 清洗脚本我们开源了一个简化版,在 GitHub 仓库 our-org/realm-to-room-migration(内部仓库,这里隐去真实名称),核心逻辑是用 realm-javaDynamicRealm API 遍历所有表,输出标准 JSON,再用 sqlite-utils 导入。这个方案针对我们的 schema 定制,不能直接套用,但思路可以参考。


如果从头开始新项目,我个人会直接用 Room + KSP + Paging 3 + Kotlin Coroutines 的组合,不再考虑 Realm。不是因为 Realm 技术上有致命缺陷,而是它的未来不确定性太高,Android 平台的支持力度已经边缘化。MongoDB 的重心在 Atlas 云服务端,移动 SDK 更多是连接云端的管道, standalone 的本地数据库不是重点。


Room 也有让人不爽的地方:SQL 字符串写错要到运行时才报错(虽然 KSP 能 catch 一部分),复杂查询的 @Query 可读性差,WAL 模式下的并发控制需要手动调 journalMode。但这些问题是"透明的难",底层机制摊开来看都能理解。Realm 的坑是"黑盒的难",线程模型、内存映射、对象生命周期都是封装好的,出了问题只能猜。


迁移这件事,本质上是用短期的确定性成本,换长期的可控性。四个月的重构很痛苦,但完成后团队睡觉踏实多了。

Kotlin 的 Wasm 目标平台,浏览器里跑 Kotlin 2026-06-15
Room 的多表关联查询,@Relation 的 N+1 问题 2026-06-15

评论区