一、MongoDB是什么,为什么在Node.js项目中常见
MongoDB属于NoSQL数据库,数据以BSON文档的形式存储。BSON是JSON的二进制扩展,支持日期、二进制、ObjectId等JSON原生不具备的类型。对Node.js而言,这一点的直接好处是:JavaScript对象与数据库文档之间的转换成本较低,不需要像关系型数据库那样先把对象拆成多行、再通过JOIN还原。
Node.js生态中访问MongoDB有两条主要路径。一条是官方mongodb驱动,直接对应MongoDB的数据库命令,控制力强,没有额外抽象层。另一条是mongoose,在驱动之上提供Schema定义、数据验证和中间件。教程型的入门示例通常从官方驱动入手,因为它能让你看清每一步到底在做什么,后面再上Mongoose时不会把ODM的行为当成黑盒。
需要提前说明一个判断:MongoDB的文档模型适合结构灵活、嵌套层级明显的数据,比如内容管理、用户生成内容、日志与事件流。如果业务本身是高度关系化的,比如订单与库存之间的强一致性扣减,关系型数据库仍然是更直接的选择。选型不是看哪个更新,而是看数据形状与查询模式是否匹配。
二、在Linux环境中安装MongoDB服务
在Debian或Ubuntu系的Linux终端中,安装MongoDB服务的命令是:
apt-get install mongodb
这条命令会从当前系统配置的软件源中拉取与系统版本匹配的MongoDB包。执行前需要sudo权限,或者在root会话中运行。安装完成后,MongoDB会注册为系统服务,可以通过以下命令启动:
service mongodb start
service命令是SysVinit风格的服务管理方式。在较新的systemd发行版中,等价写法是systemctlstartmongodb。两者都可用的情况下,建议与系统其余服务的启动方式保持一致,避免在同一个环境里混用两套服务管理逻辑。
这里有一个实际部署中容易遇到的点:通过发行版软件源安装的MongoDB版本,往往不是MongoDB官方的新版本,而是该发行版维护者选定的稳定版本。如果你的项目依赖某个较新的特性,比如特定的聚合管道操作符或多文档事务的某些行为,需要去MongoDB官方文档核对当前安装的版本号。查看版本可以用:
mongod --version
启动服务后,默认监听27017端口。可以用mongo或mongoshshell连上去确认服务是否正常:
mongosh --eval "db.runCommand({ ping: 1 })"
返回{ok:1}说明服务端已经就绪。这一步看起来简单,但它把“安装问题”和“连接问题”分开了。后面Node.js连接失败时,可以快速判断是服务端没起来,还是应用侧配置有误。
三、在Windows上安装MongoDB的路径
Windows环境下不通过apt-get安装。需要到MongoDB官方下载页面获取Windows版本的安装包,通常是.msi格式。安装过程中有一个选项需要留意:是否将MongoDB注册为Windows服务。勾选后,MongoDB会随系统启动,省去每次手动运行mongod的步骤。
安装完成后,MongoDB的默认数据目录和服务配置会写入安装路径下的bin目录与系统服务列表。如果安装时没有勾选服务注册,也可以手动执行mongod--install来注册。Windows上的路径和权限问题比Linux更常见,尤其是数据目录被安全软件锁定或权限不足导致服务启动失败,排查时优先看MongoDB的日志文件,而不是反复重装。
四、在Node.js项目中安装mongodb驱动
进入项目目录,执行:
npm install mongodb --save
--save会把依赖写入package.json的dependencies字段。在npm5之后,npminstall默认就会保存依赖,--save已经不再是必须的,但保留它可以让依赖声明更明确,也兼容一些较旧的工作流。
安装完成后,node_modules下会出现mongodb包。这个包同时包含驱动核心和连接管理逻辑,是后续所有数据库操作的入口。注意区分它和mongoose:mongoose是独立包,内部依赖mongodb驱动,两者不要混用同一个连接实例。
五、建立连接与执行基本操作
连接MongoDB服务
以下示例使用代码号学习编程的风格,展示从连接建立到数据读写的完整流程:
const { MongoClient } = require('mongodb');
const uri = 'mongodb://127.0.0.1:27017';
const client = new MongoClient(uri);
async function run() {
try {
await client.connect();
const db = client.db('codehao_learning');
const collection = db.collection('lessons');
console.log('MongoDB 连接成功');
return collection;
} catch (err) {
console.error('连接失败:', err.message);
throw err;
}
}
这里用127.0.0.1而不是localhost,原因在Node.js18之后变得明显:DNS解析顺序调整后,localhost可能优先解析为IPv6的::1,而MongoDB默认监听IPv4。结果是连接被拒绝或超时,但错误信息往往不够直观。直接用127.0.0.1可以绕开这个解析层的问题。
MongoClient内部维护连接池。不要每次请求都newMongoClient再connect,那样会反复创建和销毁连接,开销很大。正确做法是在应用启动时创建一次客户端,把collection或db对象传递给需要的地方,应用关闭时再调用client.close()。
插入数据
async function insertLesson(collection) {
const result = await collection.insertOne({
title: 'Node.js 连接 MongoDB',
level: '入门',
createdAt: new Date()
});
console.log('插入文档 _id:', result.insertedId);
}
insertOne返回的对象里,insertedId是MongoDB自动生成的ObjectId。如果业务需要自定义主键,也可以在文档中显式传入_id字段,但要注意它在内必须唯一。
查询数据
async function findLessons(collection) {
const lessons = await collection
.find({ level: '入门' })
.sort({ createdAt: -1 })
.limit(10)
.toArray();
return lessons;
}
find返回的是一个游标(Cursor),不是数组。必须调用.toArray()或通过forawait迭代才能真正取到数据。这一点在从关系型数据库转过来时容易忽略,因为很多ORM的查询方法直接返回数组。游标的好处是支持流式处理,数据量大时不必一次性全部加载到内存。
更新与删除
async function updateLesson(collection, lessonId) {
const result = await collection.updateOne(
{ _id: lessonId },
{ $set: { level: '进阶' } }
);
return result.modifiedCount;
}
async function deleteLesson(collection, lessonId) {
const result = await collection.deleteOne({ _id: lessonId });
return result.deletedCount;
}
updateOne的第二个参数必须使用更新操作符,比如$set、$inc、$push。如果直接传一个普通对象,MongoDB会用该对象整体替换原文档,而不是合并字段。这是一个高频错误,替换操作会丢失未在对象中出现的字段。
六、项目实例:课程学习记录服务
把上面的片段组合成一个可运行的小服务,用于记录用户在代码号学习编程平台上的课程进度:
const { MongoClient } = require('mongodb');
const client = new MongoClient('mongodb://127.0.0.1:27017');
async function main() {
await client.connect();
const db = client.db('codehao_learning');
const progress = db.collection('progress');
await progress.createIndex({ userId: 1, lessonId: 1 }, { unique: true });
await progress.updateOne(
{ userId: 'u1001', lessonId: 'node-mongodb-01' },
{
$set: { updatedAt: new Date() },
$setOnInsert: { startedAt: new Date() },
$inc: { completedSteps: 1 }
},
{ upsert: true }
);
const record = await progress.findOne({ userId: 'u1001' });
console.log(record);
await client.close();
}
main().catch(console.error);
这段代码里有几个值得注意的设计。createIndex建立了userId与lessonId的复合唯一索引,防止同一个用户对同一节课产生重复记录。upsert:true让更新操作在文档不存在时自动插入,这在“记录进度”这类场景中比先查再写更简洁,也避免了并发下的竞态。$setOnInsert只在插入时生效,用来记录startedAt,后续更新不会覆盖它。
七、错误处理与连接管理
官方驱动在Node.js中的错误主要分三类:连接错误、操作错误和验证错误。连接错误发生在client.connect()阶段,通常是服务未启动、地址错误或认证失败。操作错误发生在具体读写时,比如违反唯一索引、查询超时。验证错误来自MongoDB服务端的SchemaValidation,如果配置了$jsonSchema。
一个实用的处理方式是把连接建立和业务操作分开捕获,不要用一个大的try/catch把两者混在一起,否则日志里很难判断问题出在连接层还是数据层。连接层失败通常需要重试或告警,操作层失败则更多是业务逻辑需要处理的分支。
client.close()应该在应用退出前调用。在Express或Fastify这类框架中,可以把它挂在process.on('SIGINT')或框架的关闭钩子上。不关闭连接池虽然进程退出时操作系统会回收,但在测试环境中容易留下悬挂句柄,导致测试进程不退出。
八、从原生驱动到Mongoose的过渡判断
入门阶段用官方驱动能看清数据库操作的本质。当项目出现以下信号时,可以考虑引入Mongoose:字段验证逻辑开始在多个路由中重复;文档结构需要统一的默认值和类型转换;需要中间件在保存前自动处理字段,比如密码哈希或时间戳。
但引入Mongoose也意味着多一层抽象。复杂聚合查询用原生驱动写起来更直接,Mongoose的aggregate最终也是转成管道操作。我的建议是:核心业务模型用Mongoose管住Schema和验证,统计类、分析类的聚合查询直接用collection.aggregate,不必强行统一。
练习思考题
给上面的课程进度服务增加一个功能:查询某个用户最近七天内更新过的课程记录,按更新时间倒序返回。需要用到哪个查询操作符?如果数据量很大,应该在哪个字段上建立索引才能让这个查询走索引而不是全扫描?