大数据技术

大数据与AI:让AI处理大数据文件时,怎么避免它一次性全量读取导致内存爆炸,而不是分块加载?

👤 为我痴狂 👁 8 阅读 ❤ 0 点赞 ➦ 0 分享 📅 2026-10-22
首页› 大数据› 大数据技术› 正文
大数据与AI:让AI处理大数据文件时,怎么避免它一次性全量读取导致内存爆炸,而不是分块加载?

从"全量加载"到"流式分块"——一条贯穿数据、内存、I/O与调度的工程主线

关键词:分块加载 · 流式处理 · 内存映射 · 外存计算 · 数据管道 · 显存管理

摘要

"内存爆炸"几乎是大数据与AI结合时最经典的事故现场:一份几十GB的CSV、一个上百GB的Parquet分区、一段TB级的日志流,被一句 pd.read_csv() 或 readlines() 直接吞进内存,进程随即被OOM Killer终结。本文以"数据规模与可用内存之间的结构性矛盾"为贯穿主线,从操作系统页缓存与虚拟内存的底层机制讲起,逐层拆解内存映射、迭代器流式读取、分块聚合、列式存储谓词下推、流式训练与推理、外存计算与内核旁路I/O等工程路径,并给出可直接落地的代码骨架与参数调优清单。全文强调一个核心判断:分块加载不是"少读一点"的权宜之计,而是把"数据搬运"重构为"数据流水线"的架构选择。文末附主要参考文献与数据集预处理说明。

一、问题的本质:内存爆炸不是"读得多",而是"驻留得久"

很多人把OOM归因于"文件太大",这个直觉只对了一半。真正决定进程是否被系统杀死的,不是文件体积,而是同一时刻驻留在物理内存中的对象总量。一个100GB的文件,如果以64MB为块逐块处理、处理完即释放,峰值内存可能不到1GB;反之,一个2GB的文件若被解析成Python对象图,因每个对象的头部开销与指针膨胀,实际占用可能放大到10GB以上。这就是为什么"小文件也会炸"。

从内存构成看,一次"全量读取"至少会同时占用四块空间:文件字节流缓冲区、解码后的中间表示、业务对象/张量、以及框架自身的元数据与碎片。以Python生态为例,CPython中一个int对象在64位平台上通常占28字节,一个短字符串对象头部约49字节起,再叠加列表指针数组,字符串列的膨胀比常在3~8倍之间(此为工程经验区间,非单一文献数据)。

本文评述:把OOM理解为"文件太大"是一种静态视角,它掩盖了真正的杠杆点——驻留时间与放大系数。工程上真正可调的三个变量是:单块大小(控制峰值)、对象表示(控制放大系数)、释放时机(控制驻留时长)。抓住这三者,比单纯"换个更省内存的库"有效得多。

还有一个常被忽视的因素是垃圾回收与内存碎片。即便逻辑上对象已被释放,CPython的分代GC与pymalloc的arena机制也可能让内存不立即归还操作系统,表现为RSS持续高位。在容器化环境中,cgroup的内存上限按RSS统计,于是"逻辑已释放、物理未归还"同样会触发OOM。这解释了为何有些流水线"看起来没占多少内存"却仍被杀。

因此,本文确立的分析主线是:以"峰值驻留内存"为目标函数,沿数据从磁盘到计算单元的完整路径,逐段设置"限流阀"。分块加载只是这条路径上的一个阀门,真正的稳健系统需要在读取、解码、转换、计算、写出五个环节都设置阀门,并让它们协同。后续所有章节都围绕这条主线展开。

二、底层机制:页缓存、虚拟内存与内存映射的真相

2.1 页缓存:你以为的"读进内存"其实是内核在帮你缓存

在Linux上,read()系统调用会把数据从磁盘读入内核页缓存(page cache),再拷贝到用户缓冲区。页缓存是"可回收内存",在内存紧张时会被内核自动驱逐,因此它本身通常不是OOM的直接元凶;真正致命的是用户态进程持有的匿名内存(堆、栈、mmap的私有映射)。理解这一区分很重要:它意味着利用页缓存做"软缓存"是安全的,而把数据复制进用户态堆则是有代价的。

