聊天历史从 Preferences 搬到关系型数据库(RDB):为什么换、怎么换、踩了什么坑
聊天历史从 Preferences 搬到关系型数据库RDB为什么换、怎么换、踩了什么坑项目MyApplicationAI 助手 demo目标文件chat/src/main/ets/utils/ChatRdb.ets新建 controller/ChatSessionController.etscontroller/ChatHistoryController.etsEntryAbility.ets主题早期 demo 用Preferences JSON.stringify(整个 sessions 列表)存聊天记录会话数一上来就开始卡。今天把它换成关系型数据库kit.ArkData / relationalStore建两张表chat_session/chat_message 一个索引。本篇把 ArkTS 里用 RDB 的最小闭环 这次替换的设计决策记下来。一、为什么 Preferences 顶不住了旧实现ChatPersist.etsconstSTORE_NAMEchat_historyconstKEY_SESSIONSsessionsexportfunctionsaveSession(ctx,session:ChatSession):void{conststorepreferences.getPreferencesSync(ctx,{name:STORE_NAME})constrawstore.getSync(KEY_SESSIONS,[])asstringconstlistJSON.parse(raw)asChatSession[]// 1. 把全部会话读出来constidxlist.findIndex(ss.idsession.id)if(idx0)list[idx]session// 2. 在内存里改elselist.push(session)store.putSync(KEY_SESSIONS,JSON.stringify(list))// 3. 整个列表 stringify 写回store.flush()}这是经典的KV 持久化Key-Value套路一个 key 装下所有数据。问题维度Preferences 版现实情况写一次的成本序列化整个 sessions 列表 → 写整张 KV100 个会话 × 平均 30 条消息 ≈ 几百 KB JSON 每次都要全量重写读单条必须先JSON.parse全部 → 找到 id 那条想看一个会话要把无关的 99 个也解析出来查询没有 query只能加载全量后在内存 filter / sort历史页要按 updateTime 倒序 → 全表读 sort崩溃恢复如果中途 stringify 出错要么全成功要么全失败一条脏数据可能让全部历史读不出来并发flush 是异步的多次 put 间隔写可能错位流式生成边写边来时序难保证KV 是整存整取对会话这种按 id 检索 排序 增量更新的数据完全不合身。RDBSQLite 封装才是聊天记录这种数据应该用的存储维度RDB写一次单条 insert / update / delete读单条WHERE id ?索引命中毫秒级查询SQL / RdbPredicates 支持 where / order / limit / join崩溃恢复事务、WAL原子性有保障并发RDB 自带行锁 / 文件锁二、ArkTS 里用 RDB 的最小五步来自kit.ArkData的relationalStore模块。最小闭环2.1getRdbStore拿 storeimport{relationalStore}fromkit.ArkDataimport{common}fromkit.AbilityKitconstconfig:relationalStore.StoreConfig{name:chat.db,securityLevel:relationalStore.SecurityLevel.S1}conststoreawaitrelationalStore.getRdbStore(ctx,config)name—— SQLite 文件名每个 App 私有目录下一个securityLevel—— 数据敏感等级决定加密 / 访问控制策略。S1 是最低适合本地缓存类数据。如果存账户 token / 用户隐私走 S3SecurityLevel.S1 低敏感demo 数据、本地缓存 SecurityLevel.S2 低敏感但有一定隐私昵称、头像 URL SecurityLevel.S3 中敏感手机号、地理位置 SecurityLevel.S4 高敏感身份证、银行卡一旦选定升级 securityLevel 是兼容的降级是不兼容的。第一次选高一点不会错。2.2executeSql建表 建索引constSQL_CREATE_SESSIONCREATE TABLE IF NOT EXISTS chat_session ( id TEXT PRIMARY KEY, title TEXT NOT NULL, preview TEXT, create_time INTEGER NOT NULL, update_time INTEGER NOT NULL )constSQL_CREATE_MESSAGECREATE TABLE IF NOT EXISTS chat_message ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, create_time INTEGER NOT NULL, card_json TEXT )constSQL_CREATE_INDEXCREATE INDEX IF NOT EXISTS idx_msg_session ON chat_message(session_id, create_time)awaitstore.executeSql(SQL_CREATE_SESSION)awaitstore.executeSql(SQL_CREATE_MESSAGE)awaitstore.executeSql(SQL_CREATE_INDEX)IF NOT EXISTS让这段在每次 init 时都能安全跑 —— 已存在就跳过。2.3RdbPredicatesquery读constprednewrelationalStore.RdbPredicates(chat_session)pred.orderByDesc(update_time)// ORDER BY update_time DESCconstrsawaitstore.query(pred,[id,title,preview,create_time,update_time])while(rs.goToNextRow()){constsnewChatSession()s.idrs.getString(rs.getColumnIndex(id))s.titlers.getString(rs.getColumnIndex(title))// ...}rs.close()// 必须 close否则游标泄漏RdbPredicates是用链式 API 拼WHERE / ORDER / LIMIT子句的对象可读性比手拼 SQL 字符串高也避免注入。2.4ValuesBucketinsert写constsValues:relationalStore.ValuesBucket{id:session.id,title:session.title,preview:session.preview,create_time:session.createTime,update_time:session.updateTime}awaitstore.insert(chat_session,sValues)ValuesBucket就是{ 列名: 值 }的字典按列名插值。注意 ArkTS 里 key 必须是字符串字面量有引号这是 strict type 的要求。2.5delete删constprednewrelationalStore.RdbPredicates(chat_session)pred.equalTo(id,sessionId)awaitstore.delete(pred)equalTo / notEqualTo / greaterThan / lessThan / between / like / in全套都有跟 SQL WHERE 一一对应。三、这次的表设计3.1 拆成 session message 两张表旧的 Preferences 把ChatSession.messages: ChatMessage[]整段塞 JSON 里。RDB 设计的核心是把一对多关系拆开成两张表 外键列chat_session (元数据一行一会话) ├─ id 会话主键时间戳字符串 ├─ title 会话标题第一条 user 消息前 20 字 ├─ preview 预览最后一条消息前 30 字 ├─ create_time └─ update_time chat_message (内容一行一消息) ├─ id 消息主键 ├─ session_id ← 外键指向 chat_session.id ├─ role user | assistant ├─ content 文本内容 ├─ create_time └─ card_json 卡片对象 JSON.stringifyPickupCard idx_msg_session (chat_message 的复合索引) ON chat_message(session_id, create_time)3.2 为什么加idx_msg_session聊天最常见的查询是“按 session_id 拉某个会话的所有消息按时间正序”SELECT*FROMchat_messageWHEREsession_id?ORDERBYcreate_timeASC如果没有索引会全表扫 排序加上(session_id, create_time)这个复合索引session_id等值过滤直接命中索引create_time在索引上已经排好序不需要额外的 sort 操作代价每条 insert 多写一次索引很小。对读多写少的场景索引是必加的。3.3 卡片字段card_json用 JSON 兜底ChatMessagePlain.card是个PickupCard | nullPickupCard 里面套着PickupPoint[]等嵌套结构。不想为这一个字段再拆出第三张表干脆card_json:m.card!null?JSON.stringify(m.card):读出来时反序列化constcardJsonrs.getString(rs.getColumnIndex(card_json))m.cardcardJson.length0?(JSON.parse(cardJson)asObject):null判断是否有卡片用length 0而不是! null—— RDB 的 TEXT 列空值取出来是空串而不是 null写代码时要适配。四、最关键的转换ChatMessage↔ChatMessagePlainArkUI 响应式带来一个隐藏麻烦ObservedV2 / Trace装饰的对象JSON.stringify 拿不到完整字段。装饰器会改写属性描述符getter/setter 内部存值stringify走的是默认 enumerable 字段路径Trace 字段会丢。解决方案存储用普通对象UI 用 ObservedV2 对象两者之间做转换。// UI 用ChatMessageObservedV2 / Trace 装饰驱动气泡实时更新ObservedV2exportclassChatMessage{Tracecontent:stringTracerole:string// ...}// 存储用ChatMessagePlain普通 class字段裸露exportclassChatMessagePlain{content:stringrole:string// ...}写入时ChatMessage → ChatMessagePlainprivateconvertToPlain():ChatMessagePlain[]{returnthis.vm.historyMessage.map((source:ChatMessage,i:number){constplainnewChatMessagePlain()plain.idsource.id?source.id:${this.vm.sessionId}_${i}plain.rolesource.role?source.role:assistantplain.contentsource.content?source.content:plain.createTimesource.createTime?source.createTime:Date.now()plain.sessionIdsource.sessionId?source.sessionId:this.vm.sessionId plain.cardsource.card?source.card:nullreturnplain})}读出来时ChatMessagePlain → ChatMessageprivateconvertToObservable(plains:ChatMessagePlain[],sessionId:string):ChatMessage[]{returnplains.map((plain,i){constmsgnewChatMessage()msg.idplain.id?plain.id:${sessionId}_${i}msg.roleplain.role?plain.role:assistantmsg.contentplain.content?plain.content:// ...returnmsg})}每个字段都加? : fallback——不信任老数据。这一层是为了过去版本写下的脏数据 / 字段缺失的情况能正常加载。这是 ArkTS 响应式 持久化的标准模式Observable 类负责 UI 响应Plain 类负责 IO 序列化两者之间显式 mapper。不要试图给 ObservedV2 类直接 stringify。五、初始化时机EntryAbility.onCreate 调一次数据库要在 App 一启动就准备好不能等到第一次 saveSession 才 init —— 那时候已经晚了可能正在生成回复等不起 init 的 await。// entry/src/main/ets/entryability/EntryAbility.etsimport{ChatRdb}fromchatexportdefaultclassEntryAbilityextendsUIAbility{onCreate(want:Want,launchParam:AbilityConstant.LaunchParam):void{HMRouterMgr.init({context:this.context})// 数据库初始化一次ChatRdb.init(this.context)HMRouterMgr.registerGlobalInterceptor({...})// ...}}ChatRdb.init内部用静态 store 字段 短路privatestaticstore:relationalStore.RdbStore|nullnullstaticasyncinit(ctx:common.UIAbilityContext):Promisevoid{if(ChatRdb.store!null)return// 已 init 过直接返回constconfig:relationalStore.StoreConfig{name:chat.db,securityLevel:relationalStore.SecurityLevel.S1}try{ChatRdb.storeawaitrelationalStore.getRdbStore(ctx,config)awaitChatRdb.store.executeSql(SQL_CREATE_SESSION)awaitChatRdb.store.executeSql(SQL_CREATE_MESSAGE)awaitChatRdb.store.executeSql(SQL_CREATE_INDEX)LogUtil.i(ChatRdb,init success)}catch(e){LogUtil.e(ChatRdb,init failed: JSON.stringify(e))}}后续所有方法都守一道空检查 静默返回staticasyncloadSessions():PromiseChatSession[]{if(ChatRdb.storenull)return[]// ← init 还没跑完直接返回空// ...}为什么不抛错而是返回空聊天页加载历史失败时给用户看[]“暂无会话”比给个红屏好。崩了反而让用户怀疑 App 坏了错日志去 LogUtil.e 留着开发自查。六、跨模块导出把 ChatRdb 挂在 chat HAR 的对外 API 上chat/Index.ets// Chat HAR 对外公开 API // 唯一对外暴露的 UI 组件让 entry 的 HomePage 嵌入 Chat Tabexport{ChatTabComp}from./src/main/ets/components/ChatTabComp// 路由常量export{ChatRoutes}from./src/main/ets/constants/ChatRoutes// 跨页面通信export{ChatLoadState,getChatLoadState}from./src/main/ets/viewmodel/ChatLoadState// 关系型数据库聊天记录存入数据库中调用这个方法export{ChatRdb}from./src/main/ets/utils/ChatRdbentry 模块只需要 import 一个ChatRdb就能在 EntryAbility 里调 init。别让 entry 直接 import chat 模块的内部路径—— 那样就破坏 HAR 边界了。七、保存时机流式结束才写一次聊天 UI 里 AI 是边生成边追加 token 的不能每个 token 都写库写放大很离谱。ChatTabComp里用Monitor监听isLoading的变化Monitor(vm.isLoading)onLoadingChange():void{if(this.vm.isLoading){return// 流式开始不写}// 流式结束isLoading: true → false把完整会话写一次constctxthis.getUIContext().getHostContext()ascommon.UIAbilityContextthis.sessionController.persistSession(ctx)}persistSession内部走先 delete 再 insert的最简实现// 1. 删 session 行constsPrednewrelationalStore.RdbPredicates(chat_session)sPred.equalTo(id,session.id)awaitChatRdb.store.delete(sPred)// 2. 插 session 行awaitChatRdb.store.insert(chat_session,sValues)// 3. 删该 sessionId 下的所有 messageconstmPrednewrelationalStore.RdbPredicates(chat_message)mPred.equalTo(session_id,session.id)awaitChatRdb.store.delete(mPred)// 4. 批量插 messagefor(leti0;isession.messages.length;i){awaitChatRdb.store.insert(chat_message,mValues)}没有用事务 / batchInsert 是有意为之—— demo 阶段每次保存就是 1 个 session 几十条 message 量级单条 insert 跑得动。等会话规模再上一个量级500 条 msg / session再换batchInsert。不要为了看起来更专业提前优化。八、数据流四个角色协作┌────────────────────┐ │ EntryAbility │ onCreate 调 ChatRdb.init └─────────┬──────────┘ │ ▼ ┌────────────────────┐ query / orderByDesc / delete / insert │ ChatRdb │ ───────────────────────────────────────┐ │ (chat/utils) │ │ └─────────┬──────────┘ │ │ │ ┌──────┴───────┐ │ │ │ │ ▼ ▼ ▼ ┌──────────────────────┐ ┌──────────────────────┐ │ ChatHistoryController│ 历史页 load / delete │ chat.db (SQLite) │ │ │ │ chat_session │ └──────────────────────┘ │ chat_message │ ┌──────────────────────┐ 流式结束 saveSession │ idx_msg_session │ │ ChatSessionController│ ─────────────────────────│ │ │ │ ChatMessage↔Plain 双向 └──────────────────────┘ └──────────────────────┘EntryAbility.onCreate—— 全局 init 一次ChatRdb—— 唯一的 RDB 入口所有 SQL / Predicates 在这里ChatSessionController—— 流式结束触发 saveSession 双向转换 ObservedV2 ↔ PlainChatHistoryController—— 历史页加载列表 删除单条业务层完全不感知 SQL / RdbPredicates—— 它们只调ChatRdb.loadMessages(id)这种语义化方法。九、几个意外发现9.1 RdbPredicates 上的 column name 是 SQL 列名不是 ArkTS 属性名// ✅ SQL 列名下划线风格pred.orderByDesc(update_time)// ❌ 不是 ArkTS 属性名camelCasepred.orderByDesc(updateTime)// 查不到ChatSession.updateTime是 ArkTS 属性chat_session.update_time是数据库列名。RdbPredicates 操作的是数据库永远用列名。这是 ORM 缺位下手写映射要小心的地方。9.2 resultSet 必须closeconstrsawaitstore.query(pred)while(rs.goToNextRow()){...}rs.close()// ← 不写这一行会泄漏文件句柄ArkTS 没有 RAII / using资源释放靠程序员自觉。漏 close 单看不出问题但在循环里反复查询会很快耗尽句柄。9.3 PRIMARY KEY 冲突时 insert 是抛异常的我一开始想既然 id 是主键insert 同 id 时应该会失败 / 覆盖结果是抛异常。所以这次 saveSession 走的是“先 delete 再 insert”而不是 “INSERT OR REPLACE”后者需要拼原始 SQL。更优雅的写法是INSERT OR REPLACE INTO ... ON CONFLICT但 demo 阶段两步走读起来清楚。十、一句话心智模型KV 是「整存整取」RDB 是「按列检索 增量更新」。 聊天记录这种按 id 拉 按时间排的数据必须用 RDB。 五步走getRdbStore → executeSql 建表 → Predicates 查 → ValuesBucket 写 → delete 删。 ObservedV2 不能直接 stringify加一层 Plain 做 IO 双向 mapper。 Init 在 EntryAbility.onCreate 调一次业务层只走语义化接口不碰 SQL。十一、顺口溜KV 整存整取慢每改全表写一遍 RDB 列检索、查询快索引加上飞起来。 ObservedV2 别 stringifyTrace 字段会消失 Plain 类做 IO 映双向 mapper 保字段。 init 放在 onCreatestore 静态短路开 resultSet 用完要 close否则句柄泄不开。十二、参考relationalStore APIkit.ArkDataRDB 数据持久化指南Preferences APIkit.ArkData旧实现对照本系列相关04-chat-local-storage / 08-chat-history-persistence-bugfix / 23-arkts-hilog-logutil-replace-console