Реализация миграции данных при обновлении мобильного приложения
Миграция схемы и миграция данных — разные задачи. Можно идеально написать ALTER TABLE, но при этом получить битые данные в продакшене. Это происходит, когда меняется не структура таблицы, а формат или семантика хранимых значений: даты из Unix timestamp переходят в ISO 8601, суммы из float в integer-cents, статусы из числовых кодов в строковые enum. Такие преобразования требуют явной обработки для каждой строки.
Что такое миграция данных на практике
Конкретные сценарии из реальных проектов:
- Поле
amountхранилось какREAL(double), нужно перейти наINTEGERцентов, чтобы избежать floating-point ошибок при сравнении.100.10→10010. - Поле
statusбылоINTEGER(0, 1, 2), теперьTEXT("pending", "active", "completed"). Нужно смаппировать каждое число в строку. - Поле
dateбыло Unix timestamp в секундах, в новой версии — миллисекунды.1700000000→1700000000000. - JSON, хранившийся в TEXT-колонке, изменил структуру: старый
{"items":[...]}→ новый{"data":{"list":[...]}}.
Каждый из этих случаев — строковая трансформация всей таблицы внутри миграции.
Реализация в Room (Android)
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
// Конвертация суммы из float в integer cents
db.execSQL("""
UPDATE transactions
SET amount_cents = CAST(ROUND(amount * 100) AS INTEGER)
""")
// Конвертация статуса из int в string
db.execSQL("UPDATE transactions SET status = 'pending' WHERE status_code = 0")
db.execSQL("UPDATE transactions SET status = 'active' WHERE status_code = 1")
db.execSQL("UPDATE transactions SET status = 'completed' WHERE status_code = 2")
// Конвертация timestamp из секунд в миллисекунды
db.execSQL("UPDATE events SET created_at = created_at * 1000 WHERE created_at < 9999999999")
}
}
Условие WHERE created_at < 9999999999 в последнем примере защищает от повторного применения при ошибочном повторном запуске — дата в миллисекундах всегда больше этого числа.
Batch-обновление для больших таблиц
Если в таблице миллионы строк — обновление одним UPDATE может занять десятки секунд и заблокировать запуск. Батчевый подход:
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
var offset = 0
val batchSize = 1000
while (true) {
val updated = db.compileStatement("""
UPDATE transactions
SET metadata = transform_metadata(metadata)
WHERE id IN (
SELECT id FROM transactions
WHERE metadata_migrated = 0
LIMIT $batchSize
)
""").executeUpdateDelete()
if (updated == 0) break
}
}
}
Для очень больших таблиц (500 000+ строк) — миграцию лучше делать лениво: при первом обращении к записи, не в onUpgrade. Добавить поле-флаг migrated INTEGER DEFAULT 0 и трансформировать при чтении.
Ленивая миграция данных
Когда полная миграция занимает слишком долго для блокирующего выполнения при старте:
// iOS — ленивая миграция при доступе к данным
func fetchTransaction(id: String) -> Transaction {
let raw = database.fetch(id: id)
if !raw.isMigrated {
let migrated = DataMigrator.migrate(raw)
database.save(migrated)
return migrated
}
return raw
}
Плюс: приложение стартует мгновенно. Минус: нужно поддерживать оба формата в коде, пока не все записи мигрированы. Фоновый WorkManager / BGProcessingTask постепенно мигрирует оставшееся.
Тестирование трансформаций
@Test
fun testAmountConversion() {
val helper = MigrationTestHelper(instrumentation, AppDatabase::class.java)
val db = helper.createDatabase("test.db", 3)
db.execSQL("INSERT INTO transactions (id, amount) VALUES ('t1', 100.10)")
db.close()
val migrated = helper.runMigrationsAndValidate("test.db", 4, true, MIGRATION_3_4)
val cursor = migrated.query("SELECT amount_cents FROM transactions WHERE id = 't1'")
cursor.moveToFirst()
assertEquals(10010, cursor.getInt(0))
}
Особое внимание — граничным случаям: NULL значения, пустые строки, неожиданные форматы данных, которые реальные пользователи могут иметь в базе.
Откат при ошибке
SQLite поддерживает транзакции — весь onUpgrade автоматически оборачивается в транзакцию в Room. Если что-то падает — изменения откатываются. На iOS с Core Data — аналогично через NSMigrationManager.
Но откат не означает «приложение работает нормально» — при следующем запуске снова попытается мигрировать. Нужна обработка ошибок и отображение пользователю сообщения о проблеме.
Что входит в работу
- Аудит данных в текущей БД: форматы, исключения, NULL-значения
- Написание трансформаций с защитой от повторного применения
- Batch-обновление для больших таблиц
- Ленивая миграция для критически больших объёмов
- Тесты на граничных случаях
Сроки
Простые UPDATE-трансформации (1–3 таблицы): 1 день. Сложные преобразования JSON, ленивая миграция с фоновым воркером: 2–4 дня.







