关键在于用生成器循环替代全量嵌套展开,通过yield逐条产出扁平记录,结合分层引用与惰性表达式树实现流式、低内存、可中断的结构拉平。

流式清洗中做结构拉平,关键不是靠循环语句本身,而是用循环控制逻辑去驱动按需展开、逐层解构、引用即用的轻量执行模型。百万级对象若用传统嵌套循环全量展开,极易内存溢出或 GC 压力陡增。真正高效的做法,是把“循环”从数据遍历工具,转变为拓扑调度指令——它不承载数据,只定义展开时机与边界。
用可中断的生成器循环替代全量嵌套
结构拉平(如 JSON 数组字段展开、嵌套 Map/List 展开为宽表)本质是“一对多”的映射。若直接 foreach + AddRange,会一次性生成全部子项并暂存。应改用 yield return 驱动的生成器循环,让每轮只产出一个扁平化记录:
- 对每个输入对象,解析其嵌套结构(如
"items": [{"id":1},{"id":2}]) - 用
foreach (var item in obj.Items)循环,但每次仅yield return new FlatRecord { ParentId = obj.Id, ItemId = item.Id } - 消费端(如下游 filter 或 sink)按需取值,不缓存整批结果
这样,内存中始终只驻留当前正在处理的 1–2 个嵌套层级对象,而非整个原始对象+全部子项。
基于引用深度分层展开,避免长链强引用
拉平过程容易因中间结构持有父对象引用而阻塞 GC。参考可达性分析思想:
- Layer-0(根):清洗任务上下文(如
FlatContext),含 schema、路径表达式($.orders[*].items[*])、输出字段映射 - Layer-1(一级):当前主对象解析结果(
Order实例),用ReadOnlySpan<byte></byte>或不可变JsonElement表示,不深拷贝 - Layer-2(二级):展开时动态构造的
FlatRecord,字段值优先复用父对象的Utf8JsonReader.TokenStartIndex等偏移信息,避免字符串分配 - 展开循环中,禁止将
Order实例赋给FlatRecord.OrderRef这类强引用字段;改用WeakReference<order></order>或仅存OrderId字段
一旦主对象被上游释放,其子结构自动不可达,JVM/.NET GC 可立即回收。
用惰性表达式树替代硬编码循环
Polars 或类似引擎的拉平逻辑(如 .unnest("items"))不写 for 循环,而是注册表达式节点:
-
UnnestExpr(path: "items", keepName: true)被加入逻辑计划树 - 执行时,物理引擎按块(chunk)扫描,对每块调用
UnnestKernel—— 内部仍用循环,但:
▸ 循环体无状态,不累积中间 List
▸ 使用预分配的ArrayPool<t></t>复用缓冲区
▸ 支持 chunk-level 并行展开(非 record-level) - 若输入是 LazyFrame,
.unnest()不触发计算,直到.collect(streaming=true)才启动流式展开
这种设计下,“循环”被封装在底层向量化内核里,上层清洗拓扑看到的只是声明式操作。
规避常见陷阱:嵌套过深、重复展开、空数组膨胀
- 对
$.a.b.c.d.e这类超深路径,提前做 schema 探测,跳过不存在的层级(避免空循环) - 同一字段多次
.unnest()(如先 unnest items,再 unnest items.tags)时,确保第二层基于第一层输出的列名,而非原始嵌套对象 - 空数组
[]展开后默认产出 0 行 —— 若业务要求保留父记录,需显式.with_columns(pl.col("items").list.len().alias("items_len"))做兜底判断
不复杂但容易忽略。










