HarmonyOS 7 / API 26 RDB 慢查询治理实战:索引命中、explain 输出和分页回表一次验清

RDB 慢查询很容易被误判成“页面性能差”。列表滑动慢、搜索输入卡、分页越翻越迟钝,表面看是 UI 问题,实际经常是查询条件没有命中索引,或者分页方式让数据库反复扫描前面的数据。
这篇按 HarmonyOS 7 / API 26 的本地数据排查方式来写。目标不是堆 SQL,而是把慢查询复现、索引设计、explain 检查、分页回表和回归阈值放在一条链路里。后面再遇到搜索慢、列表慢,就能直接按这套方法验。
先看两个最容易踩坑的查询
我会先把问题缩小成两类:搜索条件慢,分页越翻越慢。
| 场景 | 表现 | 常见原因 |
| 关键词搜索 | 输入一个词后列表迟迟不刷新 | LIKE 写法或组合条件没有命中索引 |
| 分页加载 | 前几页正常,越往后越慢 | offset 太大,数据库反复跳过旧数据 |
这两个问题都不能只看页面耗时,要把 SQL、参数、explain 输出和返回条数一起看。
表结构和测试数据
先准备一张简化的菜谱表。这里用菜谱只是为了让字段更接近日常应用,本质上任何本地列表都一样。
~~~ts
type RecipeRow = {
id: number
title: string
category: string
updatedAt: number
score: number
}
const createRecipeTableSql = `
CREATE TABLE IF NOT EXISTS recipe (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT NOT NULL,
updated_at INTEGER NOT NULL,
score INTEGER NOT NULL
)
`
const createSearchIndexSql = `
CREATE INDEX IF NOT EXISTS idx_recipe_category_score
ON recipe(category, score DESC, updated_at DESC)
`
const createTimeIndexSql = `
CREATE INDEX IF NOT EXISTS idx_recipe_updated_id
ON recipe(updated_at DESC, id DESC)
`
~~~
这里先建两个索引:一个服务分类加推荐排序,一个服务时间流分页。不要一上来给每个字段都建索引,索引太多会拖慢写入,也会让后面维护成本变高。
案例一:搜索条件没有命中索引
问题写法通常是这样:
~~~ts
const badSearchSql = `
SELECT id, title, category, score
FROM recipe
WHERE title LIKE ? OR category = ?
ORDER BY score DESC
LIMIT 20
`
~~~
这条 SQL 有两个问题。第一,OR 条件会让优化器更难稳定命中我们想要的索引;第二,title LIKE 如果写成前后都有通配符,经常没法靠普通索引解决。
更稳的做法是先明确搜索入口。如果用户选了分类,就先走分类索引;如果用户输入关键词,再单独处理关键词过滤,不要把两种逻辑硬塞进一条 SQL。
~~~ts
type SearchParams = {
keyword?: string
category?: string
limit: number
}
function buildSearchSql(params: SearchParams): { sql: string, args: string[] } {
if (params.category) {
return {
sql: `
SELECT id, title, category, score
FROM recipe
WHERE category = ?
ORDER BY score DESC, updated_at DESC
LIMIT ?
`,
args: [params.category, String(params.limit)]
}
}
return {
sql: `
SELECT id, title, category, score
FROM recipe
WHERE title LIKE ?
ORDER BY updated_at DESC, id DESC
LIMIT ?
`,
args: [params.keyword ? params.keyword + '%' : '%', String(params.limit)]
}
}
~~~
如果业务一定要支持任意位置模糊搜索,就不要假装普通索引能解决所有问题。更现实的做法是限制关键词输入后的触发频率、控制返回条数、必要时维护一张搜索辅助表。
explain 输出要成为发布前检查项
只改 SQL 不够,还要看 explain 输出。我的检查函数会把 SQL、参数和 explain 结果一起打印出来。
~~~ts
type QueryPlanRow = {
id: number
parent: number
notused: number
detail: string
}
function printExplain(sql: string, args: string[], plan: QueryPlanRow[]): void {
console.info('[rdb-explain]', sql.replace(/s+/g, ' ').trim())
console.info('[rdb-args]', JSON.stringify(args))
for (const row of plan) {
console.info('[rdb-plan]', row.detail)
}
}
~~~
我希望看到的是 SEARCH、USING INDEX 这类信息。如果输出里经常出现全表扫描,就要回头看 WHERE 和 ORDER BY 是否能被同一个索引服务。
案例二:offset 越大越慢
第二个问题更常见。很多分页一开始都这样写:
~~~ts
const offsetSql = `
SELECT id, title, updated_at
FROM recipe
ORDER BY updated_at DESC, id DESC
LIMIT ? OFFSET ?
`
~~~
前几页没问题,翻到后面就慢。原因很简单:offset 越大,数据库要跳过的数据越多。用户看到的是“加载下一页越来越慢”。
更稳的方式是游标分页:记住上一页最后一条的 updated_at 和 id,下一页从它后面继续查。
~~~ts
type PageCursor = {
updatedAt: number
id: number
}
function buildCursorPageSql(cursor?: PageCursor): { sql: string, args: string[] } {
if (!cursor) {
return {
sql: `
SELECT id, title, updated_at
FROM recipe
ORDER BY updated_at DESC, id DESC
LIMIT 20
`,
args: []
}
}
return {
sql: `
SELECT id, title, updated_at
FROM recipe
WHERE updated_at < ? OR (updated_at = ? AND id < ?)
ORDER BY updated_at DESC, id DESC
LIMIT 20
`,
args: [String(cursor.updatedAt), String(cursor.updatedAt), String(cursor.id)]
}
}
~~~
这条 SQL 对应的索引就是前面的 idx_recipe_updated_id。排序字段和游标条件保持一致,数据库就不需要每次从头跳过大量旧数据。
给查询加耗时和返回量日志
慢查询不是靠肉眼判断。每条关键查询都应该记录耗时、返回条数和场景。
~~~ts
type QueryMetric = {
scene: string
sqlName: string
costMs: number
rowCount: number
indexed: boolean
}
function printQueryMetric(metric: QueryMetric): void {
console.info(
'[rdb-query]',
metric.scene,
metric.sqlName,
'cost=' + metric.costMs,
'rows=' + metric.rowCount,
'indexed=' + metric.indexed
)
}
~~~
我会把阈值设得很明确:
| 查询类型 | 目标 |
| 首页首屏查询 | 100ms 内返回 |
| 分类筛选查询 | 150ms 内返回 |
| 搜索建议查询 | 80ms 内返回,输入需要防抖 |
| 下一页游标查询 | 不随页码线性变慢 |
阈值不是绝对标准,但没有阈值就没有回归依据。
本地验证脚本
可以用模拟数据验证 SQL 选择是否合理。重点不是模拟真实数据库引擎,而是把查询入参、游标和回归条件跑清楚。
~~~ts
function mockCursorRows(count: number): RecipeRow[] {
const rows: RecipeRow[] = []
for (let i = 0; i < count; i++) {
rows.push({
id: i + 1,
title: 'recipe-' + i,
category: i % 2 === 0 ? 'home' : 'snack',
updatedAt: 200000 - i,
score: i % 100
})
}
return rows
}
function verifyCursorPage(rows: RecipeRow[], cursor?: PageCursor): RecipeRow[] {
return rows
.filter(row => !cursor || row.updatedAt < cursor.updatedAt || (row.updatedAt === cursor.updatedAt && row.id < cursor.id))
.sort((a, b) => b.updatedAt - a.updatedAt || b.id - a.id)
.slice(0, 20)
}
function runPageVerify(): void {
const rows = mockCursorRows(5000)
const first = verifyCursorPage(rows)
const last = first[first.length - 1]
const second = verifyCursorPage(rows, { updatedAt: last.updatedAt, id: last.id })
console.info('[verify-page]', first.length, second.length, first[19].id, second[0].id)
}
~~~
预期结果是第一页和第二页都稳定返回 20 条,而且第二页第一条紧跟第一页最后一条之后。如果这里都跑不清楚,接到真实 RDB 里更容易出错。
哪种方案更适合
| 方案 | 适合场景 | 风险 |
| offset 分页 | 数据量小、页数浅、后台管理类列表 | 深分页越来越慢 |
| 游标分页 | 信息流、历史记录、收藏列表、搜索结果 | 需要稳定排序字段 |
| 搜索辅助表 | 关键词复杂、需要多字段检索 | 写入和同步成本更高 |
我的选择是:普通列表优先游标分页;分类筛选优先组合索引;复杂关键词不要硬靠一条 LIKE SQL,把搜索辅助表或服务端检索纳入设计。
小结
HarmonyOS 7 / API 26 里处理 RDB 慢查询,关键不是把 SQL 写得更花,而是把查询目标说清楚。分类筛选看组合索引,时间流分页看游标,关键词搜索看触发频率和辅助结构。每条关键查询都要有 explain 输出、耗时日志和回归阈值。这样页面卡的时候,团队不用猜是 UI 慢还是数据库慢,直接按证据定位。
更多推荐



所有评论(0)