深入掌握 Mongoose 中间件、索引优化、性能调优,构建生产级的数据库应用。
前言 ✨
在上篇文章中,我们学习了 MongoDB 的基础概念、Mongoose ODM 的使用、Schema 设计以及基本的查询操作。
但在实际项目中,我们还会遇到这些问题:
- 🤔 如何在保存用户时自动加密密码?
- 🤔 为什么我的查询这么慢?
- 🤔 如何优化数据库性能?
- 🤔 生产环境应该注意什么?
这篇文章将为你解答这些问题。我们将学习:
- 🎯 中间件与钩子:自动化数据处理
- 📊 索引设计:让查询快如闪电
- ⚡ 性能优化:从慢查询到高性能
- 🛡️ 最佳实践:构建生产级应用
- 🔍 问题排查:常见问题与解决方案
让我们开始吧!
一、中间件与钩子
什么是钩子?
钩子在特定时刻自动执行逻辑,类似生命周期函数。
执行时机:
操作触发 → Pre 钩子 → 实际操作 → Post 钩子 → 完成常用钩子:
pre('save'):保存前post('save'):保存后pre('findOneAndUpdate'):更新前pre('deleteOne'):删除前
实战:密码自动加密
bash
pnpm add bcryptjs
pnpm add -D @types/bcryptjstypescript
// 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 中使用:
typescript
@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' }(密码已隐藏)其他常用钩子
自动更新时间戳:
typescript
UserSchema.pre('findOneAndUpdate', function(next) {
this.set({ updatedAt: new Date() });
next();
});数据验证:
typescript
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():
typescript
// ✅ 正确
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. 使用普通函数,不用箭头函数:
typescript
// ✅ 正确: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 次查找性能对比:
| 数据量 | 无索引 | 有索引 |
|---|---|---|
| 100 | 50 次 | 7 次 |
| 10,000 | 5,000 次 | 14 次 |
| 1,000,000 | 500,000 次 | 20 次 |
索引类型
1. 单字段索引:
typescript
@Prop({ index: true })
username: string;
@Prop({ unique: true }) // 唯一索引
email: string;
// 或
UserSchema.index({ username: 1 }); // 1=升序, -1=降序2. 复合索引:
typescript
// 经常这样查询?
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. 唯一索引:
typescript
UserSchema.index({ email: 1 }, { unique: true });
// 确保字段值唯一 + 提高查询性能4. 稀疏索引:
typescript
UserSchema.index({ phone: 1 }, { sparse: true });
// 允许多个文档该字段为 null
// 如果存在,必须唯一5. 文本索引:
typescript
UserSchema.index({ bio: 'text', username: 'text' });
// 全文搜索
await this.userModel.find({
$text: { $search: 'developer nodejs' }
});6. TTL 索引:
typescript
// 验证码 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() 分析:
typescript
const explanation = await this.userModel
.find({ status: 'active' })
.explain('executionStats');关键指标:
javascript
{
executionStats: {
nReturned: 5000, // 返回 5000 条
totalDocsExamined: 5000, // 检查 5000 条
executionTimeMillis: 15, // 耗时 15ms
executionStages: {
stage: 'IXSCAN' // 索引扫描(好)
// stage: 'COLLSCAN' // 全表扫描(坏)
}
}
}性能判断:
typescript
// ✅ 好:使用索引,只检查需要的文档
nReturned: 100
totalDocsExamined: 100
stage: 'IXSCAN'
// ❌ 坏:全表扫描
nReturned: 100
totalDocsExamined: 1000000
stage: 'COLLSCAN'索引设计最佳实践
1. ESR 规则(Equality, Sort, Range):
typescript
// 查询
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. 选择性原则:
typescript
// ✅ 好:选择性高的字段优先
UserSchema.index({ email: 1, status: 1 });
// email 选择性高(每个用户不同)
// status 选择性低(只有 3 个值)
// ❌ 不好
UserSchema.index({ status: 1, email: 1 });3. 避免过多索引:
typescript
// ❌ 不好:索引太多
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 查询
typescript
// ❌ 错误: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:返回过多字段
typescript
// ❌ 错误:返回所有字段(包括大字段)
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:没有分页
typescript
// ❌ 错误:一次返回所有(可能 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:低效查询条件
typescript
// ❌ 避免 NOT 查询
find({ status: { $ne: 'banned' } })
// ✅ 使用 IN
find({ status: { $in: ['active', 'inactive'] } })
// ❌ 避免正则开头通配符
find({ username: /.*alice.*/i })
// ✅ 使用前缀匹配
find({ username: /^alice/i })问题 5:并发写入冲突
typescript
// ❌ 错误:可能丢失更新
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:内存溢出
typescript
// ❌ 错误:一次加载所有
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;
}
}异步优化
typescript
// ❌ 串行:总时间 = 时间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 };
}聚合优化
typescript
// ❌ 低效:先排序所有数据
aggregate([
{ $sort: { createdAt: -1 } },
{ $match: { status: 'active' } },
{ $limit: 10 },
])
// ✅ 高效:先筛选,尽早限制
aggregate([
{ $match: { status: 'active' } }, // 1. 先筛选(用索引)
{ $sort: { createdAt: -1 } }, // 2. 再排序
{ $limit: 10 }, // 3. 尽早限制
])四、生产环境最佳实践
连接配置
typescript
// 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,
}),
}),错误处理
typescript
// 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());数据验证
typescript
// 多层验证
// 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);
}环境配置
bash
# .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数据备份
bash
# 备份
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: 如何调试慢查询?
- 使用
explain('executionStats') - 检查是否用了索引(IXSCAN vs COLLSCAN)
- 检查
totalDocsExamined是否过大 - 优化索引或查询条件
八、总结
核心知识点
中间件与钩子:
- ✅ Pre/Post 钩子自动化处理
- ✅ 密码加密实战
- ✅ 数据验证和日志
索引设计:
- ✅ 索引类型(单字段、复合、唯一、稀疏、文本、TTL)
- ✅ ESR 规则和选择性原则
- ✅ explain() 性能分析
性能优化:
- ✅ 避免 N+1 查询
- ✅ 字段选择和分页
- ✅ 并行查询
- ✅ 聚合优化
生产实践:
- ✅ 连接池配置
- ✅ 错误处理
- ✅ 多层验证
- ✅ 数据备份
学习路径
MongoDB 基础(上篇)
├─ NoSQL 概念
├─ Mongoose ODM
├─ Schema 设计
├─ 查询与聚合
└─ 数据关联
MongoDB 进阶(下篇)
├─ 中间件与钩子
├─ 索引设计
├─ 性能优化
└─ 生产实践
实战应用
├─ 用户认证系统
├─ 权限管理
└─ 完整项目架构核心原则
- 💡 先理解,再优化:不要过早优化
- 📊 用数据说话:用 explain() 分析
- 🎯 按需索引:不是越多越好
- 🔒 安全第一:永远不存明文密码
- 📝 记录日志:方便问题排查
参考资源:
这是关于赞助的一些描述
- 本文链接:https://fridolph.top/posts/2026-01-06__nest17-mongo2
- 版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 许可协议。