Node.js+MongoDB 聚合查询优化未提交提案的活跃项目查询
问题背景
在Node.js + Mongoose操作MongoDB的场景中,定义了两个集合模型:
第一个是Project(项目)模型:
import mongoose from "mongoose"; const projectSchema = mongoose.Schema({ id: String, userId: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, title: String, details: String, location: String, rate: String, status: { type: String, default: "active", }, createdAt: { type: Date, default: new Date(), }, }); const Project = mongoose.model("Project", projectSchema); export default Project;
第二个是Proposal(提案)模型:
import mongoose from "mongoose"; const proposalSchema = mongoose.Schema({ id: String, userId: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, projectId: { type: mongoose.Schema.Types.ObjectId, ref: "Project" }, rate: String, message: String, createdAt: { type: Date, default: new Date(), }, }); const Proposal = mongoose.model("Proposal", proposalSchema); export default Proposal;
业务需求
GET请求携带用户ID参数,需要返回满足以下条件的项目列表:
- 项目状态为
active - 该指定用户未对项目提交过提案
提案规则:用户提交提案后,会在proposals集合生成一条包含
userId和projectId字段的记录
现有实现问题
当前实现需要3次数据库查询,还需要在Node.js内存中遍历转换ID,执行效率低、代码冗余,实现代码如下:
export const getProjects = async (req, res) => { try { const activeProjects = await Project.find({ status: "active" }, { _id: 1 }); const projectsWithProposals = await Proposal.find( { $and: [ { userId: req.query.id }, { projectId: { $in: activeProjects } }, ], }, { _id: 0, projectId: 1 } ); const stringsIds = projectsWithProposals.map((id) => id.projectId.toString() ); const projects = await Project.find({ $and: [{ status: "active" }, { _id: { $nin: stringsIds } }], }); res.status(200).json(projects); } catch (error) { res.status(404).json({ message: error.message }); } };
需要解决两个核心问题:
- 用更高效的方式(如聚合查询)实现需求,减少数据库交互次数
- 实现ObjectId到字符串ID的高效转换
最优实现方案
方案:单次聚合查询实现(性能最优)
使用MongoDB聚合管道可以在单次数据库交互中完成所有过滤、关联逻辑,避免多次网络IO和内存数据处理,代码如下:
import mongoose from "mongoose"; import Project from "../models/project.model.js"; export const getProjects = async (req, res) => { try { // 请求传入的id是字符串,需要先转成MongoDB的ObjectId类型 const userId = new mongoose.Types.ObjectId(req.query.id); const projects = await Project.aggregate([ // 第一步:提前过滤状态为active的项目,裁剪后续处理的数据量 { $match: { status: "active" } }, // 第二步:关联查询当前用户对每个项目提交的提案,仅匹配符合条件的提案 { $lookup: { from: "proposals", // Mongoose默认集合名是模型名的小写复数形式 let: { projectId: "$_id" }, pipeline: [ { $match: { $expr: { $and: [ { $eq: ["$projectId", "$$projectId"] }, { $eq: ["$userId", userId] } ] } } } ], as: "userProposals" } }, // 第三步:过滤掉用户已经提交过提案的项目(关联结果数组为空即未提交) { $match: { "userProposals.0": { $exists: false } } }, // 第四步:移除多余的关联字段,按需返回字段 { $project: { userProposals: 0 } } ]); res.status(200).json(projects); } catch (error) { // 服务内部错误应该返回500状态码,而非404 res.status(500).json({ message: error.message }); } };
性能优化补充
可以通过添加索引进一步提升查询速度,还能顺便防止重复提交提案:
- 给Project集合的
status字段加普通索引 - 给Proposal集合的
userId和projectId加复合唯一索引,代码如下:
// 写在projectSchema定义后、创建model前 projectSchema.index({ status: 1 }); // 写在proposalSchema定义后、创建model前 proposalSchema.index({ userId: 1, projectId: 1 }, { unique: true });
ObjectId转字符串的高效方案
不需要手动遍历调用toString()做内存转换,有两种更高效的方式:
- 数据库层面直接转换:在聚合的
$project阶段使用$toString操作符完成转换,避免Node.js侧的遍历开销,示例:
// 在聚合的$project阶段添加配置 { $project: { userProposals: 0, id: { $toString: "$_id" }, // 直接生成字符串类型的id字段 // 其余需要返回的字段按需设置为1即可 } }
- 全局Schema配置自动转换:在定义Schema时配置
toJSON的转换规则,所有查询结果转JSON时会自动把_id转成字符串类型的id字段,不用每次单独处理,示例:
// 两个Schema都可以添加该配置,写在Schema定义后、创建model前 const schemaTransformOption = { virtuals: true, transform: (doc, ret) => { ret.id = ret._id.toString(); delete ret._id; delete ret.__v; // 顺便移除版本号字段 } }; projectSchema.set('toJSON', schemaTransformOption); proposalSchema.set('toJSON', schemaTransformOption);
注意:原实现中把ObjectId手动转成字符串再传入
$nin做查询是多余操作,Mongoose会自动把字符串格式的ID转成ObjectId类型做匹配,手动转换反而增加了不必要的开销。
内容的提问来源于stack exchange,提问作者Muhammad Naeem
相关产品推荐
相关产品推荐

