HarmonyOS 7 API 26 RDB 慢查询治理图

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 慢还是数据库慢,直接按证据定位。

Logo

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

更多推荐