从批量物化到流式枚举:移动端大结果集的峰值内存治理实践

0 阅读9分钟

摘要

移动端应用经常需要处理来自系统框架、数据库或网络接口的大结果集。一个常见实现是:先把所有结果转换成数组,再交给下游消费。代码直观,但会把“生产结果”和“消费结果”同时留在内存中,形成不必要的峰值。

本文抽象总结一次大结果集内存治理实践:将一次性数组物化改为逐项枚举,并通过可回滚开关和交叉顺序 A/B 实验验证收益。实验使用合成的 100,000 条标识数据,测得两轮交叉顺序的平均峰值增量下降约 4.13 MB,约 65.2%。这个数字只代表中间数组物化成本,不应直接外推为线上 OOM 下降。

一、为什么“只是一些字符串”也会造成峰值内存

假设上游返回一个惰性结果集,下游只需要每条记录的短标识。一次性物化的典型流程如下:

结果集
  -> 创建可变数组
  -> 逐项读取并追加标识
  -> 返回完整数组
  -> 下游再次遍历数组

在下游开始消费之前,数组已经持有全部元素。此时进程内存至少包含:

峰值内存
  = 原有工作集
  + 结果对象/框架对象的临时开销
  + 数组存储空间
  + 元素对象或字符串的持有开销
  + 下游消费期间的其他临时对象

这里有三个容易被忽略的放大因素:

  1. 容器空间不是零成本。 动态数组通常会按容量增长,扩容时可能同时存在旧存储和新存储。
  2. 对象持有会延长生命周期。 即使单个标识很小,数组也会让全部标识保持存活到消费完成。
  3. 生产和消费可能重叠。 下游处理一条记录时,完整数组仍然存在,临时对象会叠加在数组之上。

因此,OOM 风险往往不是“单条数据太大”,而是“批量中间结果的生命周期太长”。

二、核心改造:从一次性物化到流式枚举

流式方案把接口从“返回集合”改成“逐项交付”:

结果集
  -> 读取一项
  -> 校验并交给消费者
  -> 释放当前项的临时引用
  -> 读取下一项

抽象接口可以表达为:

旧接口:identifiers = makeAllIdentifiers(result)
        consume(identifiers)
​
新接口:enumerateIdentifiers(result) { identifier in
          consumeOne(identifier)
        }

两者的主要区别不是算法复杂度,而是中间结果的生命周期

方案中间容器单项生命周期峰值随结果数量增长
一次性数组完整数组至少持续到消费结束明显增长
流式枚举无完整数组通常只覆盖一次回调主要由下游临时工作集决定

流式方案并不意味着“完全没有内存成本”。上游框架可能有自己的缓存或分页策略,下游也可能主动积累数据。它解决的是应用层主动创建的那一份完整中间数组。

三、实现时需要守住的边界

1. 让消费者决定是否继续

实际接口通常需要支持提前停止,例如达到数量上限、任务取消或内存压力升高时退出:

enumerate(result, handler) -> stopReason
​
for each item in result:
    if cancelled:
        return cancelled
    if invalid(item):
        recordSkippedItem()
        continue
    if handler(item) == stop:
        return stoppedByConsumer
​
return completed

提前停止可以减少无意义的读取,也能让内存压力控制从“事后清理”变成“主动限流”。

2. 不在回调中偷偷重新积累

如果流式接口内部仍然把元素追加到另一个数组,或者消费者立即复制全部元素,那么表面上的流式只是换了接口名,峰值并没有真正下降。评审时应沿着数据流检查:

  • 是否存在隐藏的全量缓存;
  • 回调是否同步执行,避免未受控的并发积压;
  • 下游是否把单项对象转换成更大的对象并长期持有;
  • 取消和异常路径是否能及时结束遍历。

3. 控制临时对象的作用域

对于 Objective-C 或混编工程,单项处理可以放在明确的局部作用域或自动释放池中。重点不是机械地增加 autoreleasepool,而是确认临时对象不会跨越整个批次的生命周期。

4. 保留兼容路径

涉及线上稳定性时,建议先新增流式实现,再通过配置选择新旧路径。开关需要满足:

  • 默认值明确;
  • 关闭后行为与旧版本一致;
  • 新旧路径共享相同的输入校验和错误处理契约;
  • 开关变更有日志或指标可追踪;
  • 发现异常时可以快速回滚,而不需要重新发布包体。

开关是风险控制手段,不是替代测试的理由。新路径仍需完成同输入、同设备条件下的功能和内存验证。

