PAYATHON 2026

MongoDB 聊天系统将所有消息存入同一个 collection,如何水平扩展?

技术老齐

可以继续把所有聊天消息存放在同一个 messages collection 中,每条消息对应一个 document。仅凭数百万条消息,还不能判断这个设计是否需要调整。

建议先完善会话标识、查询索引和分页方式,再根据实际负载决定是否启用 MongoDB Sharding。分片后,应用仍然访问同一个 collection,由 MongoDB 将数据分布到不同 shard。

为什么同一个 collection 可以继续使用

collection 是逻辑上的数据集合,其中的数据可以通过 MongoDB 分片存放在多台服务器上,分担存储和读写负载。

判断是否需要分片,要看实际运行情况:

  • 高峰期的消息写入量,以及历史消息查询的延迟。
  • 消息和索引的大小、内存占用、磁盘容量与 I/O 压力。
  • 单个会话是否特别活跃,负载是否集中在少数群聊。
  • 查询是否使用合适的索引,是否扫描了大量无关文档。

消息数量达到某个整数,并不意味着需要立即拆分 collection。通常也不建议默认按用户或会话分别建 collection,这会增加 collection 和索引的管理成本,让跨会话查询更复杂。

Replica Set 和 Sharding 的作用也有区别:

  • Replica Set 主要提供数据冗余和故障切换;在满足一致性要求的前提下,可以让 secondary 承担部分读取。
  • Sharding 将数据分布到多个 shard,用于扩展存储容量和分担读写负载。

增加 Replica Set 成员不会自动把消息写入分散到多个节点。

先给消息增加 conversationId

现有的 Conversation 表示一条消息,改名为 Message 更容易理解。会话信息可以单独保存,例如:

conversations
  _id
  type
  participants

messages
  _id
  conversationId
  senderID
  message
  createdAt
  replyTo

conversations 也可以放在 MySQL 中,具体取决于业务如何管理会话和成员关系。无论存在哪里,每个会话都应有稳定的 ID,并在每条消息中保存为 conversationId。

读取私聊记录时,就可以按会话查询,不必每次拼接两个方向的 senderID、receiverID 条件。群聊也能使用相同的消息查询方式。

NestJS/Mongoose 中的消息模型可以这样定义。示例只保留主要字段,附件和通话事件可按后文建议补充:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';

export type MessageDocument = HydratedDocument<Message>;

@Schema({ collection: 'messages' })
export class Message {
  @Prop({ type: String, required: true })
  conversationId!: string;

  @Prop({ type: String, required: true })
  senderID!: string;

  @Prop({ type: String, required: true })
  message!: string;

  @Prop({ type: Types.ObjectId })
  replyTo?: Types.ObjectId;

  @Prop({
    type: Date,
    required: true,
    default: Date.now,
    immutable: true,
  })
  createdAt!: Date;
}

export const MessageSchema = SchemaFactory.createForClass(Message);

MessageSchema.index({
  conversationId: 1,
  createdAt: -1,
  _id: -1,
});

这里假设会话 ID 是字符串,消息 _id 使用 MongoDB 默认的 ObjectId。实际项目中应统一字段类型。

如果支持纯图片或纯附件消息,需要调整 message 的必填规则,并在业务层校验正文与附件至少有一项,允许消息不包含文本。

已有数据还需要回填 conversationId,查询逻辑也要迁移。这些工作需要单独完成,修改 Schema 不会自动处理。

按会话建立索引,用游标读取历史消息

读取聊天记录时,通常会指定一个会话,按时间倒序取最近几十条消息。因此,复合索引以 conversationId 开头,后面接排序字段。

第一页的查询可以这样写:

const messages = await messageModel
  .find({ conversationId })
  .sort({ createdAt: -1, _id: -1 })
  .limit(50)
  .lean()
  .exec();

加载更早的消息时,用上一页最后一条消息的位置作为游标:

import { Model, Types } from 'mongoose';

type MessageCursor = {
  createdAt: Date;
  id: Types.ObjectId;
};

async function loadOlderMessages(
  messageModel: Model<Message>,
  conversationId: string,
  cursor: MessageCursor,
) {
  return messageModel
    .find({
      conversationId,
      $or: [
        { createdAt: { $lt: cursor.createdAt } },
        {
          createdAt: cursor.createdAt,
          _id: { $lt: cursor.id },
        },
      ],
    })
    .sort({ createdAt: -1, _id: -1 })
    .limit(50)
    .lean()
    .exec();
}

多条消息的 createdAt 可能相同,需要用 _id 确定它们之间的顺序。游标必须同时携带这两个值,接口接收时要完成日期和 ObjectId 的解析、校验。

游标分页适合向上翻阅聊天记录。如果使用 skip(),页码越大,需要跳过的结果越多,查询成本也会增加。

