You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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 });
  }
};

需要解决两个核心问题:

  1. 用更高效的方式(如聚合查询)实现需求,减少数据库交互次数
  2. 实现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()做内存转换,有两种更高效的方式:

  1. 数据库层面直接转换:在聚合的$project阶段使用$toString操作符完成转换,避免Node.js侧的遍历开销,示例:
// 在聚合的$project阶段添加配置
{
  $project: {
    userProposals: 0,
    id: { $toString: "$_id" }, // 直接生成字符串类型的id字段
    // 其余需要返回的字段按需设置为1即可
  }
}
  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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.30 19:48:29