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的兼容分支可使用querySqlResultSet,但同样必须关闭。

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不应持有ResultSetLiteResultSet,否则页面生命周期会把数据库资源拖得过长。

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.querySqlWithoutRowCountLiteResultSetRdbPredicates:以本机HarmonyOS SDK API 23声明为准。

游标分页并不是把offset换成另一个参数,而是重新定义页面边界。稳定排序给出唯一位置,联合索引让数据库快速抵达该位置,结果集及时关闭保证资源可持续。三者缺一,深分页仍可能在数据增长后暴露问题。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