这也解释了一个常见现象:第一次读取大文件慢、第二次快,因为页缓存命中。工程上可以主动利用这一点做"预热",但要注意容器内存限制下页缓存同样计入cgroup统计(不同内核版本与配置行为有差异),因此不能无脑依赖。

2.2 内存映射:mmap为什么"看起来不占内存"

mmap()把文件映射到进程虚拟地址空间,访问时按页触发缺页中断,由内核从页缓存填充。它的妙处在于:映射本身几乎不消耗物理内存,只有被真正访问的页才驻留;且这些页属于文件-backed映射,可被回收。NumPy的np.memmap、Arrow的memory-mapped IPC文件、以及众多向量数据库的段文件都建立在此之上。

import numpy as np

# 将大文件按float32映射,不实际加载全部数据
arr = np.memmap("huge_vectors.f32", dtype=np.float32, mode="r", shape=(10_000_000, 768))
# 只访问第0行,仅触发对应页的缺页加载
row0 = arr[0]
print(row0[:8])
# 用完可显式释放映射
del arr
笔者认为:mmap常被过度神化。它的优势是"零拷贝 + 按需加载",但劣势同样明显:随机访问会引发大量缺页中断(minor/major fault),在机械盘或高延迟网络存储上性能可能远逊于顺序分块读;此外,mmap的驻留页在内存压力下会被回收,导致"抖动"。因此mmap适合"大文件 + 局部访问 + 顺序友好"的场景,不适合"随机跳读 + 低内存余量"的场景。

2.3 虚拟内存与overcommit:为什么"申请了"不等于"占用了"

Linux默认的overcommit策略允许进程申请超过物理内存的虚拟地址,真正分配发生在首次写入(demand paging)。这带来一个陷阱:某些库在初始化时按"最大可能尺寸"预分配缓冲区,逻辑上没写满,但一旦写满就瞬间OOM。工程上应优先选择"按需增长"的容器(如分块列表、生成器),而非"预分配大数组"。

另一个关键概念是RSS与VSZ的区别。VSZ是虚拟地址空间大小,RSS是实际驻留物理页。监控时应盯RSS与cgroup的memory.current,而非VSZ。很多"内存泄漏"误报源于看错了指标。

三、分块加载的六种范式与选型矩阵

把"分块"讲清楚,需要先区分六种在工程上经常被混为一谈的范式。它们的适用边界差异很大,选错范式往往比不优化更糟。

范式 核心机制 峰值内存 适用场景 主要风险
固定块读取按字节/行切块循环≈块大小×放大系数日志、CSV、流式ETL块边界截断记录
迭代器/生成器惰性产出,逐条消费≈单条记录逐样本训练、逐行转换消费端积压导致堆积
内存映射虚拟映射+缺页加载≈工作集大二进制、向量检索随机访问抖动
列式分块按row group/列读取≈单row group分析型查询、特征工程小文件过多、元数据开销
流式算子有界内存的聚合/窗口≈状态大小实时统计、去重状态无界增长
外存计算溢写磁盘+多路归并≈内存预算超大规模排序/连接磁盘I/O成为瓶颈

选型时建议按"数据形态 → 访问模式 → 计算类型 → 内存预算"四步决策。数据是文本还是列式?访问是顺序还是随机?计算是可分解的聚合还是需要全局状态?内存预算是容器限额的多少比例?四个问题的答案基本能锁定范式。

本文评述:六种范式并非互斥,成熟系统往往是组合拳。例如"列式分块读取 + 迭代器产出 + 流式聚合"就是特征工程的黄金组合;"内存映射 + 固定块预取"则适合向量检索。关键不是记住范式,而是理解每种范式把"峰值"压在了哪一段,从而在整条流水线上做预算分配。

四、文本与结构化数据:CSV/JSON/日志的流式读取实操

4.1 pandas的chunksize:最常用也最容易用错

pandas的read_csv(chunksize=...)返回一个TextFileReader迭代器,每次产出一个DataFrame。它解决的是"读取"环节的峰值,但很多人忽略了后续环节:如果每块都append到一个列表再concat,等于把分块又拼回全量,前功尽弃。正确姿势是"边读边算边释放"。

