聊聊数据的“冷热分离“:如何对历史爬虫数据进行合理的归档与压缩存储?

作者按:这是一篇写给爬虫工程师和数据处理同学的实战笔记。我们不谈虚的,直接从"数据是怎么堆成山的"讲起,再给一套能直接抄走的代码。文中的代理部分基于亿牛云的隧道代理产品,代码全部带中文注释。

一、为什么爬虫团队迟早都要面对"数据堆成山"

每个爬虫项目开始时都很轻巧:写个脚本,跑通几个页面,把结果塞进一张表。前两周一切正常,查询飞快,磁盘空闲。

问题出在第三周以后。

一旦你接上了正规代理、把采集做成"长期任务",数据量会超出大多数人的直觉。以我做财经舆情采集的经验为例,单是一个标的每天的新增新闻、公告、研报就能有几百条,正文 + 元数据存下来,一个月就是几十 GB。半年之后,那张主表轻轻松松破亿行。

这时候会出现三个典型症状:

  1. 热查询变慢:你只想查"今天抓到的数据",数据库却要扫整张大表。
  2. 存储账单变贵:热存储(高 IOPS 的关系库)按容量收费,存三年历史数据等于一直为"几乎不读"的数据付溢价。
  3. 备份和恢复变重:每次备份都要带着那 90% 几乎不访问的历史数据一起走。

这三个症状,本质上说的是同一件事------你的数据不是均匀的,它们天然分成了"热"和"冷"。把它们一视同仁地放在一起,是工程上的偷懒,迟早要还。

二、什么是冷热分离

一句话:按"被访问的频率"把数据分层存放,热数据追求快,冷数据追求省。

维度 热数据(Hot) 冷数据(Cold)
时间范围 最近 N 天(如 30 天) N 天之前的所有历史
访问频率 高,日常查询/可视化都打它 极低,仅在回溯、复盘、训练时偶发访问
存储介质 关系库 / 高速 NoSQL 列式文件(Parquet)+ 对象存储 / NAS
核心诉求 低延迟读写 高压缩比、低成本长期留存
可接受延迟 毫秒级 秒级甚至分钟级都行

关键认知:冷数据不是"垃圾数据",它往往比热数据更有长期价值(训练语料、合规留存、历史回溯),只是"现在用不到"。所以我们的目标不是删掉它,而是用更便宜的方式把它"妥善冻存"。

三、先看看数据是怎么被"生产"出来的

要谈归档,得先谈采集。这里必须点名一个现实:很多时候数据堆得快,是因为代理太好用了。

我长期用爬虫代理(隧道版)。它的工作方式是------你只需要在请求里配置一次代理地址,之后每一次请求的出口 IP 都在后台自动轮换,业务侧完全不用自己维护 IP 池、不用写换 IP 逻辑、不用处理 IP 失效:

python 复制代码
# 亿牛云隧道代理:一次配置,出口 IP 自动轮换
PROXY_HOST = "proxy.16yun.cn"   # 理服务器域名
PROXY_PORT = "3100"             # 隧道端口(以控制台实际分配为准)
PROXY_USER = "16YUNxxxx"        # 账号用户名
PROXY_PASS = "xxxxxxxx"         # 账号密码

# requests 标准代理字典格式:http://用户名:密码@域名:端口
PROXIES = {
    "http":  f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}",
    "https": f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}",
}

这种"无感采集"恰恰是冷热分离最好的理由:隧道代理把采集成本压到极低,于是你天然会去跑长期、大规模的采集任务,数据自然分层。 换言之,代理解决了"能不能持续抓"的问题,而冷热分离解决的是"抓了一年后怎么办"的问题------它们是同一套工程的两端。

补充:还有"优质代理"(短效 IP 列表)产品线,适合需要自己精细控制 IP 切换节奏的场景。本文的主线用隧道版即可,因为它最省心,也最能说明"数据为何会快速堆积"。

四、分层架构设计

一套最小可用的冷热分离架构长这样:

