最近在开发者社区掀起了一股"十亿行挑战"(1BRC)的热潮,这个挑战要求参与者处理一个包含十亿行气象站温度数据的文本文件(约13.8GB),为每个站点计算最小值、平均值和最大值。作为一名长期使用Python的数据工程师,我决定接受这个挑战,看看Python在这种极限场景下的表现如何。
十亿行挑战的核心规则相当明确:
我的测试环境是一台16英寸M3 Pro Macbook Pro,配备12核CPU和36GB内存。为了确保结果可靠,每个实现我都运行了5次并取平均值。
最直接的实现方式是单线程逐行读取文件,使用字典记录每个站点的统计信息:
python复制def process_file(file_name: str):
result = dict()
with open(file_name, "rb") as f:
for line in f:
location, measurement = line.split(b";")
measurement = float(measurement)
if location not in result:
result[location] = [
measurement, # min
measurement, # max
measurement, # sum
1, # count
]
else:
_result = result[location]
_result[0] = min(_result[0], measurement)
_result[1] = max(_result[1], measurement)
_result[2] += measurement
_result[3] += 1
# 输出结果...
这个实现简单直接,但性能显然不会太好。在我的测试中,处理完整数据集需要约10分钟。
注意:使用二进制模式('rb')读取文件比文本模式稍快,因为避免了编码解码的开销。对于纯ASCII数据,这是安全的优化。
为了利用多核CPU,我们可以将文件分割成多个块,并行处理:
python复制import os
import multiprocessing as mp
def get_file_chunks(file_name: str, max_cpu: int = 8):
"""将文件分割为适合多进程处理的块"""
cpu_count = min(max_cpu, mp.cpu_count())
file_size = os.path.getsize(file_name)
chunk_size = file_size // cpu_count
start_end = []
with open(file_name, "r+b") as f:
chunk_start = 0
while chunk_start < file_size:
chunk_end = min(file_size, chunk_start + chunk_size)
# 确保块结束在行边界
while chunk_end > chunk_start:
f.seek(chunk_end)
if f.read(1) == b"\n":
break
chunk_end -= 1
start_end.append((file_name, chunk_start, chunk_end))
chunk_start = chunk_end + 1
return cpu_count, start_end
def _process_file_chunk(file_name, chunk_start, chunk_end):
"""处理单个文件块的函数"""
result = {}
with open(file_name, "rb") as f:
f.seek(chunk_start)
while f.tell() < chunk_end:
line = f.readline()
# 处理逻辑与单核版本相同...
return result
def process_file(cpu_count, start_end):
"""主处理函数"""
with mp.Pool(cpu_count) as pool:
chunk_results = pool.starmap(_process_file_chunk, start_end)
# 合并各块结果...
多核实现将处理时间从10分钟缩短到约2分钟,提升显著。关键在于:
PyPy是Python的即时编译实现,对纯Python代码通常有显著加速效果。我们只需用PyPy解释器运行相同的多核代码:
bash复制pypy multiprocessing_impl.py
PyPy版本将处理时间进一步缩短到约90秒,比CPython快约33%。PyPy的优势在于:
实测心得:PyPy对纯Python代码优化效果显著,但如果使用了C扩展模块,可能反而会变慢。在这个挑战中,由于只使用标准库,PyPy是最简单的优化手段。
虽然使用第三方库违反官方规则,但实际工作中我们完全可以利用这些工具。我测试了四个主流数据处理库的表现。
Pandas是最流行的数据分析库,虽然不以速度见长:
python复制import pandas as pd
df = pd.read_csv("data/measurements.txt", sep=";",
header=None, names=["station", "temp"],
engine="pyarrow") # 使用PyArrow引擎加速
result = df.groupby("station").agg(["min", "mean", "max"])
# 格式化输出...
Pandas实现耗时约45秒,比纯Python快很多,但内存占用较高。关键点:
Polars是基于Rust的多线程DataFrame库:
python复制import polars as pl
df = pl.scan_csv("data/measurements.txt", separator=";",
has_header=False, new_columns=["station", "temp"])
.group_by("station")
.agg([
pl.min("temp").alias("min"),
pl.mean("temp").alias("mean"),
pl.max("temp").alias("max")
])
.sort("station")
.collect(streaming=True) # 流式处理降低内存占用
Polars仅用约15秒就完成了处理,优势在于:
DuckDB是嵌入式OLAP数据库,擅长分析查询:
python复制import duckdb
conn = duckdb.connect()
result = conn.execute("""
SELECT station,
min(temp) as min,
avg(temp) as mean,
max(temp) as max
FROM read_csv('data/measurements.txt',
delim=';',
header=false,
columns={'station':'VARCHAR','temp':'FLOAT'},
parallel=true)
GROUP BY station
ORDER BY station
""").fetchall()
DuckDB表现最佳,仅需约12秒。其优势包括:
各方案在13.8GB文本数据上的表现:
| 实现方式 | 耗时(秒) | 内存占用 | 代码复杂度 |
|---|---|---|---|
| 单核Python | ~600 | 低 | 简单 |
| 多核Python | ~120 | 中 | 复杂 |
| PyPy多核 | ~90 | 中 | 复杂 |
| Pandas | ~45 | 高 | 简单 |
| Polars | ~15 | 中 | 中等 |
| DuckDB | ~12 | 低 | 简单 |
从结果看,DuckDB和Polars是性能最好的解决方案,而Pandas在易用性和生态系统方面仍有优势。
原始文本格式效率低下,我们可以将数据转换为列式存储的Parquet格式:
python复制# 转换脚本
import pandas as pd
df = pd.read_csv("measurements.txt", sep=";", header=None,
names=["station", "temp"], engine="pyarrow")
df.to_parquet("measurements.parquet", compression="zstd")
转换后文件从13.8GB缩小到2.5GB。各库处理Parquet的表现:
| 库 | 文本格式(秒) | Parquet格式(秒) | 提升 |
|---|---|---|---|
| Pandas | 45 | 30 | 33% |
| Polars | 15 | 8 | 47% |
| DuckDB | 12 | 3.8 | 68% |
DuckDB处理Parquet仅需3.8秒,比处理文本快3倍多!Parquet的优势:
处理大数据时,内存是关键限制因素:
Polars示例:
python复制# 流式处理避免内存溢出
df = pl.scan_csv("data.txt").group_by("station").agg(
pl.col("temp").min().alias("min"),
# ...
).collect(streaming=True)
问题1:内存不足错误
SET memory_limit='16GB'问题2:性能不如预期
问题3:结果不正确
经过全面测试,我的结论是:
Python可能永远无法在纯性能上击败Java等编译语言,但通过合理选择工具链,我们完全可以在保证开发效率的同时,获得足够好的性能表现。对于大多数实际应用场景,3.8秒处理十亿行数据已经绰绰有余。
最后的小技巧:DuckDB可以直接查询远程文件,这对于分析存储在云存储中的数据特别方便:
python复制# 直接查询S3上的Parquet文件
duckdb.sql("""
SELECT * FROM read_parquet('s3://bucket/data.parquet')
WHERE temperature > 30
""")