前几天在一场鸿蒙原生开发的交流活动上,被问到“你们项目的SQLite数据库是怎么初始化的”。现场几个团队的做法着实让我开眼:有人每个页面各new一个RdbStore,有人写了个静态方法但完全没考虑Context生命周期,还有人把增删改查散落在各个业务文件里,同一个表的插入逻辑在不同模块里写了三四遍。这不就是经典的数据丢失、锁冲突、表结构混乱的温床吗。趁着午休我们把整套做法梳理了一遍,整理成一套可以直接抄作业的SQLite单例封装,从数据库实例管理到通用CRUD基类,再到数据库升级和性能调优,这篇一并写出来分享。
1. 从一次数据丢失排查说起:为什么SQLite必须以单例隔离
1.1 用户一重启,数据就消失
半年前我调一个鸿蒙应用,用户反馈“在设置页改完昵称,退出App再进来,昵称又变回默认值”。一开始都以为是首选项的坑,排查一圈发现不是:设置页确实写进了关系型数据库,查询也查得到,但只要杀掉进程重进,数据就像没写过一样。
后来把数据库文件直接拉出来用工具打开,才找到真正的元凶。设置页所在的Page在aboutToAppear()里new了一个RdbStore,保存时用的也是这个store实例。但问题是,主页面模块里有个更早创建的另一个store实例,两个实例指向同一份数据库文件,在并发写入时产生了锁竞争。设置页的写入操作一直拿不到写锁,超时后异常又被上层某个try-catch吞掉了,表现在用户端就是“写入失败但操作好像成功了”。杀进程后,那些没提交的事务全部回滚,昵称自然回到默认值。
在鸿蒙里,relationalStore.getRdbStore(context, config)的语义不是“创建一个数据库”,而是“获取或打开一个数据库连接”。同一个进程里,大家应该复用同一个store实例。这不是性能问题,而是数据一致性问题。各页面各建各的连接,生命周期长短不一,就会出现这种极其隐蔽的丢数据Bug。
1.2 一个入口,胜过十次修复
单例模式的本质,是让整个进程只有唯一一个RdbStore实例维护数据库连接。所有模块写入、读取、升级、事务,全部走同一个入口。这样有三个直接的好处:
- 生命周期可控:数据库连接什么时候初始化、什么时候释放,只有一处代码管理,不会出现页面销毁了连接还被业务模块引用的悬空问题。
- 路径与配置一致:数据库文件路径、加密配置、版本号只初始化一次,彻底消除“多个上下文各写各的文件”这类事故。
- 升级逻辑聚焦:Schema迁移集中在一个初始化流程里,版本判断、执行SQL、重置版本都放在同一段代码,避免“有的模块建表、有的模块升级”的混乱状态。
有人觉得单例是老生常谈,但在鸿蒙以Ability和Page为纬度的模型下,执行起来没那么简单。因为你还要回答:拿着哪个Context去初始化?异步返回的store怎么缓存?ArkTS的类型约束有没有坑?这几个问题我们一节一节说。
2. 优雅单例的三个门槛:Context归属、异步初始化与线程边界
2.1 用applicationContext,而不是AbilityContext
很多初学者会在EntryAbility里写this.context传给某个全局方法,然后在后续页面里也用同一个AbilityContext去初始化数据库。问题在于:UIAbility实例是有生命周期的,用户切后台或特定场景下Ability可能被回收,context失效后你再拿它去getRdbStore,轻则连接异常,重则直接抛错。
正确做法是只取一次applicationContext,它属于应用级上下文,生命周期跟进程一致。在EntryAbility的onCreate里初始化:
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; import { DatabaseHelper } from '../common/DatabaseHelper'; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { DatabaseHelper.init(this.context.applicationContext); } }注意这一行:DatabaseHelper.init(this.context.applicationContext)。我见过的错误代码里,十有八九是直接传this.context。你永远不知道后续哪个页面会触发数据库操作,所以老老实实用applicationContext。
2.2 getRdbStore是异步的,别在多个页面各自await
getRdbStore返回的是Promise<RdbStore>,第一次调用慢,因为要建文件、建表、还可能要跑初始化SQL。如果你在页面A调一次、页面B又调一次,同时没有统一缓存同一个Promise,就会导致重复初始化。正确姿势是把Promise本身缓存下来。
我在DatabaseHelper里是这么处理的:
import { relationalStore } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; export class DatabaseHelper { private static instance: DatabaseHelper | null = null; private static appContext: common.Context | null = null; private store: relationalStore.RdbStore | null = null; private storePromise: Promise<relationalStore.RdbStore> | null = null; private constructor() {} static init(context: common.Context): void { if (DatabaseHelper.instance === null) { DatabaseHelper.appContext = context.applicationContext; DatabaseHelper.instance = new DatabaseHelper(); } } static getInstance(): DatabaseHelper { if (DatabaseHelper.instance === null) { throw new Error('DatabaseHelper must be initialized in EntryAbility.onCreate'); } return DatabaseHelper.instance; } async getStore(): Promise<relationalStore.RdbStore> { if (this.store) { return this.store; } if (this.storePromise === null) { const config: relationalStore.StoreConfig = { name: 'app.db', securityLevel: relationalStore.SecurityLevel.S1 }; this.storePromise = relationalStore.getRdbStore(DatabaseHelper.appContext!, config); } this.store = await this.storePromise; return this.store; } }关键代码就两行:storePromise只赋值一次,所有getStore调用共享这同一个Promise。后续即便并发调用,也只是在等待同一个初始化过程,不会重复建库。有人问:为什么不直接在init里await一次,把store赋值好?因为onCreate里做异步初始化意味着页面可能在store还没ready的时候就发起查询,反而引入一堆启动时序判断。让getStore在方法内部自消化“首次初始化”这件事,对调用方最友好。
2.3 ArkTS的类型约束,决定了不能照搬Java/TS的泛型玩法
很多后端同学习惯MyBatis-Plus那种“无状态CRUD”的爽快感,到了鸿蒙端发现没有等价的ORM框架。ArkTS是TypeScript的子集,静态类型检查比TS严格不少,尤其是泛型场景,没法在运行时随意new T()或者反射字段名。所以封装Repository基类时,要把“表名、字段映射”这类变化的部分下沉给子类,基类只负责写死的增删改查骨架。具体代码下一节给。
2.4 关于线程边界的一点提醒
RdbStore本身可以在异步任务里调用,但注意不要在UI线程里做耗时的全表查询。等后面压测你会看到,十万条数据的批量操作即使有事务加持,也要好几秒。这种操作如果放在UI线程,页面直接掉帧,严重一点还会被系统判为卡死。建议用TaskPool把这些长任务放到后台线程。
还有一点容易被忽略:异步回调里如果要刷新UI,需要切回UI线程再操作,直接在线程池里更新组件状态会报错。老规矩,耗时操作做完,把结果通过回调或者Emitter抛出来。
3. 增删改查的通用封装:一份可以直接抄走的Repository基类
3.1 先约定实体和表结构
为了讲得具体,我拿一个user表来示范。表结构:
CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER, avatar TEXT );对应的ArkTS实体类:
export class UserEntity { id: number = 0; name: string = ''; age: number = 0; avatar: string = ''; }这里有个约定:实体字段名和数据库列名保持一致,这样基类里可以用Object.keys遍历实体属性,自动拼出列传值和读取列值。如果你习惯下划线列名也可以,那就在子类里做一层映射,但那样基类就复杂了。我的经验是鸿蒙本地库优先保持驼峰一致,架构简单比命名风格重要。
3.2 DatabaseHelper的初始化链路
刚才那张代码里其实还缺一句:初始化时顺手把Schema迁移跑掉。完整一点的getStore可以长这样:
async getStore(): Promise<relationalStore.RdbStore> { if (this.store) { return this.store; } if (this.storePromise === null) { const config: relationalStore.StoreConfig = { name: 'app.db', securityLevel: relationalStore.SecurityLevel.S1 }; this.storePromise = relationalStore.getRdbStore(DatabaseHelper.appContext!, config) .then(async (store) => { await DatabaseMigration.upgrade(store); return store; }); } this.store = await this.storePromise; return this.store; }升级逻辑我在第四节展开。先记住:任何一张表的建表SQL、任何一次版本迁移,都应该集中在这个初始化链路里执行。别在页面里写CREATE TABLE,你拦不住哪天两张表建表的顺序就不一致了。
3.3 BaseRepository基类:增删改查一次成型
基类需要的基础能力是四件事:insert、update、delete、query。我写成这样:
import { relationalStore } from '@kit.ArkData'; import { DatabaseHelper } from './DatabaseHelper'; export abstract class BaseRepository<T extends { id?: number }> { protected abstract tableName: string; private getStore(): Promise<relationalStore.RdbStore> { return DatabaseHelper.getInstance().getStore(); } async insert(entity: T): Promise<number> { const store = await this.getStore(); const bucket: relationalStore.ValuesBucket = {}; Object.keys(entity as object).forEach((key: string) => { const value = (entity as Record<string, relationalStore.ValueType>)[key]; if (value !== undefined && value !== null) { bucket[key] = value; } }); return await store.insert(this.tableName, bucket); } async update(entity: T, id: number): Promise<number> { const store = await this.getStore(); const bucket: relationalStore.ValuesBucket = {}; Object.keys(entity as object).forEach((key: string) => { const value = (entity as Record<string, relationalStore.ValueType>)[key]; if (value !== undefined && value !== null && key !== 'id') { bucket[key] = value; } }); const predicates = new relationalStore.RdbPredicates(this.tableName); predicates.equalTo('id', id); return await store.update(bucket, predicates); } async delete(id: number): Promise<number> { const store = await this.getStore(); const predicates = new relationalStore.RdbPredicates(this.tableName); predicates.equalTo('id', id); return await store.delete(predicates); } async queryAll(): Promise<T[]> { const store = await this.getStore(); const predicates = new relationalStore.RdbPredicates(this.tableName); return await this.queryWithPredicates(predicates); } protected async queryWithPredicates(predicates: relationalStore.RdbPredicates): Promise<T[]> { const store = await this.getStore(); const resultSet = await store.query(predicates); const list: T[] = []; try { while (resultSet.goToNextRow()) { const entity: Record<string, relationalStore.ValueType> = {}; resultSet.getColumnNames().forEach((name: string) => { entity[name] = resultSet.getValue(resultSet.getColumnIndex(name)); }); list.push(entity as T); } } finally { resultSet.close(); } return list; } }有几个细节值得单独说明,不然照抄会踩坑。
第一,insert返回值是rowId,也就是自增主键。如果需要把id回填到实体对象上,可以insert完再拿返回值赋值给entity.id。
第二,update用的是Predicates而不是拼SQL。RdbPredicates是鸿蒙提供的条件封装,用equalTo、and、orderByAsc这些方法,远比手写SQL字符串安全,至少不用头疼单引号转义和注入问题。
第三,ResultSet必须close。ResultSet底层持有文件句柄和游标,不关的话反复查询几十次就会耗光句柄。哪怕异常了也要在finally里关,这是我吃了多次亏才养成的习惯。
如果你的ArkTS版本对Object.keys的类型推导比较严格,可以把这段收集字段的逻辑抽成一个工具函数,参数和返回类型写得更明确一点,思路和上面完全一样。
3.4 用子类把变化隔离开
有了基类,业务侧只剩一点点声明式代码:
export class UserRepository extends BaseRepository<UserEntity> { protected tableName: string = 'user'; async findByName(name: string): Promise<UserEntity[]> { const predicates = new relationalStore.RdbPredicates(this.tableName); predicates.equalTo('name', name); return await this.queryWithPredicates(predicates); } }调用的时候逻辑非常干净:
const repo = new UserRepository(); const newId = await repo.insert(user); await repo.update(user, newId); await repo.delete(newId); const allUsers = await repo.queryAll();有复杂查询时,在子类里追加方法,把需要的predicates拼好,然后复用基类的queryWithPredicates。这样既保证数据访问集中,又不至于为了通用性把代码写得像能猜谜一样。
3.5 顺手补一个批量插入
批量插入单独说,因为单条insert在循环里写会导致一个非常典型的性能问题。先把方法加到基类:
async insertBatch(entities: T[]): Promise<void> { const store = await this.getStore(); store.beginTransaction(); try { for (const entity of entities) { const bucket: relationalStore.ValuesBucket = {}; Object.keys(entity as object).forEach((key: string) => { const value = (entity as Record<string, relationalStore.ValueType>)[key]; if (value !== undefined && value !== null) { bucket[key] = value; } }); await store.insert(this.tableName, bucket); } store.commit(); } catch (e) { store.rollBack(); throw e; } }第五节的压测数据就基于这个方法跑的,效果待会儿看。
4. 数据库升级与字段变更:最容易翻车的Schema迁移实战
4.1 鸿蒙的升级机制和Android不一样
如果你是Android的SQLiteOpenHelper转过来的,可能会下意识找onUpgrade回调。鸿蒙的relationalStore没有给你这个回调,它只负责打开一个数据库连接。版本升级要自己管理,通常用store.getVersion()/store.setVersion(),底层对应SQLite的PRAGMA user_version。
这是一件很反直觉的事:数据库文件已经存在了,你建表SQL写再多也不会自动执行。版本号不升级,新增的字段永远缺席。所以我的DatabaseMigration模块是这么设计的:
import { relationalStore } from '@kit.ArkData'; export class DatabaseMigration { static async upgrade(store: relationalStore.RdbStore): Promise<void> { const currentVersion = await store.getVersion(); if (currentVersion < 1) { await store.executeSql( 'CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER, avatar TEXT)' ); await store.setVersion(1); } if (currentVersion < 2) { await store.executeSql('ALTER TABLE user ADD COLUMN email TEXT'); await store.setVersion(2); } } }注意每个if判断都使用currentVersion这个打开数据库时的快照,而不是每次setVersion后再重新读取。这样写的好处是:无论用户从哪个历史版本升级上来,都会按顺序执行缺失的那一截。坏处是想偷懒只给一个“最新版建表SQL”的团队会被现实毒打——老库升级不会重建表,你必须老老实实写增量迁移脚本。
4.2 加字段容易,改字段类型就是一场小型手术
热搜里很多人问“SQLite修改字段的类型”,这个问题相当现实。SQLite不支持ALTER TABLE ... MODIFY COLUMN这种语法,想改一个字段类型,标准做法是“重命名-重建-迁移-切换”四步:
- 把旧表改名为user_old。
- 按新结构创建user表。
- 从user_old里把数据查出来,插入新user表。
- 删掉user_old。
代码长这样:
if (currentVersion < 3) { await store.executeSql('ALTER TABLE user RENAME TO user_old'); await store.executeSql( 'CREATE TABLE user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER, avatar TEXT, email TEXT)' ); await store.executeSql( 'INSERT INTO user (id, name, age, avatar, email) SELECT id, name, age, avatar, email FROM user_old' ); await store.executeSql('DROP TABLE user_old'); await store.setVersion(3); }这一段的经验点是:先用RENAME把旧表挪走,再建新表。如果新表创建失败,旧表还在user_old里,数据不会丢,还能补救。如果一开始就把旧表DROP了再建新表,中途任何一步报错,数据就真的没了。迁移脚本上线前,务必要拿一个跟线上版本一致的旧库文件在本地跑一遍。
另外,AUTOINCREMENT字段类型是INTEGER PRIMARY KEY AUTOINCREMENT,不叫INT AUTO_INCREMENT,跟MySQL习惯不一样,写错了一建表就报错。
4.3 从MySQL思维调整到SQLite的几个关键差异
如果你以前在MySQL上开发,最近切到鸿蒙本地库,有几个点需要重新适应:
| 维度 | MySQL习惯 | SQLite/鸿蒙关系型数据库 |
|---|---|---|
| 自增主键 | INT AUTO_INCREMENT | INTEGER PRIMARY KEY AUTOINCREMENT |
| 字段类型 | INT/VARCHAR/DATETIME等细分类型 | 只有INTEGER/TEXT/REAL/BLOB |
| 修改列 | ALTER TABLE MODIFY COLUMN | 不支持,必须重建表 |
| 日期时间 | DATETIME/TIMESTAMP | 通常存INTEGER毫秒时间戳或TEXT字符串 |
| 外键 | 默认都建 | 默认关闭,需要PRAGMA foreign_keys=ON |
| 服务端数据库模式 | 有连接池、主从 | 单文件、单写者,并发写会锁 |
VARCHAR在SQLite里可以被接受,但它实际当成TEXT处理,长度限制、精度这些全都别指望。日期字段我建议一律存INTEGER类型的时间戳,查询排序快、可比较,不需要纠结时区转换。从MySQL导出来的表经常带一堆ENGINE=InnoDB之类的尾巴,导入SQLite前记得清理。
5. 十万条数据压测实录:事务、索引与查询耗时真相
5.1 压测前的疑虑
很多人搜“sqlite查询需要多久”,结论千奇百怪,有用几十万条数据秒开的,也有几千条就卡的。差距通常不在SQLite本身,而在用法。我在DevEco Studio的模拟器上做了一组简单压测,一张user表,插入十万条记录,分三种方式对比,数据如下。
5.2 逐条插入 vs 事务批量插入
先跑最笨的方式:在for循环里逐条调用insert。十万条数据跑完,大约38秒。再跑我们基类里的insertBatch,一条事务包到底,大约2.5秒。中途再试一次“每1000条提交一次”的分批事务,大约3秒。
| 插入方式 | 耗时 | 说明 |
|---|---|---|
| 逐条insert,循环100000次 | 约38秒 | 每一条自动提交,磁盘同步次数接近10万 |
| 一条事务包到底 | 约2.5秒 | 只做一次事务提交,同步次数接近1 |
| 每批1000条提交 | 约3秒 | 内存占用可控,适合大批量导入 |
为什么差这么多?SQLite默认的journal模式下,每条insert提交时都要保证数据落盘,一次事务落盘一次。逐条insert等于把落盘成本重复了十万次;批量事务只落盘一次,成本从N次降到1次。代价是事务期间占的内存更多,且中途进程被杀会回滚整个事务,所以“每批1000条”是在内存和稳定性之间取平衡。
5.3 索引对查询的影响
装满十万条数据后,我模拟一个最常见的查询:按name精确查一个人。没有索引时,EXPLAIN QUERY PLAN显示全表扫描,查询一次大概50到80毫秒。听起来不多,但如果App里频繁做这种查询,并且表还在不断增长,延迟会线性上升。
建索引之后:
CREATE INDEX IF NOT EXISTS idx_user_name ON user(name);同一个查询的耗时基本在1到2毫秒内,差了接近两个数量级。如果你的查询是组合条件,比如where age > 18 and name like '张%',就按实际where条件建联合索引。索引不是越多越好,每次insert和update都要同步维护索引,索引多了写入变慢,存储也会膨胀。原则是:先用慢查询定位高频条件,只给真正拖慢查询的列建索引。
顺带提一句LIKE的坑。SQLite对前缀通配('张%')可以用上索引,但'%张%'这种中缀匹配会退化成全表扫描。业务上能用前缀匹配的,尽量用前缀匹配。
5.4 分页深翻页优化
十万条数据做list分页,limit offset比较直观,但offset越大越慢,因为数据库要逐行扫到offset位置再开始取数。实测offset 50000、limit 20的查询,耗时大约是offset 0的5到6倍。
更推荐游标式分页,前提是业务场景适合按顺序翻页:
const predicates = new relationalStore.RdbPredicates(this.tableName); if (lastId > 0) { predicates.greaterThan('id', lastId); } predicates.orderByAsc('id'); predicates.limit(20);用“上一页最后一条记录的id”作为游标,不管翻到多深,查询走的都是索引范围扫描,耗时稳定。缺点是不能直接跳页。如果产品经理要求“随便跳第100页”,那就只能limit offset了,但可以把offset限制在几千条内,或者做个总页数缓存。
6. 调试利器与常见坑位:db browser、命令行与排查清单
6.1 先装一个DB Browser for SQLite
开发鸿蒙本地数据库,强烈建议装一个DB Browser for SQLite,也就是圈子里常说的DB4S。它是开源跨平台的SQLite可视化工具,支持Windows、Linux、macOS。Linux环境下装起来也简单:
sudo apt install sqlitebrowser如果不想装GUI,纯命令行工具够用:
sudo apt install sqlite3打开数据库文件用sqlite3 app.db,接下来就能直接跑SQL。调试阶段我经常把app.db拉到电脑上,用DB4S打开,直接看表结构、翻数据、跑SQL,比在日志里一行行找快多了。导出JSON、比对前后数据这种事,GUI顺手不少。
6.2 把鸿蒙设备里的数据库文件倒腾出来
数据库文件默认在应用沙箱的数据目录下,比如/data/app/el2/100/database/com.example.app/app.db。用hdc工具可以拉出来:
hdc file recv /data/app/el2/100/database/com.example.app/app.db ./app.db路径里的com.example.app换成你的包名。如果pull出来发现表是空的,先确认是不是权限问题,再确认初始化数据库时用的context确实是applicationContext,而不是另一个context创建的另一个库文件。这个坑在第一节已经演示过了。
6.3 高频踩坑点清单
我在这个项目上前后踩了不少坑,列一个清单,遇到问题可以对照着查:
- database is locked:通常是多个写操作并发,SQLite同一时刻只允许一个写事务。检查是不是有页面重复初始化store,或者有循环里没结束的事务。
- ResultSet泄漏:查询后不close,跑几次查询后文件句柄耗尽,后面的查询全挂。记住用try/finally包住ResultSet,并在finally里close。
- 字符串拼接SQL:永远不要用模板字符串去拼where条件,遇到单引号就出事。用RdbPredicates的equalTo、like方法。
- 升级脚本在旧库上不执行:检查setVersion有没有执行。忘记setVersion会导致每次启动都重复跑同一段迁移SQL,轻则重复建临时表,重则数据被洗。
- 在UI线程跑长事务:掉帧、卡死、被系统回收。耗时的批量写放到TaskPool或异步队列里。
- 数据库加密配置改动了:StoreConfig里的encrypt字段,一旦从false改成true,老文件是读不出来的,相当于换了个密码。改配置前先考虑数据迁移。
6.4 我的排查固定流程
遇到数据库相关的Bug,我习惯按这个顺序排查:先看日志里的错误码和异常栈,确认是锁、IO还是SQL语法;然后把设备里的db文件拉到本地,用DB4S直接打开看数据是否如预期;如果数据对不上,对比不同库文件的修改时间,基本能定位是不是有多实例写岔了路径;最后用sqlite3命令行在db文件上手动复现那几条SQL,排除是应用层传参问题还是SQL本身问题。
这套流程跑下来,大部分问题十几分钟内能定位,省去了在代码里反复加日志的折腾。如果你也在搞鸿蒙原生开发,建议从今天起不要在任何页面里直接new RdbStore,所有数据库访问都收敛到单例和Repository,后面升级表结构、做性能优化都会轻松得多。