做社交类小程序的开发者几乎都遇到过这样的问题:上线初期一切流畅,用户量涨到几万之后,动态列表的加载越来越慢,偶尔还会触发云数据库的查询超时限制。绝大多数情况下,问题并不在云函数的代码逻辑,而是集合上的索引没有跟上数据量的增长。微信小程序云开发数据库底层是文档型存储,索引的原理和关系型数据库类似,但用法上有不少细节差异。这篇文章以一个完整的社交场景为例,把动态流、评论、点赞三个核心模块的索引优化过程完整过一遍。

社交场景的数据模型与查询瓶颈定位
先明确业务模型。假设有一个校园社交小程序,核心集合有三个:posts存用户发布的动态,comments存评论,likes存点赞记录。动态流页面最常见的查询是:拉取关注者发布的、状态为已发布的动态,按发布时间倒序,每次取10条。对应的查询代码大致如下:
db.collection('posts')
.where({
authorId: _.in(followingIds), // 关注列表
status: 1 // 已发布
})
.orderBy('createTime', 'desc')
.skip((page - 1) * 10)
.limit(10)
.get()这个查询在数据量小时毫无压力,因为云开发数据库会做全表扫描,几万条记录扫一遍也就几十毫秒。但当posts集合增长到几十万条,全表扫描的成本会线性上升,最终表现为接口耗时从100毫秒涨到2秒以上。定位这类问题的方法很直接:在云开发控制台的数据库面板中查看慢查询日志,如果一条查询没有命中任何索引,就会被记录下来,这是最可靠的证据。
另一个容易被忽视的现象是分页越深越慢。第1页很快,第5页明显变慢,第20页几乎超时。原因是skip操作即使有索引命中,也需要跳过前面所有的记录才能取到目标段,页码越深代价越高。这说明索引优化不只是建索引这一件事,还涉及查询写法的配合调整,后面会专门展开。
复合索引的字段顺序设计
针对动态流的查询,正确的做法是建立复合索引。云开发数据库的复合索引遵循最左前缀原则,字段顺序直接决定哪些查询能命中。查询条件中有等值匹配(authorId、status)和排序字段(createTime),索引字段的顺序应该遵循一个经典规则:等值条件字段在前,范围或排序字段在后。
对posts集合,理想的索引定义是把authorId放第一位、status放第二位、createTime放第三位。这样查询会先按作者精确锁定一段索引区间,再按状态过滤,最后索引内的记录天然按createTime有序,排序操作可以直接省掉。如果顺序反了,把createTime放在第一位,等值条件就无法利用索引定位,效果会大打折扣。
在控制台创建索引时需要注意两点。第一,createTime字段要设置排序方向为降序,与orderBy的方向一致,方向不一致会导致排序无法利用索引。第二,authorId的in查询属于多点等值查询,云开发数据库可以将其拆解为多个索引区间,依然能命中索引,不必担心_.in会让索引失效。索引建好后,在控制台触发一次查询分析,确认执行计划中显示索引扫描而非全表扫描。
// posts 集合的复合索引定义 // 字段顺序:authorId(升序) -> status(升序) -> createTime(降序) // 唯一性:否 // 适用查询:按关注列表拉取已发布动态并按时间倒序分页
评论场景的索引思路类似但有区别。评论的查询是按postId等值匹配、按createTime升序排列,索引就是postId加createTime的组合。点赞记录的查询重点是去重校验,这就要用到唯一索引。
唯一索引解决重复点赞与并发问题
点赞功能的经典坑是重复点赞。用户快速双击点赞按钮,如果云函数里先查询再写入,两个并发请求可能同时通过校验,最后插入两条一模一样的点赞记录。用事务可以缓解,但更简洁的方案是直接在likes集合上建唯一索引。
具体做法是把postId和userId组成复合唯一索引,两个联合起来唯一。这样即使并发写入,第二条也会因为唯一性冲突而失败,云函数里捕获这个错误码做幂等处理即可。数据库层面的约束永远比应用层校验更可靠,因为数据库的检查是原子的。
// 云函数中的点赞处理
try {
await db.collection('likes').add({
data: { postId, userId, createTime: Date.now() }
})
return { code: 0, msg: '点赞成功' }
} catch (err) {
// 唯一索引冲突时 err.errCode 为 -502001 或包含 duplicate 关键字
if (String(err.errMsg || '').includes('duplicate')) {
return { code: 0, msg: '已点赞过,幂等处理' }
}
throw err
}取消点赞时同样要注意,不能用add和remove的方式各自为政,而是基于同一个唯一索引做增删,这样点赞数统计才准确。另外,点赞记录的增长速度通常是三个集合中最快的,建议在写入时同步冗余一个postId单字段索引,方便后台按动态维度清理和统计历史数据。
分页优化与索引维护的落地建议
解决了索引命中问题后,还剩深分页这一难题。推荐的做法是游标分页,也就是记录上一页最后一条的createTime,下一页查询时加上createTime < 上一页最后时间的条件。这样每页查询都直接定位到索引区间,代价恒定,不会随页码加深而变慢。代价是放弃了跳页能力,但社交信息流本来就不需要跳到第20页。
// 游标分页:cursor 为上一页最后一条的 createTime
const query = {
authorId: _.in(followingIds),
status: 1
}
if (cursor) {
query.createTime = _.lt(cursor)
}
const list = await db.collection('posts')
.where(query)
.orderBy('createTime', 'desc')
.limit(10)
.get()索引维护方面,给出几条实践建议。第一,索引不是越多越好,每个索引都会占用存储并拖慢写入,社交场景写多读多,单集合索引数量控制在5个以内比较稳妥。第二,上线前先在测试环境用控制台的索引分析工具验证执行计划,不要等到线上超时才回头排查。第三,建立索引的常规清单:所有where条件中的等值字段、排序字段、以及组合后的复合索引,逐一核对是否覆盖了高频查询。第四,定期查看慢查询日志,数据分布变化后原来的索引可能不再适用,比如某个过滤字段的选择性变差,就需要调整索引结构。
最后验证优化效果。按上面的方案调整后,同样的动态流查询在50万条数据下,命中复合索引加游标分页的耗时稳定在50毫秒以内,而优化前的全表扫描加skip分页在20万条时就已经接近2秒。索引优化是社交小程序数据库调优中投入产出比最高的环节,把查询语句和索引结构放在一起设计,才是真正有效的做法。