plain 复制代码
采集层 ──(隧道代理)──▶ 热层 (SQLite/PostgreSQL, 仅最近 N 天)
                                      │
                         定时归档任务 (每天凌晨)
                                      ▼
                              冷层 (Parquet + zstd, 对象存储/NAS)
                                      ▲
                          查询网关:热查热层,冷查冷层,必要时"回暖"

三个组件职责清晰:

  • 热层:承接实时写入和高频查询,只留最近窗口的数据,保持"小而快"。
  • 归档任务:独立于采集,按天跑,把超出窗口的数据搬去冷层并压缩,再从热层清理。
  • 冷层:列式存储 + 高压缩,几乎不花钱,但随时能读回来。

五、实战代码(可直接运行)

下面给一套完整的可运行示例。依赖只有两个:requests(采集)、pyarrow(写 Parquet)。

bash 复制代码
pip install requests pyarrow
# 定时任务用到 schedule(可选):pip install schedule

5.1 接入隧道代理,抓取并封装记录

python 复制代码
"""基于隧道代理的爬虫数据冷热分离示例。"""
from __future__ import annotations

import sqlite3
import time
from dataclasses import dataclass, asdict
from datetime import datetime, timedelta

import requests

# ============================================================
# 1) 亿牛云隧道代理配置
#    隧道代理特点:一次配置,出口 IP 自动轮换,
#    业务侧无需自己维护 IP 池,适合长期大规模采集。
# ============================================================
PROXY_HOST = "proxy.16yun.cn"   # 代理服务器域名
PROXY_PORT = "3100"             # 隧道端口(以控制台实际分配为准)
PROXY_USER = "16YUNxxxx"        # 账号用户名
PROXY_PASS = "xxxxxxxx"         # 账号密码

# requests 标准代理字典格式:http://用户名:密码@域名:端口
PROXIES: dict[str, str] = {
    "http":  f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}",
    "https": f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}",
}


@dataclass
class CrawledRecord:
    """一条爬虫落地数据。"""
    url: str
    title: str
    content: str
    crawled_at: str  # ISO 格式时间字符串


def fetch_page(target_url: str) -> CrawledRecord:
    """通过亿牛云隧道代理抓取页面,并封装为热层记录。

    隧道代理会自动切换出口 IP,这里只需把 PROXIES 透传给 requests,
    不需要任何手动换 IP 的逻辑,也不用担心单个 IP 被目标站封禁。
    """
    resp = requests.get(
        target_url,
        proxies=PROXIES,          # 关键:所有请求经由亿牛云隧道转发
        timeout=10,
        headers={"User-Agent": "Mozilla/5.0"},
    )
    resp.raise_for_status()
    # 简化演示:真实场景请用 readability / parsel 抽取正文
    return CrawledRecord(
        url=target_url,
        title=resp.url,
        content=resp.text[:5000],                 # 截断,避免单条过大
        crawled_at=datetime.now().isoformat(timespec="seconds"),
    )

5.2 热层写入:只保留最近窗口的数据

python 复制代码
# ============================================================
# 2) 热层:最近数据落在 SQLite(生产可平滑换成 PostgreSQL)
#    热层追求写入/查询的低延迟,只保留最近 N 天。
# ============================================================
HOT_DB = "hot_crawl.db"
HOT_RETENTION_DAYS = 30  # 热数据保留窗口(天)


def init_hot_db(conn: sqlite3.Connection) -> None:
    """建表:热层只存最近窗口内的数据。"""
    conn.execute(
        """CREATE TABLE IF NOT EXISTS hot_records (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            url         TEXT UNIQUE,   -- 用 URL 去重,避免重复抓取污染热数据
            title       TEXT,
            content     TEXT,
            crawled_at  TEXT
        )"""
    )
    conn.commit()


def save_hot(conn: sqlite3.Connection, rec: CrawledRecord) -> None:
    """写入热层;URL 冲突则忽略(INSERT OR IGNORE)。"""
    conn.execute(
        "INSERT OR IGNORE INTO hot_records(url, title, content, crawled_at) "
        "VALUES (:url, :title, :content, :crawled_at)",
        asdict(rec),
    )
    conn.commit()

