爬虫数据高效存储——Parquet 格式与增量写入实战

爬虫数据量大了以后,CSV 和 JSON 有几个硬伤------文件太大、读写慢、不支持列式压缩。Parquet 是大数据生态中最主流的列式存储格式,文件体积小、读写速度快、自带 schema,非常适合存储大规模爬虫数据。

一、CSV vs JSON vs Parquet

对比 CSV JSON Parquet
100万行体积 ~80MB ~120MB ~15MB 🏆
读写速度 慢(全表扫描) 慢(全量解析) 快(列式读取) 🏆
类型支持 全是字符串 部分支持 完整支持 🏆
压缩率 一般 极高(Snappy/ZSTD) 🏆
可读性 二进制,不可直接看

简单说: 爬几百条数据用 CSV/JSON 没问题。每天爬几万条以上,上 Parquet 能省 80% 的磁盘空间。

二、安装

bash 复制代码
pip install pandas pyarrow  # pyarrow 提供 Parquet 支持

三、写入 Parquet

python 复制代码
import pandas as pd
import os

# 1. 爬虫数据(DataFrame)
df = pd.DataFrame({
    "title": ["iPhone 15", "华为 Mate 60", "小米 14"],
    "price": [6999, 5999, 3999],
    "brand": ["Apple", "华为", "小米"],
    "stock": [100, 50, 200],
    "crawl_time": pd.Timestamp.now(),
})

# 2. 保存为 Parquet
df.to_parquet(
    "products.parquet",
    engine="pyarrow",
    compression="snappy",  # 压缩算法,速度和压缩比的平衡
    index=False,
)

# 查看文件大小
size = os.path.getsize("products.parquet")
print(f"Parquet 文件大小: {size / 1024:.1f} KB")

# CSV 对比(同样数据)
df.to_csv("products.csv", index=False)
csv_size = os.path.getsize("products.csv")
print(f"CSV 文件大小: {csv_size / 1024:.1f} KB")

四、读取 Parquet

python 复制代码
# 读取
df = pd.read_parquet("products.parquet", engine="pyarrow")
print(df.head())

# 只读取指定列(列式存储的优势)
df = pd.read_parquet("products.parquet", columns=["title", "price"])
print(df.head())

五、增量写入------每天追加新数据

爬虫每天跑一次,需要把新数据追加到已有的 Parquet 文件中。Parquet 本身不可变,但可以用分区合并的方式实现增量。

1. 按天分区(推荐)

python 复制代码
from datetime import datetime
import os

def save_daily_data(df, base_dir="crawled_data"):
    """
    按天分区存储,每天一个 Parquet 文件
    这样每天只需写入当天的文件,不需要合并历史数据
    """
    today = datetime.now().strftime("%Y-%m-%d")
    day_dir = os.path.join(base_dir, today)
    os.makedirs(day_dir, exist_ok=True)

    file_path = os.path.join(day_dir, "data.parquet")
    df.to_parquet(file_path, engine="pyarrow", compression="snappy", index=False)
    print(f"已保存: {file_path} ({len(df)} 条)")

# 使用
df = crawl_today_data()
save_daily_data(df)

目录结构:

复制代码
crawled_data/
├── 2026-06-01/
│   └── data.parquet
├── 2026-06-02/
│   └── data.parquet
├── 2026-06-03/
│   └── data.parquet
└── ...

2. 合并到历史文件

如果不想分区,可以每天合并到同一个大文件:

python 复制代码
def append_to_parquet(new_df, file_path):
    """
    增量追加到 Parquet 文件
    """
    if os.path.exists(file_path):
        # 1. 读取历史数据
        old_df = pd.read_parquet(file_path)

        # 2. 合并
        combined = pd.concat([old_df, new_df], ignore_index=True)

        # 3. 去重(按标题去重,保留最新的)
        combined = combined.drop_duplicates(subset=["title"], keep="last")
    else:
        combined = new_df

    # 4. 写回
    combined.to_parquet(file_path, engine="pyarrow", compression="snappy", index=False)

    print(f"写入完成,共 {len(combined)} 条 (新增 {len(new_df)} 条)")

3. 生产环境推荐方案

