返回文章列表
Node.js 数据库设计与 MongoDB 最佳实践
MongoDB 是 Node.js 生态中最常用的 NoSQL 数据库。它的文档存储模型灵活高效,但灵活不等于随便设计——好的数据库设计能让项目后期少掉很多头发。本文从实际项目出发,讲清楚 MongoDB 的数据建模、Mongoose Schema 设计、索引优化和数据验证。
一、为什么选 MongoDB?
和传统关系型数据库(MySQL、PostgreSQL)相比,MongoDB 有几个天然优势:
- 文档模型:数据结构灵活,字段可以随时增减,适合快速迭代的产品
- JSON 原生:和 Node.js 的 JavaScript 天然契合,无需 ORM 转换
- 横向扩展:内置分片(Sharding)支持,数据量大了可以直接水平扩容
- 丰富的查询:支持聚合管道、全文索引、地理空间查询等高级功能
二、数据库连接管理
使用 Mongoose 连接 MongoDB 时,正确管理连接池非常重要:
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGO_URI, {
maxPoolSize: 10, // 连接池大小
minPoolSize: 2, // 最小连接数
serverSelectionTimeoutMS: 5000, // 服务器选择超时
socketTimeoutMS: 45000, // Socket 超时
});
// 监听连接事件
mongoose.connection.on('disconnected', () => {
console.warn('MongoDB 连接断开,尝试重连...');
});
mongoose.connection.on('error', (err) => {
console.error('MongoDB 连接错误:', err);
});
} catch (error) {
console.error('MongoDB 连接失败:', error.message);
process.exit(1);
}
};
module.exports = connectDB;
三、Schema 设计原则
3.1 用户模型
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: [true, '用户名不能为空'],
unique: true,
trim: true,
minlength: [2, '用户名至少2个字符'],
maxlength: [20, '用户名最多20个字符'],
match: [/^[a-zA-Z0-9_\u4e00-\u9fa5]+$/, '用户名只能包含中英文、数字和下划线'],
},
email: {
type: String,
required: [true, '邮箱不能为空'],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, '邮箱格式不正确'],
},
password: {
type: String,
required: [true, '密码不能为空'],
minlength: 6,
select: false, // 默认查询不返回密码字段
},
avatar: {
type: String,
default: '',
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user',
},
// 软删除
isDeleted: {
type: Boolean,
default: false,
},
}, {
timestamps: true, // 自动添加 createdAt 和 updatedAt
toJSON: { virtuals: true }, // toJSON 时包含虚拟字段
toObject: { virtuals: true },
});
// 索引
userSchema.index({ email: 1 });
userSchema.index({ username: 1 });
// 保存前加密密码
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
// 实例方法:比较密码
userSchema.methods.matchPassword = async function(enteredPassword) {
return await bcrypt.compare(enteredPassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
3.2 文章模型(含分页优化)
const postSchema = new mongoose.Schema({
title: {
type: String,
required: [true, '标题不能为空'],
trim: true,
maxlength: 100,
},
content: {
type: String,
required: true,
},
summary: {
type: String,
maxlength: 200,
},
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
tags: [{
type: String,
lowercase: true,
trim: true,
}],
// 点赞数 —— 用计数器模式避免频繁更新
likeCount: {
type: Number,
default: 0,
},
viewCount: {
type: Number,
default: 0,
},
// 发布状态
status: {
type: String,
enum: ['draft', 'published'],
default: 'draft',
},
isDeleted: {
type: Boolean,
default: false,
},
}, {
timestamps: true,
});
// 复合索引:状态 + 创建时间(分页查询优化)
postSchema.index({ status: 1, createdAt: -1 });
// 作者索引
postSchema.index({ author: 1 });
// 标签索引
postSchema.index({ tags: 1 });
// 虚拟字段:评论数
postSchema.virtual('commentCount', {
ref: 'Comment',
localField: '_id',
foreignField: 'post',
count: true,
});
module.exports = mongoose.model('Post', postSchema);
3.3 聊天消息模型
const messageSchema = new mongoose.Schema({
conversationId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Conversation',
required: true,
index: true,
},
sender: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
content: {
type: String,
required: true,
trim: true,
},
type: {
type: String,
enum: ['text', 'image', 'voice', 'system'],
default: 'text',
},
// 消息状态
status: {
type: String,
enum: ['sent', 'delivered', 'read'],
default: 'sent',
},
isDeleted: {
type: Boolean,
default: false,
},
}, {
timestamps: true,
});
// TTL 索引:30天后自动删除已删除的消息
messageSchema.index(
{ isDeleted: 1, updatedAt: 1 },
{ expireAfterSeconds: 2592000, partialFilterExpression: { isDeleted: true } }
);
module.exports = mongoose.model('Message', messageSchema);
四、聚合管道实战
MongoDB 的聚合管道是处理复杂数据查询的利器。下面展示几个常用场景:
4.1 文章列表带作者信息
const getPosts = async (page = 1, limit = 10) => {
const posts = await Post.aggregate([
// 过滤条件
{ $match: { status: 'published', isDeleted: false } },
// 关联作者
{
$lookup: {
from: 'users',
localField: 'author',
foreignField: '_id',
as: 'authorInfo',
},
},
// 展开作者数组
{ $unwind: { path: '$authorInfo', preserveNullAndEmptyArrays: true } },
// 排序
{ $sort: { createdAt: -1 } },
// 分页
{ $skip: (page - 1) * limit },
{ $limit: limit },
// 字段投影
{
$project: {
title: 1,
summary: 1,
tags: 1,
likeCount: 1,
viewCount: 1,
createdAt: 1,
'authorInfo.username': 1,
'authorInfo.avatar': 1,
},
},
]);
return posts;
};
4.2 统计数据
const getDashboardStats = async (userId) => {
const stats = await Post.aggregate([
{ $match: { author: mongoose.Types.ObjectId(userId), isDeleted: false } },
{
$group: {
_id: null,
totalPosts: { $sum: 1 },
totalViews: { $sum: '$viewCount' },
totalLikes: { $sum: '$likeCount' },
avgViews: { $avg: '$viewCount' },
},
},
]);
return stats[0] || { totalPosts: 0, totalViews: 0, totalLikes: 0 };
};
五、索引优化
索引是数据库性能的关键。MongoDB 索引和 MySQL 类似,但也有自己的特点:
// 单字段索引
userSchema.index({ email: 1 });
// 复合索引(注意字段顺序)
postSchema.index({ status: 1, createdAt: -1 });
// 文本索引(支持全文搜索)
postSchema.index({ title: 'text', content: 'text' });
// 地理空间索引
locationSchema.index({ coordinates: '2dsphere' });
// 唯一索引
userSchema.index({ email: 1 }, { unique: true });
// 部分索引(只对满足条件的文档建索引)
messageSchema.index(
{ isDeleted: 1 },
{ partialFilterExpression: { isDeleted: false } }
);
// 查看当前集合索引
// db.posts.getIndexes()
// 分析查询是否使用索引
// db.posts.find({ status: 'published' }).explain('executionStats')
六、数据备份与安全
// 导出数据库
// mongodump --uri="mongodb://localhost:27017/myapp" --out=./backup
// 导入数据库
// mongorestore --uri="mongodb://localhost:27017/myapp" ./backup
// 安全配置要点:
// 1. 启用认证:security.authorization: enabled
// 2. 创建最小权限账户
// 3. 禁用默认端口对外暴露
// 4. 启用 SSL/TLS 加密连接
// 5. 定期备份(建议每天)
总结
MongoDB 的灵活文档模型让它特别适合快速迭代的 Node.js 项目。但灵活不等于随意——良好的 Schema 设计、合理的索引策略、完善的数据验证,是项目长期健康运行的基础。希望这篇文章能帮你在实际项目中少踩一些坑。