第42篇|RDB 查询拖慢首屏:用索引分页和快照缓存拆开
摘要:本地 RDB 数据多了以后,首屏卡顿经常不是 UI 写得慢,而是页面一出现就做了全表排序、统计和复杂过滤。更稳的做法是把首屏快照、分页查询和统计重算拆开:首屏先读轻量快照,列表分页加载,统计在写入后维护或后台刷新。
一个记录类应用首屏从 300ms 变成 2 秒,表面看是列表项太多,实际是首页 aboutToAppear 里同时查全部记录、按时间排序、计算月统计、再渲染卡片。数据少时没问题,半年后本地记录多了,首屏就被 RDB 查询拖住。修复时不能只优化组件,得把查询路径拆开。

这篇文章解决四个实际问题:
- 区分首屏快照、列表分页、统计重算三类读请求。
- 给常用排序和筛选字段补索引,不在页面里全量排序。
- 写入后更新轻量摘要,首屏先用摘要展示。
- 用耗时日志证明慢在查询还是渲染。


先把首页查询拆成三类
首屏慢时不要只看列表组件。先列出首页到底读了哪些数据,每一类数据是否都必须阻塞首帧。
| 位置 | 现象 | 风险 | 处理方向 |
|---|---|---|---|
| 今日摘要 | 只需要总数和最近一条 | 全表扫描太重 | 读 SummaryStore |
| 记录列表 | 需要分页 | 一次查全部卡住 | limit/offset 或游标 |
| 月统计 | 计算量大 | 不该阻塞首屏 | 后台刷新 |
| 筛选排序 | 按时间和类型查 | 无索引导致慢 | 迁移补索引 |
这一步的价值是先把问题放回运行链路。只要能确认问题停在哪个位置,后面就不用靠猜测改页面。
页面不能自己拼复杂查询
页面应该向 Service 要 HomeSnapshot 和 PageResult,而不是直接拿 repository 全量数据后自己排序过滤。
| 模块 | 应该负责 | 不应该负责 |
|---|---|---|
| HomePage | 快照、分页结果、加载态 | SQL 条件 |
| HomeService | 组合首屏数据 | RDB 连接细节 |
| RecordRepository | 分页和筛选查询 | 卡片展示 |
| SummaryStore | 轻量摘要缓存 | 业务按钮 |
| Migration | 索引和版本升级 | 页面生命周期 |
边界定清后,代码就不会在页面、服务和回调之间来回复制同一段判断。后续新增场景也能先判断应该落在哪一层。
首屏快照只放轻量字段
ts
export interface HomeSnapshot {
todayCount: number
latestTitle: string
latestTime: number
monthTotal: number
generatedAt: number
}
export interface PageQuery {
cursorTime: number
pageSize: number
typeFilter: string
}
export interface PageResult<T> {
items: T[]
nextCursorTime: number
hasMore: boolean
}
首屏快照是为快速展示设计的,不放完整列表。分页查询用 cursor 表达下一页位置,避免每次都从头读到尾。
这类模型最好放在 models 或 common 中,页面、Service 和仓储都使用同一份类型,避免各层用字符串互相猜。
Repository 只提供分页读
ts
export class RecordRepository {
async queryPage(query: PageQuery): Promise<PageResult<RecordItem>> {
const rows = await this.queryRows({
beforeTime: query.cursorTime,
limit: query.pageSize + 1,
typeFilter: query.typeFilter
})
const items = rows.slice(0, query.pageSize)
const last = items[items.length - 1]
return {
items,
nextCursorTime: last?.createdAt ?? 0,
hasMore: rows.length > query.pageSize
}
}
}
Repository 不返回全量列表。多查一条用来判断 hasMore,页面只关心下一页 cursor,不需要知道底层查询细节。
Service 的目标不是把所有逻辑都塞进去,而是把跨页面、跨生命周期或需要持久化的判断集中起来。页面只表达用户动作。
首页先显示快照,再加载列表
ts
@Component
struct RecordHomePage {
@State snapshot: HomeSnapshot = createEmptyHomeSnapshot()
@State records: RecordItem[] = []
@State loadingMore: boolean = false
private service: HomeRecordService = createHomeRecordService()
async aboutToAppear(): Promise<void> {
this.snapshot = await this.service.loadHomeSnapshot()
await this.loadFirstPage()
}
private async loadFirstPage(): Promise<void> {
const page = await this.service.loadRecords({ cursorTime: Date.now(), pageSize: 20, typeFilter: '' })
this.records = page.items
}
}
首屏不会等月统计重新计算完成才显示。快照先给用户一个稳定首页,列表再分页补齐。
页面层要控制展示和交互节奏,但不应该拥有底层事实。这样页面重建、横竖屏变化或返回前台时,都能重新从 Service 拿到可信结果。
写入后同步更新摘要
ts
export class HomeRecordService {
constructor(
private readonly repository: RecordRepository,
private readonly summaryStore: SummaryStore
) {}
async saveRecord(input: SaveRecordInput): Promise<void> {
const record = normalizeRecord(input)
await this.repository.save(record)
await this.summaryStore.updateAfterWrite(record)
}
async loadHomeSnapshot(): Promise<HomeSnapshot> {
return this.summaryStore.loadSnapshot()
}
}
摘要不是页面临时算出来的,而是在写入路径维护。这样下一次打开首页时能直接读快照,不用全表重算。
实际项目里,很多问题不是第一次进入页面暴露,而是在冷启动、返回前台、页面重建或回调晚到时暴露。把这段生命周期补上,修复才算闭环。
慢查询要留下耗时证据
| 情况 | 页面表现 | 工程处理 |
|---|---|---|
| 首屏快照慢 | 记录 SummaryStore 耗时 | 检查是否退回全表统计 |
| 分页慢 | 记录 cursor 和 pageSize | 检查索引和过滤字段 |
| 写入后摘要错 | 对比原始记录重算 | 修 updateAfterWrite |
| 列表渲染慢 | 查询快但首帧慢 | 再看组件和图片 |
兜底路径不是可有可无。用户遇到异常时,页面至少要给出当前状态、可执行动作和可排查线索。
排查时找全量查询和排序
powershell
rg -n "queryAll|findAll|ORDER BY|sort\(|filter\(" entry features common
rg -n "limit|offset|cursor|pageSize|SummaryStore|HomeSnapshot" entry features common
rg -n "aboutToAppear|onShown|loadHome|loadRecords" entry features common
第一组命令找旧路径,第二组命令找新边界,第三组命令看生命周期或回调位置。命中后不要只看字段名,要判断它是否仍在直接改页面状态。
索引和摘要要跟版本迁移一起走
很多首屏慢的问题不是某一次查询写错,而是版本演进时没有同步维护本地结构。新增筛选条件后,页面开始按 type、createdAt、updatedAt 查,但迁移里没有补索引;新增首页统计后,写入路径没有更新摘要,只能在首页临时全量重算。
| 变更 | 需要同步处理 | 不处理的后果 |
|---|---|---|
| 新增时间排序 | 给时间字段补索引 | 数据多后排序拖慢首屏 |
| 新增类型筛选 | 给类型和时间组合查询设计路径 | 筛选页每次全表过滤 |
| 新增首页统计 | 写入后更新摘要 | 首页打开时全量重算 |
| 新增导入功能 | 导入结束后重建摘要 | 导入后统计和列表不一致 |
所以 RDB 优化不是最后才做的性能补丁。每次本地数据结构变化,都应该顺手问一句:首屏读什么、列表怎么分页、摘要在哪里更新、旧版本升级后这些结构是否存在。
验证清单
| 验证项 | 预期结果 |
|---|---|
| 千条记录冷启动 | 先出现快照,再分页出列表 |
| 新增记录 | 首页摘要立即更新 |
| 加载更多 | 每次只追加一页 |
| 筛选切换 | 查询耗时可记录 |
| 无记录 | 空状态不触发全量统计循环 |
这些验证最好在真机或模拟器里按顺序走一遍。只看源码很难发现冷启动、重启和回调晚到这类问题。
常见问题和处理方式
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 首屏 2 秒 | 全量查加统计阻塞 | 快照和分页拆开 |
| 越用越慢 | 无索引且全表排序 | 迁移补索引 |
| 统计不准 | 页面手动改计数 | 写入后统一更新摘要 |
| 列表卡顿 | 一次渲染全部记录 | 分页加载 |
如果现象没有出现在表里,仍然建议按"输入来源 -> 中间状态 -> 持久化或页面展示 -> 失败兜底"的顺序排查。
小结:首屏不要背负全量数据
RDB 首屏慢要先拆读路径。首页读快照,列表分页,统计在写入或后台维护,慢查询有日志。这样数据增长后,首屏仍然是一个可控链路,而不是所有本地数据的总入口。
|
| 列表卡顿 | 一次渲染全部记录 | 分页加载 |
如果现象没有出现在表里,仍然建议按"输入来源 -> 中间状态 -> 持久化或页面展示 -> 失败兜底"的顺序排查。
小结:首屏不要背负全量数据
RDB 首屏慢要先拆读路径。首页读快照,列表分页,统计在写入或后台维护,慢查询有日志。这样数据增长后,首屏仍然是一个可控链路,而不是所有本地数据的总入口。