使用时还需注意:

  • 服务端必须检查当前用户是否有权访问该会话。
  • 消息写入后,排序字段应保持稳定。
  • createdAt + _id 可以确定展示顺序。如果业务要求严格的会话内消息顺序,需要另外设计序列号及其分配机制。
  • 用 explain('executionStats') 检查实际查询的执行计划,关注扫描文档数量和排序方式。建了索引,也仍需检查查询性能。

需要水平扩展时,再选择分片键

如果系统主要按会话读写,可以评估用 conversationId 作为分片键。例如:

// mongosh:连接已配置好的分片集群中的 mongos
db.getSiblingDB("chat").messages.createIndex({
  conversationId: "hashed"
});

sh.shardCollection("chat.messages", {
  conversationId: "hashed"
});

这段代码用于说明部署方式。执行前,应核对 MongoDB 版本、已有索引、唯一性约束和集群配置。在 Mongoose Schema 中定义索引,不会自动将 collection 变成分片集合。

采用 { conversationId: "hashed" } 时:

  • 不同会话可以分布到不同 shard。
  • 包含 conversationId 等值条件的查询可以定向路由。
  • 同一会话的数据具有相同的分片键值,会集中在一个 shard。
  • 按时间读取会话消息,仍需要前面的普通复合索引,哈希索引不能替代它。

如果流量来自大量普通会话,这种设计可能较好地分散负载。但同一会话仍集中在一个 shard,因此,当一个超大群聊产生大量写入时,单独对 conversationId 做哈希无法分散该会话的负载。

遇到这类热点,再进一步评估包含时间桶或其他分桶字段的分片设计。拆分同一会话会增加历史消息查询、结果合并和排序的复杂度,需要通过实际负载验证收益。

单调递增的时间戳也不宜直接作为范围分片键,因为新消息会集中写入最新的范围,容易形成写入热点。选择分片键时,除了字段是否唯一、是否方便生成,还要考虑查询条件、取值分布和热点。

原有 model 中还应调整的地方

修正已读状态的类型

原代码中:

@Prop({ required: true, default: false })
readReceipt!: ReadReceipt;

默认值是布尔值,属性类型却是 ReadReceipt,两者不一致。

如果这里只表示一个简单状态,可以明确声明为布尔值:

@Prop({ type: Boolean, default: false })
isRead!: boolean;

这个字段主要适用于简单的一对一聊天。群聊中的已读状态属于具体用户,单个布尔值无法完整表示。

如果产品只关心用户读到了哪里,可以在会话成员记录中保存 lastReadMessageId 或 lastReadSequence。如果需要保存每条消息被每个成员读取的时间,可以单独设计回执集合。两种方案的查询能力和写入成本不同。

控制嵌入数据的增长

持续增长的聊天记录适合按消息分别保存。不要把一个会话的全部消息追加到同一个 document 的数组中:MongoDB 单个 BSON document 的大小上限是 16 MiB,大文档的更新成本也更高。

是否将附件、回执和通话事件嵌入消息,要看它们的数量有没有明确上限:

  • 少量附件元数据可以嵌入。文件内容通常放在对象存储中,消息保存存储键、文件大小、MIME 类型等信息。
  • 回执数量会随成员数量增长,可以单独存储。
  • 持续追加的通话事件可以独立保存,通过会话或通话 ID 关联。

Attachment[]、CallEvent[]、ReadReceipt[] 只是 TypeScript 类型声明,不能代替运行时的 Mongoose 子 Schema。需要字段校验时,应明确声明子 Schema 和数组元素类型。

明确冗余字段的含义

是否保留 senderName,取决于要显示哪个时间点的昵称:

  • 如果显示发送时的昵称,senderName 就是历史快照,可以保留。
  • 如果始终显示当前昵称,可以只保存 senderID,展示时查询或缓存用户资料。

一对一聊天可以保留 receiverID。引入会话模型后,通常由会话成员关系确定消息的接收范围,处理群聊也更方便。

MySQL 与 MongoDB 可以混用,但需要为跨库操作失败设计补偿和重试。例如,会话创建成功、首条消息写入失败时,应有恢复机制。即使两套数据库运行在同一个服务中,也不会自动共享事务。

当前阶段怎么落地

先保留一个 messages collection,按以下顺序处理:

  1. 为消息增加稳定的 conversationId,明确会话与消息的关系。
  2. 建立匹配实际查询的复合索引,使用游标分页。
  3. 修正字段类型,限制嵌入数组的增长。
  4. 用接近实际业务的消息大小、并发量和会话分布进行压测。
  5. 查询和索引已合理,但单个 Replica Set 的容量或吞吐仍无法满足目标时,再实施分片。

备注:内容仅供参考。