
HarmonyOS RDB 深分页优化游标翻页、联合索引与ResultSet释放本地消息、操作记录和离线订单积累到几十万条后第一页仍然很快第几千页却突然变慢这是典型的深分页问题。LIMIT 20 OFFSET 100000只返回20行并不代表数据库只处理20行为了找到起点前面的记录仍可能被扫描或跳过。更麻烦的是数据在翻页期间发生插入时offset还可能带来重复或漏项。本文使用“时间戳加主键”的游标分页替代深offset建立与排序方向一致的联合索引并用finally保证结果集关闭。示例同时说明API 23的querySqlWithoutRowCount适用边界便于项目按兼容版本选择LiteResultSet或传统ResultSet。1. offset越深数据库丢弃的工作越多假设每页20条第5001页的offset是100000。数据库需要按排序规则定位并跳过前100000条再返回20条。如果排序列没有合适索引还会叠加临时排序。页码越大响应时间通常越不稳定。SELECTid,created_at,titleFROMtimelineORDERBYcreated_atDESC,idDESCLIMIT20OFFSET100000;浅页、数据量很小或允许直接跳页时offset仍然简单实用。问题不在语法本身而在把它用于无限下拉和高页码历史数据。2. 游标协议记录上一页的最后位置游标分页不再说“跳过多少行”而是说“从上一页最后一条之后继续”。排序使用created_at DESC, id DESC时下一页条件必须同时包含两个字段。interfaceTimelineCursor{createdAt:number;id:number;}interfaceTimelineRow{id:number;createdAt:number;title:string;}interfaceTimelinePage{rows:TimelineRow[];nextCursor?:TimelineCursor;}只记录时间戳是不够的。同一毫秒可能写入多条数据若没有id作为稳定次序边界记录会在相邻页重复或消失。3. 排序条件与索引顺序必须一致先建立表和联合索引。索引字段顺序、升降序与查询排序保持一致数据库才更容易从游标位置继续扫描而不是重新处理大段历史数据。CREATETABLEIFNOTEXISTStimeline(idINTEGERPRIMARYKEYAUTOINCREMENT,created_atINTEGERNOTNULL,titleTEXTNOTNULL,payloadTEXTNOTNULLDEFAULT);CREATEINDEXIFNOTEXISTSidx_timeline_created_idONtimeline(created_atDESC,idDESC);如果查询还固定按用户或会话过滤索引通常要把等值过滤列放在前面例如(session_id, created_at DESC, id DESC)。不要为每个查询随意叠加索引写入成本和数据库体积也要纳入评估。4. 游标翻页闭环要把释放动作放进去收到游标后查询从索引边界继续读取一页页面最后一条生成新游标无论读取成功、字段解析失败还是页面提前退出结果集都必须关闭。把关闭动作视为查询的一部分才能避免长期运行后句柄逐渐耗尽。5. 第一页与后续页使用不同SQL第一页没有游标只需要排序和限制后续页使用严格“小于”边界。复合比较写成两个条件兼顾相同时间戳。-- 第一页SELECTid,created_at,titleFROMtimelineORDERBYcreated_atDESC,idDESCLIMIT?;-- 后续页SELECTid,created_at,titleFROMtimelineWHEREcreated_at?OR(created_at?ANDid?)ORDERBYcreated_atDESC,idDESCLIMIT?;降序分页使用升序分页使用。如果排序方向改了但边界符号没有同步修改常见表现是第二页为空或不断重复第一页尾部。6. API 23优先避免无用的rowCount计算API 23提供querySqlWithoutRowCount并返回LiteResultSet。无限列表只需要逐行读取不需要结果总数时可以避免为rowCount付出额外工作。低于API 23的兼容分支可使用querySql与ResultSet但同样必须关闭。import{relationalStore}fromkit.ArkData;asyncfunctionqueryTimeline(store:relationalStore.RdbStore,cursor:TimelineCursor|undefined,pageSize:number):PromiseTimelinePage{constsafeSizeMath.max(1,Math.min(pageSize,100));constsqlcursor?SELECT id, created_at, title FROM timeline WHERE created_at ? OR (created_at ? AND id ?) ORDER BY created_at DESC, id DESC LIMIT ?:SELECT id, created_at, title FROM timeline ORDER BY created_at DESC, id DESC LIMIT ?;constargs:relationalStore.ValueType[]cursor?[cursor.createdAt,cursor.createdAt,cursor.id,safeSize]:[safeSize];constresultawaitstore.querySqlWithoutRowCount(sql,args);constrows:TimelineRow[][];try{constidIndexresult.getColumnIndex(id);consttimeIndexresult.getColumnIndex(created_at);consttitleIndexresult.getColumnIndex(title);while(result.goToNextRow()){rows.push({id:result.getLong(idIndex),createdAt:result.getLong(timeIndex),title:result.getString(titleIndex)});}}finally{result.close();}constlastrows[rows.length-1];return{rows,nextCursor:rows.lengthsafeSizelast?{createdAt:last.createdAt,id:last.id}:undefined};}safeSize限制调用方传入的页大小防止一次读取数千条。SQL参数必须使用绑定变量不能把游标直接拼接进字符串。7. 页面请求、索引和结果集各守一层边界页面请求只携带不透明游标仓储层解析游标并执行SQL数据库通过联合索引定位结果集负责短暂遍历并立即关闭。UI不应持有ResultSet或LiteResultSet否则页面生命周期会把数据库资源拖得过长。classTimelineRepository{constructor(privatestore:relationalStore.RdbStore){}asyncnext(cursor?:TimelineCursor):PromiseTimelinePage{returnqueryTimeline(this.store,cursor,30);}}仓储层返回普通数据对象页面销毁后不会遗留数据库游标也便于单元测试构造固定页面结果。8. 游标最好编码成不可随意修改的字符串跨页面或跨进程传递时可把两个数字编码为JSON再转为Base64。解码后必须检查数值范围不能相信外部传入的游标。functionencodeCursor(cursor:TimelineCursor):string{return${cursor.createdAt}:${cursor.id};}functiondecodeCursor(value:string):TimelineCursor|undefined{constpartsvalue.split(:);if(parts.length!2){returnundefined;}constcreatedAtNumber(parts[0]);constidNumber(parts[1]);if(!Number.isSafeInteger(createdAt)||!Number.isSafeInteger(id)){returnundefined;}return{createdAt,id};}若游标来自服务端还应加入版本、查询条件摘要或签名避免筛选条件改变后继续使用旧游标。9. 刷新与继续翻页不能共用游标下拉刷新代表建立新的数据快照应清空旧游标并从第一页重新读取继续加载才使用上一页游标。两种动作同时进行时需要请求代次阻止旧结果覆盖新列表。classTimelineLoader{privategeneration:number0;privatecursor?:TimelineCursor;asyncrefresh(repo:TimelineRepository):PromiseTimelineRow[]{constcurrentthis.generation;constpageawaitrepo.next();if(current!this.generation){return[];}this.cursorpage.nextCursor;returnpage.rows;}}若数据会删除游标方案仍能继续从排序边界向后读若记录的排序字段会被修改则游标稳定性会下降应选择不可变创建时间或独立序列号。10. ResultSet关闭要覆盖所有异常路径不要只在正常循环后调用close()。字段名写错、类型转换失败、页面取消或业务逻辑抛错都会跳过普通语句。统一使用try/finally且不要在关闭后访问列值。constresultawaitstore.querySql(sql,args);try{while(result.goToNextRow()){// 读取并立即转换为普通对象}}finally{result.close();}数据库连接由应用级仓储管理查询结果集则应在单次函数内关闭。二者生命周期不同不能因为store长期存在就让result长期存在。11. 用查询计划确认索引真正生效通过EXPLAIN QUERY PLAN观察查询是否使用目标联合索引并构造相同时间戳的大量数据验证边界。性能对照至少包含offset浅页、offset深页和游标深页。EXPLAINQUERYPLANSELECTid,created_at,titleFROMtimelineWHEREcreated_at?OR(created_at?ANDid?)ORDERBYcreated_atDESC,idDESCLIMIT?;如果计划仍出现大范围扫描或临时排序检查筛选列、排序方向、OR条件与索引顺序。不要仅凭索引已经创建就认定查询会使用它。12. 深分页验收清单[ ] 同一created_at下使用id稳定排序 [ ] 下一页边界与排序方向一致 [ ] 页大小有明确上限 [ ] SQL值全部通过参数绑定 [ ] refresh会丢弃旧游标 [ ] 每个结果集都在finally中关闭 [ ] 深页耗时不会随页码线性增长 [ ] 插入新数据时相邻页无重复、无漏项13. RDB分页资料索引ResultSet接口参考RdbStore.querySqlWithoutRowCount、LiteResultSet与RdbPredicates以本机HarmonyOS SDK API 23声明为准。游标分页并不是把offset换成另一个参数而是重新定义页面边界。稳定排序给出唯一位置联合索引让数据库快速抵达该位置结果集及时关闭保证资源可持续。三者缺一深分页仍可能在数据增长后暴露问题。