HarmonyOS RDB 深分页优化:游标翻页、联合索引与ResultSet释放
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条。如果排序列没有合适索引,还会叠加临时排序。页码越大,响应时间通常越不稳定。
SELECT id, created_at, title
FROM timeline
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
浅页、数据量很小或允许直接跳页时,offset仍然简单实用。问题不在语法本身,而在把它用于无限下拉和高页码历史数据。
2. 游标协议记录上一页的最后位置
游标分页不再说“跳过多少行”,而是说“从上一页最后一条之后继续”。排序使用created_at DESC, id DESC时,下一页条件必须同时包含两个字段。
interface TimelineCursor {
createdAt: number;
id: number;
}
interface TimelineRow {
id: number;
createdAt: number;
title: string;
}
interface TimelinePage {
rows: TimelineRow[];
nextCursor?: TimelineCursor;
}
只记录时间戳是不够的。同一毫秒可能写入多条数据,若没有id作为稳定次序,边界记录会在相邻页重复或消失。
3. 排序条件与索引顺序必须一致
先建立表和联合索引。索引字段顺序、升降序与查询排序保持一致,数据库才更容易从游标位置继续扫描,而不是重新处理大段历史数据。
CREATE TABLE IF NOT EXISTS timeline (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at INTEGER NOT NULL,
title TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_timeline_created_id
ON timeline(created_at DESC, id DESC);
如果查询还固定按用户或会话过滤,索引通常要把等值过滤列放在前面,例如(session_id, created_at DESC, id DESC)。不要为每个查询随意叠加索引,写入成本和数据库体积也要纳入评估。
4. 游标翻页闭环要把释放动作放进去

收到游标后,查询从索引边界继续读取一页;页面最后一条生成新游标;无论读取成功、字段解析失败还是页面提前退出,结果集都必须关闭。把关闭动作视为查询的一部分,才能避免长期运行后句柄逐渐耗尽。
5. 第一页与后续页使用不同SQL
第一页没有游标,只需要排序和限制;后续页使用严格“小于”边界。复合比较写成两个条件,兼顾相同时间戳。
-- 第一页
SELECT id, created_at, title
FROM timeline
ORDER BY created_at DESC, id DESC
LIMIT ?;
-- 后续页
SELECT id, created_at, title
FROM timeline
WHERE created_at < ?
OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC
LIMIT ?;
降序分页使用<,升序分页使用>。如果排序方向改了但边界符号没有同步修改,常见表现是第二页为空或不断重复第一页尾部。
6. API 23优先避免无用的rowCount计算
API 23提供querySqlWithoutRowCount并返回LiteResultSet。无限列表只需要逐行读取,不需要结果总数时,可以避免为rowCount付出额外工作。低于API 23的兼容分支可使用querySql与ResultSet,但同样必须关闭。
import { relationalStore } from '@kit.ArkData';
async function queryTimeline(
store: relationalStore.RdbStore,
cursor: TimelineCursor | undefined,
pageSize: number
): Promise<TimelinePage> {
const safeSize = Math.max(1, Math.min(pageSize, 100));
const sql = cursor
? `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 ?`;
const args: relationalStore.ValueType[] = cursor
? [cursor.createdAt, cursor.createdAt, cursor.id, safeSize]
: [safeSize];
const result = await store.querySqlWithoutRowCount(sql, args);
const rows: TimelineRow[] = [];
try {
const idIndex = result.getColumnIndex('id');
const timeIndex = result.getColumnIndex('created_at');
const titleIndex = result.getColumnIndex('title');
while (result.goToNextRow()) {
rows.push({
id: result.getLong(idIndex),
createdAt: result.getLong(timeIndex),
title: result.getString(titleIndex)
});
}
} finally {
result.close();
}
const last = rows[rows.length - 1];
return {
rows,
nextCursor: rows.length === safeSize && last
? { createdAt: last.createdAt, id: last.id }
: undefined
};
}
safeSize限制调用方传入的页大小,防止一次读取数千条。SQL参数必须使用绑定变量,不能把游标直接拼接进字符串。
7. 页面请求、索引和结果集各守一层边界

页面请求只携带不透明游标;仓储层解析游标并执行SQL;数据库通过联合索引定位;结果集负责短暂遍历并立即关闭。UI不应持有ResultSet或LiteResultSet,否则页面生命周期会把数据库资源拖得过长。
class TimelineRepository {
constructor(private store: relationalStore.RdbStore) {}
async next(cursor?: TimelineCursor): Promise<TimelinePage> {
return queryTimeline(this.store, cursor, 30);
}
}
仓储层返回普通数据对象,页面销毁后不会遗留数据库游标,也便于单元测试构造固定页面结果。
8. 游标最好编码成不可随意修改的字符串
跨页面或跨进程传递时,可把两个数字编码为JSON再转为Base64。解码后必须检查数值范围,不能相信外部传入的游标。
function encodeCursor(cursor: TimelineCursor): string {
return `${cursor.createdAt}:${cursor.id}`;
}
function decodeCursor(value: string): TimelineCursor | undefined {
const parts = value.split(':');
if (parts.length !== 2) {
return undefined;
}
const createdAt = Number(parts[0]);
const id = Number(parts[1]);
if (!Number.isSafeInteger(createdAt) || !Number.isSafeInteger(id)) {
return undefined;
}
return { createdAt, id };
}
若游标来自服务端,还应加入版本、查询条件摘要或签名,避免筛选条件改变后继续使用旧游标。
9. 刷新与继续翻页不能共用游标
下拉刷新代表建立新的数据快照,应清空旧游标并从第一页重新读取;继续加载才使用上一页游标。两种动作同时进行时,需要请求代次阻止旧结果覆盖新列表。
class TimelineLoader {
private generation: number = 0;
private cursor?: TimelineCursor;
async refresh(repo: TimelineRepository): Promise<TimelineRow[]> {
const current = ++this.generation;
const page = await repo.next();
if (current !== this.generation) {
return [];
}
this.cursor = page.nextCursor;
return page.rows;
}
}
若数据会删除,游标方案仍能继续从排序边界向后读;若记录的排序字段会被修改,则游标稳定性会下降,应选择不可变创建时间或独立序列号。
10. ResultSet关闭要覆盖所有异常路径
不要只在正常循环后调用close()。字段名写错、类型转换失败、页面取消或业务逻辑抛错都会跳过普通语句。统一使用try/finally,且不要在关闭后访问列值。
const result = await store.querySql(sql, args);
try {
while (result.goToNextRow()) {
// 读取并立即转换为普通对象
}
} finally {
result.close();
}
数据库连接由应用级仓储管理,查询结果集则应在单次函数内关闭。二者生命周期不同,不能因为store长期存在就让result长期存在。
11. 用查询计划确认索引真正生效
通过EXPLAIN QUERY PLAN观察查询是否使用目标联合索引,并构造相同时间戳的大量数据验证边界。性能对照至少包含offset浅页、offset深页和游标深页。
EXPLAIN QUERY PLAN
SELECT id, created_at, title
FROM timeline
WHERE created_at < ? OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC
LIMIT ?;
如果计划仍出现大范围扫描或临时排序,检查筛选列、排序方向、OR条件与索引顺序。不要仅凭索引已经创建就认定查询会使用它。
12. 深分页验收清单
[ ] 同一created_at下使用id稳定排序
[ ] 下一页边界与排序方向一致
[ ] 页大小有明确上限
[ ] SQL值全部通过参数绑定
[ ] refresh会丢弃旧游标
[ ] 每个结果集都在finally中关闭
[ ] 深页耗时不会随页码线性增长
[ ] 插入新数据时相邻页无重复、无漏项
13. RDB分页资料索引
- ResultSet接口参考
RdbStore.querySqlWithoutRowCount、LiteResultSet与RdbPredicates:以本机HarmonyOS SDK API 23声明为准。
游标分页并不是把offset换成另一个参数,而是重新定义页面边界。稳定排序给出唯一位置,联合索引让数据库快速抵达该位置,结果集及时关闭保证资源可持续。三者缺一,深分页仍可能在数据增长后暴露问题。
更多推荐


所有评论(0)