前言
先纠正一个常见误解:"大规模"没有绝对标准,它是相对于你手上这台机器的内存而言的。对一台 8 GB 内存的笔记本,2 GB 的 CSV 就已经是"大规模"——因为你不能把它一次性读成 DataFrame;而对一个有几十个节点的集群,同样 2 GB 的数据连热身都算不上。
所以本文不吹"用 Python 处理大数据",而是给出一个诚实的判断框架:当数据放不进内存时,有哪几条路可选,每条路的代价是什么,以及什么时候根本不需要上分布式框架。
一个必须点破的事实:分布式框架不是免费午餐。引入 Dask 或 Spark 意味着额外的调度开销、序列化开销、调试难度和部署成本。如果数据只有几百 MB,用它们往往比直接用标准库慢。先量数据规模,再选工具。
本机没有 Python 解释器和任何第三方库,代码只能逐行人工推演,不声称运行过。
一、先量清楚:你到底有多大
动手之前先回答三个问题:
- 数据总量是多少(压缩前 / 解压后)?
- 单机可用内存是多少(要留出至少一半余量给中间结果)?
- 是"必须全量加载"还是"可以逐批处理"?
大多数分析任务属于第三类的后一种:求计数、求和、均值、去重、过滤、分组聚合——这些都能流式(streaming)完成,不需要把全部数据同时放在内存里。真正必须全量加载的场景是:需要对整个数据集排序、需要随机访问、需要多轮迭代的算法。
判断标准很简单:能不能只遍历一遍数据就得到答案?能,就走流式;不能,再考虑加内存或换引擎。
| 数据规模 | 推荐路径 | 代价 |
|---|
| 远小于内存 | 直接读进内存最省事 | 无 |
| 接近或略超内存 | 分块处理、生成器流水线 | 需要改写成流式写法 |
| 显著超过内存 | 换引擎(Dask / Spark 等)或上更多内存 | 部署与调试成本明显上升 |
二、第一选择:分块与生成器
分块(chunking)的思路是:不一次读完,而是每次读一部分,处理完就释放。pandas 的read_csv有chunksize参数,返回一个可迭代对象,每次产出一个较小的 DataFrame。用法思路是:对每个块做聚合,把结果累积到一个小的中间结构里,最后合并。具体参数名与返回类型以 pandas 官方文档为准。
标准库这边的 CSV 读取天然就是流式的:csv.reader是一个迭代器,逐行产出列表,不需要把整个文件读进内存。
# 适用于 Python 3.8+
import csv
from collections import defaultdict
def sum_by_category(path, key_col, value_col):
"""流式分组求和:只保留聚合结果,不保留原始行。"""
totals = defaultdict(float)
counts = defaultdict(int)
# newline="" 是 csv 模块的要求,否则换行符处理会出问题
with open(path, "r", encoding="utf-8", newline="") as f:
reader = csv.reader(f)
header = next(reader) # 跳过表头
key_idx = header.index(key_col)
val_idx = header.index(value_col)
for row in reader:
if len(row) <= max(key_idx, val_idx):
continue # 跳过残缺行
key = row[key_idx]
try:
value = float(row[val_idx])
except ValueError:
continue # 跳过无法解析的单元格
totals[key] += value
counts[key] += 1
return {k: totals[k] / counts[k] for k in totals}
if __name__ == "__main__":
# 演示调用;实际运行需存在该 CSV 文件
result = sum_by_category("data.csv", "category", "amount")
for k in sorted(result):
print(k, round(result[k], 2))这段代码的内存占用与文件大小无关:无论文件是 1 MB 还是 10 GB,驻留内存只取决于分组的类别数。这就是流式的价值。
生成器(generator)是构造流式管道的工具。用yield逐条产出记录,把"读取 → 清洗 → 过滤 → 聚合"拆成若干个小函数,每个都是惰性的。好处是:管道里没有任何一个环节持有全部数据,而且环节之间可以独立调试。
# 适用于 Python 3.8+
import csv
def read_rows(path, encoding="utf-8"):
"""逐行产出字典,惰性求值。"""
with open(path, "r", encoding=encoding, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
yield row
def keep_valid(rows, required):
"""过滤掉关键字段为空的行。"""
for row in rows:
if all(row.get(col) for col in required):
yield row
def to_number(rows, col):
"""把指定列转成浮点数,转换失败的行被丢弃。"""
for row in rows:
try:
yield float(row[col])
except (TypeError, ValueError):
continue
# 管道:每一步都是生成器,只有最后一步才真正驱动计算
pipeline = to_number(keep_valid(read_rows("data.csv"), ["amount"]), "amount")
total = 0.0
count = 0
for value in pipeline:
total += value
count += 1
print("均值:", total / count if count else None)注意生成器只能遍历一次。上面pipeline一旦被for消费,就空了;想再算一遍必须重新构造管道。这是最常见的误用。
三、第二选择:换引擎
当分块也解决不了(比如需要全局排序、需要多轮迭代的机器学习训练),才考虑专门的大数据引擎。
Dask的思路是"并行化的 pandas / numpy":它把大数据切成很多小块,用任务图调度,接口尽量贴近 pandas,学习成本相对低。适合"本来用 pandas 写,但数据放不下"的场景。
Spark是更重的一套:独立集群、独立的执行模型、需要序列化与调度。它适合数据量确实很大、且已经有集群资源的场景。
选型时要记住几条现实约束:
- 小数据上用分布式框架,几乎总是更慢,因为调度和序列化的固定开销摆在那里;
- 分布式框架的报错信息通常比单机难读,调试成本要高一个量级;
- 换引擎意味着重写代码,不是换个 import 就行;
- 先试着加内存、改数据类型(比如用更省的整数类型)、只加载需要的列,这些"土办法"经常能解决问题,而且改动最小。
具体 API、参数与部署方式以各框架官方文档为准。
四、GIL 与并发的边界
谈"大规模"绕不开并行。这里必须讲准,因为错误结论流传很广。
CPython 有全局解释器锁(GIL, Global Interpreter Lock),它保证同一进程内同一时刻只有一个线程执行 Python 字节码。由此推出两个结论:
- CPU 密集型任务用多线程没有加速效果——所有线程抢同一把锁,本质还是轮流跑。
- I/O 密集型任务用多线程有效——线程在等待网络或磁盘时会释放 GIL,别的线程可以继续跑。
CPU 密集要真正并行,得用多进程(multiprocessing),每个进程有独立的解释器与独立的 GIL;或者调用会释放 GIL 的 C 扩展;或者换语言实现。
还有一个必须澄清的说法:"Python 已经没有 GIL 了"是错的。CPython 从 3.13 起提供实验性的自由线程(no-GIL)构建,但它不是默认构建,需要单独安装,生态兼容性也还在推进中。默认的 CPython 依然有 GIL。
因此,对一个数据管道,"并行"的正确姿势通常是:多进程读不同的分片文件,每个进程内部仍是流式处理;而不是开一堆线程去读同一个文件。
五、把原则落到清单上
处理超内存数据时的检查顺序:
- 先量总量与内存,确认是不是真的超了;
- 能不能改成单遍流式?能就别折腾;
- 必须全量时,能不能只加载需要的列、只保留必要的类型?
- 还不行,才考虑分块 + 外部排序 / 中间结果落盘;
- 最后才考虑引第三方引擎,并评估它带来的复杂度。
常见坑点
- 不量数据规模就上分布式框架。
❌ 几百 MB 的数据直接上 Spark 集群,还觉得"这样才专业"。 ✅ 先量总量,能用标准库或单机 pandas 解决就别引入调度开销。
- 把"大规模"当成绝对概念。
❌ 认为"没上分布式就等于没做大数据"。 ✅ 认清"大规模"是相对于可用内存而言的,同一个文件在不同机器上是不同量级。
- 生成器被消费后重复遍历。
❌ 用一个生成器表达式算完总和后,又拿它去算平均值,得到空结果。 ✅ 生成器只能遍历一次;需要多次统计时重新构造管道,或显式落成列表(但要评估内存)。
- 一次性把大文件读进列表。
❌rows = list(csv.reader(f)),把整个文件驻留内存。 ✅ 直接迭代 reader 本身,或写成生成器管道,只保留聚合结果。
- 认为多线程能加速 CPU 密集任务。
❌ 用ThreadPoolExecutor去做大量纯计算,期待线性提速。 ✅ 受 GIL 限制,CPU 密集要用多进程或释放 GIL 的原生扩展;多线程的收益在 I/O 等待上。
- 断言"Python 已经没有 GIL"。
❌ 因为听说过 no-GIL 就说 GIL 已经消失。 ✅ CPython 3.13 起只有实验性自由线程构建,默认构建仍然有 GIL。
open不写encoding。
❌ 在跨平台场景下用默认编码读数据文件,得到乱码或解析失败。 ✅ 显式指定encoding="utf-8"(或数据真实的编码),读 CSV 还要加newline=""。
- 忽视中间结果的落盘策略。
❌ 每一步都把所有中间结果留在内存里,管道走到一半内存爆掉。 ✅ 只在必要时保留聚合结果;需要多轮使用时把中间结果写到磁盘,用文件换内存。
总结
| 场景 | 推荐做法 | 关键提醒 |
|---|
| 数据远小于内存 | 直接加载 | 别为了"专业"引入框架 |
| 可单遍流式完成 | 生成器管道 /csv.reader迭代 | 生成器只能遍历一次 |
| 略超内存 | 分块处理,只保留聚合值 | 内存占用与文件大小解耦 |
| 远超内存 | 换引擎(Dask / Spark)或加内存 | 有部署与调试成本,需权衡 |
| 需要并行 | CPU 密集用多进程,I/O 密集用多线程 | 默认 CPython 仍有 GIL |
这条路上最实用的态度是克制:先确认规模,再尝试把写法改成流式,最后才谈换引擎。绝大多数所谓"Python 处理不了的大数据",其实是被一次性加载的写法卡住的,而不是 Python 本身不行。所有第三方库的具体参数与行为,请以各自官方文档的当前版本为准。