多实例Node.js应用中MongoDB生成存储唯一序列的实现方案
解决多实例部署下MongoDB唯一reference_id生成问题
核心问题分析
你遇到的重复问题本质是并发场景下的竞态条件:多个实例同时读取当前的sequence_number,各自加1后生成相同的reference_id,而mongoose的unique属性只是插入时的约束,无法在生成阶段避免重复。下面是几个落地可行的解决方案:
方案1:用MongoDB原子操作维护序列(推荐)
通过单独的序列集合+原子递增操作,从数据库层面保证每次获取的sequence_number唯一,彻底解决并发问题。
步骤1:创建序列计数器集合
定义一个sequence_counters集合,用来存储每个分类组合的当前序列值:
const mongoose = require('mongoose'); const SequenceCounterSchema = new mongoose.Schema({ _id: { type: String, required: true }, // 用`order_category_id-order_sub_category_id`作为唯一键 sequence_value: { type: Number, default: 0 } }); const SequenceCounter = mongoose.model('SequenceCounter', SequenceCounterSchema);
步骤2:原子获取下一个序列值
使用findOneAndUpdate结合$inc操作符,这个操作是MongoDB层面原子执行的,同一时间只有一个请求能成功递增序列:
async function getNextSequence(categoryId, subCategoryId) { const counterKey = `${categoryId}-${subCategoryId}`; // upsert: true 表示如果不存在该分类的计数器则自动创建 // new: true 表示返回更新后的文档 const counter = await SequenceCounter.findOneAndUpdate( { _id: counterKey }, { $inc: { sequence_value: 1 } }, { new: true, upsert: true } ); return counter.sequence_value; }
步骤3:生成唯一reference_id
在写入detail集合前,调用上述方法获取序列值:
// 示例写入逻辑 async function createDetailDocument(data) { const { order_category_id, order_sub_category_id } = data; const sequenceNumber = await getNextSequence(order_category_id, order_sub_category_id); const reference_id = `${order_category_id}${order_sub_category_id}${sequenceNumber}`; const detailDoc = new Detail({ ...data, reference_id, sequence_number: sequenceNumber }); await detailDoc.save(); return detailDoc; }
方案2:添加唯一复合索引+重试机制
如果不想新增序列集合,可以给detail集合的reference_id添加唯一索引,同时在插入失败时重试生成。
步骤1:创建唯一索引
确保reference_id的唯一索引生效(之前可能索引未正确创建):
const DetailSchema = new mongoose.Schema({ order_category_id: String, order_sub_category_id: String, sequence_number: Number, reference_id: { type: String, unique: true } }); // 显式创建唯一索引,避免mongoose自动创建失败 DetailSchema.index({ reference_id: 1 }, { unique: true }); const Detail = mongoose.model('Detail', DetailSchema);
步骤2:插入时捕获重复键错误并重试
当并发导致重复reference_id时,MongoDB会抛出E11000重复键错误,此时重试生成即可:
async function createDetailDocumentWithRetry(data, retryCount = 3) { try { const { order_category_id, order_sub_category_id } = data; // 先查询当前最大的sequence_number const latestDoc = await Detail.findOne( { order_category_id, order_sub_category_id }, { sequence_number: 1 } ).sort({ sequence_number: -1 }); const sequenceNumber = latestDoc ? latestDoc.sequence_number + 1 : 1; const reference_id = `${order_category_id}${order_sub_category_id}${sequenceNumber}`; const detailDoc = new Detail({ ...data, reference_id, sequence_number }); return await detailDoc.save(); } catch (err) { if (err.code === 11000 && retryCount > 0) { // 重复键错误,重试 await new Promise(resolve => setTimeout(resolve, 100)); return createDetailDocumentWithRetry(data, retryCount - 1); } throw err; } }
注意:这个方案的可靠性略低于方案1,因为查询最大序列值和插入之间仍存在极小的竞态窗口,适合并发量不极高的场景。
方案3:分布式锁(适合已有Redis的场景)
如果你的系统已经部署了Redis,可以用分布式锁来保证同一分类组合下只有一个请求能生成sequence_number:
const redis = require('redis'); const redisClient = redis.createClient(); async function getNextSequenceWithLock(categoryId, subCategoryId) { const lockKey = `lock:seq:${categoryId}-${subCategoryId}`; const lockTimeout = 5000; // 锁超时时间,防止死锁 // 尝试获取锁 const lockAcquired = await redisClient.set(lockKey, 'locked', { EX: lockTimeout, NX: true }); if (!lockAcquired) { // 未获取到锁,等待后重试 await new Promise(resolve => setTimeout(resolve, 100)); return getNextSequenceWithLock(categoryId, subCategoryId); } try { // 查询当前最大序列值 const latestDoc = await Detail.findOne( { order_category_id: categoryId, order_sub_category_id: subCategoryId }, { sequence_number: 1 } ).sort({ sequence_number: -1 }); return latestDoc ? latestDoc.sequence_number + 1 : 1; } finally { // 释放锁 await redisClient.del(lockKey); } }
这个方案需要额外维护Redis,复杂度较高,仅当你已有Redis基础设施时考虑。
内容的提问来源于stack exchange,提问作者Batman
相关产品推荐
相关产品推荐

