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,按以下顺序处理:
- 为消息增加稳定的
conversationId,明确会话与消息的关系。 - 建立匹配实际查询的复合索引,使用游标分页。
- 修正字段类型,限制嵌入数组的增长。
- 用接近实际业务的消息大小、并发量和会话分布进行压测。
- 查询和索引已合理,但单个 Replica Set 的容量或吞吐仍无法满足目标时,再实施分片。
备注:内容仅供参考。