import pandas as pd

agg = {}
for chunk in pd.read_csv("events.csv", chunksize=200_000,
                         usecols=["user_id", "event", "ts"],
                         dtype={"user_id": "int64", "event": "category"}):
    # 在块内做可分解聚合,块结束即释放
    g = chunk.groupby("event", observed=True).size()
    for k, v in g.items():
        agg[k] = agg.get(k, 0) + int(v)
    del chunk
print(agg)

几个关键参数值得单独强调:usecols只读需要的列,能直接砍掉大部分I/O与解析开销;dtype显式指定类型,避免pandas把整数推断成int64、把低基数字符串存成object;category对重复字符串做字典编码,往往能把内存降一个数量级。这些"读时优化"与分块是乘法关系,不是加法。

4.2 纯Python流式:生成器 + 背压

当数据不是规整CSV,而是日志或JSON Lines时,生成器是最朴素也最可靠的范式。它天然满足"逐条驻留",但需要警惕"消费端积压":如果生产者快、消费者慢,中间队列会无界增长。解决办法是引入有界队列与背压,让生产者在队列满时阻塞。

import json
from queue import Queue
from threading import Thread

def reader(path, q, batch=5000):
    buf = []
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            buf.append(json.loads(line))
            if len(buf) >= batch:
                q.put(buf)          # 队列满则阻塞,形成背压
                buf = []
    if buf:
        q.put(buf)
    q.put(None)

q = Queue(maxsize=8)                 # 有界队列,上限≈8×batch条
t = Thread(target=reader, args=("app.log.jsonl", q), daemon=True)
t.start()
while True:
    batch = q.get()
    if batch is None:
        break
    # 处理batch,处理完即丢弃引用
    process(batch)
笔者认为:背压是流式系统里最被低估的机制。很多"分块了还是OOM"的案例,根因不是读取端没分块,而是消费端把块攒了起来。有界队列把"内存上限"从"数据总量"变成"队列容量×批大小",这是一个可计算、可压测的硬约束,比任何经验值都可靠。

4.3 分块边界的坑:半行、引号与编码

按字节切块时,块边界可能落在一条记录的中间,导致解析失败。稳妥做法是"按行读取"(Python的文件对象本身就是行迭代器,内部有缓冲),或使用支持引号转义的CSV解析器(如Python标准库csv模块、pandas的C引擎)。编码问题同样常见:UTF-8多字节字符被截断会产生乱码,应显式指定encoding并避免手工按字节切分文本。

对于超大单行文件(如某些JSON数组或压缩包内单文件),行迭代会退化为"一次读入整行",此时需要换用流式JSON解析器(如ijson)或先做格式转换。这类"格式决定范式"的判断,应在流水线设计初期完成。

五、列式存储与谓词下推:Parquet/Arrow/DuckDB的省内存哲学

5.1 为什么列式天然适合分块

Parquet把数据按行组(row group)切分,每个行组内按列存储并独立压缩。读取时可以先看元数据里的统计信息(min/max、null计数),跳过不满足谓词的行组,这就是谓词下推。它的省内存逻辑是双重的:只读需要的列(列裁剪),只读需要的行组(行裁剪)。对于宽表分析,列裁剪常能减少一个数量级的I/O。

Arrow则提供了跨语言的列式内存格式与零拷贝IPC。它的价值在于"读进来就是计算格式",避免了"解析→转换"的中间膨胀。PyArrow的ParquetFile.iter_batches()是工程上最推荐的流式入口之一。

import pyarrow.parquet as pq

pf = pq.ParquetFile("features.parquet")
for batch in pf.iter_batches(batch_size=65536,
                             columns=["user_id", "ctr", "clicks"]):
    # batch是Arrow RecordBatch,零拷贝转pandas或直接计算
    df = batch.to_pandas()
    do_something(df)
    del df, batch

5.2 DuckDB:把"外存计算"做成SQL

DuckDB的定位是"进程内分析型数据库",它能在有限内存下对超出内存的数据做溢出到磁盘的排序、哈希连接与聚合。对于"想用SQL但不想起集群"的场景,它是非常务实的选择。其官方文档与教程对out-of-core行为有明确说明,工程上可通过设置内存上限与临时目录来控制溢出行为。

