应使用独立集合存储会话,而非单文档嵌套;conversations集合中每个文档包含user_ids(排序数组)、last_message_id、last_message_at、unread_counts(对象)、is_pinned等字段,配合复合索引和原子操作实现高效查询与更新。

会话列表用单文档嵌套还是独立集合?
直接用 conversations 集合存每条会话,别嵌套在用户文档里。嵌套会导致更新冲突、查询低效、无法原子更新未读数,且分页拉取会话列表时 MongoDB 无法用索引跳过大量嵌套字段。
每个会话文档结构建议如下:
{
"_id": ObjectId("..."),
"user_ids": ["u123", "u456"],
"last_message_id": ObjectId("..."),
"last_message_at": ISODate("..."),
"unread_counts": {
"u123": 0,
"u456": 2
},
"is_pinned": false
}
关键点:
-
user_ids用排序后的数组(如["u123", "u456"]而非["u456", "u123"]),方便用复合索引{ user_ids: 1, last_message_at: -1 }快速查某用户的会话列表 -
unread_counts是对象而非数组,避免每次只增一个用户未读数时重写整个数组;用$inc可原子更新单个字段,如{"unread_counts.u456": 1} - 不要存
last_message_content,只存 ID 和时间;内容从messages集合按需查,避免会话列表膨胀和双写不一致
未读数更新为什么不能靠客户端算?
未读数必须由服务端在消息写入后立即原子更新,客户端本地计数或轮询拉取会丢数据、产生竞态、无法处理离线重连场景。
正确流程是:收到新消息 → 写入 messages 集合 → 用 findOneAndUpdate 原子更新对应会话的 unread_counts 字段 → 返回更新后的未读值给客户端。
示例更新操作:
db.conversations.findOneAndUpdate(
{ user_ids: { $all: ["u123", "u456"] } },
{
$set: {
last_message_id: ObjectId("..."),
last_message_at: new Date()
},
$inc: { "unread_counts.u456": 1 }
}
)
注意:
- 查询条件必须用
$all而非$in,否则可能匹配到三方群聊(虽然当前是双人,但结构要预留扩展) - 如果用户已读,用
$set清零对应字段,如{"unread_counts.u123": 0},别用$unset—— 字段缺失会让后续$inc初始化为 0 再加 1,导致多算 - 务必给
user_ids和last_message_at建复合索引,否则分页查“我参与的所有会话”会全表扫
如何高效查“我的未读总数”?
别遍历所有会话累加 unread_counts,用聚合管道一次算完:
db.conversations.aggregate([
{ $match: { user_ids: "u123" } },
{ $group: { _id: null, total: { $sum: "$unread_counts.u123" } } }
])
这个查询快的前提是:
-
user_ids字段上有索引(哪怕只是单字段索引也能命中$match) -
unread_counts.u123是稀疏字段,但聚合时 MongoDB 能跳过缺失值,不影响结果 - 如果并发极高,可考虑额外维护一个
users集合里的unread_total字段,用$inc同步更新,但需接受轻微延迟(比如后台任务补偿)
纯聚合方案更简单可靠,99% 场景够用;加冗余字段只在顶部红点要求毫秒级实时且日活超百万时才值得权衡。
删除会话或用户注销时怎么清理未读数?
删会话直接删 conversations 文档即可,但要注意:如果只是“隐藏”而非删除(比如微信的“不显示该聊天”),就加个 is_archived: true 字段,并在所有查询中显式排除它。
用户注销时,不能只删 users 文档,必须清理两处:
- 从所有
conversations.user_ids数组中移出该用户 ID(用$pull),否则残留的user_ids: ["u123", "deleted_user"]会导致查询失效 - 清空所有会话里该用户的
unread_counts.xxx字段(用$unset),否则下次同 ID 重建用户时,旧未读数会复活
这两步建议放在同一个事务里(MongoDB 4.0+ 支持),尤其当会话量大时,避免中间状态被其他请求读到脏数据。没事务就先 $pull 再 $unset,并确保应用层幂等重试。
最容易被忽略的是:user_ids 数组长度变化后,原来基于固定长度的索引可能失效——所以别依赖数组长度做业务逻辑,也别用 {$size: 2} 查双人会话,老实用 $all + 显式 ID 列表。

















