移动端为什么常用 SQLite?离线存储和同步怎么设计?
SQLite 在移动开发里常见,是因为它刚好踩中了移动端本地数据的几个硬需求:不需要单独部署服务、文件就是数据库、离线可用、系统和生态支持成熟。iOS、Android、Flutter、React Native 都能用 SQLite,只是上层封装不同。
它适合保存用户资料、缓存列表、草稿箱、离线队列、搜索索引和少量分析数据。它不适合替代服务端数据库,也不适合在多设备之间自动解决复杂冲突。移动端用 SQLite 的关键,不是会不会 CREATE TABLE,而是能不能处理升级、并发、同步和数据安全。
表结构应该怎么设计?
移动端数据库要尽量围绕页面和同步模型设计。过度规范化会让查询依赖很多 JOIN,在低端设备上更容易卡顿;完全反规范化又会让更新和冲突处理很痛苦。比较稳的做法是:核心实体单独建表,列表页常用字段适度冗余。
sqlCREATE TABLE notes ( id TEXT PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL, updated_at INTEGER NOT NULL, sync_state TEXT NOT NULL DEFAULT 'pending', deleted_at INTEGER ); CREATE INDEX idx_notes_updated ON notes(updated_at); CREATE INDEX idx_notes_sync ON notes(sync_state);
时间字段建议用 Unix 时间戳或标准 ISO 字符串,并全项目统一。同步状态不要靠“有没有上传过”猜,明确记录 pending、synced、failed 会省很多排查时间。
移动端写入为什么必须重视事务?
单条插入没问题,批量写入如果不用事务,SQLite 会频繁落盘,速度差距可能非常明显。事务还能保证应用崩溃、电量耗尽时不留下半批数据。
kotlindb.beginTransaction() try { val stmt = db.compileStatement( "INSERT OR REPLACE INTO notes(id,title,body,updated_at,sync_state) VALUES(?,?,?,?,?)" ) for (note in notes) { stmt.bindString(1, note.id) stmt.bindString(2, note.title) stmt.bindString(3, note.body) stmt.bindLong(4, note.updatedAt) stmt.bindString(5, "synced") stmt.executeInsert() stmt.clearBindings() } db.setTransactionSuccessful() } finally { db.endTransaction() }
不要用字符串拼接 SQL,移动端一样会遇到引号、换行、emoji、恶意输入。预编译语句既安全,也能减少重复解析 SQL 的开销。
离线同步怎么做才不乱?
最简单的模型是“本地先写,后台再同步”。用户编辑时先写 SQLite,并把记录标为 pending;网络恢复后上传变更,成功后标为 synced。如果服务端返回更新后的版本号或时间戳,也要一起落库。
冲突处理要提前定规则。常见方案有:服务端时间戳覆盖、本地优先、字段级合并、保留冲突副本。选择哪一种取决于业务:购物车可以合并数量,文档编辑不能简单覆盖,金融类数据则更不应该在端上自行合并。
性能和稳定性要看哪些点?
开启 WAL 模式可以改善读写并发,尤其是一边读列表、一边后台同步的场景。分页查询要避免大 OFFSET,数据量上来后可以改成基于游标的分页。
sqlPRAGMA journal_mode = WAL; SELECT * FROM notes WHERE updated_at < ? ORDER BY updated_at DESC LIMIT 30;
索引不是越多越好。读多写少的缓存表可以多建一点索引,写入频繁的事件表则要克制。每个索引都会增加写入成本和数据库体积,移动端存储空间、闪存寿命、冷启动时间都要考虑。
数据安全和升级怎么处理?
敏感数据不要明文裸放。可以用系统 Keychain/Keystore 保存密钥,再配合 SQLCipher 等方案加密数据库。注意密钥轮换、备份恢复和性能开销,别只在 demo 里跑通就上线。
版本升级必须写成可重复、可追踪的迁移脚本。Android 的 onUpgrade、iOS 的迁移框架或 Room/GRDB 的 migration 都应该按版本递增执行,不能简单 DROP TABLE。线上用户可能从很旧的版本直接升级到最新版本,跳版本迁移是移动端最常见的坑。
追问
SQLite、Room/Core Data、Realm 应该怎么选?
直接用 SQLite 控制力最强,但样板代码多,适合对 SQL 和迁移有明确要求的团队。Room、GRDB 这类封装保留 SQL 能力,同时减少游标和对象映射代码,是多数业务 App 的折中选择。Core Data 和 Realm 更偏对象模型,开发体验好,但复杂查询、跨端一致性和迁移细节要额外评估。取舍关键不是谁更先进,而是团队是否能长期维护它的迁移和调试成本。
移动端为什么常建议开启 WAL?
WAL 让读和写更容易并行,后台同步写入时,前台列表读取不容易被锁住。代价是会多出 wal/shm 文件,数据库备份、导出和清理时要一起考虑。低端设备或异常退出后,WAL 文件可能短时间变大,需要合理 checkpoint。不要只开 PRAGMA journal_mode=WAL 就不管,最好观察真实设备上的文件大小和锁等待。
离线同步冲突应该在端上解决还是服务端解决?
简单业务可以端上先做本地体验,最终以服务端规则为准。复杂业务最好让服务端生成权威版本,客户端只负责展示冲突和提交用户选择。边界在于数据是否可逆、是否涉及金额或权限:越重要的数据,越不能让客户端“猜”。常见踩坑是用 updated_at 谁大谁赢,结果用户在两个设备上分别编辑不同字段,后保存的把先保存的全部覆盖。
数据库升级最危险的地方是什么?
最危险的是只测试相邻版本升级,没有测试跨版本升级。真实用户可能半年没更新,一次从 v3 升到 v9,中间每个迁移都要能顺序执行。另一个坑是迁移脚本里改了列名或索引,却没有处理旧数据默认值,导致新版本查询直接崩溃。上线前至少准备几份老版本数据库样本跑自动化迁移测试。
日期和主键在移动端有什么讲究?
跨端同步时,自增整数主键容易和服务端 ID、其他设备数据冲突,客户端生成 UUID 或雪花类 ID 更稳。日期字段要统一 UTC 时间戳或 ISO 字符串,不要一会儿本地时区、一会儿服务端时区。踩坑最多的是夏令时和手动改系统时间,排序、过期判断、增量同步都会受影响。业务上真正需要可信时间时,应以服务端时间为准。