1. 从一次连接超时说起:mongoose 连 mongoDB 到底卡在哪
很多人第一次在 Node.js 里用 mongoose 连 mongoDB,代码照着示例敲完,node app.js一跑,控制台要么一直转圈,要么甩出一句MongooseServerSelectionError: connect ECONNREFUSED 127.0.0.1:27017。这时候新手容易怀疑代码写错了,其实八成是数据库服务没起来,或者连接字符串里的库名、端口对不上。
mongoose 是什么?它是 Node.js 生态里最常用的 mongoDB 对象建模工具(ODM)。你可以把它理解成一个“翻译官”:你在 JS 里定义 Schema(数据结构长什么样),mongoose 负责把它翻译成 mongoDB 能懂的文档格式,同时帮你做类型校验、默认值填充、链式查询。它适合谁?适合所有用 Node.js 写后端、需要持久化数据、又不想手写原生驱动回调的开发者。哪怕你只做一个待办清单小项目,用 mongoose 也比裸写mongodb驱动舒服得多。
mongoDB 本身是文档型数据库,数据以 BSON(类似 JSON)形式存在集合里。这里先厘清两个概念,后面全程会用到:集合(collection)相当于 MySQL 里的表,文档(document)相当于表里的一行记录。一个数据库(database)下可以有多个集合,一个集合里放多条文档。mongoose 的模型(Model)就是集合的“操作入口”,你通过模型来增删改查。
这篇内容聚焦的场景很明确:在 Node.js 项目里,从零完成 mongoose 连接 mongoDB,定义 Schema 和 Model,跑通增删改查,最后核对结果。我会把连接字符串、模型定义、CRUD 代码、验证步骤、报错排查全部给全,你复制过去改改就能用。整个过程不需要任何额外中间件,本地装好 mongoDB 和 Node.js 即可。
先说清楚环境前提:Node.js 建议 16 以上,mongoose 用当前稳定版(写这篇时是 8.x)。mongoDB 本地服务默认监听27017端口。如果你用的是云端数据库,把连接字符串换成对应的即可,后面的建模和 CRUD 逻辑完全一样。下面从安装依赖开始,一步步来。
2. 前置准备:装依赖、起服务、拿连接串
动手写代码前,有三件事必须先落地,否则后面一定报错。第一件是初始化项目并安装 mongoose,第二件是确认 mongoDB 服务在跑,第三件是把连接字符串准备好。这三步做完,你才有资格谈“连接成功”。
先建目录、初始化package.json、装依赖。打开终端,依次执行:
mkdir mongoose-crud-demo && cd mongoose-crud-demo npm init -y npm install mongoose装完后package.json的dependencies里会出现"mongoose": "^8.x.x"。这里提醒一句,mongoose 8 对 Node.js 版本有要求,低于 16 会装不上或运行报错,用node -v确认一下。
接着确认 mongoDB 服务。如果你本地装了 mongoDB Community Server,macOS 用brew services start mongodb-community,Linux 用sudo systemctl start mongod,Windows 则在“服务”里找 MongoDB Server 启动。启动后用下面命令验证端口是否在监听:
# macOS / Linux lsof -i :27017 # 或者直接用 mongosh 连一下 mongosh "mongodb://127.0.0.1:27017"能进到test>提示符就说明服务正常。如果lsof没有任何输出,或者mongosh报连接被拒,那就是服务没起来,先解决这个,别急着写代码。
连接字符串的格式是mongodb://用户名:密码@主机:端口/数据库名?参数。本地无认证的情况下就是mongodb://127.0.0.1:27017/mydatabase。这里有个关键点:连接字符串末尾的mydatabase是数据库名,如果这个库不存在,mongoDB 会在你第一次写入数据时自动创建它。注意是“第一次写入”才创建,光连接不写数据,库不会凭空出现。这一点很多人误解成“连接即创建”,结果去mongosh里show dbs找不到库,以为出错了。
如果你用云端数据库,连接串通常长这样:mongodb+srv://user:pass@cluster0.xxxxx.mongodb.net/mydatabase?retryWrites=true&w=majority。mongodb+srv表示通过 DNS 种子列表发现节点,不需要写端口。把用户名密码替换成你自己的即可。不管本地还是云端,mongoose 的connect方法用法一致。
还有个小细节:连接字符串里的库名不要带特殊字符,用字母数字下划线最稳。密码里如果有@、:、/这类字符,必须做 URL 编码,否则解析会出错。比如密码是p@ss,要写成p%40ss。这个坑我在实际项目里踩过,报错信息是MongoParseError: Unescaped at-sign in authority section,看到这个就去检查密码编码。
准备工作做完,目录结构建议这样:根目录放app.js(入口)和db.js(连接封装),模型放models/User.js。当然你也可以全写一个文件里先跑通,后面再拆。下面进入连接配置环节。
3. 可复制配置:连接封装、Schema 与 Model 定义
这一节是全文的核心,所有代码都可以直接复制。我按“连接 → 建模 → 导出”的顺序给,路径和文件名保持一致,你照着建文件就行。
先写数据库连接文件db.js。这里用async/await配合try/catch,比.then()链式更清晰,也方便统一处理错误:
// db.js const mongoose = require('mongoose'); const MONGO_URI = 'mongodb://127.0.0.1:27017/mydatabase'; async function connectDB() { try { await mongoose.connect(MONGO_URI, { // 以下参数在 mongoose 8 中大多已默认,显式写出便于理解 serverSelectionTimeoutMS: 5000, // 5秒选不到节点就报错,避免无限等待 socketTimeoutMS: 45000, }); console.log('connected to database:', mongoose.connection.name); } catch (err) { console.error('mongoDB 连接失败:', err.message); process.exit(1); // 连接失败直接退出,避免后续操作挂在空连接上 } } module.exports = connectDB;serverSelectionTimeoutMS这个参数很实用。默认值是 30000 毫秒,也就是连不上要等 30 秒才报错,调试时非常煎熬。改成 5000 后,5 秒内连不上立刻抛错,你能快速定位问题。mongoose.connection.name会打印出实际连接的库名,验证连接串有没有写对。
接着定义 Schema 和 Model,建文件models/User.js:
// models/User.js const mongoose = require('mongoose'); const { Schema } = mongoose; const userSchema = new Schema({ name: { type: String, required: true }, age: { type: Number, min: 0 }, gender: { type: String, default: 'male' }, children: { type: Array, default: [] }, createdAt: { type: Date, default: Date.now }, }); const User = mongoose.model('User', userSchema); module.exports = User;这里有几个点值得展开。第一,mongoose.model('User', userSchema)里的'User'会被 mongoose 自动转成小写复数形式users作为集合名。也就是说,你操作的是users集合,不是User集合。如果集合不存在,第一次写入时自动创建。第二,children字段类型设为Array且给了default: [],即使创建文档时不传这个字段,mongoDB 里也会存一个空数组。而像age这种没给默认值的字段,不传就是undefined,文档里不会出现该字段。这个差异在查询时要注意。
第三,required: true和min: 0是 mongoose 层面的校验,不是 mongoDB 原生的。也就是说,你用mongosh直接插数据可以绕过这些校验,但通过 mongoose 的save()或create()就会拦截。校验失败会抛ValidationError,后面排错章节会讲。
如果你想把配置抽成 JSON 或环境变量,可以这样写一个config/default.json:
{ "mongo": { "uri": "mongodb://127.0.0.1:27017/mydatabase", "options": { "serverSelectionTimeoutMS": 5000 } } }然后在db.js里require('./config/default.json')读取。这样切换本地和线上环境时只改配置文件,不动代码。生产环境更推荐用环境变量process.env.MONGO_URI,配合dotenv加载.env文件。不管用哪种方式,核心是 Base URL(连接串)、认证信息、库名三件套要齐全。
模型定义好之后,就可以在入口文件里调用连接并做 CRUD 了。下一节给完整的增删改查代码和运行验证步骤。
4. 跑通增删改查:从插入到删除的完整验证
这一节把 CRUD 四个操作串起来,写成可运行的app.js。每步都有预期输出,你跑完对照控制台就能确认成功。先看完整代码:
// app.js const connectDB = require('./db'); const User = require('./models/User'); async function main() { await connectDB(); // 1. 增:创建文档 const created = await User.create({ name: 'John Doe chpeng', age: 30, }); console.log('新增成功,文档 _id:', created._id.toString()); console.log('gender 默认值:', created.gender, '| children 默认值:', created.children); // 2. 查:条件查询 const found = await User.find({ name: 'John Doe chpeng' }); console.log('查询到', found.length, '条记录'); // 3. 改:更新文档 const updated = await User.findByIdAndUpdate( created._id, { age: 31 }, { new: true } // 返回更新后的文档 ); console.log('更新后 age:', updated.age); // 4. 删:删除文档 const deleted = await User.findByIdAndDelete(created._id); console.log('删除的文档 name:', deleted.name); // 收尾:确认删除 const count = await User.countDocuments({ name: 'John Doe chpeng' }); console.log('删除后剩余记录数:', count); await require('mongoose').connection.close(); console.log('连接已关闭'); } main().catch((err) => { console.error('运行出错:', err); process.exit(1); });运行node app.js,正常输出应该类似:
connected to database: mydatabase 新增成功,文档 _id: 66f1a2b3c4d5e6f7a8b9c0d1 gender 默认值: male | children 默认值: [] 查询到 1 条记录 更新后 age: 31 删除的文档 name: John Doe chpeng 删除后剩余记录数: 0 连接已关闭逐段解释。User.create()是new User()+save()的语法糖,返回创建后的文档对象,_id是 mongoDB 自动生成的 ObjectId。注意created.gender打印出male,created.children打印出[],这就是 Schema 默认值生效的证据。如果你把children的default去掉,这里会打印undefined,文档里也不会有该字段。
User.find({ name: 'John Doe chpeng' })返回数组,条件匹配所有name等于该值的文档。如果只想取一条,用findOne()。查询条件支持操作符,比如{ age: { $gte: 18 } }查成年用户,{ name: /john/i }做不区分大小写的模糊匹配。
findByIdAndUpdate的第三个参数{ new: true }很关键。不加的话返回的是更新前的旧文档,你会看到age还是 30,误以为没更新成功。加上才返回新文档。这个坑新手几乎必踩,记住就行。
findByIdAndDelete返回被删除的文档,如果_id不存在则返回null。最后用countDocuments确认记录数为 0,形成闭环验证。收尾时mongoose.connection.close()关闭连接,避免进程挂起不退出。如果你不加这句,node app.js跑完可能一直不结束,因为连接池还开着。
想验证数据真的进了 mongoDB,可以另开终端用mongosh查:
mongosh "mongodb://127.0.0.1:27017/mydatabase"进去后执行db.users.find().pretty(),能看到文档结构。注意集合名是users(复数小写),不是User。如果show collections里没有users,说明你的写入操作没成功,回去看app.js的报错。
批量操作也顺带提一下。批量插入用User.insertMany([{...}, {...}]),批量更新用User.updateMany({ gender: 'male' }, { $set: { gender: 'unknown' } }),批量删除用User.deleteMany({ age: { $lt: 18 } })。这些在数据迁移和清理时很常用。
到这里,增删改查的完整链路就跑通了。下一节专门处理你可能遇到的报错。
5. 常见报错排查:401、连接拒绝、choices 读取失败
实际跑的时候,报错几乎不可避免。我把最常见的几类列出来,对照错误信息定位。
第一类:MongooseServerSelectionError: connect ECONNREFUSED 127.0.0.1:27017
这是最高频的。原因就一个:mongoDB 服务没在27017端口监听。排查步骤:先用lsof -i :27017或netstat -an | grep 27017看端口有没有进程。没有的话启动服务。如果服务启动了还报这个错,检查连接串里的主机和端口是不是写错了,比如写成了localhost但系统解析到 IPv6 的::1,而 mongoDB 只监听了 IPv4。解决办法是把连接串里的localhost换成127.0.0.1,强制走 IPv4。这个细节很隐蔽,我遇到过好几次。
第二类:MongoServerError: Authentication failed或 401 未授权
云端数据库常见。说明用户名或密码不对,或者该用户没有对应数据库的权限。检查连接串里的用户名密码,密码含特殊字符要做 URL 编码。另外确认用户在哪个库下创建的——mongoDB 的用户是绑定到某个库的,authSource参数要指对。比如用户在admin库创建,连接串要加?authSource=admin。少了这个参数就会 401。
第三类:MongoParseError: Unescaped at-sign in authority section
连接串解析失败,通常是密码里有@、:、/、#等未编码字符。用encodeURIComponent()处理密码再拼进去。比如encodeURIComponent('p@ss:w0rd')得到p%40ss%3Aw0rd。
第四类:ValidationError: User validation failed: name: Path 'name' is required
这是 mongoose 的 Schema 校验拦截,不是数据库报错。说明你创建文档时没传required: true的字段。检查create()或save()的入参。这类错误信息很明确,直接告诉你哪个字段缺了。
第五类:TypeError: Cannot read properties of undefined (reading 'choices')
这个报错通常出现在你调用某个返回结构不符合预期的接口时,比如把查询结果当成了带choices字段的对象去读。在 mongoose 场景里,多半是你把find()返回的数组当对象用了,或者某个异步操作返回了undefined你还去取属性。排查方法:在报错行前面console.log一下那个变量,看它到底是什么。十有八九是查询没返回数据,或者await漏写了导致拿到 Promise 而非结果。
第六类:MongooseError: Operation users.findOne() buffering timed out after 10000ms
这个叫“缓冲超时”。mongoose 默认会把操作缓冲起来,等连接建立后再执行。如果连接一直没建立成功,缓冲的操作就会超时。根因还是连接没成功,回到第一类去查。可以在连接配置里加bufferCommands: false让操作立即失败而不是缓冲,这样报错更直接。
第七类:OAuth 相关的报错
如果你用的是某些云平台的托管数据库,连接时可能涉及 OAuth 令牌认证。报错信息里会出现OAuth字样。这类问题通常是令牌过期或权限范围不对,重新生成凭证即可。本地开发一般用不上 OAuth,用用户名密码就够了。
排查通用思路:先看报错第一行,确定是连接层、认证层还是校验层的问题。连接层查服务和连接串,认证层查用户名密码和 authSource,校验层查 Schema 定义和入参。把serverSelectionTimeoutMS调小能让你更快拿到连接层的错误,别让它默默等 30 秒。
另外,如果你在代码里同时用了多个 mongoose 连接(createConnection),注意模型要绑定到对应的连接实例上,否则会报Schema hasn't been registered for model。单连接场景不用管这个。
6. 把连接配置沉淀成项目习惯
跑通一次 CRUD 不难,难的是把它变成项目里稳定复用的东西。我的做法是把连接封装成独立模块,启动时调用一次,全局共享同一个连接。mongoose 默认就有连接池,不需要你手动管理,mongoose.connect()调一次即可,重复调用会复用已有连接。
模型文件按业务拆分,一个集合一个文件,统一放models/目录。Schema 里该加的校验、默认值、索引都加上。比如经常按name查询,就给name加index: true,查询会快很多。索引在 Schema 里声明后,mongoose 启动时会自动创建(autoIndex默认开启,生产环境建议关掉改为手动建索引,避免启动变慢)。
连接字符串千万别硬编码在代码里。本地用.env文件,生产用环境变量。.env记得加进.gitignore,别把密码提交到仓库。一个典型的.env长这样:
MONGO_URI=mongodb://127.0.0.1:27017/mydatabase代码里process.env.MONGO_URI读取,配合require('dotenv').config()加载。这样换环境只改配置,不动一行代码。
最后说个实用技巧:写 CRUD 调试时,把mongoose.set('debug', true)打开,控制台会打印出实际执行的 mongoDB 命令,你能清楚看到 mongoose 把你的 JS 操作翻译成了什么查询。排查“为什么查不到数据”时特别有用。调完记得关掉,不然日志会很多。
如果你后续要做更复杂的聚合查询、事务、或者连接多个数据库,mongoose 都支持。事务需要 mongoDB 副本集,本地单机默认不支持事务,这点提前知道能少走弯路。把上面这套连接配置和 CRUD 模板存成项目脚手架,下次开新项目直接复制,能省不少时间。