ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

鸿蒙SQLite从单例封装到性能调优:一套可直接复用的数据库方案

鸿蒙SQLite从单例封装到性能调优:一套可直接复用的数据库方案 前几天在一场鸿蒙原生开发的交流活动上被问到“你们项目的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是异步的别在多个页面各自awaitgetRdbStore返回的是PromiseRdbStore第一次调用慢因为要建文件、建表、还可能要跑初始化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: PromiserelationalStore.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(): PromiserelationalStore.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(): PromiserelationalStore.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 BaseRepositoryT extends { id?: number } { protected abstract tableName: string; private getStore(): PromiserelationalStore.RdbStore { return DatabaseHelper.getInstance().getStore(); } async insert(entity: T): Promisenumber { const store await this.getStore(); const bucket: relationalStore.ValuesBucket {}; Object.keys(entity as object).forEach((key: string) { const value (entity as Recordstring, relationalStore.ValueType)[key]; if (value ! undefined value ! null) { bucket[key] value; } }); return await store.insert(this.tableName, bucket); } async update(entity: T, id: number): Promisenumber { const store await this.getStore(); const bucket: relationalStore.ValuesBucket {}; Object.keys(entity as object).forEach((key: string) { const value (entity as Recordstring, 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): Promisenumber { const store await this.getStore(); const predicates new relationalStore.RdbPredicates(this.tableName); predicates.equalTo(id, id); return await store.delete(predicates); } async queryAll(): PromiseT[] { const store await this.getStore(); const predicates new relationalStore.RdbPredicates(this.tableName); return await this.queryWithPredicates(predicates); } protected async queryWithPredicates(predicates: relationalStore.RdbPredicates): PromiseT[] { const store await this.getStore(); const resultSet await store.query(predicates); const list: T[] []; try { while (resultSet.goToNextRow()) { const entity: Recordstring, 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 BaseRepositoryUserEntity { protected tableName: string user; async findByName(name: string): PromiseUserEntity[] { 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[]): Promisevoid { 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 Recordstring, 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): Promisevoid { 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_INCREMENTINTEGER PRIMARY KEY AUTOINCREMENT字段类型INT/VARCHAR/DATETIME等细分类型只有INTEGER/TEXT/REAL/BLOB修改列ALTER TABLE MODIFY COLUMN不支持必须重建表日期时间DATETIME/TIMESTAMP通常存INTEGER毫秒时间戳或TEXT字符串外键默认都建默认关闭需要PRAGMA foreign_keysON服务端数据库模式有连接池、主从单文件、单写者并发写会锁VARCHAR在SQLite里可以被接受但它实际当成TEXT处理长度限制、精度这些全都别指望。日期字段我建议一律存INTEGER类型的时间戳查询排序快、可比较不需要纠结时区转换。从MySQL导出来的表经常带一堆ENGINEInnoDB之类的尾巴导入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后面升级表结构、做性能优化都会轻松得多。
返回列表