【NestJS】17 MongoDB 性能优化与生产环境最佳实践
3192 字
16 分钟
【NestJS】17 MongoDB 性能优化与生产环境最佳实践
深入掌握 Mongoose 中间件、索引优化、性能调优,构建生产级的数据库应用。
前言 ✨
在上篇文章中,我们学习了 MongoDB 的基础概念、Mongoose ODM 的使用、Schema 设计以及基本的查询操作。
但在实际项目中,我们还会遇到这些问题:
- 🤔 如何在保存用户时自动加密密码?
- 🤔 为什么我的查询这么慢?
- 🤔 如何优化数据库性能?
- 🤔 生产环境应该注意什么?
这篇文章将为你解答这些问题。我们将学习:
- 🎯 中间件与钩子:自动化数据处理
- 📊 索引设计:让查询快如闪电
- ⚡ 性能优化:从慢查询到高性能
- 🛡️ 最佳实践:构建生产级应用
- 🔍 问题排查:常见问题与解决方案
让我们开始吧!
一、中间件与钩子
什么是钩子?
钩子在特定时刻自动执行逻辑,类似生命周期函数。
执行时机:
操作触发 → Pre 钩子 → 实际操作 → Post 钩子 → 完成常用钩子:
pre('save'):保存前post('save'):保存后pre('findOneAndUpdate'):更新前pre('deleteOne'):删除前
实战:密码自动加密
pnpm add bcryptjspnpm add -D @types/bcryptjsimport * as bcrypt from 'bcryptjs';
@Schema({ timestamps: true })export class User extends Document { @Prop({ required: true, unique: true }) username: string;
@Prop({ required: true, unique: true }) email: string;
@Prop({ required: true, minlength: 6 }) password: string;}
export const UserSchema = SchemaFactory.createForClass(User);
// ========== Pre Save 钩子:加密密码 ==========UserSchema.pre('save', async function(next) { // 1. 检查密码是否被修改 if (!this.isModified('password')) { return next(); // 没修改,跳过 }
try { // 2. 生成盐并加密 const salt = await bcrypt.genSalt(10); this.password = await bcrypt.hash(this.password, salt); next(); } catch (error) { next(error); }});
// ========== Post Save 钩子:记录日志 ==========UserSchema.post('save', function(doc) { console.log(`✅ 用户 ${doc.username} 已保存`);});
// ========== 实例方法:比对密码 ==========UserSchema.methods.comparePassword = async function( candidatePassword: string,): Promise<boolean> { return bcrypt.compare(candidatePassword, this.password);};
// ========== 实例方法:隐藏敏感字段 ==========UserSchema.methods.toJSON = function() { const obj = this.toObject(); delete obj.password; return obj;};在 Service 中使用:
@Injectable()export class UserService { constructor( @InjectModel(User.name) private userModel: Model<User>, ) {}
// 注册:密码自动加密 async register(dto: CreateUserDto) { return this.userModel.create(dto); // 钩子自动加密 }
// 登录:验证密码 async login(email: string, password: string) { const user = await this.userModel .findOne({ email }) .select('+password') // 显式包含密码 .exec();
if (!user) { throw new UnauthorizedException('用户不存在'); }
const isValid = await user.comparePassword(password); if (!isValid) { throw new UnauthorizedException('密码错误'); }
return user; // 返回时密码已隐藏 }}工作流程:
1. 注册:{ username: 'alice', password: 'password123' } ↓2. Pre Save 钩子检测到 password 被修改 ↓3. 加密:'password123' → '$2a$10$N9qo8uLO...' ↓4. 保存到数据库 ↓5. Post Save 钩子打印日志 ↓6. 返回:{ username: 'alice' }(密码已隐藏)其他常用钩子
自动更新时间戳:
UserSchema.pre('findOneAndUpdate', function(next) { this.set({ updatedAt: new Date() }); next();});数据验证:
UserSchema.pre('save', async function(next) { // 检查用户名是否包含敏感词 const bannedWords = ['admin', 'root', 'system']; if (bannedWords.some(w => this.username.toLowerCase().includes(w))) { return next(new Error('用户名包含被禁止的词汇')); } next();});钩子注意事项
1. 必须检查 isModified():
// ✅ 正确UserSchema.pre('save', async function(next) { if (!this.isModified('password')) return next(); // 加密逻辑...});
// ❌ 错误:每次保存都加密,会重复加密UserSchema.pre('save', async function(next) { this.password = await bcrypt.hash(this.password, 10);});2. 使用普通函数,不用箭头函数:
// ✅ 正确:this 指向文档UserSchema.pre('save', async function(next) { console.log(this.username);});
// ❌ 错误:this 是 undefinedUserSchema.pre('save', async (next) => { console.log(this.username); // undefined!});二、索引设计与性能
索引的本质
无索引:扫描所有数据
查询 username='alice'扫描 100,000 条 → 平均 50,000 次比较有索引:B-tree 快速定位
查询 username='alice'通过索引 → 只需 3-4 次查找性能对比:
| 数据量 | 无索引 | 有索引 |
|---|---|---|
| 100 | 50 次 | 7 次 |
| 10,000 | 5,000 次 | 14 次 |
| 1,000,000 | 500,000 次 | 20 次 |
索引类型
1. 单字段索引:
@Prop({ index: true })username: string;
@Prop({ unique: true }) // 唯一索引email: string;
// 或UserSchema.index({ username: 1 }); // 1=升序, -1=降序2. 复合索引:
// 经常这样查询?find({ status: 'active' }).sort({ createdAt: -1 })
// 创建复合索引UserSchema.index({ status: 1, createdAt: -1 });
// ✅ 可以使用的查询find({ status: 'active' })find({ status: 'active' }).sort({ createdAt: -1 })
// ❌ 不能使用的查询find({ createdAt: { $gte: date } }) // 跳过了第一个字段3. 唯一索引:
UserSchema.index({ email: 1 }, { unique: true });// 确保字段值唯一 + 提高查询性能4. 稀疏索引:
UserSchema.index({ phone: 1 }, { sparse: true });// 允许多个文档该字段为 null// 如果存在,必须唯一5. 文本索引:
UserSchema.index({ bio: 'text', username: 'text' });
// 全文搜索await this.userModel.find({ $text: { $search: 'developer nodejs' }});6. TTL 索引:
// 验证码 Schema@Schema()export class VerificationCode extends Document { @Prop() code: string; @Prop({ type: Date, default: Date.now }) createdAt: Date;}
// 1 小时后自动删除VerificationCodeSchema.index( { createdAt: 1 }, { expireAfterSeconds: 3600 });查询性能分析
使用 explain() 分析:
const explanation = await this.userModel .find({ status: 'active' }) .explain('executionStats');关键指标:
{ executionStats: { nReturned: 5000, // 返回 5000 条 totalDocsExamined: 5000, // 检查 5000 条 executionTimeMillis: 15, // 耗时 15ms executionStages: { stage: 'IXSCAN' // 索引扫描(好) // stage: 'COLLSCAN' // 全表扫描(坏) } }}性能判断:
// ✅ 好:使用索引,只检查需要的文档nReturned: 100totalDocsExamined: 100stage: 'IXSCAN'
// ❌ 坏:全表扫描nReturned: 100totalDocsExamined: 1000000stage: 'COLLSCAN'索引设计最佳实践
1. ESR 规则(Equality, Sort, Range):
// 查询find({ status: 'active', // Equality(等值) age: { $gte: 18, $lte: 30 } // Range(范围)}).sort({ createdAt: -1 }); // Sort(排序)
// 索引顺序:E → S → RUserSchema.index({ status: 1, // 1. Equality createdAt: -1, // 2. Sort age: 1, // 3. Range});2. 选择性原则:
// ✅ 好:选择性高的字段优先UserSchema.index({ email: 1, status: 1 });// email 选择性高(每个用户不同)// status 选择性低(只有 3 个值)
// ❌ 不好UserSchema.index({ status: 1, email: 1 });3. 避免过多索引:
// ❌ 不好:索引太多UserSchema.index({ username: 1 });UserSchema.index({ email: 1 });UserSchema.index({ status: 1 });UserSchema.index({ age: 1 });UserSchema.index({ createdAt: 1 });// 问题:占内存、写入慢
// ✅ 好:只为常用查询建索引UserSchema.index({ email: 1 }, { unique: true });UserSchema.index({ status: 1, createdAt: -1 });三、性能优化实战
常见问题与解决方案
问题 1:N+1 查询
// ❌ 错误:11 次查询(1 + 10)async getUsersWithInterviews() { const users = await this.userModel.find().limit(10); // 1 次
for (const user of users) { user.interviews = await this.interviewModel.find({ userId: user._id }); // 10 次 }
return users;}
// ✅ 正确:2 次查询async getUsersWithInterviews() { return this.userModel .find() .limit(10) .populate('interviews') // 自动关联 .exec();}
// ✅ 更好:1 次查询(聚合)async getUsersWithInterviews() { return this.userModel.aggregate([ { $limit: 10 }, { $lookup: { from: 'interviews', localField: '_id', foreignField: 'userId', as: 'interviews' } } ]);}问题 2:返回过多字段
// ❌ 错误:返回所有字段(包括大字段)async getUsers() { return this.userModel.find();}
// ✅ 正确:只返回需要的字段async getUsers() { return this.userModel .find() .select('username email status') .exec();}
// ✅ 或排除大字段async getUsers() { return this.userModel .find() .select('-password -bio -avatar') .exec();}问题 3:没有分页
// ❌ 错误:一次返回所有(可能 100 万条)async getAllUsers() { return this.userModel.find();}
// ✅ 正确:分页async getUsers(page = 1, pageSize = 20) { const skip = (page - 1) * pageSize;
const [data, total] = await Promise.all([ this.userModel.find().skip(skip).limit(pageSize).exec(), this.userModel.countDocuments(), ]);
return { data, total, page, pageSize };}问题 4:低效查询条件
// ❌ 避免 NOT 查询find({ status: { $ne: 'banned' } })
// ✅ 使用 INfind({ status: { $in: ['active', 'inactive'] } })
// ❌ 避免正则开头通配符find({ username: /.*alice.*/i })
// ✅ 使用前缀匹配find({ username: /^alice/i })问题 5:并发写入冲突
// ❌ 错误:可能丢失更新async incrementScore(userId: string) { const user = await this.userModel.findById(userId); user.score += 10; // 并发时会丢失 await user.save();}
// ✅ 正确:原子操作async incrementScore(userId: string) { return this.userModel.findByIdAndUpdate( userId, { $inc: { score: 10 } }, // 原子操作 { new: true } );}问题 6:内存溢出
// ❌ 错误:一次加载所有async processAllUsers() { const users = await this.userModel.find(); // 可能几百万条 for (const user of users) { await this.processUser(user); }}
// ✅ 正确:使用流async processAllUsers() { const stream = this.userModel.find().cursor();
for await (const user of stream) { await this.processUser(user); }}
// ✅ 或批处理async processAllUsers() { const batchSize = 100; let skip = 0;
while (true) { const users = await this.userModel .find() .skip(skip) .limit(batchSize);
if (users.length === 0) break;
await Promise.all(users.map(u => this.processUser(u))); skip += batchSize; }}异步优化
// ❌ 串行:总时间 = 时间1 + 时间2 + 时间3async getUserData(userId: string) { const user = await this.userModel.findById(userId); const interviews = await this.interviewModel.find({ userId }); const comments = await this.commentModel.find({ userId }); return { user, interviews, comments };}
// ✅ 并行:总时间 = max(时间1, 时间2, 时间3)async getUserData(userId: string) { const [user, interviews, comments] = await Promise.all([ this.userModel.findById(userId), this.interviewModel.find({ userId }), this.commentModel.find({ userId }), ]); return { user, interviews, comments };}聚合优化
// ❌ 低效:先排序所有数据aggregate([ { $sort: { createdAt: -1 } }, { $match: { status: 'active' } }, { $limit: 10 },])
// ✅ 高效:先筛选,尽早限制aggregate([ { $match: { status: 'active' } }, // 1. 先筛选(用索引) { $sort: { createdAt: -1 } }, // 2. 再排序 { $limit: 10 }, // 3. 尽早限制])四、生产环境最佳实践
连接配置
MongooseModule.forRootAsync({ useFactory: () => ({ uri: process.env.MONGODB_URI,
// 连接池 maxPoolSize: 10, minPoolSize: 2,
// 超时 serverSelectionTimeoutMS: 5000, socketTimeoutMS: 45000, connectTimeoutMS: 10000,
// 重试 retryWrites: true, retryReads: true,
// 生产环境关闭自动创建 autoIndex: false, autoCreate: false, }),}),错误处理
@Catch(MongoError)export class MongoExceptionFilter implements ExceptionFilter { catch(exception: MongoError, host: ArgumentsHost) { const response = host.switchToHttp().getResponse();
let status = 500; let message = '数据库错误';
switch (exception.code) { case 11000: // 唯一索引冲突 status = 409; message = '数据已存在'; break; case 121: // 文档验证失败 status = 400; message = '数据验证失败'; break; default: console.error('MongoDB Error:', exception); }
response.status(status).json({ statusCode: status, message, timestamp: new Date().toISOString(), }); }}
// main.tsapp.useGlobalFilters(new MongoExceptionFilter());数据验证
// 多层验证// 1. DTO 层export class CreateUserDto { @IsString() @MinLength(3) @MaxLength(20) username: string;}
// 2. Schema 层@Prop({ required: true, minlength: 3, maxlength: 20 })username: string;
// 3. Service 层async create(dto: CreateUserDto) { const existing = await this.userModel.findOne({ username: dto.username }); if (existing) { throw new ConflictException('用户名已存在'); } return this.userModel.create(dto);}环境配置
MONGODB_URI=mongodb://localhost:27017/devMONGODB_AUTO_INDEX=true
# .env.productionMONGODB_URI=mongodb://user:pass@prod:27017/prod?replicaSet=rs0MONGODB_AUTO_INDEX=false数据备份
# 备份mongodump --uri="mongodb://localhost:27017/mydb" --out=/backup/$(date +%Y%m%d)
# 恢复mongorestore --uri="mongodb://localhost:27017/mydb" /backup/20240115
# 自动化脚本#!/bin/bashBACKUP_DIR="/backup/mongodb"DATE=$(date +%Y%m%d)mongodump --uri="$MONGODB_URI" --out="$BACKUP_DIR/$DATE"
# 删除 7 天前的备份find $BACKUP_DIR -type d -mtime +7 -exec rm -rf {} \;五、完整数据流程
用户注册流程
1. 前端请求 POST /api/users/register { username: 'alice', password: 'password123' } ↓2. DTO 验证(ValidationPipe) ✅ username 长度 3-20 ✅ password 长度 >= 6 ↓3. Controller 接收 @Post('register') register(@Body() dto: CreateUserDto) ↓4. Service 业务逻辑 检查用户是否已存在(使用索引) ↓5. Pre Save 钩子 检测 password 被修改 加密:'password123' → '$2a$10$...' ↓6. Schema 验证 ✅ required、unique、minlength ↓7. 保存到数据库 { username: 'alice', password: '$2a$10$...' } ↓8. Post Save 钩子 打印日志:✅ 用户 alice 已保存 ↓9. toJSON 转换 删除 password 字段 ↓10. 返回响应 { username: 'alice', email: '...', status: 'active' }涉及的知识点:
- ✅ DTO 验证
- ✅ 索引查询
- ✅ Pre/Post 钩子
- ✅ Schema 验证
- ✅ 默认值
- ✅ 时间戳
- ✅ toJSON 转换
六、性能优化检查清单
部署前检查
索引:
- 常查询字段有索引
- 复合索引顺序正确(ESR)
- 无多余索引
- 唯一索引已设置
查询:
- 无 N+1 查询
- 使用字段选择
- 实现分页
- 避免全表扫描
- 用 explain() 分析过
Schema:
- 数据关联设计合理
- 字段验证完整
- 无过度正规化/反正规化
安全:
- 密码已加密
- 敏感字段不返回
- 输入验证完整
监控:
- 慢查询日志
- 索引使用统计
- 连接池监控
- 错误日志
配置:
- 连接池大小合理
- 超时配置正确
- 生产环境关闭 autoIndex
- 备份策略已设置
七、常见问题 FAQ
Q1: 什么时候用引用,什么时候用嵌入?
- 引用:数据常更新、一对多、数据大
- 嵌入:数据小且不常变化、总是一起查询
Q2: 索引越多越好吗?
- ❌ 不是!占内存、拖慢写入
- 只为常用查询建索引
Q3: 如何处理大数据量查询?
- 分页、流处理、批处理、缓存
Q4: 钩子会影响性能吗?
- 会的!避免在钩子中执行耗时操作
Q5: 如何调试慢查询?
- 使用
explain('executionStats') - 检查是否用了索引(IXSCAN vs COLLSCAN)
- 检查
totalDocsExamined是否过大 - 优化索引或查询条件
八、总结
核心知识点
中间件与钩子:
- ✅ Pre/Post 钩子自动化处理
- ✅ 密码加密实战
- ✅ 数据验证和日志
索引设计:
- ✅ 索引类型(单字段、复合、唯一、稀疏、文本、TTL)
- ✅ ESR 规则和选择性原则
- ✅ explain() 性能分析
性能优化:
- ✅ 避免 N+1 查询
- ✅ 字段选择和分页
- ✅ 并行查询
- ✅ 聚合优化
生产实践:
- ✅ 连接池配置
- ✅ 错误处理
- ✅ 多层验证
- ✅ 数据备份
学习路径
MongoDB 基础(上篇)├─ NoSQL 概念├─ Mongoose ODM├─ Schema 设计├─ 查询与聚合└─ 数据关联
MongoDB 进阶(下篇)├─ 中间件与钩子├─ 索引设计├─ 性能优化└─ 生产实践
实战应用├─ 用户认证系统├─ 权限管理└─ 完整项目架构核心原则
- 💡 先理解,再优化:不要过早优化
- 📊 用数据说话:用 explain() 分析
- 🎯 按需索引:不是越多越好
- 🔒 安全第一:永远不存明文密码
- 📝 记录日志:方便问题排查
参考资源:
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!
【NestJS】17 MongoDB 性能优化与生产环境最佳实践
https://blog.fridolph.top/posts/2026-01-06__nest17-mongo2/ 相关文章 智能推荐
1
【NestJS】16 MongoDB + Mongoose 实战(上)
NestJS 2026-01-05
2
【NestJS】19 用户认证系统设计与实践总结
NestJS 2026-02-05
3
【NestJS】18 项目架构全景图:从零散知识到完整体系
NestJS 2026-02-01
4
【NestJS】15 最佳实践与学习总结
NestJS 2026-01-20
5
「部署认知」一、前端转全栈必会的 Docker + CI/CD 20% 核心
工程化 2026-04-17
随机文章 随机推荐