ThinkJS 的模型层通过适配器机制兼容多种数据库,MongoDB 作为文档型数据库,接入后虽然可以使用类似 ActiveRecord 的链式调用,但字段类型、主键机制、聚合语法与关系型数据库差异很大。如果直接把 MySQL 的使用经验套到 MongoDB 上,很容易出现查询结果为空、更新不生效、统计金额不准确等问题。核心思路是先正确配置 think-mongo 适配器,再根据 MongoDB 的特性设计模型和索引,最后针对聚合、连接池、事务等环节做专门排查。

一、安装适配器与数据库连接配置
ThinkJS 默认模型驱动面向关系型数据库,接入 MongoDB 需要额外安装适配器。在项目根目录执行 npm install think-mongo,安装完成后进入模型配置文件。不同 ThinkJS 版本的位置略有差异,常见于 src/config/adapter.js 或 src/config/database.js。配置的核心是将 model 的 type 指定为 mongo,并把 handle 指向 think-mongo 模块,这样框架在加载模型时会自动切换到 MongoDB 连接方式。
下面是一份可直接使用的配置示例,option 参数会透传给 Node.js 官方 mongodb 驱动,建议显式开启 useNewUrlParser 和 useUnifiedTopology,否则控制台会持续出现连接解析相关的告警信息。
const mongo = require('think-mongo');
exports.model = {
type: 'mongo',
common: {
logConnect: true,
logSql: true,
logger: msg => think.logger.info(msg)
},
mongo: {
handle: mongo,
host: '127.0.0.1',
port: 27017,
user: '',
password: '',
database: 'thinkjs_demo',
options: {
useNewUrlParser: true,
useUnifiedTopology: true,
poolSize: 10,
connectTimeoutMS: 10000,
socketTimeoutMS: 45000
}
}
};
这份配置中 host、port、database 需要按实际环境填写,user 和 password 为空表示本地无认证模式。poolSize 控制连接池大小,单实例项目通常设置为 5 到 10 即可,过大的连接池反而会增加 MongoDB 服务器的线程切换负担。connectTimeoutMS 和 socketTimeoutMS 分别控制建立连接和单次 socket 操作的超时时间,如果业务中经常出现慢查询,socketTimeoutMS 不要设置过小,否则复杂聚合可能被驱动提前中断。
如果 MongoDB 以副本集方式部署,还可以在 options 中继续追加 replicaSet 和 readPreference 参数。例如 readPreference 设置为 secondaryPreferred,可以让读请求优先走从节点,从而降低主节点压力。但需要注意副本集部署时从节点可能有一定延迟,写后立刻读的业务不要无条件走从库,否则会出现刚写入的数据读不到的情况。
二、模型定义与增删改查操作要点
MongoDB 的集合概念对应 ThinkJS 模型中的 tableName,模型文件一般放在 src/model 目录。与关系型数据库模型不同的是,MongoDB 默认会为每条文档生成 _id 字段,类型为 ObjectId。即便 schema 中不声明 _id,插入数据时驱动也会自动补充。如果 schema 中声明了 _id 为 string 类型,查询时可以直接用字符串匹配;如果未声明而使用默认 ObjectId,查询条件需要做类型转换,这一点是新手最容易忽略的地方。
下面的模型定义了 user 集合的基本字段和校验规则,createdAt 使用函数作为默认值,每次插入都会生成新的时间对象。email 字段声明了 unique 约束,实际唯一性需要在 MongoDB 索引层保证,schema 中的声明主要用于模型层校验。
module.exports = class extends think.Model {
get tableName() {
return 'user';
}
get schema() {
return {
_id: {
type: 'string',
default: ''
},
name: {
type: 'string',
required: true
},
age: {
type: 'int',
default: 0
},
email: {
type: 'string',
trim: true,
unique: true
},
createdAt: {
type: 'date',
default: () => new Date()
}
};
}
};
增删改查的链式调用写法与关系型数据库基本一致,但要注意 add 方法返回的主键值可能是 ObjectId 也可能是字符串,取决于模型对 _id 的定义。下面代码中的 tom@ipipp.com 仅作为测试邮箱示例,实际使用时请替换为真实邮箱地址。
// 新增
const insertId = await this.model('user').add({
name: 'Tom',
age: 28,
email: 'tom@ipipp.com'
});
// 单条查询
const user = await this.model('user').where({ name: 'Tom' }).find();
// 分页查询
const list = await this.model('user').page(1, 10).countSelect();
// 更新
const affectRows = await this.model('user').where({ _id: insertId }).update({
age: 29
});
// 删除
const affectRows = await this.model('user').where({ _id: insertId }).delete();
在实际业务中,update 和 delete 操作一定要谨慎拼接 where 条件。MongoDB 的 update 默认只更新匹配到的第一条,不过 ThinkJS 的 update 方法通常会执行 updateMany,具体行为取决于适配器实现。为避免误更新,建议在操作前先用同样的条件查询一次,确认影响范围。如果需要批量更新多个字段,可以传入对象;如果只想更新某一个字段,可以用 MongoDB 的更新操作符,例如将 age 自增 1 可以写成 update({ age: 29 }),但更安全的做法是使用原生操作符,避免覆盖未指定的字段。
三、聚合管道与索引设计要点
单表 CRUD 只能解决基本数据读写,统计汇总必须依赖 MongoDB 的聚合管道。ThinkJS 提供了 aggregate 方法,参数是一个管道阶段数组。与 MySQL 的 GROUP BY 不同,聚合管道的阶段是有顺序的,$match 用于过滤,$group 用于分组统计,$sort 和 $limit 控制输出。初学者最典型的错误是把金额字段定义成字符串,然后在 $group 中使用 $sum,结果变成了字符串拼接而不是数值相加,而且不会直接报错,排查起来非常隐蔽。
下面是一个订单聚合统计示例,先过滤掉未支付订单,再按用户分组计算总金额和订单数量,最后按总金额倒序取前 10 条。字段类型必须保证 amount 为数值型,否则 $sum 的结果会和预期完全不同。
const result = await this.model('order').aggregate([
{ $match: { status: 'paid' } },
{ $group: { _id: '$userId', totalAmount: { $sum: '$amount' }, count: { $sum: 1 } } },
{ $sort: { totalAmount: -1 } },
{ $limit: 10 }
]);
聚合查询依赖索引,尤其是 $match 阶段使用到的字段。没有索引时,MongoDB 需要对整个集合扫描,数据量增大后响应时间会明显上升。为高频率查询字段建立索引是基本优化手段,唯一索引还能从数据库层保证数据不重复。复合索引的字段顺序要与查询条件的顺序尽量匹配,例如经常按 age 和 name 组合查询,可以创建 { age: 1, name: -1 } 这样的复合索引。
await this.model('user').db().collection('user').createIndex({ email: 1 }, { unique: true });
await this.model('user').db().collection('user').createIndex({ age: 1, name: -1 });
索引创建语句应该放在应用启动逻辑或独立的迁移脚本中,不要放到每次请求里执行。虽然 MongoDB 会自动跳过已存在的索引,但频繁检查仍会造成不必要的性能开销。创建索引后可以使用 explain 方法查看执行计划,确认查询是否命中索引。如果 explain 结果中 stage 为 COLLSCAN,说明当前查询仍在全表扫描,需要继续调整索引或查询条件。
四、常见疑问与排查思路
连接超时是接入阶段最常遇到的问题。如果连接字符串正确但连接一直失败,先检查 MongoDB 服务是否监听 127.0.0.1:27017,再确认防火墙或云安全组是否放通端口。连接池相关参数中,poolSize 设置为 10 能满足多数中小规模项目,如果单机 QPS 很高,可以适当增加到 20,但必须同时观察 MongoDB 服务器的连接数上限和内存占用。超时时间不建议设置得太短,尤其是涉及报表聚合或批量写入的场景,过小的时间会误杀正常慢请求。
关于事务支持,很多开发者容易忽略部署模式。MongoDB 4.0 以上版本在副本集模式下才支持多文档事务,单机 standalone 模式无法使用事务。开发环境如果只启动了一个 mongod 进程,执行事务时会直接报错。解决办法是将单节点以副本集模式启动,并在连接字符串中指定 replicaSet 参数。ThinkJS 本身没有对事务做高层封装,需要获取原生连接后使用 session 手动提交或回滚,因此建议只有真正涉及跨集合一致性时才引入事务,普通业务尽量通过单文档更新和幂等设计规避复杂度。
ObjectId 转换是另一个高频疑问。接口返回数据时,如果直接把 ObjectId 对象序列化,前端拿到的可能是一个对象而不是字符串。解决方式是在模型 schema 中把 _id 定义为 string,或者在输出前手动调用 toString 方法。日期字段也有类似问题,MongoDB 内部以 UTC 存储,读取后需要根据用户时区进行格式化,不要直接把数据库时间展示给终端用户。对于需要自增编号的场景,MongoDB 没有内置自增主键,需要单独的 counters 集合配合 findOneAndUpdate 原子操作生成递增序列,但这种方式会让 _id 失去分布式优势,应谨慎评估是否真正需要。
排查问题时还有一个实用思路:打开模型配置中的 logSql 开关,ThinkJS 会打印实际执行的 MongoDB 命令。当查询结果不符合预期时,先看控制台输出的真实语句,再回到 MongoDB Compass 或命令行手动执行一遍,能快速定位是代码拼接问题还是数据本身的问题。配置日志还方便观察连接是否被正确复用,如果频繁出现连接建立日志,说明连接池没有生效,需要检查 options 是否正确透传给了驱动。