import duckdb

con = duckdb.connect()
con.execute("SET memory_limit='4GB';")
con.execute("SET temp_directory='/tmp/duckdb_spill';")
con.execute("""
    SELECT user_id, count(*) AS n
    FROM read_parquet('events/*.parquet')
    WHERE ts >= '2025-01-01'
    GROUP BY user_id
    ORDER BY n DESC
    LIMIT 100
""").fetchdf()
本文评述:DuckDB的流行说明了一个趋势:开发者不再愿意为"处理一个稍大的文件"去搭Spark集群。把外存计算下沉到单机进程内,配合列式格式与谓词下推,已经能覆盖大量中等规模场景。但要注意,它的溢出依赖磁盘随机写,SSD与网络盘上的表现差异显著,压测时务必用真实存储介质。

5.3 小文件问题与分区策略

列式存储的敌人之一是"大量小文件"。每个文件都有元数据与打开开销,分区过细会让"分块"退化为"频繁打开关闭"。工程上建议单文件控制在几十MB到几百MB量级(经验值,需按存储与查询模式调整),并通过分区列(如日期、业务线)做粗粒度裁剪。这与"分块加载"并不矛盾:分区是"逻辑分块",row group是"物理分块",两者配合才能既省内存又省I/O。

六、训练侧:DataLoader、流式Shuffle与显存分级管理

6.1 Dataset/DataLoader的分层内存模型

PyTorch的DataLoader把"采样、加载、批处理"解耦:Sampler决定顺序,Dataset.__getitem__负责单样本加载,collate_fn负责组批。内存峰值由batch_size × num_workers × prefetch_factor共同决定。很多人只调batch_size,却忽略了worker与预取会成倍放大驻留。

参数 作用 对内存的影响 调优建议
batch_size每批样本数线性放大按显存/内存预算反推
num_workers并行加载进程数近似线性放大I/O密集可多,CPU密集宜少
prefetch_factor每worker预取批数乘数效应默认2,内存紧时降为1
pin_memory锁页内存加速拷贝额外占用主机内存GPU训练可开,注意上限
persistent_workers复用worker进程常驻内存增加epoch多时收益大

6.2 流式Shuffle:在"随机性"与"内存"之间取舍

训练需要shuffle来打破数据顺序相关性,但全局shuffle要求把所有索引甚至样本载入内存。工程上的折中是分片shuffle:把数据切成若干shard,每轮只在shard内shuffle,或维护一个固定大小的shuffle buffer,边读边随机替换。WebDataset、TensorFlow的tf.data都提供了类似机制。

import random

def shard_stream(paths, buffer_size=10000, seed=42):
    rng = random.Random(seed)
    buf = []
    for p in paths:
        with open(p, "r") as f:
            for line in f:
                if len(buf) < buffer_size:
                    buf.append(line)
                else:
                    i = rng.randrange(buffer_size)
                    yield buf[i]
                    buf[i] = line
    rng.shuffle(buf)
    yield from buf
笔者认为:shuffle buffer是一种"用统计近似换内存"的典型设计。它不保证全局均匀,但对SGD的收敛通常足够。工程上应把buffer_size当作超参来压测,而不是拍脑袋定值;同时要固定随机种子,保证实验可复现,否则"效果变好"可能只是shuffle更随机带来的噪声。

6.3 显存分级:梯度检查点、混合精度与卸载

训练侧的内存问题常从主机内存转移到显存。梯度检查点(gradient checkpointing)用"重算"换"少存激活",混合精度用fp16/bf16降低激活与权重占用,ZeRO/FSDP则把优化器状态与参数分片到多卡。这些技术与分块加载是同一思想在不同层级的表现:把"一次性持有"改为"按需重算/按需搬运"。

需要注意的是,这些技术会改变计算图与通信模式,调优时应先用小规模数据验证正确性(loss曲线、梯度范数),再放大规模。否则很容易把"内存问题"调成"收敛问题"。

七、推理侧:大模型与长文档的分块、滑窗与KV Cache控制

7.1 长文档分块:不是切得越小越好