5.3 归档任务:迁移 + 压缩为 Parquet(zstd)

python 复制代码
# ============================================================
# 3) 冷层归档:把超过保留窗口的数据导出为压缩 Parquet,并从热层删除。
#    冷层追求存储成本与压缩比,查询频率极低。
# ============================================================
import os
import pyarrow as pa
import pyarrow.parquet as pq

COLD_DIR = "cold_archive"  # 冷层目录(生产可换成对象存储挂载路径)


def archive_cold(conn: sqlite3.Connection, batch_date: str) -> str:
    """将热层中早于保留窗口的记录导出为压缩 Parquet,再从热层清理。

    Args:
        conn: 热层数据库连接。
        batch_date: 归档批次日期,用作文件名,如 2026-08-17。
    Returns:
        生成的冷层文件路径;若没有需要归档的数据返回空字符串。

    安全性:先写冷层并落盘,确认成功后再删热层,避免丢数据。
    """
    cutoff = (datetime.now() - timedelta(days=HOT_RETENTION_DAYS)).isoformat(timespec="seconds")
    rows = conn.execute(
        "SELECT url, title, content, crawled_at FROM hot_records WHERE crawled_at < ?",
        (cutoff,),
    ).fetchall()

    if not rows:
        return ""  # 没有需要归档的数据,直接返回

    # 转成列式结构,Parquet 对列式数据压缩效率最高
    table = pa.table(
        {
            "url":        [r[0] for r in rows],
            "title":      [r[1] for r in rows],
            "content":    [r[2] for r in rows],
            "crawled_at": [r[3] for r in rows],
        }
    )

    os.makedirs(COLD_DIR, exist_ok=True)
    out_path = os.path.join(COLD_DIR, f"crawl_{batch_date}.parquet")
    # zstd:压缩比高、解压极快,且级别可调,非常适合冷数据长期留存
    pq.write_table(table, out_path, compression="zstd", compression_level=3)

    # 归档文件已落盘,再从热层清理(先写后删,杜绝数据丢失)
    conn.execute("DELETE FROM hot_records WHERE crawled_at < ?", (cutoff,))
    conn.commit()
    return out_path

5.4 冷数据按需"回暖"

python 复制代码
# ============================================================
# 4) 冷数据按需回暖:按时间范围扫描归档文件,供临时分析/回溯。
# ============================================================
def warm_up(start_date: str, end_date: str) -> list[dict]:
    """扫描冷层 Parquet,按 crawled_at 范围过滤并返回记录。

    冷层查询频率低,逐文件扫描即可;数据量大时可加日期分区或索引 manifest。
    """
    result: list[dict] = []
    if not os.path.isdir(COLD_DIR):
        return result
    for fname in os.listdir(COLD_DIR):
        if not fname.endswith(".parquet"):
            continue
        table = pq.read_table(os.path.join(COLD_DIR, fname))
        df = table.to_pandas()
        masked = df[(df["crawled_at"] >= start_date) & (df["crawled_at"] <= end_date)]
        result.extend(masked.to_dict("records"))
    return result

5.5 串起来:抓取 → 存热层 → 定时归档

python 复制代码
# ============================================================
# 5) 串联示例:先抓一条存热层,再手动触发一次归档。
#    生产环境用 schedule / crontab / Airflow 把 daily_job 定时跑起来。
# ============================================================
def daily_job() -> None:
    """每天凌晨执行的归档任务。"""
    today = datetime.now().strftime("%Y-%m-%d")
    with sqlite3.connect(HOT_DB) as conn:
        init_hot_db(conn)
        path = archive_cold(conn, today)
        print(f"[{today}] 归档完成:{path or '无新增冷数据'}")


