好友关系查询在社交类小程序中属于典型的高频读、低频写场景,但很多项目在初期只验证了功能,索引往往只给 uid 建了一个单字段索引。当好友关系表积累到几万条后,查询我的好友列表并按照创建时间倒序分页,响应时间可能超过两秒,这在小程序端已经属于不可接受的等待。本文围绕一个社交场景的优化案例,说明如何从查询路径出发重建索引,以及为什么组合索引的顺序会直接决定这条查询能否命中。

好友关系集合与查询路径
好友关系可以放在用户文档的数组里,也可以拆成独立的关系集合。数组方案在小规模下读取方便,但好友超过几百个后,文档会持续膨胀,每次更新都要重写整个数组,而且很难对好友的备注、分组、状态做精细过滤。社交场景更推荐独立关系集合,每条关系记录一次关注或好友申请的状态。
关系文档可以这样设计:
// friend_relations 集合文档示例
{
_id: "rel_7f3a9c",
uid: "user_1001", // 当前用户 openid 或业务 uid
targetId: "user_1002", // 对方用户 id
status: 1, // 0=待确认, 1=已同意, 2=已拉黑, 3=已删除
createTime: 1717400000000, // 建立或更新时间戳
remark: "同事"
}
最频繁的查询是拉取我的好友列表,条件是 uid 等于当前用户并且 status 等于 1,再按 createTime 倒序取前 20 条。这个查询看起来简单,但它实际需要一个覆盖等值过滤和排序的组合索引。
如果把关系设计成单向写入,好友添加成功只写一条 A 到 B 的记录,查询我的好友没有问题,但查询谁把我加为好友、或者判断 B 是否在 A 的好友列表中,就要反向扫描 targetId。双向写入则可以保证任意一方都能用 uid 直接查询,但会带来双倍写入和一致性成本。本例中选择双向写入,因为读次数远大于写次数,用写入冗余换来查询路径简单。
原索引为什么失效
很多团队在 friends 或 relation 集合上只给 uid 建了一个升序索引。单独看 uid 索引,它可以快速定位某个用户的关系记录,但当查询条件中还包含 status 和排序字段 createTime 时,数据库先根据 uid 找到所有关系,再逐条过滤 status,最后在内存中排序。好友数量少时这个过程不敏感,当单个用户有几千甚至上万条关系记录时,读取量和排序开销都会线性上升。
还有一个隐蔽问题是深分页。使用 skip 跳过的页码越大,数据库需要遍历的文档越多。比如取第 10 页,skip 180 条,看起来不大,但如果每页 50 条到第 20 页,就要跳过 950 条。云数据库仍然需要扫描这些文档,并在排序后丢弃它们。深分页带来的延迟容易被误认为是云数据库本身慢。
判断索引是否命中的一个方法是观察云开发控制台的慢查询日志。如果一条查询扫描文档数远超返回文档数,基本可以确认没有走合适的索引。这个案例中,未优化前的扫描行数经常超过 1 万,而接口只需要返回 20 条。
组合索引设计与游标分页
按照等值字段在前、排序字段在后的原则,应为 friend_relations 集合创建组合索引:uid 升序、status 升序、createTime 降序。这个顺序不能随意调换,尤其是 createTime 必须放在 status 之后。因为数据库使用组合索引时,只有在 uid 和 status 都等值匹配的情况下,createTime 的顺序才有效。如果把 createTime 放在 status 前面,等值字段被拆开,就无法同时利用 status 的过滤和时间的排序。
创建索引可以在微信开发者工具的云开发控制台完成,进入数据库集合后选择索引管理,添加组合索引。索引字段顺序设置为:uid 升序、status 升序、createTime 降序。是否需要降序取决于业务默认查询是否按时间倒序,如果也有正序需求,可以再建一个不同排序的索引。
云函数中的查询代码可以这样写:
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
const _ = db.command
exports.main = async (event) => {
const wxContext = cloud.getWXContext()
const uid = event.uid || wxContext.OPENID
const pageSize = event.pageSize || 20
const lastCreateTime = event.lastCreateTime || null
const where = {
uid,
status: 1
}
// 用上一页最后一条的 createTime 作为游标,避免 skip 深分页
if (lastCreateTime) {
where.createTime = _.lt(lastCreateTime)
}
const res = await db.collection('friend_relations')
.where(where)
.orderBy('createTime', 'desc')
.limit(pageSize)
.get()
return {
list: res.data,
nextLastCreateTime: res.data.length ? res.data[res.data.length - 1].createTime : null
}
}
这段代码用 createTime 游标替代了 skip。客户端请求第一页时 lastCreateTime 为空,拿到 20 条好友关系;下拉加载第二页时把上一页最后一条的 createTime 传回,服务端用小于这个时间作为条件继续查询。这样无论翻到第几页,数据库始终只扫描固定的 20 条左右,不会被深分页拖慢。
同时可以为 uid 和 targetId 建唯一索引来防止重复发起好友申请。例如在待处理状态下只允许存在一条申请,就可以建唯一索引 uid 升序、targetId 升序。这个唯一索引在写入时会拦截重复记录,避免在应用层先查再插带来的竞态问题。需要注意的是,如果关系状态会多次变化,唯一索引需要选在不会重复的状态组合上,否则会把允许的历史记录也挡住。
共同好友与范围条件的优化边界
社交场景中另一个常见查询是共同好友。假设已经拿到了当前用户和对方用户的好友 id 列表,想在关系表中找出同时出现在两者列表中的用户,如果直接使用 in 条件,需要把一方的所有好友 id 传入查询。好友数在几百以内时没有问题,但好友数很大时,in 数组本身会变成一次大范围扫描,索引的过滤效果下降。
更合理的做法是在关系写入时维护一个共同好友缓存集合。比如 pair_common 集合中一条文档记录两个用户组合的 key 以及共同好友数组,当任意一方新增或删除好友时,通过云函数事务同步更新这个文档。查询共同好友时直接按 key 读取,复杂度从扫描多个关系文档变成读取一个文档。这样的设计属于用有限的写入成本换取高频读取的稳定响应。
范围条件也要注意放在组合索引最后。如果查询是 uid 等值、createTime 大于某个时间、status 等值,而索引顺序是 uid、createTime、status,那么 status 的过滤无法利用索引。创建索引时先确认查询条件里哪些是等值,哪些是范围,哪些是排序。通常等值字段放在最前,排序字段紧随其后,范围字段放在最后,这样能保证大多数社交列表查询可以稳定命中。
这个案例上线后的效果比较直观:同样是 8 万条好友关系文档,未优化前单用户好友列表接口 P95 约 2.3 秒,慢查询扫描接近 1.2 万行;替换为组合索引并改为游标分页后,P95 回落到 60 毫秒左右,翻页请求从第 1 页到第 30 页耗时基本一致。更重要的是,数据库的读负载下降后,其他实时消息查询和用户资料查询的毛刺也减少了。对小程序云开发场景来说,索引优化通常是最先做、成本最低的优化手段,但需要先明确高频查询模式,而不是给每个字段随意加索引。

