Next.js+MongoDB API首次插入热门数据时重复插入问题
Trending Gem重复插入异常排查与解决
问题现象
首次将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
相关产品推荐
相关产品推荐

