社交类小程序几乎都绕不开好友关系这块功能:好友列表、互相关注判断、共同好友数量统计。数据量小的时候一切正常,等用户量涨到几万、几十万,各种慢查询问题就开始集中爆发。这篇文章以一个实际项目的优化过程为主线,讲清楚微信小程序云数据库在好友关系场景下的索引应该怎么设计、怎么创建、怎么验证效果,希望能给正在做社交功能开发的你一些直接可用的参考。

一、问题定位:好友列表查询为什么越来越慢
先看这个项目最初的好友关系集合结构。为了简化,每条记录只存单向关系,即A关注B存一条记录,B关注A再存一条记录:
{
_id: "xxx",
userId: "user_001", // 关注发起方
friendId: "user_002", // 被关注方
status: 1, // 1已确认 0待确认
createdAt: 1690000000000
}查询好友列表的代码最初是这样写的:
db.collection('friendship').where({
userId: userId,
status: 1
}).orderBy('createdAt', 'desc')
.skip((page - 1) * 20)
.limit(20)
.get()这个查询在测试环境几乎秒回,但数据量到三十万条之后,好友列表首屏加载时间涨到了3秒以上。打开云开发控制台的数据库监控,能看到这条查询的扫描文档数接近全表数量,说明索引完全没被用上。
原因其实不复杂。云数据库默认只在_id上建了索引,where条件里的userId和status都是普通字段,没有索引可用时只能全表扫描。当集合数据量翻倍,扫描耗时也跟着线性增长,这就是典型的缺乏索引导致的性能劣化。
另外还有一个隐藏问题:判断两个人是否是好友的查询,需要同时查两次单向关系记录,如果每次都全表扫描,在好友列表页批量判断互关状态时,查询次数乘以扫描量,性能会崩得更快。所以这次优化不只要解决列表慢,还要把关系判断这类高频小查询一并处理掉。
二、复合索引设计与字段顺序
解决多条件查询的正确思路是建复合索引,而不是给每个字段单独建索引。在云开发控制台进入集合的索引管理页面,为friendship集合创建一个复合索引,字段顺序是userId在前、status在后,都是升序。
为什么顺序这么讲究?复合索引遵循最左前缀原则,查询条件必须从索引的第一个字段开始连续命中,索引才能生效。我们的查询条件固定包含userId(等值)和status(等值),两个等值条件下字段先后对过滤本身影响不大,但考虑到排序字段createdAt,更完整的方案是把排序字段也纳入索引,形成userId + status + createdAt的三字段复合索引。
// 通过控制台或代码创建复合索引
db.collection('friendship').createIndex({
name: 'idx_user_status_time',
unique: false,
keys: [
{ name: 'userId', direction: 'ascending' },
{ name: 'status', direction: 'ascending' },
{ name: 'createdAt', direction: 'descending' }
]
})把createdAt放进索引并且设为降序,好处是数据库可以直接按索引顺序返回结果,省掉额外的内存排序步骤。改造后同样这条查询,扫描文档数从三十万降到了两百以内,首屏加载时间稳定在100毫秒左右。
这里有个容易踩的坑要提醒:如果你只建了userId单字段索引,查询userId + status时索引确实会被用到,但只过滤userId这一层,status的过滤还是要逐条判断。用户好友数上千时性能依然不理想。复合索引的价值就在于此,多个条件一次过滤到位。
反过来的顺序则要避免。如果索引建成status + userId,而查询条件只有userId,索引就完全失效,因为不满足最左前缀。设计索引前一定要先梳理清楚业务上的查询模式,列出所有高频查询的条件组合,再决定字段顺序。
三、关系判断查询的针对性优化
社交场景里另一类高频操作是判断两人关系:是否关注、是否互关。原始写法是两次独立查询:
// 判断 A 是否关注了 B
const res = await db.collection('friendship').where({
userId: 'user_A',
friendId: 'user_B'
}).count()这个查询条件是userId + friendId的组合,前面的复合索引帮不上忙(friendId不在索引里),所以需要单独建一个userId + friendId的复合索引。由于单向关系在业务上是唯一的,这个索引还可以直接建成唯一索引,顺便防止重复关注产生的脏数据:
db.collection('friendship').createIndex({
name: 'idx_user_friend',
unique: true,
keys: [
{ name: 'userId', direction: 'ascending' },
{ name: 'friendId', direction: 'ascending' }
]
})唯一索引带来一个额外收益:并发场景下用户快速双击关注按钮,第二次插入会直接报唯一键冲突,代码里捕获这个错误做幂等处理即可,不需要再加分布式锁之类的复杂逻辑。
对于互关判断,优化的思路是改用一次查询拿双向数据,配合in查询减少请求次数:
// 一次查出 A 与 B 之间的所有关系记录
const res = await db.collection('friendship').where(_.or([
{ userId: 'user_A', friendId: 'user_B' },
{ userId: 'user_B', friendId: 'user_A' }
])).get()
// 记录数为 2 且 status 都为 1,即互为好友注意_.or的两个分支分别命中userId + friendId索引的不同区间,整体仍然是索引扫描,效率远高于拆成两次请求。前端发起判断时也可以把这类逻辑挪到云函数里批量处理,减少小程序端的连接开销。
四、分页深翻页与索引数量的权衡
索引建好之后,列表查询还剩一个隐患:深分页。用skip翻到几百页时,数据库要先跳过前面所有匹配行,页码越深越慢。社交场景的好友列表一般不会太深,但关注列表、粉丝列表可能上千条,建议改用游标分页:
// 第一页
const first = await db.collection('friendship')
.where({ userId, status: 1 })
.orderBy('createdAt', 'desc')
.limit(20).get()
// 下一页:以上一页最后一条的 createdAt 作为游标
const next = await db.collection('friendship')
.where({ userId, status: 1, createdAt: _.lt(lastCursor) })
.orderBy('createdAt', 'desc')
.limit(20).get()游标分页每次都是索引定位后顺序读取20条,耗时与页码深度无关,体验上稳定得多。唯一的要求是排序字段在业务上不重复,createdAt精度不够时可以拼上_id做二次排序。
最后要谈索引数量的问题。索引不是越多越好,每多一个索引,写入时就要多维护一棵索引树,关注、取关这类写操作的耗时会线性增加。这个项目最终只保留了三个索引:_id默认索引、userId + status + createdAt支撑列表查询、userId + friendId唯一索引支撑关系判断。至于按friendId查粉丝列表的场景,与其再加索引,不如在写入时做双向冗余存储,即建立关注关系时同时写入正向和反向两条记录,让查询永远走正向索引,用存储空间换查询性能。
总结一下这次优化的核心思路:先通过监控数据确认索引失效,再按查询模式设计复合索引并注意字段顺序和最左前缀,高频关系判断单独建唯一索引,深分页改游标方案,最后控制索引总量平衡读写性能。这套思路不局限于好友关系表,点赞、收藏、关注等任何多对多关系集合都可以套用。