必须用 status + statusUpdatedAt 两字段替代单字符串状态,status 存终态(如 "read"),statusUpdatedAt 存毫秒级 long 时间戳;水位线须存独立集合 chat_read_watermarks 并建 { userId: 1, chatWith: 1 } 唯一索引;查未读数应使用 timestamp < lastReadAt 配合 { receiverId: 1, timestamp: -1 } 索引,禁用 $ne;例外消息(如撤回)须存 chat_message_exceptions 集合并事务绑定更新。

用 status + statusUpdatedAt 二字段代替单字符串状态
直接在 Message 文档里存 status: "read" 看似简单,但线上一压就出问题:用户批量点开会话时状态翻转不幂等、撤回消息后未读数不归零、并发标记导致部分消息漏更新。根本原因是把「终态」和「生效时间」混在一起了。
必须拆成两个字段:status(当前终态,字符串) + statusUpdatedAt(毫秒级 long 时间戳)。例如用户 B 在 1741708953000(2026-03-12 16:02:33)点开会话,不是去 update 10 条消息,而是记录一条水位线:
{ "userId": "B", "chatWith": "A", "lastReadAt": 1741708953000 }
后续查未读数,只用 timestamp < lastReadAt 过滤,再排除例外(如撤回、禁言期间发的消息)。
-
statusUpdatedAt必须是毫秒long,别用Date对象——驱动序列化可能引入时区偏差 - 不要在
Message文档里嵌入水位线字段,否则每次查未读都要全表扫描 - 字符串比较(如
status == "read")无法高效走索引,数值比较才是 MongoDB 的强项
建独立集合 chat_read_watermarks 存水位线
水位线必须单独建集合,命名建议为 chat_read_watermarks,结构精简:
{ "_id": ObjectId("..."), "userId": "B", "chatWith": "A", "lastReadAt": 1741708953000, "updatedAt": 1741708953000 }
这个集合只存每对用户最新的水位线,写入频率远低于消息量,且查询路径极短。
- 索引必须建
{ userId: 1, chatWith: 1 }唯一复合索引,防止重复写入 - 避免用
{ chatWith: 1, userId: 1 }—— 查询时通常已知userId和对方 ID,顺序反了会导致索引失效 - 别把水位线塞进
messages集合:那会让消息文档膨胀,且无法对水位线做原子更新
查未读数必须用 $lt + 复合索引,禁用 $ne
有人写 { status: { $ne: "read" } } 查未读,初看没错,但上线后 QPS 直线掉:MongoDB 对枚举型字符串字段的 $ne 查询无法高效走索引,尤其当 status 还有 "sending"、"failed"、"revoked" 多种值时,会退化为全索引扫描。
正确查询是:
db.messages.find({ senderId: "A", receiverId: "B", timestamp: { $lt: 1741708953000 } })
对应索引必须是 { receiverId: 1, timestamp: -1 } —— 等值字段在前,范围字段在后,方向与 sort 一致。
- 别建
{ timestamp: -1, receiverId: 1 }:查询时receiverId变成 range scan,索引完全失效 - 用
mongosh跑explain("executionStats"),确认nReturned≈totalDocsExamined,否则就是没走对索引 - 如果还要支持按发送者查历史(比如“我发给谁还没被读”),额外加
{ senderId: 1, timestamp: -1 },但别堆砌
例外消息必须进独立集合 chat_message_exceptions
水位线模型默认假设“所有 timestamp 小于 lastReadAt 的消息都应视为已读”,但现实有例外:撤回、系统禁言期间发的消息、审核不通过的内容——这些不能被水位线覆盖。
必须建独立集合 chat_message_exceptions,存例外消息 ID 和类型:
{ "_id": "msg_abc123", "reason": "revoked", "createdAt": 1741708900000 }
查未读数时,先用水位线过滤,再用 $nin 排除这些 ID。
- 该集合要建
{ _id: 1 }索引,确保$nin查询快 - 例外写入必须和撤回/禁言操作事务绑定(或至少强最终一致性),否则会出现“已撤回却还计为未读”的错觉
- 别用
status: "revoked"混在主消息里判断——那样又回到字符串枚举陷阱,且无法利用水位线加速

