python 复制代码
import pandas as pd
import os
from datetime import datetime
import hashlib

class ParquetCrawlerStorage:
    """基于 Parquet 的爬虫存储(增量写入 + 去重)"""

    def __init__(self, base_dir="crawled_data"):
        self.base_dir = base_dir
        os.makedirs(base_dir, exist_ok=True)
        self.file_path = os.path.join(base_dir, "all_data.parquet")

    def save_batch(self, items: list[dict]):
        """批量保存爬虫数据"""
        new_df = pd.DataFrame(items)

        # 添加爬取时间
        new_df["crawl_time"] = datetime.now()

        # 添加内容指纹(用于去重)
        new_df["_fingerprint"] = new_df.apply(
            lambda row: hashlib.md5(
                str(row.to_dict()).encode()
            ).hexdigest(),
            axis=1
        )

        # 增量合并
        if os.path.exists(self.file_path):
            old_df = pd.read_parquet(self.file_path)
            df = pd.concat([old_df, new_df], ignore_index=True)

            # 按指纹去重,保留最新
            df = df.drop_duplicates(subset=["_fingerprint"], keep="last")
        else:
            df = new_df

        # 保存(去掉指纹列)
        df = df.drop(columns=["_fingerprint"])
        df.to_parquet(self.file_path, engine="pyarrow", compression="snappy", index=False)

        return len(new_df)

    def query(self, **filters):
        """查询数据"""
        df = pd.read_parquet(self.file_path)

        for col, val in filters.items():
            if col in df.columns:
                df = df[df[col] == val]

        return df

    def stats(self):
        """数据统计"""
        if not os.path.exists(self.file_path):
            return {"total": 0}

        df = pd.read_parquet(self.file_path)
        return {
            "total": len(df),
            "columns": list(df.columns),
            "file_size_mb": os.path.getsize(self.file_path) / 1024 / 1024,
        }

# 使用
storage = ParquetCrawlerStorage("products_data")

# 每天爬完调用
items = [{"title": "iPhone 15", "price": 6999}]
storage.save_batch(items)

# 查询
df = storage.query(brand="Apple")
print(df.head())

# 查看统计
print(storage.stats())

六、Parquet 文件读取性能

python 复制代码
import time

# 假设有一个 500MB 的 CSV 文件和一个同数据的 Parquet 文件

# CSV:全量读取
start = time.time()
df_csv = pd.read_csv("large_data.csv")
print(f"CSV 读取: {time.time() - start:.2f}s")

# Parquet:全量读取
start = time.time()
df_pq = pd.read_parquet("large_data.parquet")
print(f"Parquet 读取: {time.time() - start:.2f}s")

# Parquet:只读两列(列式剪枝,速度更快)
start = time.time()
df_pq_part = pd.read_parquet("large_data.parquet", columns=["title", "price"])
print(f"Parquet 部分读取: {time.time() - start:.2f}s")

百万行数据测试结果参考:

复制代码
CSV 读取: 12.5s
Parquet 全量: 2.3s
Parquet 部分列: 0.2s

七、Parquet 与数据分析工具

python 复制代码
# 按品牌分组统计
df = pd.read_parquet("products.parquet")
print(df.groupby("brand")["price"].agg(["count", "mean", "max"]))

# 存储空间对比
# 原始 DataFrame → CSV: 82MB
#                  → Parquet(snappy): 15MB (缩小 82%)
#                  → Parquet(zstd): 12MB (缩小 85%)

八、什么时候用 Parquet

数据量 推荐格式 原因
< 1万条 CSV/JSON 简单,肉眼可读
1万~10万 CSV 或 Parquet 两种都可,看习惯
10万~100万 Parquet 体积小 80%,读写快 5 倍
> 100万 Parquet + 分区 列式存储 + 分区裁剪

总结

复制代码
CSV → 小数据量,直接看
JSON → 接口数据交换
Parquet → 大规模存储和分析

如果数据量已经上万,建议转到 Parquet。代码改动很小(to_csv 改成 to_parquet),省下的磁盘和读取时间非常可观。


💡 觉得有用的话,点赞 + 关注【张老师技术栈】吧!每周更新 Java/Python/爬虫 实战干货,不让你白来。