【NestJS】17 MongoDB 性能优化与生产环境最佳实践

3192 字
16 分钟
【NestJS】17 MongoDB 性能优化与生产环境最佳实践

深入掌握 Mongoose 中间件、索引优化、性能调优,构建生产级的数据库应用。

前言 ✨#

在上篇文章中,我们学习了 MongoDB 的基础概念、Mongoose ODM 的使用、Schema 设计以及基本的查询操作。

但在实际项目中,我们还会遇到这些问题:

  • 🤔 如何在保存用户时自动加密密码?
  • 🤔 为什么我的查询这么慢?
  • 🤔 如何优化数据库性能?
  • 🤔 生产环境应该注意什么?

这篇文章将为你解答这些问题。我们将学习:

  • 🎯 中间件与钩子:自动化数据处理
  • 📊 索引设计:让查询快如闪电
  • 性能优化:从慢查询到高性能
  • 🛡️ 最佳实践:构建生产级应用
  • 🔍 问题排查:常见问题与解决方案

让我们开始吧!


一、中间件与钩子#

什么是钩子?#

钩子在特定时刻自动执行逻辑,类似生命周期函数。

执行时机

操作触发 → Pre 钩子 → 实际操作 → Post 钩子 → 完成

常用钩子

  • pre('save'):保存前
  • post('save'):保存后
  • pre('findOneAndUpdate'):更新前
  • pre('deleteOne'):删除前

实战:密码自动加密#

Terminal window
pnpm add bcryptjs
pnpm add -D @types/bcryptjs
user.schema.ts
import * 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 是 undefined
UserSchema.pre('save', async (next) => {
console.log(this.username); // undefined!
});

二、索引设计与性能#

索引的本质#

无索引:扫描所有数据

查询 username='alice'
扫描 100,000 条 → 平均 50,000 次比较

有索引:B-tree 快速定位

查询 username='alice'
通过索引 → 只需 3-4 次查找

性能对比

数据量无索引有索引
10050 次7 次
10,0005,000 次14 次
1,000,000500,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: 100
totalDocsExamined: 100
stage: 'IXSCAN'
// ❌ 坏:全表扫描
nReturned: 100
totalDocsExamined: 1000000
stage: 'COLLSCAN'

索引设计最佳实践#

1. ESR 规则(Equality, Sort, Range)

// 查询
find({
status: 'active', // Equality(等值)
age: { $gte: 18, $lte: 30 } // Range(范围)
}).sort({ createdAt: -1 }); // Sort(排序)
// 索引顺序:E → S → R
UserSchema.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' } })
// ✅ 使用 IN
find({ 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 + 时间3
async 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. 尽早限制
])

四、生产环境最佳实践#

连接配置#

app.module.ts
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,
}),
}),

错误处理#

mongo-exception.filter.ts
@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.ts
app.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);
}

环境配置#

.env.development
MONGODB_URI=mongodb://localhost:27017/dev
MONGODB_AUTO_INDEX=true
# .env.production
MONGODB_URI=mongodb://user:pass@prod:27017/prod?replicaSet=rs0
MONGODB_AUTO_INDEX=false

数据备份#

Terminal window
# 备份
mongodump --uri="mongodb://localhost:27017/mydb" --out=/backup/$(date +%Y%m%d)
# 恢复
mongorestore --uri="mongodb://localhost:27017/mydb" /backup/20240115
# 自动化脚本
#!/bin/bash
BACKUP_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: 如何调试慢查询?

  1. 使用 explain('executionStats')
  2. 检查是否用了索引(IXSCAN vs COLLSCAN)
  3. 检查 totalDocsExamined 是否过大
  4. 优化索引或查询条件

八、总结#

核心知识点#

中间件与钩子

  • ✅ Pre/Post 钩子自动化处理
  • ✅ 密码加密实战
  • ✅ 数据验证和日志

索引设计

  • ✅ 索引类型(单字段、复合、唯一、稀疏、文本、TTL)
  • ✅ ESR 规则和选择性原则
  • ✅ explain() 性能分析

性能优化

  • ✅ 避免 N+1 查询
  • ✅ 字段选择和分页
  • ✅ 并行查询
  • ✅ 聚合优化

生产实践

  • ✅ 连接池配置
  • ✅ 错误处理
  • ✅ 多层验证
  • ✅ 数据备份

学习路径#

MongoDB 基础(上篇)
├─ NoSQL 概念
├─ Mongoose ODM
├─ Schema 设计
├─ 查询与聚合
└─ 数据关联
MongoDB 进阶(下篇)
├─ 中间件与钩子
├─ 索引设计
├─ 性能优化
└─ 生产实践
实战应用
├─ 用户认证系统
├─ 权限管理
└─ 完整项目架构

核心原则#

  1. 💡 先理解,再优化:不要过早优化
  2. 📊 用数据说话:用 explain() 分析
  3. 🎯 按需索引:不是越多越好
  4. 🔒 安全第一:永远不存明文密码
  5. 📝 记录日志:方便问题排查

参考资源

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

【NestJS】17 MongoDB 性能优化与生产环境最佳实践
https://blog.fridolph.top/posts/2026-01-06__nest17-mongo2/
作者
Fridolph
发布于
2026-01-06
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录