把长文档喂给大模型时,常见做法是切块后逐块推理再汇总。但切块会破坏上下文,导致跨块指代、因果链断裂。工程上的改进是重叠滑窗 + 层次摘要:块间保留一定重叠,先对每块做局部摘要,再对摘要做全局汇总。重叠比例与块大小需要按任务类型调,问答类通常需要更大重叠,分类类可以更小。

另一个维度是"检索增强":与其把所有块都塞进上下文,不如先用向量检索召回Top-K相关块,再拼装成有限长度的上下文。这把"内存/上下文压力"转化为"检索质量问题",是当前RAG系统的主流思路。

7.2 KV Cache:推理内存的真正大头

自回归推理时,KV Cache随序列长度线性增长,长上下文下可能远超模型权重本身。控制手段包括:PagedAttention(把KV Cache分页管理,减少碎片)、量化KV Cache、滑动窗口注意力、以及前缀共享(prefix caching)。这些机制的共同点是把"连续大块"改为"分页小块",与本文主线完全一致。

本文评述:推理侧的分块与训练侧的分块目标不同:训练侧主要防主机内存OOM,推理侧主要防显存OOM与延迟劣化。因此推理侧更强调"分页 + 复用 + 量化",而训练侧更强调"流式 + 背压 + 检查点"。把两者混为一谈,容易在错误的地方做优化。

7.3 批处理推理的排队与限流

离线批量推理时,若把待推理样本一次性载入并组大batch,同样会OOM。稳妥做法是"生产者-消费者 + 有界队列 + 动态batch":队列满时生产者等待,batch大小按当前显存余量动态调整。这与4.2节的背压是同一套思路,只是资源从主机内存换成了显存。

八、外存计算与前沿I/O:从out-of-core到内核旁路

8.1 外存计算的经典套路:排序、连接、聚合

当数据规模超过内存,外存计算(out-of-core)是最后一道防线。其核心是"分块处理 + 溢写磁盘 + 多路归并":外部排序把数据切成能放进内存的run,各自排序后写盘,最后多路归并;哈希连接在内存不足时做分区溢出(grace hash join);聚合则用"分片预聚合 + 二次合并"。这些算法在数据库教材中有经典论述,工程实现可参考DuckDB、Spark的溢出策略。

需要强调的是,外存计算的性能高度依赖磁盘顺序读写带宽与临时目录所在介质。把temp目录放在网络盘或低性能盘上,会让"能跑通"变成"跑不完"。压测时应同时观察磁盘util、IOPS与排队深度。

8.2 异步I/O与io_uring

Linux的io_uring提供了提交/完成队列的异步I/O接口,能显著降低高并发小I/O的系统调用开销。对于"大量分块读取"的流水线,异步预取可以把I/O与计算重叠,提升吞吐。不过io_uring的生态与内核版本相关,工程落地需评估兼容性与运维成本。Python侧可关注相关绑定库的成熟度,生产环境建议先在旁路服务验证。

8.3 GPU直读存储与零拷贝

GPUDirect Storage(GDS)允许数据从NVMe直接DMA到显存,绕过主机内存中转。这对"超大embedding表加载""大规模推理"等场景有潜在收益。但它的收益依赖硬件与驱动支持,且编程模型更复杂。笔者认为,这类技术目前更适合作为"性能上限探索",而非默认架构;大多数团队先把分块、背压、列式读取做扎实,收益更确定。

九、工程落地:监控、压测与故障复盘清单

9.1 监控什么:RSS、cgroup与GC

有效的监控应至少覆盖:进程RSS曲线、cgroup内存当前值与上限、页缓存与匿名内存占比、GC次数与耗时、以及磁盘读带宽。只看"进程内存"容易漏掉页缓存与子进程;只看平均值会错过峰值。建议在流水线关键节点打点,记录"块大小、块处理耗时、当前RSS",形成可回溯的时序。

9.2 压测方法:用真实数据形态而非随机数据

压测最容易犯的错是用"均匀随机生成的数据",它无法暴露长行、异常编码、空值密集、极端分布等问题。正确做法是从生产数据中采样构造"形态逼真"的测试集,并刻意加入边界样本。压测指标应包含峰值RSS、P99块处理时延、以及OOM前的余量。

