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

Next.js+MongoDB API首次插入热门数据时重复插入问题

问题现象

首次将Gem插入trending-gems集合时出现重复插入的异常,后端代码中明明在判断!isGemTrending后执行插入并return,按逻辑应该终止流程,但插入操作仍被多次触发。初步排查发现前端调用逻辑存在漏洞,同时后端也未处理并发场景下的原子性问题。

前端调用问题分析与修复

核心问题

前端使用useMutation结合useEffect调用API,虽然用hasTrended状态跟踪执行状态,但存在以下问题:

  • useEffect的依赖项包含mutation,而useMutation返回的mutation对象在组件每次渲染时会重新生成,导致useEffect被重复触发
  • setHasTrended是异步更新,在第一次请求未完成时,若其他依赖项(如id/wallet/gem.url)发生微小变化,useEffect会再次触发,此时hasTrended仍为false,进而重复调用mutate

前端修复方案

修改useEffect的依赖逻辑,用useRef标记请求状态,避免重复发起:

const hasInitiatedRequest = useRef(false);
const mutation = useMutation({
    mutationFn: async ({ address, gemURL }) => {
        const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}api/gems/post/update-trending`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ address, gemURL }),
        });

        if (!response.ok) {
            const errorData = await response.json();
            throw new Error(errorData.message);
        }

        const data = await response.json();
        return data;
    },
    onSuccess: (data) => {
        console.log(data.message);
        setHasTrended(true);
        hasInitiatedRequest.current = false; // 请求完成后重置标记
    },
    onError: (error) => {
        console.error('Failed to update trending:', error.message);
        hasInitiatedRequest.current = false; // 失败也重置标记
    }
});

useEffect(() => {
    if (id && wallet && address && !hasTrended && !hasInitiatedRequest.current) {
        hasInitiatedRequest.current = true; // 标记已发起请求
        mutation.mutate({ address, gemURL: gem.url });
    }
}, [id, wallet, address, hasTrended, gem.url]); // 移除mutation依赖

后端并发漏洞修复

即使前端修复了调用逻辑,并发请求场景下仍可能出现重复插入——因为findOne和insertOne是两个独立操作,无法保证原子性。两个请求同时进入时,都会查到isGemTrending为null,进而执行插入。

后端修复方案

改用findOneAndUpdate的upsert模式,将查询和插入/更新合并为原子操作:

import clientPromise from '@/lib/mongoDB';
import { ObjectId } from 'mongodb';

export default async function handler(req, res) {
    if (req.method !== 'POST') {
        res.status(405).json({ error: true, message: 'Wrong method' });
        return;
    }

    try {
        const client = await clientPromise;
        const db = client.db('ton3-space');

        const trendingCollection = db.collection('trending-gems');
        const gemCollection = db.collection('gems');

        const { address, gemURL } = req.body;

        if (!address || !gemURL) {
            res.status(400).json({ error: true, message: 'Missing arguments' });
            return;
        }

        const gem = await gemCollection.findOne({ url: gemURL });
        if (!gem) {
            res.status(400).json({ error: true, message: 'Gem does not exist' });
            return;
        }

        const gemID = new ObjectId(gem._id);
        const now = new Date();
        const trendExpire = new Date(now.getTime() + 24 * 60 * 60 * 1000);

        // 原子操作:查询并插入(不存在则创建),避免并发重复插入
        const result = await trendingCollection.findOneAndUpdate(
            { gem: gemID },
            {
                $setOnInsert: {
                    gem: gemID,
                    trends_by: [address],
                    trends: 1,
                    trend_expire: trendExpire,
                }
            },
            { upsert: true, returnDocument: 'after' }
        );

        // 判断是插入还是更新
        if (result.lastErrorObject.upserted) {
            res.status(200).json({ success: true, message: 'Gem is now trending' });
            return;
        }

        // 后续逻辑保持不变,处理已存在的情况
        const isGemTrending = result.value;
        const { trends_by, trend_expire } = isGemTrending;

        if (now > trend_expire) {
            await trendingCollection.updateOne(
                { gem: gemID },
                {
                    $set: {
                        trends_by: [address],
                        trends: 1,
                        trend_expire: trendExpire,
                    },
                }
            );
            res.status(200).json({ success: true, message: 'Gem trend has been reset and now trending' });
        } else {
            if (trends_by.includes(address)) {
                res.status(400).json({ error: true, message: 'Address has already trended this gem within the last 24 hours' });
                return;
            } else {
                await trendingCollection.updateOne(
                    { gem: gemID },
                    {
                        $push: { trends_by: address },
                        $inc: { trends: 1 },
                    }
                );
                res.status(200).json({ success: true, message: 'Gem trend updated' });
            }
        }
    } catch (error) {
        console.error('Error updating trending gem: ', error);
        res.status(500).json({ error: true, message: 'Internal server error' });
    }
}

总结

  • 前端通过useRef标记请求状态,避免useEffect重复触发请求
  • 后端通过原子化的findOneAndUpdate+upsert,从根本上防止并发场景下的重复插入

内容的提问来源于stack exchange,提问作者Oliver

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.19 05:39:55