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 × 单行字节数 × 放大系数。逐笔数据单行通常在 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 当成万能药,却仍在循环里执行 all_chunks.append(chunk),这等于把全量数据又拼了回来,内存不降反升。第二个误区是忽略 skiprows、nrows 与 chunksize 的配合:若只需抽样验证,用 nrows 更直接;若要跳过表头说明行,skiprows 应在读取器创建时一次设定。第三个误区是块内做全量排序,排序需要把整块载入并复制,块越大代价越高。
合理的调优顺序是:先加 dtype 和 usecols 压缩单块体积,再确定聚合方式保证结果不膨胀,最后才在 5 万、10 万、20 万等几个档位上做小规模对比,选出内存平稳且耗时较低的值。对逐笔 CSV 批量任务而言,流式读取配合块内归约,通常能把内存占用从“随文件线性增长”变为“与文件大小无关”,这才是 chunksize 真正的价值所在。