Flutter Web中Firestore get()随机挂起问题排查求助
使用Flutter Web开发,Firestore中有schools集合(约5000条数据),每个学校文档下包含pupils子集合(总计约50万条学生数据)。为遍历所有学生数据,编写了批量处理代码,通过Future.wait以5个任务为批次执行提升效率,但代码会随机挂起——启动5个onSchool任务后无响应。
单任务执行时代码正常,挂起时机随机(有时遍历1000所学校,有时4000所)。Debug模式下程序会像触发断点一样暂停,无堆栈信息,但恢复后可正常执行完毕。
相关代码如下:
await visitSchoolPupils( onSchoolPupil: (s, school, pupil) async { //print("School ${school.schoolName}, pupil ${pupil.userName}"); return true; });
Future<void> visitSchoolPupils({required Future<bool> Function(DocumentSnapshot s, School school, Pupil pupil) onSchoolPupil}) async { int start = DateTime.now().millisecondsSinceEpoch; int pupilCtr = 0; await visitSchools( onSchool: (s, school) async { print(">>>DEBUG onSchool ${school.schoolId}"); try { //pick all pupils QuerySnapshot pupilSnapshot = await s.reference.collection(keyPupils).get(); List<Future> pupilTasks = []; for (DocumentSnapshot doc in pupilSnapshot.docs) { JsonMap pupilData = (doc.data()) as JsonMap; Pupil pupil = Pupil.fromJson(pupilData); pupilTasks.add(onSchoolPupil(doc, school, pupil)); // Execute in batches if (pupilTasks.length >= 5) { await Future.wait(pupilTasks); pupilTasks.clear(); } } await Future.wait(pupilTasks); pupilTasks.clear(); print("<<<DEBUG onSchool ${school.schoolId}"); return true; } catch (e, trace) { print("pupil tasks1 $e $trace"); return false; } }); print("DEBUG visitSchoolPupils: READY took ${DateTime.now().millisecondsSinceEpoch - start} ms for $pupilCtr pupils"); }
Future<void> visitSchools({required Future<bool> Function(DocumentSnapshot s, School school) onSchool}) async { int start = DateTime.now().millisecondsSinceEpoch; QuerySnapshot snapshot = await FirebaseFirestore.instance.collection(keySchools).get(); int ctr=0; int nr = snapshot.docs.length; print("DEBUG visitSchools: START $nr schools"); //for all schools List<Future> tasks = []; for (DocumentSnapshot s in snapshot.docs) { JsonMap schoolData = (s.data()) as JsonMap; School school = School.fromJson(schoolData); ctr++; if(ctr%100==0)print("DEBUG[${DateTime.now().millisecondsSinceEpoch - start}] visitSchools[$ctr] ${school.schoolId}"); tasks.add(onSchool(s, school)); // Execute in batches of 10 or on final iteration if (tasks.length >= 5) { await Future.wait(tasks); tasks.clear(); } } await Future.wait(tasks); tasks.clear(); print("DEBUG visitSchools: READY took ${DateTime.now().millisecondsSinceEpoch - start} ms"); }
疑问:是否存在线程安全、内存累积问题?Future.wait处理已完成的Future会有问题吗?求问题排查及代码优化建议。
一、核心问题排查
1. 线程安全相关
Flutter Web基于单线程事件循环,不存在多线程共享内存的线程安全问题,但批量并发任务会抢占事件循环资源,导致UI或其他任务阻塞,表现为“挂起”。当前代码在学校和学生两层同时做批量并发(5个学校任务×每个学校5个学生任务),实际并发数达25,加上Firestore网络请求的异步回调,容易导致事件循环队列积压,出现无响应。
2. 内存累积问题
- 每次调用
get()获取学校或学生集合时,QuerySnapshot会把所有文档数据一次性加载到内存中,50万条学生数据会占用大量内存,而Flutter Web的内存限制比原生更严格,内存不足时会触发浏览器GC,导致程序短暂暂停。 - 未及时清理的
Future列表、DocumentSnapshot、Pupil实例如果被闭包隐式持有,会导致内存泄漏,累积到一定程度引发GC长时间暂停。
3. Future.wait的潜在问题
Future.wait本身处理已完成的Future没有问题,但如果批量中的某个Future出现未捕获的异步异常(比如Firestore请求超时、网络中断),或者某个任务卡住,会导致Future.wait一直等待,表现为程序挂起。当前代码中onSchool的catch块仅返回false,但未处理超时场景,存在卡住风险。
二、代码优化建议
1. 控制并发层级与数量
不要在学校和学生两层同时做高并发,建议只在单一维度控制并发,降低事件循环压力:
- 降低学校任务的批量数(比如从5改为2-3),或者改为串行执行学校任务,仅在学生层面做并发。
- 使用
async包的Stream.asyncMap更灵活控制并发数,避免手动维护任务列表。
示例(使用Stream.asyncMap控制学校任务并发):
import 'package:async/async.dart'; Future<void> visitSchools({required Future<bool> Function(DocumentSnapshot s, School school) onSchool}) async { int start = DateTime.now().millisecondsSinceEpoch; QuerySnapshot snapshot = await FirebaseFirestore.instance.collection(keySchools).get(); int ctr=0; int nr = snapshot.docs.length; print("DEBUG visitSchools: START $nr schools"); // 控制学校任务并发数为3 await Stream.fromIterable(snapshot.docs) .asyncMap((s) async { JsonMap schoolData = (s.data()) as JsonMap; School school = School.fromJson(schoolData); ctr++; if(ctr%100==0)print("DEBUG[${DateTime.now().millisecondsSinceEpoch - start}] visitSchools[$ctr] ${school.schoolId}"); return await onSchool(s, school); }, concurrency: 3) .toList(); print("DEBUG visitSchools: READY took ${DateTime.now().millisecondsSinceEpoch - start} ms"); }
2. 优化内存使用
- 不要一次性获取整个
pupils子集合,使用分页查询(limit()+startAfterDocument())分批次加载学生数据,减少单次内存占用。 - 处理完学生数据后,及时解除对
DocumentSnapshot、Pupil实例的引用,帮助GC回收内存。
示例(学生数据分页加载):
// 修改onSchool中的学生获取逻辑 QuerySnapshot pupilSnapshot; DocumentSnapshot? lastDoc; do { Query query = s.reference.collection(keyPupils).limit(50); // 每次加载50条 if (lastDoc != null) { query = query.startAfterDocument(lastDoc); } pupilSnapshot = await query.get(); List<Future> pupilTasks = []; for (DocumentSnapshot doc in pupilSnapshot.docs) { JsonMap pupilData = (doc.data()) as JsonMap; Pupil pupil = Pupil.fromJson(pupilData); pupilTasks.add(onSchoolPupil(doc, school, pupil)); if (pupilTasks.length >= 5) { await Future.wait(pupilTasks); pupilTasks.clear(); } lastDoc = doc; } await Future.wait(pupilTasks); pupilTasks.clear(); } while (pupilSnapshot.docs.isNotEmpty);
3. 增加超时与异常处理
给Firestore请求添加超时,避免某个请求卡住导致整个批量任务挂起:
// 使用Future.timeout包装Firestore请求 QuerySnapshot pupilSnapshot = await s.reference.collection(keyPupils).get() .timeout(const Duration(seconds: 10), onTimeout: () { print("Timeout loading pupils for school ${school.schoolId}"); return QuerySnapshot.empty; });
4. 调试辅助
- 在
onSchool和onSchoolPupil中添加更详细的日志,记录每个任务的开始、结束时间,以及当前内存使用(Flutter Web可通过dart:html的window.performance.memory获取内存信息)。 - 使用浏览器DevTools的Performance面板分析挂起时的事件循环阻塞情况,查看是否有长任务占用事件循环。
三、总结
随机挂起主要是因为并发数过高导致事件循环阻塞,加上大量数据加载引发的内存GC暂停。通过控制并发层级、分页加载数据、添加超时处理,可以有效解决问题。
内容的提问来源于stack exchange,提问作者Marc Van Daele