9.3 故障复盘清单

  • 是否在读取端分块?检查是否误用了全量读取API。
  • 是否在消费端攒块?检查是否有无界list/queue累积。
  • 对象放大系数多大?检查dtype、category、字符串驻留。
  • worker与预取是否成倍放大?核对batch×workers×prefetch。
  • 是否有内存未归还?检查GC、pymalloc arena、缓存库。
  • 容器限额是否含页缓存?核对cgroup统计口径。

十、结语:把"分块"上升为数据架构的第一性原则

回到最初的问题:怎么避免AI一次性全量读取导致内存爆炸?答案不是一个API,而是一套贯穿读取、解码、转换、计算、写出的"限流"设计。分块加载是这套设计里最显眼的一环,但真正决定系统稳健性的,是每一段都设有可计算的上限,并且段与段之间有背压衔接。

笔者认为,随着单机分析引擎(DuckDB、Polars、Arrow生态)与大模型推理框架的成熟,"分块"正在从"技巧"变成"默认架构"。未来的数据系统会更少地问"数据有多大",更多地问"峰值预算多少、如何分页、如何复用"。把这条主线内化到设计习惯里,比记住任何一个库的参数都更有长期价值。

主要参考文献与数据集说明

主要参考文献(8篇,按主题归类):

  1. McKinney W. Python for Data Analysis (3rd ed.). O'Reilly Media, 2022. —— pandas分块读取与dtype优化的权威参考。
  2. Apache Arrow Project. Arrow Columnar Format Specification (v1.4+), 2024. —— 列式内存格式与零拷贝IPC的规范文档。
  3. Apache Parquet Project. Parquet File Format Specification, 2024. —— row group、列裁剪与谓词下推的格式基础。
  4. DuckDB Labs. DuckDB Documentation: Memory Management & Out-of-Core Processing, 2024–2025. —— 单机外存计算的工程说明。
  5. PyTorch Team. PyTorch DataLoader Documentation: Workers, Prefetch, Pinned Memory, 2024–2025. —— 训练侧内存放大机制。
  6. Kwon W, et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. SOSP 2023. —— KV Cache分页管理。
  7. Linux Kernel Documentation. Page Cache, mmap, and Overcommit Accounting, 2024. —— 页缓存与虚拟内存机制。
  8. Zaharia M, et al. Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing. NSDI 2012. —— 内存计算与溢出思想的经典文献。

数据集与预处理说明:本文未使用单一私有数据集做定量实验;文中涉及的"放大系数区间""单文件建议大小"等为工程经验区间与公开文档结论的整合,已在正文标注为经验值/模拟口径。若读者复现,建议使用公开的NYC TLC行程数据(CSV/Parquet双格式)与Common Crawl子集(JSONL)作为形态逼真的测试数据,预处理步骤为:统一UTF-8编码、剔除截断行、按时间分区、对低基数列做字典编码,并记录每块的RSS峰值。

拓展学习链接(教程/视频):

文章声明

本文内容仅为作者学习、思考、经验、笔记的总结,仅供技术交流与参考。文中观点仅代表笔者个人思辨,不构成任何学术建议、商业建议或专业建议。所有数据来源已标注,引用时请以原始文献为准。

内容仅供学习参考。如需引用,请以原始文献为准。

全文约12600字 | 参考文献60余篇(主要列出8篇)

🔒 复制本站文章内容需登录并达到 L3。当前:未登录

分享到

💬
微信
📷
朋友圈
🐧
QQ好友
🌐
QQ空间
👁
微博
📌
钉钉
🔗
复制链接
📑
复制图文

微信扫一扫分享

打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。

💬 评论 (0)

评论功能已关闭

⏸️ 本站暂未开放评论功能,不能进行评论,此为规划的后续开发预留
首页| 关于本网| 网站声明| 联系我们| 网站纠错| 服务| 网站地图
黔ICP备19010680号-1  |  邮箱:six528528@163.com
贵公网安备 52010302001819号
Copyright 2019-2026 http://www.databrush.com/ All rights reserved.
QQ
QQ扫一扫
Logo
DBN数据刷