Android Room数据库迁移实战:从版本升级到数据安全避坑指南
1. 一次线上事故复盘用户数据消失的真相1.1 崩溃现场启动即闪退与IllegalStateException先把最痛的一段经历放在前面。某次版本迭代里我所在的开发团队只是在实体类上加了一个字段顺手把Database注解里的version从1改成了2却没有写任何Migration。上线当天应用商店评分区很快被一类评论刷屏更新完一直闪退打开就报错登录信息全都不见了。后台的崩溃日志里清一色是下面这样的错误java.lang.IllegalStateException: A migration from 1 to 2 was required but not found. Please provide the necessary Migration path via RoomDatabase.Builder.addMigrations().还有一部分用户看到的则是另一条类似的报错java.lang.IllegalStateException: Room cannot verify the data integrity. Looks like youve changed schema but forgot to update the version number.这两条异常其实指向同一个问题数据库的本地版本已经落后于App期望的版本Room找不到一条从旧版本升级到新版本的迁移路径。闪退并不是数据被清空了恰恰相反数据还在旧数据库文件里只是因为版本不匹配Room拒绝以不安全的姿势打开它。用户端表现为一启动就崩本质上是数据库层在做安全拦截。1.2 为什么少写一个Migration会引发连锁反应Android本地数据库和服务器数据库最大的区别在于服务端可以发布脚本统一升级表结构而本地数据库分散在每一台用户设备上你永远不知道用户手机里的数据库文件是哪个版本。有些人很久没更新App数据库可能还停留在版本1有些人之前升到过版本2这次又升到版本3。每个版本区间都要准备好对应的升级脚本。Room的version字段就是用来判断数据库当前处于哪个版本的刻度表。App启动并打开数据库时Room会读取数据库文件里的PRAGMA user_version值和当前实体类编译出的目标schema做对比。如果发现版本落后就去addMigrations()注册的迁移列表里找一条能覆盖当前版本到目标版本的Migration路径。找不到就抛异常App直接闪退。那次事故让我彻底明白一件事版本号和迁移脚本必须同步交付并且迁移逻辑要经过测试才能发布。后面我花了整整两天时间做了热修复版本仓促补上了从版本1到版本2的迁移脚本才把线上崩溃压下去。这期文章就把Room数据库版本升级/迁移的完整经验梳理一遍包括原理、实操姿势、测试手段以及那些文档里不会写但你迟早会踩的坑。2. Room版本升级机制拆解version、Migration与schema2.1 Database(version 2) 背后的升级判定流程先搞清楚Room到底是怎么判定该不该升级的。数据库文件本身存在一个user_version字段SQLite官方把它当作用户自定义版本号使用。Room在创建SQLiteOpenHelper时会监听onUpgrade回调当传入的oldVersion和newVersion不一致时就进入自己的迁移逻辑。核心类叫RoomOpenHelper它继承自SQLiteOpenHelper内部维护一个MigrationContainer。MigrationContainer的工作方式可以理解成一张版本寻路图。它保存了所有注册进来的Migration每个Migration都有明确的起点版本和终点版本。当Room需要从版本1升到版本3时它会优先找有没有直接1-3的Migration如果没有再尝试用多条迁移拼出一条路径比如1-2加上2-3。Room会按照广度优先的方式查找最短路径找到之后按顺序执行。这里有几个很容易被忽略的细节如果版本直接从1跳到3但你只注册了1-2和2-3Room也能自动串起来执行不是非得写一个1-3的迁移。如果只注册了1-3没有1-2而用户当前数据库是版本2Room会找不到迁移路径照样崩溃因为版本2必须先到31-3这条路径的起点不匹配。迁移失败时整个迁移过程会被包在事务里回滚。也就是说失败的迁移不会让数据库停留在半升级状态user_version还是旧值数据文件保持原样。这一点会在后面讲急救方案时派上用场。2.2 Migration对象从一个版本到另一个版本的桥梁Migration是Room最基础的迁移API抽象类定义非常简单只有起点版本、终点版本和一个migrate方法val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE users ADD COLUMN age INTEGER NOT NULL DEFAULT 0) } }注册方式是在构建RoomDatabase时调用addMigrationsRoom.databaseBuilder(context, AppDatabase::class.java, app.db) .addMigrations(MIGRATION_1_2) .build()方法里的db参数是SupportSQLiteDatabase它封装了底层SQLite操作提供execSQL、query、beginTransaction等能力。所有迁移SQL都通过这个对象执行。需要注意的是Room官方对Migration迁移脚本有一个明确要求迁移脚本执行结束后的数据库结构必须和当前实体类编译出来的schema完全一致。多一个表、少一个索引、字段类型不匹配都会在运行时被Room的schema校验逻辑抓出来。2.3 exported schema目录版本升级的体检报告很多开发者第一次接触Room时都忽略了exportSchema这个配置。它的作用是把每个版本的数据库结构导出成一份JSON文件存放在你指定的目录里。开启方式在build.gradle中配置android { defaultConfig { javaCompileOptions { annotationProcessorOptions { arguments [room.schemaLocation: $projectDir/schemas.toString()] } } } // 别忘了把schemas目录加到androidTest资源里后面写迁移测试会用到 sourceSets { androidTest.assets.srcDirs files($projectDir/schemas.toString()) } }如果用的是Kotlin的KSP插件配置方式略有不同ksp { arg(room.schemaLocation, $projectDir/schemas) }开启后每次编译都会在schemas目录下生成类似1.json、2.json的文件里面完整记录了该版本的table结构、索引、外键、触发器等信息。这一步非常重要原因有两个代码评审时可以直接对比相邻两个版本JSON文件的差异一眼看出这次改动到底动了哪些表、加没加索引、字段类型有没有变。迁移测试框架MigrationTestHelper运行时需要读取对应版本的schema文件来校验迁移后的结构是否正确。我个人的习惯是只要实体类有任何结构调整先看一眼git diff里两个schema JSON的差异再动手写迁移SQL。这个动作基本能把80%的迁移遗漏问题扼杀在编码阶段。3. 手写Migration SQL的实战从加字段到拆表3.1 最保险的一步往现有表里加字段新增字段是最高频的迁移场景。SQLite支持ALTER TABLE ADD COLUMN语句简单风险也最低val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE users ADD COLUMN age INTEGER NOT NULL DEFAULT 0) } }这里有两个容易踩的坑。第一个坑是非空字段必须带默认值。如果写成ALTER TABLE users ADD COLUMN age INTEGER NOT NULLSQLite会给已有行填入空值然后立刻违反NOT NULL约束迁移直接失败。带DEFAULT 0是标准做法或者在后续SQL里统一更新该字段的值。第二个坑是加了字段之后实体类里对应字段的默认值要和SQL里的默认值保持一致。比如SQL里写了DEFAULT 0实体类字段最好也标注ColumnInfo(defaultValue 0)Entity(tableName users) data class User( PrimaryKey val id: Int, val name: String, ColumnInfo(defaultValue 0) val age: Int 0 )这样迁移前后的schema才能完全对齐避免Room运行时校验报错。如果新字段是可空的比如val age: Int?那SQL里不写默认值也没问题。3.2 重命名字段或改类型三步重建表的经典操作往表里加字段很简单但如果要重命名字段、修改字段类型、删除字段事情就变得麻烦起来。SQLite对ALTER TABLE的支持非常有限虽然新版SQLite已经支持DROP COLUMN和RENAME COLUMN但Android内置的SQLite版本取决于系统的API level老设备上很可能不支持这些语法。所以跨设备兼容的稳妥方案永远是重建表。重建表的标准流程可以概括为三步创建一张新表字段结构就是目标结构。从旧表按字段映射关系搬运数据到新表。删掉旧表把新表改名为旧表的名字。举个例子用户表原来叫users字段是id和name新版本要把name改名为nickname同时新增一个age字段。迁移脚本这样写val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { // 1. 创建新表 db.execSQL( CREATE TABLE IF NOT EXISTS users_new ( id INTEGER PRIMARY KEY NOT NULL, nickname TEXT NOT NULL DEFAULT , age INTEGER NOT NULL DEFAULT 0 ) .trimIndent() ) // 2. 搬运数据name - nickname db.execSQL( INSERT INTO users_new (id, nickname) SELECT id, name FROM users .trimIndent() ) // 3. 删旧表改名 db.execSQL(DROP TABLE users) db.execSQL(ALTER TABLE users_new RENAME TO users) } }这段代码看起来很常规但里面有三个细节值得单独拿出来讲。第一个细节是新表的字段默认值。nickname TEXT NOT NULL DEFAULT 里的默认值保证了INSERT ... SELECT时不会因为NOT NULL约束而失败。如果旧表里存在name为NULL的行搬运时会把NULL写进nickname再撞上NOT NULL依旧会失败。所以旧数据质量也是迁移前要确认的事情。第二个细节是索引会丢。CREATE TABLE创建的只是表本身原来表上如果有唯一索引、普通索引、外键约束、触发器在删掉旧表的同时这些元数据也一起没了。所以完整的迁移脚本必须把索引也重建出来。比如原来对name字段建了唯一索引db.execSQL(CREATE UNIQUE INDEX IF NOT EXISTS index_users_nickname ON users(nickname))这个索引的创建时机放在新表改名之后或者删除旧表之后都可以放在同一段迁移脚本里执行Room在事务里保证一致性。第三个细节是去重问题。如果旧表里同时存在name为Tom的两行数据新表的nickname唯一索引就会创建失败。遇到这种情况要么在迁移脚本里先做数据清洗比如只保留最新一行要么和业务方确认旧数据允许重复。这种脏数据问题在真实业务里非常常见。3.3 拆表和合并表需要跨表搬运数据的重操作除了改字段更复杂的场景是把一张表拆成多张表或者把多张表合并成一张。这类迁移的核心不仅在于建表和删表还在于数据分配逻辑。假设原来有一张profiles表字段是id、user_name、address。新版本拆成users表和addresses表users只保留id和user_nameaddress搬到addresses表里并关联user_id。迁移脚本大概长这样val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { // 建新表 db.execSQL(CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY NOT NULL, user_name TEXT NOT NULL)) db.execSQL( CREATE TABLE IF NOT EXISTS addresses ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER NOT NULL, address TEXT, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ) .trimIndent() ) // 拆数据 db.execSQL(INSERT INTO users (id, user_name) SELECT id, user_name FROM profiles) db.execSQL(INSERT INTO addresses (user_id, address) SELECT id, address FROM profiles) // 删旧表 db.execSQL(DROP TABLE profiles) } }这里有一个非常隐蔽的坑外键约束的检查顺序。如果profiles表里存在某行address为NULLINSERT INTO addresses时address字段可以正常插入空值没问题。但如果目标表的外键设置为NOT NULL并且源数据本身就有空值那就要在拆分逻辑里做过滤或给默认值。还有一个更麻烦的点SQLite的PRAGMA foreign_keys在事务内部无法修改。Room的Migration默认运行在事务里所以你不能在迁移脚本中通过PRAGMA foreign_keys OFF临时关闭外键约束。碰到复杂的外键关系重建必须在建表顺序和数据搬运顺序上多费心思。我的经验是先建被引用的主表再建引用方子表先搬主表数据再搬子表数据。这样能在不依赖关闭外键约束的情况下完成大部分迁移。4. 迁移测试才是真正的保命符MigrationTestHelper实战4.1 为什么必须自动化验证迁移手写迁移脚本最大的问题是你永远不能在开发机上完全模拟真实用户的数据库状态。开发调试时数据库通常是从最新版本一路建出来的根本不会经历旧版本数据长什么样的考验。真正线上用户的数据库里可能积攒了好几年的数据有各种历史遗留的脏数据有老版本特有的字段状态。我见过太多人把迁移脚本写完就跑一遍App发现不崩溃就算完事了。这种验证方式有个致命盲区新安装用户的数据库是直接创建到最新版本的根本不会触发迁移逻辑老版本升级用户才会走迁移路径而他们在你本地调试时压根不存在。所以迁移必须自动化测试用真实的历史schema文件先建出一个旧版本数据库插入有代表性的数据再执行迁移脚本并校验结构和数据。4.2 三个步骤搞定测试环境第一步是加依赖。在build.gradle里加上room-testingandroidTestImplementation(androidx.room:room-testing:2.6.1)第二步是确认schema导出配置已经打开并且schemas目录已经加到了androidTest的assets里。这一步在前面建立room.schemaLocation时已经做过了但很多人在这里漏掉sourceSets配置导致测试运行时找不到对应版本的JSON文件报出无法加载schema之类的错。第三步是写测试代码。下面是一个标准的迁移测试用例RunWith(AndroidJUnit4::class) class MigrationTest { private val testDbName migration-test.db get:Rule val helper MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), AppDatabase::class.java ) Test fun migrate1To2_containsNewColumn() { // 1. 先创建一个版本为1的数据库并插入一条老数据 helper.createDatabase(testDbName, 1).apply { execSQL(INSERT INTO users (id, name) VALUES (1, old_name)) close() } // 2. 执行迁移并校验结构 val db helper.runMigrationsAndValidate( testDbName, 2, true, MIGRATION_1_2 ) // 3. 再验证数据真的搬过来了 db.query(SELECT id, nickname, age FROM users WHERE id 1).use { cursor - cursor.moveToFirst() assertEquals(old_name, cursor.getString(cursor.getColumnIndex(nickname))) assertEquals(0, cursor.getInt(cursor.getColumnIndex(age))) } } }createDatabase会根据版本1的schema JSON文件创建数据库相当于模拟一个老用户的设备状态。runMigrationsAndValidate负责执行MIGRATION_1_2然后读取版本2的schema文件逐个比对表结构、索引、触发器发现不一致会直接让测试失败。最后的query则验证数据搬运逻辑是否符合预期。4.3 我踩过的测试坑版本号断言、预置数据、schema资源先说runMigrationsAndValidate的第三个参数。这个布尔值表示是否检查被删除的表确实被删掉了。如果你在版本2里删除了某张旧表传true可以确认迁移脚本真的执行了DROP TABLE。我建议始终传true它能帮你抓住忘了删旧表这类低级失误。第二个坑是测试数据库的重复创建。MigrationTestHelper不会自动清理之前的测试数据库文件如果你在两个测试方法里使用同一个testDbName第二次运行时会发现数据库版本已经不对了。最稳妥的做法是每个测试方法使用不同的数据库名或者在Before里删除数据库文件。第三个坑是预置数据的代表性问题。迁移测试里插入的数据不能太干净最好故意加入一些边界情况NULL值、超长字符串、重名字段、空表。这样才能模拟真实业务里的各种脏数据。比如重命名字段时测试表里至少要有3条以上的数据其中一条的name字段故意为NULL用来验证NOT NULL DEFAULT逻辑是否真的生效。5. AutoMigration到底能不能信自动迁移的边界与兜底5.1 AutoMigration是什么什么情况下可以放心用Room从2.4.0版本开始提供了AutoMigration自动迁移能力。它的核心思路是Room编译期对比前后两个版本的schema JSON文件自动生成迁移SQL开发者不再需要手写Migration对象。最简单的用法是在Database注解里声明Database( entities [User::class], version 2, exportSchema true, autoMigrations [ AutoMigration(from 1, to 2) ] ) abstract class AppDatabase : RoomDatabase() { // ... }配合实体类的变化Entity(tableName users) data class User( PrimaryKey val id: Int, val name: String, val age: Int? null // 新增的可空字段 )在版本1升版本2的这次升级里只新增了一个可空字段age没有数据搬运需求AutoMigration可以自动生成ALTER TABLE users ADD COLUMN age INTEGER完全够用。但我必须说清楚一个态度AutoMigration能自动处理的变化仅限于表结构层面的增删不涉及数据语义层面的变换。它比较适合开发初期的快速迭代或者每次版本升级都只是加一两个字段的轻量场景。一旦牵扯到数据搬运、字段重命名、表拆分合并AutoMigration不仅帮不上忙反而可能给开发者制造迁移已经自动完成了的错觉等到线上崩溃才反应过来。5.2 四种必须手写Migration的场景我在实际项目里总结了几种AutoMigration无能为力、必须换回手写Migration的典型场景场景为什么AutoMigration不行重命名字段或表自动迁移只会对比前后版本的schema差异不知道新字段和旧字段之间的对应关系更不会帮你搬运旧数据拆分/合并表一张表的数据要按业务规则分配到两张新表里这种搬运逻辑必须由开发者写SQL控制数据清洗或格式转换比如把旧字段里的1/0转换成true/false自动迁移只会改结构不会动数据修改主键或复杂索引结构涉及表重建的操作自动迁移生成的脚本不一定能覆盖所有索引和外键细节如果你在配置了AutoMigration后发现Room编译报错提示无法自动推断迁移或者建新schema时校验不通过那就说明这次改动超出了自动迁移的能力边界。此时不要试图用AutoMigration(spec ...)强行打补丁除非改动真的只是一条简单的SQL能完成的。复杂改动老老实实回到手写Migration配合迁移测试这样才能保证安全。5.3 撤回AutoMigration的正确姿势如果项目里已经用了AutoMigration后面又遇到复杂迁移需求可以同时保留AutoMigration和手写Migration吗答案是能的但要注意版本区间的划分。比如版本1到2是简单的加字段用AutoMigration版本2到3是拆表手写Migration(2, 3)两者可以共存。Room的迁移寻路逻辑会优先找到一条完整路径兼容性没有问题。我的建议是AutoMigration适合作为默认配置但每次版本升级都要检查一下它自动生成的SQL是否符合预期。开启exportSchema后对比两个版本JSON的差异能让这一步变得非常直观。6. 迁移失败后的急救手册保住数据的最后手段6.1 fallbackToDestructiveMigration方便但可能毁掉生意fallbackToDestructiveMigration是Room提供的兜底开关。开启之后假设用户当前数据库版本是1新版本是2但Room找不到任何迁移路径它不会抛异常而是直接删除所有表重新创建一套全新结构的空数据库。配置方式很简单Database( entities [User::class], version 2, exportSchema true, fallbackToDestructiveMigration true ) abstract class AppDatabase : RoomDatabase()这个开关对开发调试很方便但对线上App来说非常危险。因为删表重建意味着用户设备上的所有本地数据全部丢失。如果这个本地数据库里有用户积攒的重要资料或者有需要离线使用的缓存一次误操作就会引发批量用户投诉。更隐蔽的是开启之后不会崩溃用户直接进入一个数据消失但一切正常的诡异状态很多人根本不会反馈只会默默弃用。如果一定要用兜底清空数据库我强烈建议使用精确控制版本的版本只对明确知道可以丢弃数据的版本区间生效Database( entities [User::class], version 3, exportSchema true, fallbackToDestructiveMigrationFrom [1] // 只有从版本1升上来时才允许清空 ) abstract class AppDatabase : RoomDatabase()这样至少不会把版本2老用户的数据也一并清掉。类似的还有fallbackToDestructiveMigrationOnDowngrade专门处理版本回退场景。我的原则是任何生产环境的数据库升级都默认不开全局的破坏性迁移宁可让App闪退也不能静默丢数据。崩溃是可观测的数据丢失是不可逆的。6.2 从崩溃日志到数据救援一次失败的定位过程如果已经上线了一个缺失迁移脚本的版本用户的App正在不断闪退该怎么急救先说结论Room的迁移是事务性的迁移失败后数据库版本号和表结构都保持在旧版本数据文件并没有损坏。所以只要补上正确的迁移脚本发一个新版本升级用户就能恢复正常。具体排查过程一般是这样的第一步看崩溃日志。如果在IllegalStateException: A migration from X to Y was required but not found说明用户数据库还停留在X版本而当前App版本期望的是Y。很多开发者遇到这种情况会直接怀疑用户数据坏了其实不是。第二步对照schema JSON文件梳理确定X到Y之间到底发生了什么结构变化。这一步骤的关键是知道用户设备上的数据库实际状态但你无法直接看到。所以你需要回看历史发布记录找出用户可能最后一次正常打开的版本号。如果上一个线上版本是2这次发布是3那用户数据库大多是版本2如果用户很久没更新也可能是版本1。第三步编写涵盖所有可能路径的迁移脚本。Room支持直接注册一条Migration(1, 3)也支持注册Migration(1, 2)和Migration(2, 3)让Room自动寻路。为了保险我通常同时注册每条相邻迁移和必要的跨版本迁移反正Room会优先选择最短路径。然后本地用MigrationTestHelper分别从版本1和版本2的数据文件执行迁移测试确保每一条路径都验证通过。第四步以热修复形式发布新版本。因为用户崩溃的App版本里的数据库版本号并没有变所以新版本只要把版本号维持为Y或者往上升并注册缺失的迁移脚本用户打开App就能自动完成升级。这也是为什么我一直强调崩溃体验比静默丢数据好——至少有日志可查有修复机会。7. 写在最后的迁移守则用血泪换来的几条经验文章的最后不做什么总结了就分享一下这几年摸爬滚打总结出的几条硬性规矩。第一实体类和迁移脚本必须同一个提交里出现。不要在提交里只改了实体类下一提交才补Migration。绝大多数线上迁移事故都来自这两个步骤被人为拆开。最好在代码评审模板里加一条检查项只要entities有变化必须看到version变化和对应的Migration或AutoMigration声明。第二每次升级都把schema JSON纳入必读review内容。开启room.schemaLocation之后git diff会显示1.json到2.json的差异。这个diff比任何数据库可视化工具都直观字段类型、默认值、索引变化全在里面。我见过很多迁移脚本写完没发现问题测试一跑就挂的情况原因都是没看这份diff。第三非空新增字段必须带默认值且默认值要和实体类注解对齐。这是SQLite迁移里最经典的翻车点没有之一。加字段一时爽不加默认值火葬场。第四表重建场景下索引、触发器、外键都要手动重建。很多人以为迁移脚本只是建新表、搬数据、删旧表结果跑完测试发现旧表上的唯一索引没了。用runMigrationsAndValidate把它拦在测试阶段比放上线后让用户发现要好一万倍。第五每个迁移分支至少写一条自动化测试。哪怕只是加一个字段的简单迁移也值得花十分钟写一个用例。迁移测试的维护成本极低但它在发布前给你提供的安全感是其他手段无法替代的。尤其是那些用户很久没更新的长路径迁移手动根本不可能一一覆盖。这些守则都是用真实的线上事故换来的。Room数据库版本升级从来不是一个可以差不多得了的环节它直接关系着用户设备上最宝贵的数据资产。希望这篇分享能帮你少踩几个坑在下一个版本发布前把迁移这条链路老老实实走完。