如何防止Mongoose调用save()方法时重复保存相同数据
问题描述
当前业务逻辑暂未定位到偶发异常的根因,其余场景运行正常。原有数据保存逻辑为创建NowPlaying模型实例后调用save()方法完成数据写入,初始实现代码如下:
var Insert = new NowPlaying({ "type": "A", "station": req.params.stationname, "cover": addocs[0].match.banner[0], "url": addocs[0].match.url, "song": addocs[0].match.title, "artist": addocs[0].match.campaignName, "timeplay": npdate, "campaignID": addocs[0].match._id, "areaID": addocs[0].areaID, history: [uuid] }); Insert.save(function (err, book) { if (err) return console.log(err); console.log(book.name + " saved."); });
实际运行时偶发相同数据被多次重复保存的问题,需要修改逻辑确保该保存操作仅执行一次,相关完整业务代码如下:
var aquery = Adverts.aggregate(advertquery); aquery.exec(function (err, addocs) { // console.error(addocs[0]); if (addocs[0]) { // remote = "https://storage.googleapis.com/ad-system/testfolder/OUTOFAREA.mp3"; remote = addocs[0].match.audio; /* add to now playing */ let date_ob = new Date(); let date = ("0" + date_ob.getDate()).slice(-2); let month = ("0" + (date_ob.getMonth() + 1)).slice(-2); let year = date_ob.getFullYear(); let hours = ("0" + (date_ob.getHours() + 1)).slice(-2); let minutes = ("0" + (date_ob.getMinutes() + 1)).slice(-2); let seconds = ("0" + (date_ob.getSeconds() + 1)).slice(-2); var npdate = year + "-" + month + "-" + date + "T" + hours + ":" + minutes +":"+ seconds; const query = { "type": "A", "station": req.params.stationname, "cover": addocs[0].match.banner[0], "url": addocs[0].match.url, "song": addocs[0].match.title, "artist": addocs[0].match.campaignName, "timeplay": npdate, "campaignID": addocs[0].match._id, "areaID": addocs[0].areaID, history: [uuid] }; console.log(uuid+" is PLAYING"+ JSON.stringify(query)) var Insert = new NowPlaying({ "type": "A", "station": req.params.stationname, "cover": addocs[0].match.banner[0], "url": addocs[0].match.url, "song": addocs[0].match.title, "artist": addocs[0].match.campaignName, "timeplay": npdate, "campaignID": addocs[0].match._id, "areaID": addocs[0].areaID, history: [uuid] }); Insert.save(function (err, book) { if (err) return console.log(err); console.log(book.name + " saved."); }); /* end now playing */ } }) // CODE REMOVED AS NOT NEEDED FOR QUESTION
问题根因
重复写入的核心原因有两点:
- 业务逻辑无幂等控制:只要这段代码被重复触发(常见场景包括客户端重复提交请求、事件机制重复投递回调、并发请求同时走到这段逻辑),就会无条件执行新建实例+
save()的操作,没有判断当前待写入的记录是否已经存在 - 数据库层无唯一约束兜底:就算业务层判断逻辑存在漏洞,数据库也不会拦截重复数据,直接导致多条相同记录落库
代码中已经为每次操作生成了唯一的uuid并存入history数组首位,这个值天然适合作为幂等判断的唯一标识,原有逻辑没有利用这个字段做校验。
修复方案
从业务层原子操作、数据库层唯一索引两层做拦截,彻底解决重复写入问题:
1. 数据库层加唯一索引(核心兜底)
给history数组的第一个元素(即单次操作的唯一uuid)加唯一索引,就算业务逻辑出现异常重复触发写入,数据库也会直接拒绝重复uuid的写入请求,从存储层挡住重复数据。
在NowPlaying的Schema定义中添加索引:
NowPlaying.schema.index({ "history.0": 1 }, { unique: true });
2. 业务层改用原子upsert逻辑
替换原有直接新建实例+save()的实现,用findOneAndUpdate加upsert: true实现原子操作:先查当前uuid对应的记录是否存在,不存在才写入,存在则直接跳过,避免并发场景下的判断竞态问题。同时补全原有逻辑缺失的错误捕获、空值判断。
修改后的完整代码:
var aquery = Adverts.aggregate(advertquery); aquery.exec(function (err, addocs) { // 补全查询错误捕获 if (err) { console.error("广告数据查询失败:", err); return; } // 补全空值判断,避免访问addocs[0]抛异常 if (!addocs || !addocs[0]) { return; } remote = addocs[0].match.audio; /* add to now playing */ let date_ob = new Date(); let date = ("0" + date_ob.getDate()).slice(-2); let month = ("0" + (date_ob.getMonth() + 1)).slice(-2); let year = date_ob.getFullYear(); let hours = ("0" + (date_ob.getHours() + 1)).slice(-2); let minutes = ("0" + (date_ob.getMinutes() + 1)).slice(-2); let seconds = ("0" + (date_ob.getSeconds() + 1)).slice(-2); var npdate = year + "-" + month + "-" + date + "T" + hours + ":" + minutes +":"+ seconds; const playRecord = { "type": "A", "station": req.params.stationname, "cover": addocs[0].match.banner[0], "url": addocs[0].match.url, "song": addocs[0].match.title, "artist": addocs[0].match.campaignName, "timeplay": npdate, "campaignID": addocs[0].match._id, "areaID": addocs[0].areaID, history: [uuid] }; console.log(uuid + " is PLAYING" + JSON.stringify(playRecord)) // 原子upsert操作:存在对应uuid记录则跳过,不存在才写入 NowPlaying.findOneAndUpdate( { "history.0": uuid }, { $setOnInsert: playRecord }, // 仅插入时设置字段,不会修改已有记录 { upsert: true, new: true }, function (err, record) { if (err) { // 捕获唯一索引冲突错误,属于正常幂等拦截,直接忽略 if (err.code === 11000) { console.log("记录已存在,跳过重复写入,uuid:", uuid); return; } console.error("播放记录写入失败:", err); return; } console.log("播放记录写入成功"); } ); /* end now playing */ })
注意事项
- 不要用拼接生成的
npdate作为重复判断依据,时间精度仅到秒,同一秒内的重复请求无法被拦截,使用预生成的唯一uuid做判断可靠性最高 - 数据库唯一索引是最后一道防线,就算后续业务逻辑改动出现问题,也不会产生重复数据
内容的提问来源于stack exchange,提问作者Russell Harrower
相关产品推荐
相关产品推荐

