第42篇|RDB 查询拖慢首屏:用索引分页和快照缓存拆开
摘要:本地 RDB 数据多了以后,首屏卡顿经常不是 UI 写得慢,而是页面一出现就做了全表排序、统计和复杂过滤。更稳的做法是把首屏快照、分页查询和统计重算拆开:首屏先读轻量快照,列表分页加载,统计在写入后维护或后台刷新。
一个记录类应用首屏从 300ms 变成 2 秒,表面看是列表项太多,实际是首页 aboutToAppear 里同时查全部记录、按时间排序、计算月统计、再渲染卡片。数据少时没问题,半年后本地记录多了,首屏就被 RDB 查询拖住。修复时不能只优化组件,得把查询路径拆开。
这篇文章解决四个实际问题:
- 区分首屏快照、列表分页、统计重算三类读请求。
- 给常用排序和筛选字段补索引,不在页面里全量排序。
- 写入后更新轻量摘要,首屏先用摘要展示。
- 用耗时日志证明慢在查询还是渲染。
先把首页查询拆成三类
首屏慢时不要只看列表组件。先列出首页到底读了哪些数据,每一类数据是否都必须阻塞首帧。
| 位置 | 现象 | 风险 | 处理方向 |
|---|---|---|---|
| 今日摘要 | 只需要总数和最近一条 | 全表扫描太重 | 读 SummaryStore |
| 记录列表 | 需要分页 | 一次查全部卡住 | limit/offset 或游标 |
| 月统计 | 计算量大 | 不该阻塞首屏 | 后台刷新 |
| 筛选排序 | 按时间和类型查 | 无索引导致慢 | 迁移补索引 |
这一步的价值是先把问题放回运行链路。只要能确认问题停在哪个位置,后面就不用靠猜测改页面。
页面不能自己拼复杂查询
页面应该向 Service 要 HomeSnapshot 和 PageResult,而不是直接拿 repository 全量数据后自己排序过滤。
| 模块 | 应该负责 | 不应该负责 |
|---|---|---|
| HomePage | 快照、分页结果、加载态 | SQL 条件 |
| HomeService | 组合首屏数据 | RDB 连接细节 |
| RecordRepository | 分页和筛选查询 | 卡片展示 |
| SummaryStore | 轻量摘要缓存 | 业务按钮 |
| Migration | 索引和版本升级 | 页面生命周期 |
边界定清后,代码就不会在页面、服务和回调之间来回复制同一段判断。后续新增场景也能先判断应该落在哪一层。
首屏快照只放轻量字段
exportinterfaceHomeSnapshot{todayCount:numberlatestTitle:stringlatestTime:numbermonthTotal:numbergeneratedAt:number}exportinterfacePageQuery{cursorTime:numberpageSize:numbertypeFilter:string}exportinterfacePageResult<T>{items:T[]nextCursorTime:numberhasMore:boolean}首屏快照是为快速展示设计的,不放完整列表。分页查询用 cursor 表达下一页位置,避免每次都从头读到尾。
这类模型最好放在models或common中,页面、Service 和仓储都使用同一份类型,避免各层用字符串互相猜。
Repository 只提供分页读
exportclassRecordRepository{asyncqueryPage(query:PageQuery):Promise<PageResult<RecordItem>>{constrows=awaitthis.queryRows({beforeTime:query.cursorTime,limit:query.pageSize+1,typeFilter:query.typeFilter})constitems=rows.slice(0,query.pageSize)constlast=items[items.length-1]return{items,nextCursorTime:last?.createdAt??0,hasMore:rows.length>query.pageSize}}}Repository 不返回全量列表。多查一条用来判断hasMore,页面只关心下一页 cursor,不需要知道底层查询细节。
Service 的目标不是把所有逻辑都塞进去,而是把跨页面、跨生命周期或需要持久化的判断集中起来。页面只表达用户动作。
首页先显示快照,再加载列表
@Componentstruct RecordHomePage{@Statesnapshot:HomeSnapshot=createEmptyHomeSnapshot()@Staterecords:RecordItem[]=[]@StateloadingMore:boolean=falseprivateservice:HomeRecordService=createHomeRecordService()asyncaboutToAppear():Promise<void>{this.snapshot=awaitthis.service.loadHomeSnapshot()awaitthis.loadFirstPage()}privateasyncloadFirstPage():Promise<void>{constpage=awaitthis.service.loadRecords({cursorTime:Date.now(),pageSize:20,typeFilter:''})this.records=page.items}}首屏不会等月统计重新计算完成才显示。快照先给用户一个稳定首页,列表再分页补齐。
页面层要控制展示和交互节奏,但不应该拥有底层事实。这样页面重建、横竖屏变化或返回前台时,都能重新从 Service 拿到可信结果。
写入后同步更新摘要
exportclassHomeRecordService{constructor(privatereadonlyrepository:RecordRepository,privatereadonlysummaryStore:SummaryStore){}asyncsaveRecord(input:SaveRecordInput):Promise<void>{constrecord=normalizeRecord(input)awaitthis.repository.save(record)awaitthis.summaryStore.updateAfterWrite(record)}asyncloadHomeSnapshot():Promise<HomeSnapshot>{returnthis.summaryStore.loadSnapshot()}}摘要不是页面临时算出来的,而是在写入路径维护。这样下一次打开首页时能直接读快照,不用全表重算。
实际项目里,很多问题不是第一次进入页面暴露,而是在冷启动、返回前台、页面重建或回调晚到时暴露。把这段生命周期补上,修复才算闭环。
慢查询要留下耗时证据
| 情况 | 页面表现 | 工程处理 |
|---|---|---|
| 首屏快照慢 | 记录 SummaryStore 耗时 | 检查是否退回全表统计 |
| 分页慢 | 记录 cursor 和 pageSize | 检查索引和过滤字段 |
| 写入后摘要错 | 对比原始记录重算 | 修 updateAfterWrite |
| 列表渲染慢 | 查询快但首帧慢 | 再看组件和图片 |
兜底路径不是可有可无。用户遇到异常时,页面至少要给出当前状态、可执行动作和可排查线索。
排查时找全量查询和排序
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 首屏慢要先拆读路径。首页读快照,列表分页,统计在写入或后台维护,慢查询有日志。这样数据增长后,首屏仍然是一个可控链路,而不是所有本地数据的总入口。