4. 分块读取策略与chunksize调优

逐笔成交数据的一个典型特征是“行数极多、单行很窄”:一笔成交往往只有证券代码、成交时间、成交价格、成交数量、买卖方向等少数几个字段,但一天之内可能产生数十万到数百万行。如果直接用 pd.read_csv("trades.csv") 一次性读入,pandas 会在内存中构造一个完整的 DataFrame,中间还要经历字符串解析、类型推断、块扩容等过程,峰值内存常常是文件体积的数倍。对于多日、多市场、多文件的批量处理任务,这种全量加载方式很容易把内存打满,甚至触发操作系统换页,让整个批处理从“慢”变成“卡死”。chunksize 参数正是为解决这一类问题而设计的。

chunksize 的工作原理:返回迭代器而非 DataFrame

当调用 pd.read_csv(path, chunksize=N) 时,pandas 的返回类型会从 DataFrame 变成 TextFileReader,它是一个可迭代对象。每次迭代时,读取器从文件当前位置继续解析,凑够 N 行(最后一块可能不足 N 行)就产出一个 DataFrame 块,然后暂停,等待下一次迭代。也就是说,任意时刻内存中只有“一个块”加上少量解析缓冲,而不是整个文件。其本质是把“一次性解析全部行”拆成“按行推进的流式解析”,文件指针只向前移动,不会回退。

需要特别注意的是,chunksize 控制的是行数而不是字节数。由于逐笔数据每行长度相近,按行分块在实践中足够稳定;但如果文件中存在超长字段或大量空行,单块的实际内存占用仍会波动。另外,chunksize 只影响读取阶段的切分,不会改变后续 groupby、merge 等操作的内存行为——如果某个块被聚合后仍保留全量明细,优化就前功尽弃了。

chunksize 流式读取:文件指针单向推进,内存只驻留单块 trades.csv(磁盘) 约 120 万行逐笔成交 第 1 块:行 1–100000 第 2 块:行 100001–200000 第 3 块:行 200001–300000 ⋮ 第 12 块:行 1100001–1200000 TextFileReader 迭代器 for chunk in reader: 每次 next() 解析 N 行 产出 1 个 DataFrame 块 文件指针只前进,不回退 块处理完即释放 内存峰值 ≈ 单块大小 块内处理(逐块执行) ① 类型转换:价格 float32 ② 过滤:剔除撤单/异常价 ③ 聚合:按代码汇总成交量 ④ 结果追加到累加器 ⑤ 释放当前块 最终结果(小体积) 每只证券的成交量/成交额 成交笔数、均价、最大单笔 结果行数 ≈ 证券数量 而非 120 万行明细 示意:chunksize=100000 时,120 万行文件被切为 12 块,内存中同时只存在 1 块明细

如何为逐笔数据选择块大小

块大小没有放之四海皆准的数值,它取决于三个量:单行字节数、可用内存、以及每块要做的计算量。可以用一个简单的估算式来定初值:单块内存 ≈ chunksize × 单行字节数 × 放大系数。逐笔数据单行通常在 60~120 字节,pandas 解析后转为对象/数值列会有 2~4 倍放大。若希望单块控制在 50MB 以内,按每行 100 字节、放大 3 倍计算,chunksize 约为 50×1024×1024 ÷ 300 ≈ 17 万行,取整到 10 万行是稳妥的起点。

块太小会导致迭代次数过多,每次迭代都有固定的解析器开销和 Python 循环开销,反而拖慢整体;块太大则失去流式处理的意义,内存峰值逼近全量加载。经验上,逐笔数据常用区间是 5 万到 50 万行。若每块只做轻量过滤,可以取大一些;若每块要做排序、groupby 或与参考表 merge,则应取小一些,因为这类操作会在块内再复制数据。

场景 建议 chunksize 理由
仅过滤字段、写回小结果 20 万~50 万行 块内计算轻,减少迭代次数
块内 groupby 聚合 10 万行左右 聚合会产生中间对象,需留余量
块内与证券信息表 merge 5 万~10 万行 merge 会复制块数据,峰值翻倍
内存紧张的批处理环境 2 万~5 万行 优先保证不 OOM,牺牲部分速度

流式处理的实现要点

分块读取的关键不是“读”,而是“每块读完就归约”。下面是一个按证券汇总成交量的示例,示例参数与结果均为教学假设,用于说明写法:

import pandas as pd

dtype = {
    "code": "category",
    "price": "float32",
    "volume": "int32",
    "side": "category",
}

acc = {}  # 累加器:code -> [成交量, 成交额, 笔数]

reader = pd.read_csv(
    "trades_20240102.csv",
    chunksize=100_000,
    dtype=dtype,
    usecols=["code", "price", "volume", "side"],
)

for chunk in reader:
    chunk = chunk[chunk["price"] > 0]          # 剔除异常价
    g = chunk.groupby("code", observed=True).agg(
        vol=("volume", "sum"),
        amt=("price", lambda s: 0),            # 占位,实际用 price*volume
        cnt=("volume", "size"),
    )
    for code, row in g.iterrows():
        cur = acc.setdefault(code, [0, 0.0, 0])
        cur[0] += int(row["vol"])
        cur[2] += int(row["cnt"])

result = pd.DataFrame(
    [(c, v[0], v[2]) for c, v in acc.items()],
    columns=["code", "total_volume", "trade_count"],
)
print(result.head())

这段代码体现了三个要点。第一,用 dtype 显式声明列类型,避免 pandas 对每块重复做类型推断,也防止 code 被推断成数值而丢失前导零。第二,usecols 只读需要的列,直接减少单块内存。第三,聚合结果写入外部累加器 acc,块对象在下一轮迭代时被覆盖释放,内存不会随块数增长。示例中成交额列用占位写法,实际应改为 (chunk["price"] * chunk["volume"]).groupby(chunk["code"]).sum() 之类的向量化计算,避免逐行 iterrows 带来的性能损失。

判断块大小是否合适,可以观察两个信号:一是处理过程中进程内存是否稳定在一条水平线附近(稳定说明流式生效);二是总耗时随 chunksize 变化的曲线是否出现明显拐点。若内存随迭代持续上升,通常说明某处把每块结果都存进了列表,需要检查累加逻辑。

常见误区与调优顺序

第一个误区是把 chunksize 当成万能药,却仍在循环里执行 all_chunks.append(chunk),这等于把全量数据又拼了回来,内存不降反升。第二个误区是忽略 skiprows、nrows 与 chunksize 的配合:若只需抽样验证,用 nrows 更直接;若要跳过表头说明行,skiprows 应在读取器创建时一次设定。第三个误区是块内做全量排序,排序需要把整块载入并复制,块越大代价越高。

合理的调优顺序是:先加 dtype 和 usecols 压缩单块体积,再确定聚合方式保证结果不膨胀,最后才在 5 万、10 万、20 万等几个档位上做小规模对比,选出内存平稳且耗时较低的值。对逐笔 CSV 批量任务而言,流式读取配合块内归约,通常能把内存占用从“随文件线性增长”变为“与文件大小无关”,这才是 chunksize 真正的价值所在。