if __name__ == "__main__":
    # 演示:抓一条 → 存热层 → 触发归档
    with sqlite3.connect(HOT_DB) as conn:
        init_hot_db(conn)
        rec = fetch_page("https://example.com")
        save_hot(conn, rec)
    daily_job()

    # 常驻定时模式(需 pip install schedule):
    # import schedule
    # schedule.every().day.at("03:00").do(daily_job)
    # while True:
    #     schedule.run_pending()
    #     time.sleep(60)

六、压缩选型:为什么选 zstd

冷层的核心指标是压缩比 × 解压速度。常见列式压缩对文本型爬虫数据的表现大致如下(定性,具体看数据):

算法 压缩比 压缩速度 解压速度 适用场景
gzip 高(~3--4x) 通用,但不如 zstd 灵活
snappy 中(~2x) 纯追求速度、不在意体积
zstd 高(~3--5x,可调) 中--快 极快 冷数据长期留存首选

我选 zstd 的理由:爬虫正文是高度重复的文本,zstd 的压缩比很能打;而当你某天要"回暖"一批历史数据做复盘时,zstd 的解压速度几乎是即时的,不会拖垮分析任务。级别设 3 是性价比甜点------再往上压,CPU 成本上升但体积收益递减。

七、踩坑与最佳实践

  1. 归档是破坏性操作,先写后删 。永远先把冷层文件落盘并校验,再从热层 DELETE。不要反过来,也不要在没备份时直接 rm 冷文件。
  2. 给冷层建一份 manifest 。每个 Parquet 文件名带上时间范围(如 crawl_2026-08-17.parquet),再维护一个索引文件记录"哪个时间段在哪个文件",回暖时就能先定位、再读取,不必全扫。
  3. 保留窗口要和业务对齐 。热窗口不是越短越好------如果你每天要看"近 45 天趋势",那就把 HOT_RETENTION_DAYS 设成 45,而不是拍脑袋定 30。
  4. 代理层要做超时与重试封装 。隧道代理虽然自动换 IP,但单请求仍可能超时;务必给 requeststimeout 和退避重试,避免把"半截脏数据"写进热层。
  5. 合规留存期优先于一切优化。金融、舆情类数据常有合规留存要求,冷热分离是"挪地方存",不是"少存",别为了省空间把该留的也清了。

八、总结

冷热分离不是什么高深架构,它只是承认了一个朴素事实:数据被访问的频率从来都不均匀。把"天天用"的和"三年用一次"的放在同一种存储里,既浪费钱又拖慢系统。

而它和代理的关系很微妙:像隧道代理这种"一次配置、自动换 IP"的产品,把采集门槛降到了极低,于是你天然会去跑长年累月的采集------数据堆得快,恰恰说明你更需要一套像样的冷热分层来接住它。

相关推荐
袁袁袁袁满1 小时前
Python爬虫实战:利用巨量IP获取跨境电商数据
爬虫·python·网络爬虫·爬虫实战·跨境电商·电商爬虫
拿本唠嗑AI研究3 小时前
现代前端架构爬虫指南:从静态页面到 React/Vue 单页应用
前端·爬虫·架构
一个处女座的程序猿21 小时前
Crawler之Tool:MediaCrawler的简介、安装和使用方法、案例应用之详细攻略
爬虫·mediacrawler
深蓝电商API1 天前
Hook Canvas 获取浏览器指纹
爬虫·hook canvas
深蓝电商API1 天前
Hook Crypto API 获取加密参数
爬虫·hook crypto api
在世修行2 天前
3D轮廓仪智能定位激光打标上位机:系统整体架构设计深度解析
上位机·架构设计·plc通信·3d轮廓仪·激光打标
瓦学妹2 天前
Wikimon Scraper:用 Python 构建数据爬虫
开发语言·爬虫·python
零域码客2 天前
从 SQLite 到 PostgreSQL:轻量单机到分布式架构选型、避坑与迁移全解析
分布式·postgresql·sqlite·wal·架构设计·后端开发·数据库选型
小白学大数据3 天前
长周期爬虫的数据一致性:断点续爬 + 事务回滚保障采集质量
开发语言·爬虫·测试工具