You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何防止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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.29 18:24:46