3.2 列表获取:后端分页与查询优化
上一个函数能 ping 通之后,第一个真正的业务接口是文章列表。这个接口看起来最简单,却是整个项目里返工次数最多的一个:从一开始的二十行代码,最后演进成一套带游标分页、字段过滤和索引约束的完整方案。这一节把演进过程完整记录下来,为什么传统的 skip 分页不能用、游标分页怎么写、索引怎么配,都在这里。
场景:列表接口的三重压力
图文应用里列表接口调用量最大,它同时承受三重压力。一是数据量随时间线性增长,上线半年后文章表就有上万条记录。二是排序规则复杂,要按发布时间倒序、还要支持置顶和精华混排。三是前端无限滚动,分页参数完全由用户行为决定,什么页码都可能传上来。
第一版接口写得飞快,核心逻辑就五行:
// 第一版列表,已废弃
const db = cloud.database()
const result = await db.collection('articles')
.orderBy('createTime', 'desc')
.skip((page - 1) * 20)
.limit(20)
.get()
上线两周问题就来了。有用户反馈刷到几百条之后列表变卡,查日志发现这一个接口的耗时飙到了三秒以上。性能问题的根源在 skip,这是这一节最想讲清楚的一件事。
skip 分页为什么慢
skip 的实现方式决定了它的性能曲线:数据库要先从头扫描跳过前 N 条记录,再返回后面的数据。跳过的记录越多,耗时越长,而且是线性增长。简单说就是越往后翻越慢。
用我们自己的历史数据做个对照,下面是当时压测的记录:
| 分页位置 | skip 耗时 | 游标耗时 | 差距 |
|---|---|---|---|
| 第 1 页 | 40ms | 35ms | 几乎无差距 |
| 第 10 页 | 120ms | 40ms | 3 倍 |
| 第 50 页 | 520ms | 42ms | 12 倍 |
| 第 200 页 | 2400ms | 38ms | 60 倍以上 |
差距大到不需要犹豫。结论很明确:只要列表可能被翻到很深,就别用 skip。这个结论后来也分享给了写另一本 Serverless 实战的朋友,他在自己的项目里遇到了完全一样的问题。
游标分页方案
替代 skip 的标准做法是游标分页:记住上一页最后一条的排序键,下一页直接从这个键之后取。改造后的核心代码:
// 游标分页列表
async function listArticles({ cursor, limit = 20 }) {
const db = cloud.database()
const coll = db.collection('articles')
const where = cursor
? coll.where({ createTime: db.command.lt(cursor.time) })
: coll
const result = await where
.orderBy('createTime', 'desc')
.limit(limit + 1)
.get()
const list = result.data.slice(0, limit)
const hasMore = result.data.length > limit
const next = hasMore
? { time: list[list.length - 1].createTime }
: null
return { list, next, hasMore }
}
三个细节值得注意。第一是查 limit + 1 条,多查一条用来判断还有没有下一页,这样前端不用拿总数。第二是游标用上一页最后一条的 createTime 而不是页码,天然免疫跳页问题。第三是返回结构里自带 hasMore,前端无限滚动只看这一个字段。
排序键的选择有个真实教训。最初游标里只存了 createTime,结果某次两条记录的 createTime 完全相同(脚本批量导入的数据),游标落在这两条之间,第二页直接跳过了其中一条。修复方法是游标里同时存 createTime 和 _id,查询条件改成小于或等于并且排除已读:
// 处理排序键重复的边界情况
const cmd = db.command
const where = cursor
? cmd.or([
{ createTime: cmd.lt(cursor.time) },
{ createTime: cmd.eq(cursor.time), _id: cmd.gt(cursor.id) }
])
: {}
排序键重复这个坑在数据表设计定型时如果选了更好的字段就能提前避掉,可惜当时没想到。
字段过滤与聚合
第一版接口返回的是全字段,包括可能几百字的正文和一堆审核中间状态。列表页只需要标题、摘要、封面几个字段,全量返回纯属浪费带宽,响应体动辄上百 KB。加上 field 做投影之后,响应体缩到原来的十分之一:
// 只取列表需要的字段
const result = await coll.field({
title: true,
summary: true,
cover: true,
createTime: true,
likeCount: true
}).orderBy('createTime', 'desc').limit(20).get()
另一个优化是计数策略。以前的接口同时返回总数,前端拿总数算页码。云开发里 count 是独立一次查询,等于列表接口的耗时翻倍。改成游标分页后前端只依赖 hasMore,总数完全不需要了,只在管理后台才单独查一次。
各种取数策略的取舍对照如下,项目里最终选用的是第二行:
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| skip 分页 | 实现最简单支持跳页 | 深分页性能崩 | 列表不会超过几百条 |
| 游标分页 | 性能稳定不限深度 | 不能跳到指定页 | 无限滚动列表 |
| 游标加字段投影 | 响应体小速度快 | 前端要改交互 | 移动端信息流 |
| 全量返回前端分页 | 后端零逻辑 | 首次加载慢 | 管理后台小数据量 |
索引与非必要查询
数据库的查询条件如果不是索引字段,每次都是全表扫描。文章列表按 createTime 排序,就必须给 createTime 建索引,否则数据量一上来列表接口必然超时。建索引在控制台集合页面操作,一条命令的事,但非常容易漏。
除了索引,还有一类非必要查询值得消灭:拉黑列表。最初只要用户拉黑了某作者,列表里就要过滤掉对方的所有文章,查询条件里塞了一个 not in 一个大数组。数据量小时没问题,某用户拉黑了上百人之后查询直接从 50ms 涨到 1 秒。最后的解法是把它挪到前端做——后端照常返回列表,前端按本地存储的拉黑名单过滤。交互上牺牲了一点(可能某次拉黑后刷不出来对方消失),换来后端查询的绝对稳定。
这个案例被我们称为让索引走主干道,属于典型的用空间换策略:后端只优化主干路径,边缘脏活交给客户端。同样的思路在集合权限里也出现过,能用权限挡掉的就不要用查询条件挡。
踩坑记录
这一节施工过程中踩的坑比任何一节都多,挑三个最典型的。
第一是排序字段的方向和游标比较方向必须一致。有一版我把排序改成 desc 但游标里还在用 gt 比较,结果第二页返回的是最旧的内容,前端无限滚动的尽头是项目第一篇文章。这类错误的特征是页面 1 正常、页面 2 就不对,排查时要第一时间怀疑游标方向。
第二是 limit 的上限。云数据库单次查询最多返回 100 条记录(可通过配置调整),有次前端传了 limit: 200,接口静默返回 100 条且 hasMore 算错,列表出现死循环刷新。解法是在云函数里对入参做 clamp:
// 入参消毒
const safeLimit = Math.min(Math.max(parseInt(limit) || 20, 1), 50)
第三是客户端缓存。文章发布后前端列表没刷新,用户以为没发成功连点三次,后台多了三条重复记录。后来在发布接口加了基于内容哈希的短时防重,同时在列表接口的响应头里带上更新时间戳,前端据此决定是否使用缓存。顺带一提,前端缓存策略的具体实现在SDK 接入那一节。
小结
列表接口的演进路线可以总结为四步:skip 换游标解决深分页、加字段投影缩小响应体、建索引保证主干道、边缘逻辑下沉到前端。这四步做完,一个跑在免费额度内的列表接口也能稳稳扛住上万条数据和持续增长的用户。这套分页方案后来在整本书里被反复引用,下一节要写的评论和点赞接口会立刻用到它——而那里真正的难题是计数器的原子性,比列表难一个量级,在监控与成本里你会发现它到底有多重要。
延伸阅读
- Python 云开发实战目录:本书章节索引与评论点赞章节
- Node 云函数实战:Serverless 数据查询优化同类案例
- 腾讯云实践:云数据库性能问题排查实录
