RDB 慢查询很容易被误判成“页面性能差”。列表滑动慢、搜索输入卡、分页越翻越迟钝表面看是 UI 问题实际经常是查询条件没有命中索引或者分页方式让数据库反复扫描前面的数据。这篇按 HarmonyOS 7 / API 26 的本地数据排查方式来写。目标不是堆 SQL而是把慢查询复现、索引设计、explain 检查、分页回表和回归阈值放在一条链路里。后面再遇到搜索慢、列表慢就能直接按这套方法验。先看两个最容易踩坑的查询我会先把问题缩小成两类搜索条件慢分页越翻越慢。场景表现常见原因关键词搜索输入一个词后列表迟迟不刷新LIKE 写法或组合条件没有命中索引分页加载前几页正常越往后越慢offset 太大数据库反复跳过旧数据这两个问题都不能只看页面耗时要把 SQL、参数、explain 输出和返回条数一起看。表结构和测试数据先准备一张简化的菜谱表。这里用菜谱只是为了让字段更接近日常应用本质上任何本地列表都一样。~~~tstype RecipeRow {id: numbertitle: stringcategory: stringupdatedAt: numberscore: 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_scoreON recipe(category, score DESC, updated_at DESC)const createTimeIndexSql CREATE INDEX IF NOT EXISTS idx_recipe_updated_idON recipe(updated_at DESC, id DESC)~~~这里先建两个索引一个服务分类加推荐排序一个服务时间流分页。不要一上来给每个字段都建索引索引太多会拖慢写入也会让后面维护成本变高。案例一搜索条件没有命中索引问题写法通常是这样~~~tsconst badSearchSql SELECT id, title, category, scoreFROM recipeWHERE title LIKE ? OR category ?ORDER BY score DESCLIMIT 20~~~这条 SQL 有两个问题。第一OR 条件会让优化器更难稳定命中我们想要的索引第二title LIKE 如果写成前后都有通配符经常没法靠普通索引解决。更稳的做法是先明确搜索入口。如果用户选了分类就先走分类索引如果用户输入关键词再单独处理关键词过滤不要把两种逻辑硬塞进一条 SQL。~~~tstype SearchParams {keyword?: stringcategory?: stringlimit: number}function buildSearchSql(params: SearchParams): { sql: string, args: string[] } {if (params.category) {return {sql: SELECT id, title, category, scoreFROM recipeWHERE category ?ORDER BY score DESC, updated_at DESCLIMIT ?,args: [params.category, String(params.limit)]}}return {sql: SELECT id, title, category, scoreFROM recipeWHERE title LIKE ?ORDER BY updated_at DESC, id DESCLIMIT ?,args: [params.keyword ? params.keyword % : %, String(params.limit)]}}~~~如果业务一定要支持任意位置模糊搜索就不要假装普通索引能解决所有问题。更现实的做法是限制关键词输入后的触发频率、控制返回条数、必要时维护一张搜索辅助表。explain 输出要成为发布前检查项只改 SQL 不够还要看 explain 输出。我的检查函数会把 SQL、参数和 explain 结果一起打印出来。~~~tstype QueryPlanRow {id: numberparent: numbernotused: numberdetail: 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 越大越慢第二个问题更常见。很多分页一开始都这样写~~~tsconst offsetSql SELECT id, title, updated_atFROM recipeORDER BY updated_at DESC, id DESCLIMIT ? OFFSET ?~~~前几页没问题翻到后面就慢。原因很简单offset 越大数据库要跳过的数据越多。用户看到的是“加载下一页越来越慢”。更稳的方式是游标分页记住上一页最后一条的 updated_at 和 id下一页从它后面继续查。~~~tstype PageCursor {updatedAt: numberid: number}function buildCursorPageSql(cursor?: PageCursor): { sql: string, args: string[] } {if (!cursor) {return {sql: SELECT id, title, updated_atFROM recipeORDER BY updated_at DESC, id DESCLIMIT 20,args: []}}return {sql: SELECT id, title, updated_atFROM recipeWHERE updated_at ? OR (updated_at ? AND id ?)ORDER BY updated_at DESC, id DESCLIMIT 20,args: [String(cursor.updatedAt), String(cursor.updatedAt), String(cursor.id)]}}~~~这条 SQL 对应的索引就是前面的 idx_recipe_updated_id。排序字段和游标条件保持一致数据库就不需要每次从头跳过大量旧数据。给查询加耗时和返回量日志慢查询不是靠肉眼判断。每条关键查询都应该记录耗时、返回条数和场景。~~~tstype QueryMetric {scene: stringsqlName: stringcostMs: numberrowCount: numberindexed: 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 选择是否合理。重点不是模拟真实数据库引擎而是把查询入参、游标和回归条件跑清楚。~~~tsfunction 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 慢还是数据库慢直接按证据定位。