
1. 为什么 MongoDB 里没有“自增字段”这回事MongoDB 的_id默认是 ObjectId天生带时间戳和随机因子分布式环境下几乎不会撞车。但很多从 MySQL 迁过来的业务表前端、报表、对账系统都习惯了一个从 1 开始、每次加 1 的整数编号比如订单号、工单号、客户编号。你打开 MongoDB 官方文档会发现它明确说数据库本身不提供自增字段的创建方法需要自己在应用层实现。这件事在 Node.js 里做坑比想象中多。单机低并发时随便写个findOneAndUpdate就能跑一旦并发上来重复值、跳号、写入失败重试全冒出来。更麻烦的是很多同学在本地调试阶段就把 Key、连接串、模型配置散落在各个文件里等到要接真实模型做字段校验、生成编号规则说明时配置已经乱成一团。这篇就聚焦一条完整链路Node.js 连上 MongoDB用计数器集合实现自增字段同时把 TaoToken 的统一 Key 配置骨架搭好最后跑一次真实的自增写入验证。适合正在写 RESTful 服务、需要给文档加业务编号的后端同学也适合想把模型调用和数据库操作配置统一管理的人。下面所有代码都可以直接复制到本地跑。2. TaoToken 统一 Key 配置把模型调用和数据库配置收口先说清楚 TaoToken 在这里的角色。它是一个统一的大模型 API 接入层你拿一个 Key 就能调用多种模型官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。在我们这个场景里它的用处是当你需要让模型帮你生成编号规则、校验自增字段的语义、或者写一段字段说明文档时不用再单独维护一套模型调用的鉴权配置和数据库配置放在一起管理即可。API 入口是 https://taotoken.net/api 注意这个地址不带 UTM 参数直接用于代码里的 baseURL。Key 的获取在控制台的 API Keys 页面地址是 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。拿到 Key 之后我建议不要硬编码在业务代码里而是用一个独立的配置文件承载Node.js 项目里常见两种格式settings.json或者config.toml。下面两个片段你按自己项目习惯选一个。settings.json版本放在项目根目录的config/下{ taotoken: { baseUrl: https://taotoken.net/api, apiKey: sk-你的Key填这里, defaultModel: claude-sonnet-4-20250514, timeoutMs: 30000 }, mongodb: { uri: mongodb://127.0.0.1:27017/autoincrement_demo, options: { maxPoolSize: 10, serverSelectionTimeoutMS: 5000 } }, autoIncrement: { counterCollection: counters, defaultStartValue: 1000, defaultStep: 1 } }config.toml版本适合喜欢 TOML 可读性的项目[taotoken] baseUrl https://taotoken.net/api apiKey sk-你的Key填这里 defaultModel claude-sonnet-4-20250514 timeoutMs 30000 [mongodb] uri mongodb://127.0.0.1:27017/autoincrement_demo maxPoolSize 10 serverSelectionTimeoutMS 5000 [autoIncrement] counterCollection counters defaultStartValue 1000 defaultStep 1读取配置的代码以 JSON 为例用 Node.js 内置的fs就够了不需要额外依赖// config/loadConfig.js const fs require(fs); const path require(path); function loadConfig() { const filePath path.join(__dirname, settings.json); const raw fs.readFileSync(filePath, utf-8); const config JSON.parse(raw); if (!config.taotoken || !config.taotoken.apiKey) { throw new Error(缺少 taotoken.apiKey请检查 settings.json); } if (!config.mongodb || !config.mongodb.uri) { throw new Error(缺少 mongodb.uri请检查 settings.json); } return config; } module.exports { loadConfig };这里有个细节值得说baseUrl我写的是https://taotoken.net/api不带任何查询参数。有些同学会把带 UTM 的官网地址直接塞进 baseURL结果请求路径拼出来是错的。记住官网地址用于浏览器访问和了解产品API 地址才是代码里用的。3. 计数器集合设计原子更新才是关键自增字段的核心思路官方文档给过两个方向计数器集合和乐观循环。计数器集合更常用因为它把“取号”这个动作收敛到一次原子操作里并发安全靠的是 MongoDB 的findOneAndUpdate原子性而不是应用层的重试循环。设计上我们单独建一个counters集合每个需要自增的字段对应一条文档结构长这样{ _id: companyId, // 用字段名作为主键天然唯一 seq: 1000 // 当前序列值 }_id直接用字段名好处是查询时不需要额外索引findOneAndUpdate按_id定位走主键索引快且稳。seq存当前值每次取号时$inc加 stepupsert: true保证第一次调用时自动创建文档returnDocument: after保证返回的是加完之后的值。用 Mongoose 实现一个通用的取号函数// services/sequence.js const mongoose require(mongoose); const counterSchema new mongoose.Schema({ _id: { type: String, required: true }, seq: { type: Number, default: 0 } }); const Counter mongoose.model(Counter, counterSchema, counters); /** * 获取下一个自增序列值 * param {string} name 字段名如 companyId * param {number} step 步长默认 1 * returns {Promisenumber} */ async function getNextSequence(name, step 1) { const result await Counter.findOneAndUpdate( { _id: name }, { $inc: { seq: step } }, { new: true, upsert: true, returnDocument: after } ); return result.seq; } module.exports { getNextSequence, Counter };注意returnDocument: after这个选项。Mongoose 6 之后推荐用它替代老的new: true两者效果一样但新写法更明确。如果你用的是 Mongoose 5new: true也能跑。接下来定义业务模型以公司文档为例companyId就是要自增的字段// models/company.js const mongoose require(mongoose); const companySchema new mongoose.Schema({ companyId: { type: Number, required: true, unique: true, index: true }, name: { type: String, required: true }, clientId: { type: Number }, createdAt: { type: Date, default: Date.now } }); module.exports mongoose.model(Company, companySchema, companies);companyId上加了unique: true和index: true。唯一索引是最后一道防线万一取号逻辑出问题导致重复写入会直接报 11000 错误而不是悄悄写进去两条一样的编号。这个错误码后面排障会用到。写入一条公司文档的完整流程// services/companyService.js const { getNextSequence } require(./sequence); const Company require(../models/company); async function createCompany({ name, clientId }) { const companyId await getNextSequence(companyId, 1); const company new Company({ companyId, name, clientId }); await company.save(); return company; } module.exports { createCompany };这里有个顺序问题先取号再保存。如果保存失败这个号就“浪费”了序列出现跳号。大多数业务能接受跳号因为编号只要求唯一和递增不要求连续。如果你的业务强要求连续那就得用乐观循环那套代价是并发下重试变多。我实测下来计数器集合方案在几百 QPS 的写入场景下表现稳定跳号概率极低够用。4. 可复制配置连接、初始化与一次自增写入验证把前面的片段串起来写一个可以直接node verify.js跑起来的验证脚本。它会连 MongoDB、初始化计数器、连续插入三条公司文档然后打印结果确认companyId从 1000 开始每次加 1。// verify.js const mongoose require(mongoose); const { loadConfig } require(./config/loadConfig); const { getNextSequence } require(./services/sequence); const Company require(./models/company); async function main() { const config loadConfig(); await mongoose.connect(config.mongodb.uri, config.mongodb.options); console.log(MongoDB 已连接:, config.mongodb.uri); // 清理旧数据保证验证结果可预期 await Company.deleteMany({}); await mongoose.connection.collection(counters).deleteMany({}); const startValue config.autoIncrement.defaultStartValue; const step config.autoIncrement.defaultStep; // 初始化计数器让第一个号从 startValue 开始 await mongoose.connection.collection(counters).updateOne( { _id: companyId }, { $set: { seq: startValue - step } }, { upsert: true } ); const results []; for (let i 1; i 3; i) { const companyId await getNextSequence(companyId, step); const doc new Company({ companyId, name: 测试公司-${i} }); await doc.save(); results.push({ companyId: doc.companyId, name: doc.name }); } console.log(写入结果:); console.table(results); const all await Company.find({}).sort({ companyId: 1 }).lean(); console.log(数据库中的文档:); console.table(all.map(d ({ companyId: d.companyId, name: d.name }))); await mongoose.disconnect(); console.log(验证完成连接已关闭); } main().catch(err { console.error(验证失败:, err); process.exit(1); });运行前确认本地 MongoDB 已启动然后执行node verify.js预期输出类似MongoDB 已连接: mongodb://127.0.0.1:27017/autoincrement_demo 写入结果: ┌─────────┬───────────┬──────────────┐ │ (index) │ companyId │ name │ ├─────────┼───────────┼──────────────┤ │ 0 │ 1000 │ 测试公司-1 │ │ 1 │ 1001 │ 测试公司-2 │ │ 2 │ 1002 │ 测试公司-3 │ └─────────┴───────────┴──────────────┘ 数据库中的文档: ┌─────────┬───────────┬──────────────┐ │ (index) │ companyId │ name │ ├─────────┼───────────┼──────────────┤ │ 0 │ 1000 │ 测试公司-1 │ │ 1 │ 1001 │ 测试公司-2 │ │ 2 │ 1002 │ 测试公司-3 │ └─────────┴───────────┴──────────────┘ 验证完成连接已关闭看到companyId从 1000 连续递增到 1002说明计数器集合和原子更新都工作正常。这一步跑通后面接真实业务只需要把Company换成你自己的模型把companyId换成你要自增的字段名。如果你还想让模型帮你检查这段取号逻辑有没有并发隐患或者生成一份字段说明文档可以用 TaoToken 的模型对话入口地址是 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 把代码贴进去问它“这段 findOneAndUpdate 在并发下会不会产生重复值”它会给你分析。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有各语言 SDK 的调用示例。5. 本篇常见错排查5.1 报错 E11000 duplicate key error这是最常见的。原因通常是companyId的唯一索引生效了但取号逻辑返回了重复值。排查顺序先确认getNextSequence里用的是findOneAndUpdate而不是先findOne再updateOne后者在并发下必然重复。再确认counters集合里_id为companyId的文档只有一条如果有多条比如手动插过findOneAndUpdate只会更新其中一条另一条不参与递增就会撞号。清理掉多余文档即可。5.2 第一个号不是 1000而是 1 或者 1001两种可能。如果输出是 1说明初始化计数器那步没执行upsert创建了新文档seq默认 0加 1 得 1。如果输出是 1001说明初始化时seq设成了startValue而不是startValue - step第一次$inc后变成 1001。检查verify.js里$set: { seq: startValue - step }这一行。5.3 Mongoose 报returnDocument不是有效选项这是 Mongoose 版本差异。Mongoose 6 及以上支持returnDocument: afterMongoose 5 只认new: true。如果你锁定了老版本把returnDocument: after换成new: true即可。两个选项不要同时写会冲突。5.4 连接超时 serverSelectionTimeoutMS本地 MongoDB 没启动或者uri里的端口写错。默认端口 27017如果你改过配置检查settings.json里的mongodb.uri。另外serverSelectionTimeoutMS: 5000表示 5 秒内选不到可用节点就报错本地开发可以调大到 10000给自己留点启动时间。5.5 取号成功但保存失败序列跳号前面提过这是先取号后保存的固有代价。如果你在日志里看到companyId从 1000 直接跳到 1002中间 1001 没写进数据库说明有一次保存失败了。排查保存失败的原因通常是字段校验没过比如name为空或者唯一索引冲突。跳号本身不影响业务但如果你的对账系统要求编号连续就得改用乐观循环方案在插入失败时重试并重新取号。6. 把配置和取号逻辑收进你的项目到这里一条完整链路已经跑通TaoToken 统一 Key 配置放在settings.json或config.tomlMongoDB 连接参数和自增参数一起管理计数器集合用_id做字段名、seq做当前值findOneAndUpdate加$inc保证原子性业务模型上给自增字段加唯一索引兜底验证脚本连续写入三条文档确认编号从 1000 递增。如果你打算把这个模式用到长期运行的服务里尤其是需要模型辅助生成编号规则、做字段语义校验的场景可以了解一下 Coding Plan地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 它适合把模型调用和工程配置统一管理的开发方式。API Key 的管理仍然在控制台 https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 建议给不同环境分配不同的 Key本地开发用一个线上用一个方便排查问题时区分调用来源。最后留一个实用技巧counters集合不要和业务集合混在同一个数据库里做权限控制如果你的 MongoDB 开了鉴权给应用账号单独授予counters集合的findAndModify权限就够了不需要给它整个库的写权限。这样即使业务代码有注入风险计数器也不会被随意篡改。