MongoDB并发读一致性保障:Node.js预约API并发问题求解
你遇到的是典型的竞态条件问题:并发请求时,"检查有效预约→创建新预约"的两步操作不是原子性的,导致第二个请求在第一个请求完成写入前就执行了检查,最终重复创建有效预约。结合你的限制(不能用指定复合唯一索引、不能分片/副本集),这里有几个可行的解决方案,按推荐优先级排序:
1. 使用MongoDB原子操作替代"检查+创建"两步操作
MongoDB的findOneAndUpdate/updateOne支持原子性的查询+修改/插入操作,能从根本上消除竞态窗口。核心思路是:把"检查是否存在有效预约"和"不存在则创建"合并成一个原子操作。
具体实现:
用updateOne配合upsert和$setOnInsert,仅当没有匹配的有效预约时才插入新记录:
// 替换原代码中getActiveAppointment + createAppointment的两步逻辑 appointmentManager.collection.updateOne( { doctor_id: ObjectId(doctor_id), patient_id: ObjectId(patient_id), is_active: true }, { // 仅当没有找到匹配记录时,插入新预约 $setOnInsert: { doctor_id: ObjectId(doctor_id), patient_id: ObjectId(patient_id), is_active: true, created_at: new Date() // 其他需要的字段如更新时间等 } }, { upsert: true } ) .then(result => { // matchedCount>0表示已存在有效预约,不执行插入 if (result.matchedCount > 0) { throw new Error("已存在有效预约"); } // 处理成功响应 })
这个操作是MongoDB原子执行的,完全避免了中间的竞态窗口,是最简洁的解决方案。
2. 基于额外字段的唯一索引方案
如果你更倾向用索引约束来控制重复,可以新增一个字段(比如active_identifier),通过它的唯一索引实现需求:
- 当预约
is_active: true时,active_identifier设为${doctor_id}_${patient_id}(固定值) - 当预约
is_active: false时,active_identifier设为${doctor_id}_${patient_id}_${ObjectId()}(追加唯一ID,确保每条历史记录标识唯一)
然后创建唯一索引:
appointmentManager.collection.createIndex({ active_identifier: 1 }, { unique: true })
这样,创建新有效预约时,若已有同医生同患者的有效预约,MongoDB会因唯一索引冲突抛出错误;而历史无效预约因为标识唯一,不会触发冲突,完美满足保留历史数据的需求。
3. 应用层分布式锁(兜底方案)
如果以上两种MongoDB原生方案不适用,你可以在应用层实现分布式锁,确保同一医生+患者的预约操作串行执行。比如用MongoDB自身实现锁集合:
实现思路:
- 创建
locks集合,存储锁记录,包含lock_key(格式如appointment_${doctor_id}_${patient_id})、expires_at(锁过期时间,避免死锁) - 处理预约请求前,用
findOneAndUpdate原子性获取锁(仅当锁不存在或已过期时成功) - 获取锁后执行原有的"检查-创建"逻辑,完成后释放锁
示例代码:
// 获取锁 async function acquireLock(doctorId, patientId) { const lockKey = `appointment_${doctorId}_${patientId}`; const expireTime = new Date(Date.now() + 5000); // 锁5秒过期 const result = await db.collection('locks').findOneAndUpdate( { lock_key: lockKey, expires_at: { $lt: new Date() } }, { $set: { lock_key: lockKey, expires_at: expireTime } }, { upsert: true, returnDocument: 'after' } ); return result.ok === 1; } // 释放锁 async function releaseLock(doctorId, patientId) { const lockKey = `appointment_${doctorId}_${patientId}`; await db.collection('locks').deleteOne({ lock_key: lockKey }); } // 修改后的API逻辑 async function handleCreateAppointment(doctor_id, patient_id) { let lockAcquired = false; try { lockAcquired = await acquireLock(doctor_id, patient_id); if (!lockAcquired) { throw new Error("当前有预约操作正在处理,请稍后重试"); } // 原有的医生/患者检查、有效预约检查逻辑 const doctor = await doctorManager.getDoctor(doctor_id); const patient = await patientManager.getPatient(patient_id); const activeAppointment = await getActiveAppointment(doctor_id, patient_id); if (activeAppointment) { throw new Error("已存在有效预约"); } await appointmentManager.createAppointment(doctor_id, patient_id); // 返回成功响应 } catch (error) { // 处理错误 } finally { // 无论成功失败都释放锁,避免死锁 if (lockAcquired) { await releaseLock(doctor_id, patient_id); } } }
这个方案需要额外维护锁逻辑,且存在锁残留风险(依赖过期时间规避),适合作为兜底方案。
总结
优先推荐方案1(原子操作),它直接利用MongoDB的原子特性解决竞态,无需额外索引或锁逻辑;如果偏好索引约束,方案2是更优雅的选择;方案3适合无法修改核心操作逻辑的场景。
内容的提问来源于stack exchange,提问作者Dheemanth Bhat

