在 Node.js 生态中,除了 MySQL、PostgreSQL 等关系型数据库,NoSQL 数据库同样占据着重要位置。它们以灵活的数据模型和高扩展性著称,特别适合数据结构多变、需要快速迭代的项目。MongoDB 是其中最具代表性的一种,官方驱动的 Node.js 支持度极高,而 Mongoose 则进一步提供了声明式的 Schema 定义、数据校验和查询抽象。本节将聚焦 MongoDB 的核心用法,用真实的项目视角串联从连接、建模到聚合查询的完整链路。
13.2.1 MongoDB 的文档模型与设计哲学
MongoDB 是一个面向文档的数据库,它不再使用表、行和列,而是将数据存储为类似 JSON 格式的 BSON 文档。一个集合(collection)就可以看成是一堆文档的容器,而同一集合内的文档结构可以完全不同。不过在实际应用中,我们通常会通过应用层(如 Mongoose)来约束结构,以保证数据的可维护性。
文档模型最突出的优势是数据内嵌:你可以把关联数据直接嵌入到父文档中,而不必像关系型数据库那样总是执行 JOIN。例如,一个用户文档可以直接内嵌多个地址对象:
{
"_id": ObjectId("..."),
"name": "张三",
"email": "zhangsan@example.com",
"addresses": [
{ "type": "home", "street": "中山路1号", "city": "北京" },
{ "type": "work", "street": "科技园路100号", "city": "上海" }
]
}
这不仅简化了读取操作(一次查询就能拿到用户及其所有地址),也天然适配现代前端对聚合数据的需求。当然,内嵌也带来了更新一致性和存储膨胀的潜在问题,需要在设计时就仔细权衡。
13.2.2 Mongoose:优雅地定义 Schema 与数据校验
官方 MongoDB Node.js 驱动提供了基础操作能力,但直接使用时会面临这些问题:缺乏结构约束、无内建数据校验、回调或 Promise 链写起来冗长。而 Mongoose 作为应用最广的 ODM(Object Document Mapper),把数据建模、校验、业务逻辑都封装进了 Schema 和 Model 体系。
1. 安装与连接
npm install mongoose
连接 MongoDB 比较简单,推荐在连接字符串中设置数据库名和连接池大小:
const mongoose = require('mongoose');
mongoose.connect('mongodb://localhost:27017/shop', {
maxPoolSize: 10, // 连接池大小
serverSelectionTimeoutMS: 5000, // 超时
}).then(() => console.log('MongoDB 连接成功'))
.catch(err => console.error('连接失败', err));
2. 定义 Schema 与 Model
Schema 是 Mongoose 的核心,它描述文档的结构、字段类型、默认值、校验规则和索引。定义好 Schema 后,用 mongoose.model() 转换为可以增删改查的 Model。
const { Schema, model } = mongoose;
const productSchema = new Schema({
name: {
type: String,
required: [true, '商品名不能为空'],
trim: true,
maxlength: [100, '商品名最多100字符']
},
price: {
type: Number,
required: true,
min: [0.01, '价格必须大于0']
},
tags: [String], // 字符串数组
stock: {
type: Number,
default: 0
},
category: {
type: Schema.Types.ObjectId,
ref: 'Category' // 关联 Category 集合
},
createdAt: {
type: Date,
default: Date.now
}
});
// 创建索引(通常在 Schema 级别定义更优)
productSchema.index({ name: 1, category: 1 });
productSchema.index({ tags: 1 });
const Product = model('Product', productSchema);
3. 内建校验与自定义逻辑
Mongoose 内置了丰富的校验选项(required、min、max、enum、match 等),你也可以添加自定义校验函数。此外,利用 pre / post 钩子可以在保存前后执行逻辑,比如自动更新时间或密码哈希。
userSchema.pre('save', async function(next) {
if (this.isModified('password')) {
this.password = await bcrypt.hash(this.password, 10);
}
next();
});
13.2.3 增删改查与复杂查询
Mongoose Model 提供了简洁的 API 完成 CRUD,同时支持原生 MongoDB 查询语法。以下是一些典型场景:
创建文档
const product = new Product({
name: '蓝牙耳机',
price: 299.99,
tags: ['电子', '音频'],
stock: 100
});
await product.save();
或用 create 一步完成:
await Product.create({ name: '鼠标', price: 79, stock: 200 });
查询文档
查询支持链式调用,条件、投影、排序、分页一气呵成:
// 查找所有价格在 50 到 500 之间的商品,按价格排序,分页
const products = await Product.find({
price: { $gte: 50, $lte: 500 },
stock: { $gt: 0 }
})
.select('name price stock') // 投影
.sort({ price: -1 })
.skip(0)
.limit(20);
常用查询操作符:$gt、$lt、$in、$nin、$exists、$regex 等,几乎可以表达任意业务逻辑。对于需要引用其他集合的字段,Mongoose 提供 populate 来“连接”数据:
const product = await Product.findById(productId).populate('category', 'name');
console.log(product.category.name);
更新文档
可以使用 updateOne、findByIdAndUpdate 等方法:
await Product.updateOne(
{ name: '蓝牙耳机' },
{ $set: { price: 259.99 }, $inc: { stock: -1 } }
);
$set 和 $inc 等原子操作避免了并发更新时的数据不一致。
删除文档
await Product.deleteOne({ _id: productId });
// 或 findByIdAndDelete
13.2.4 聚合查询:强大的数据处理管道
MongoDB 的聚合框架(Aggregation Pipeline)可以在数据库内部完成复杂的数据转换、分组、排序、连接,避免将大量数据拉到应用层再处理。对于统计、报表等业务,聚合查询几乎是标配。
一条聚合管道由多个阶段组成,每个阶段处理上层输出并传给下一阶段。常用阶段:$match(过滤)、$group(分组计算)、$sort(排序)、$project(投影)、$unwind(展开数组)、$lookup(关联查询)等。
例如,统计每个分类下商品的均价和总数:
const result = await Product.aggregate([
{ $match: { stock: { $gt: 0 } } },
{ $group: {
_id: '$category',
avgPrice: { $avg: '$price' },
total: { $sum: 1 }
}},
{ $sort: { avgPrice: -1 } },
{ $lookup: {
from: 'categories',
localField: '_id',
foreignField: '_id',
as: 'category'
}},
{ $unwind: '$category' },
{ $project: {
name: '$category.name',
avgPrice: 1,
total: 1
}}
]);
这段管道依次完成:过滤有库存的商品 → 按分类分组计算均价和数量 → 降序排列 → 关联分类集合获取名称 → 展开数组 → 输出所需的字段。整个复杂逻辑一次往返数据库即可完成,效率远高于多次查询并在 Node.js 中手工合并。
13.2.5 索引与性能优化
MongoDB 的索引机制和关系型数据库类似,都是为了加速查询。没有索引的情况下,查询会进行全集合扫描,在数据量稍大时性能急剧下降。在 Mongoose 中,推荐在 Schema 级别定义索引:
productSchema.index({ price: 1 }); // 单字段索引
productSchema.index({ category: 1, price: -1 }); // 复合索引
productSchema.index({ name: 'text' }); // 文本索引,用于全文搜索
在生产环境,使用 explain() 分析方法可以查看查询到底使用了哪个索引、扫描了多少文档。
此外,几个实用优化技巧:
- 覆丶盖查询:使用
select()只返回需要的字段,并确保这些字段均已索引,可实现“仅索引扫描”,无需读取文档数据。 - 控制文档大小:内嵌数组不要无限增长,例如用户购物车可用单独集合存储,而不是直接塞进用户文档。
- 避免耗时的跳过:使用范围查询配合索引替代大偏移量的
skip,或者使用基于时间戳的流式分页。 - 慢查询监控:MongoDB 提供 Profiler,可记录执行时间超过阈值的操作,再通过
explain分析优化。
13.2.6 Mongoose 与原生驱动的选择
虽然 Mongoose 抽象了很多便利功能,但并非所有项目都必须使用它。在某些轻量场景或 Serverless 中,直接使用 MongoDB 原生驱动可能更灵活,也减少了依赖体积。对于需要强 Schema 校验、中间件逻辑、关联引用的复杂业务,Mongoose 提供的结构性价值则非常突出。团队可根据项目规模自行权衡。
13.2.7 与关系型数据库的互补
我们已经讨论过 MySQL 和 Sequelize/TypeORM,那么什么时候应该选择 MongoDB 呢?
- 数据模型经常变动:创业初期或需求频繁调整时,无需 ALTER TABLE 的特性可以大幅提升迭代速度。
- 明显的嵌入关系:如内容管理中的文章与评论、用户与地址等,内嵌读取非常高效。
- 高写入吞吐:MongoDB 的写入性能通常优于传统关系型数据库,适合日志、埋点等场景。
但关系型数据库的事务、JOIN 能力依然是强需求时的优势。现代 MongoDB 已支持多文档事务,但开销较大,不建议滥用。如果业务需要频繁的跨集合关联和强一致性,可能应该回头考虑关系型方案。很多大型项目甚至混合使用两者,各取所长。
以上就是 Node.js 中 NoSQL 数据库环节的核心实践。拥有 Mongoose 和 MongoDB 的组合,你的全栈工具箱中就多了一种极其灵活的数据存储方案。在后续章节中,我们将进入缓存与中间件领域,探讨 Redis 如何为 Node.js 应用注入性能加速剂。