Firestore批量文档查询优化求助:NextJS页面加载过慢问题
我用NextJS开发了一个网页,从Firestore拉取调研数据渲染图表。大部分客户每月调研量很少,但有个客户积累了数千份调研,加上当前的数据获取方式,页面加载慢到离谱,甚至触发**"Too many outstanding requests"**错误。核心瓶颈在fetchCustomersAndFeedbacks里的const customerFeedbacksSnapshot = await getDocs(customerFeedbacksQuery);这行——每次拉取单个客户的调研都要发一次请求,客户多了请求量直接爆炸。要求优化Firestore数据获取方式,同时要一次性拿到所有数据,保证前端按日期筛选时无延迟交互。
当前代码采用嵌套式批量请求:先拉取所有客户,再遍历每个客户拉取他们的调研。这种模式在客户/调研量大时会产生成百上千个并发请求,既触发Firestore的请求限制,又因为请求排队导致加载超时。
方案1:集合组查询批量拉取所有调研
利用Firestore的集合组查询,一次性拉取所有层级(业务下、门店下、服务员下)的调研数据,再在前端关联到对应的客户,彻底消除嵌套请求。
代码实现
export const getBusinessDataFromUser = async (userId: string) => { const userDocRef = doc(getFirebase().db, USERS_COLLECTION_NAME, userId || ''); try { const [userDocSnap, businessDocSnap] = await Promise.all([ getDoc(userDocRef), getBusinessDocSnap(userId) ]); const userData: User = userDocSnap.data() as User; const businessData: Business = businessDocSnap.data() as Business; const response = { ...businessData, parentId: userData.businessId, Id: userData.businessId, customers: [] as Customer[], feedbacks: [] as (Feedback | FeedbackHooters)[], sucursales: [] as Branch[], meseros: [] as Waiter[] }; response.Icono = await getBusinessIcon(businessData); // 1. 拉取所有层级的客户、门店、服务员基础数据(不含调研) await Promise.all([ fetchCustomersWithoutFeedbacks(response, businessDocRef), fetchBranchesWithoutFeedbacks(response, businessDocRef, userData.businessId, businessData), fetchMainWaitersWithoutFeedbacks(response, businessDocRef) ]); // 2. 收集所有需要关联调研的客户文档ID const allCustomerDocIds: string[] = []; response.customers.forEach(c => allCustomerDocIds.push(c.id)); response.sucursales.forEach(branch => { branch.customers.forEach(c => allCustomerDocIds.push(c.id)); branch.meseros.forEach(waiter => waiter.customers.forEach(c => allCustomerDocIds.push(c.id))); }); response.meseros.forEach(waiter => waiter.customers.forEach(c => allCustomerDocIds.push(c.id))); // 3. 集合组查询所有feedbacks,过滤目标客户的调研 const feedbacksQuery = collectionGroup(getFirebase().db, 'feedbacks') .where('__name__', 'in', allCustomerDocIds); const feedbacksSnapshot = await getDocs(feedbacksQuery); // 4. 把调研数据按客户ID分组 const feedbackMap = new Map<string, (Feedback | FeedbackHooters)[]>(); feedbacksSnapshot.docs.forEach(doc => { const customerId = doc.ref.parent.parent?.id; if (!customerId) return; const feedbackData = doc.data(); const feedback = businessData.parentId === 'hooters' ? {...feedbackData as FeedbackHooters, Visits: 0} : {...feedbackData as Feedback, Visits: 0}; feedbackMap.has(customerId) ? feedbackMap.get(customerId)!.push(feedback) : feedbackMap.set(customerId, [feedback]); }); // 5. 给调研的Visits字段按顺序赋值 feedbackMap.forEach((feedbacks) => { feedbacks.forEach((fb, idx) => fb.Visits = idx); }); // 6. 将调研数据关联到对应层级的客户 const assignFeedbacks = (customers: Customer[]) => { customers.forEach(customer => { customer.feedbacks = feedbackMap.get(customer.id) || []; }); }; assignFeedbacks(response.customers); response.sucursales.forEach(branch => { assignFeedbacks(branch.customers); branch.meseros.forEach(waiter => assignFeedbacks(waiter.customers)); }); response.meseros.forEach(waiter => assignFeedbacks(waiter.customers)); return response; } catch (error) { console.error('Error al obtener información del negocio:', error); return null; } }; // 拉取业务下的客户(不含调研) async function fetchCustomersWithoutFeedbacks(response: any, businessDocRef: DocumentReference) { const customersSnapshot = await getDocs(collection(businessDocRef, 'customers')); response.customers = customersSnapshot.docs.map(doc => ({ ...doc.data() as Customer, id: doc.id, feedbacks: [] })); } // 拉取门店及下属客户、服务员(不含调研) async function fetchBranchesWithoutFeedbacks(response: any, businessDocRef: DocumentReference, parentId: string, businessData: Business) { const branchesSnapshot = await getDocs(collection(businessDocRef, 'sucursales')); const branchesData = await Promise.all(branchesSnapshot.docs.map(async (branchDoc) => { const branch = { ...branchDoc.data() as Branch, id: branchDoc.id, parentId, customers: [] as Customer[], feedbacks: [] as (Feedback | FeedbackHooters)[], meseros: [] as Waiter[] }; // 拉取门店下的客户 const branchCustomersSnapshot = await getDocs(collection(branchDoc.ref, 'customers')); branch.customers = branchCustomersSnapshot.docs.map(doc => ({ ...doc.data() as Customer, id: doc.id, feedbacks: [] })); // 拉取门店下的服务员及他们的客户 const waitersSnapshot = await getDocs(collection(branchDoc.ref, 'meseros')); branch.meseros = await Promise.all(waitersSnapshot.docs.map(async (waiterDoc) => { const waiter = { ...waiterDoc.data() as Waiter, id: waiterDoc.id, customers: [] as Customer[], feedbacks: [] as (Feedback | FeedbackHooters)[] }; const waiterCustomersSnapshot = await getDocs(collection(waiterDoc.ref, 'customers')); waiter.customers = waiterCustomersSnapshot.docs.map(doc => ({ ...doc.data() as Customer, id: doc.id, feedbacks: [] })); return waiter; })); return branch; })); response.sucursales = branchesData; } // 拉取业务下的主服务员及他们的客户(不含调研) async function fetchMainWaitersWithoutFeedbacks(response: any, businessDocRef: DocumentReference) { const waitersSnapshot = await getDocs(collection(businessDocRef, 'meseros')); const waitersData = await Promise.all(waitersSnapshot.docs.map(async (waiterDoc) => { const waiter = { ...waiterDoc.data() as Waiter, id: waiterDoc.id, customers: [] as Customer[], feedbacks: [] as Feedback[] }; const waiterCustomersSnapshot = await getDocs(collection(waiterDoc.ref, 'customers')); waiter.customers = waiterCustomersSnapshot.docs.map(doc => ({ ...doc.data() as Customer, id: doc.id, feedbacks: [] })); return waiter; })); response.meseros = waitersData; }
注意事项
- 集合组查询需要在Firebase控制台创建对应的索引,执行代码时会收到控制台的索引创建提示,按提示操作即可。
- 这种方式将原本成百上千的请求压缩到3-5个批量请求,大幅降低请求数和加载时间。
方案2:批量读取API(集合组查询不适用时)
如果集合组的过滤逻辑无法满足需求,可以使用Firestore的getAll API批量拉取文档引用,最多一次拉取500个文档,分批次处理也能减少请求次数。
代码片段示例
// 收集所有调研文档的引用 const allFeedbackRefs: DocumentReference[] = []; // 遍历所有客户,收集他们的调研文档引用 // (需先拉取客户数据,再遍历每个客户的feedbacks集合获取所有文档引用) // 分批次拉取,每500个为一批 for (let i = 0; i < allFeedbackRefs.length; i += 500) { const batchRefs = allFeedbackRefs.slice(i, i + 500); const batchSnapshots = await getAll(...batchRefs); // 处理这批调研数据 batchSnapshots.forEach(snap => { // 映射到对应客户的逻辑 }); }
方案3:数据预聚合(长期优化)
如果调研数据不需要实时更新,可以在后端(如Cloud Functions)定期预聚合,将每个客户的调研数据直接存储在客户文档的字段中,前端拉取客户数据时就能直接拿到调研,彻底消除嵌套请求。
Cloud Functions示例
exports.syncCustomerFeedbacks = functions.firestore .document('{parentPath}/customers/{customerId}/feedbacks/{feedbackId}') .onWrite(async (change, context) => { const parentParts = context.params.parentPath.split('/'); const customerRef = doc( getFirebase().db, parentParts[0], parentParts[1], 'customers', context.params.customerId ); const feedbacksSnapshot = await getDocs(collection(customerRef, 'feedbacks')); const feedbacks = feedbacksSnapshot.docs.map(doc => ({ ...doc.data(), id: doc.id })); // 更新客户文档的feedbacks字段 await updateDoc(customerRef, { feedbacks }); });
优势
- 前端无需额外拉取调研数据,加载速度大幅提升。
- 避免Firestore请求限制问题。
内容的提问来源于stack exchange,提问作者Daniel Erazo