四、如何设计可信的 A/B 内存实验

固定输入契约

实验至少要固定以下条件:

  • 输入数量和数据形态;
  • 上游结果集的访问方式;
  • 下游消费逻辑;
  • 设备架构和系统版本;
  • 测试进程是否隔离;
  • 构建产物和编译配置。

如果输入规模每轮变化,或者旧路径和新路径使用了不同的消费逻辑,结果就无法归因到“数组物化”这一变量。

选择进程内存指标

在 Apple 平台上,建议优先记录 phys_footprint。它比单纯的虚拟内存或 RSS 更接近系统用于内存压力判断的物理 footprint。每个分支至少记录:

before = 进入分支前的 footprint
peak   = 分支执行期间观测到的最大 footprint
after  = 分支结束后的 footprint
​
peak_delta  = peak - before
after_delta = after - before

采样频率需要足够捕获短暂峰值;如果只在分支前后各采一次,可能完全错过扩容瞬间。

交叉执行顺序

同一进程先后运行两个分支时,allocator 的缓存、页回收和前一个分支留下的工作集都会影响后一个分支。只做“旧路径先、新路径后”容易把顺序偏差误认为方案收益。

最低限度应执行两种顺序:

Run 1: legacy -> stream
Run 2: stream -> legacy

报告中应同时给出每轮原始值、执行顺序和均值,不要只保留一个看起来最好的数字。

五、脱敏 A/B 结果与正确解读

在一个合成 workload 中,固定处理 100,000 条标识,使用真实 iOS 设备采样 phys_footprint,得到如下结果:

执行顺序一次性数组流式枚举峰值下降相对下降
数组优先7.55 MB0.97 MB6.58 MB87.2%
流式优先5.11 MB3.44 MB1.67 MB32.7%
两轮均值6.33 MB2.21 MB4.13 MB65.2%

这个结果说明:去掉应用层的全量中间数组后,峰值确实下降;同时,执行顺序对绝对数值有明显影响。因此更严谨的结论是:

在该合成 workload 和测试条件下,流式枚举使应用层中间数组物化带来的峰值内存降低约 4.13 MB,观测范围为 1.67~6.58 MB。

不能从这个实验直接推出:

  • 系统框架内部缓存一定下降了同样的数值;
  • 任意真实数据分布都能获得 65.2% 的收益;
  • 线上 OOM 发生率会下降 65.2%;
  • 所有设备档位上的收益都相同。

这些问题需要真实数据集、多个设备档位、足够的重复次数和端到端 OOM 观测来回答。

六、上线后的观测与回滚

流式改造上线后,建议同时观测功能正确性和内存指标:

维度建议指标
完整性实际处理数、跳过数、取消数、提前停止数
正确性新旧路径结果数量和关键校验结果的一致率
内存峰值 footprint、分支前后增量、内存警告次数
稳定性OOM/Jetsam 率、任务失败率、耗时 P50/P95
回滚开关命中率、回滚后指标恢复情况

回滚条件应提前定义,例如:结果完整性低于阈值、任务失败率显著升高、峰值内存没有改善,或出现新的崩溃/异常。回滚后仍要保留问题样本,避免只关闭开关而丢失根因证据。

七、可迁移的内存治理清单

遇到“大结果集导致内存峰值”时,可以按以下顺序排查:

  1. 是否把惰性结果一次性转换成完整数组、字典或集合?
  2. 是否存在数组扩容导致的短时双份存储?
  3. 生产阶段和消费阶段是否出现生命周期重叠?
  4. 是否可以改为逐项回调、迭代器或分页?
  5. 消费者是否有提前停止和取消能力?
  6. 是否会在回调中隐藏地重新积累全量数据?
  7. 是否用 phys_footprint 记录了真实峰值,而非只看前后瞬时值?
  8. 是否交叉执行了两种顺序并保留完整日志?
  9. 是否有旧路径回滚和新旧结果一致性指标?
  10. 是否明确了合成实验与真实线上收益之间的证据边界?

结语

流式枚举的价值不在于把某个数组换成一个回调,而在于重新设计数据的生命周期:让数据尽快被消费,让中间容器不再跨越整个批次,让取消和内存压力能够提前终止工作。

对于移动端的大结果集处理,最可靠的路径通常是:先用数据流分析定位中间物化,再用固定输入的真机 A/B 测量验证,最后通过可回滚开关和线上指标控制发布风险。只有当合成 workload、真实数据和线上观测逐层闭合时,才能把“看起来省内存”升级为可信的稳定性收益。