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

一、为什么爬虫团队迟早都要面对"数据堆成山"
每个爬虫项目开始时都很轻巧:写个脚本,跑通几个页面,把结果塞进一张表。前两周一切正常,查询飞快,磁盘空闲。
问题出在第三周以后。
一旦你接上了正规代理、把采集做成"长期任务",数据量会超出大多数人的直觉。以我做财经舆情采集的经验为例,单是一个标的每天的新增新闻、公告、研报就能有几百条,正文 + 元数据存下来,一个月就是几十 GB。半年之后,那张主表轻轻松松破亿行。
这时候会出现三个典型症状:
- 热查询变慢:你只想查"今天抓到的数据",数据库却要扫整张大表。
- 存储账单变贵:热存储(高 IOPS 的关系库)按容量收费,存三年历史数据等于一直为"几乎不读"的数据付溢价。
- 备份和恢复变重:每次备份都要带着那 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 成本上升但体积收益递减。
七、踩坑与最佳实践
- 归档是破坏性操作,先写后删 。永远先把冷层文件落盘并校验,再从热层
DELETE。不要反过来,也不要在没备份时直接rm冷文件。 - 给冷层建一份 manifest 。每个 Parquet 文件名带上时间范围(如
crawl_2026-08-17.parquet),再维护一个索引文件记录"哪个时间段在哪个文件",回暖时就能先定位、再读取,不必全扫。 - 保留窗口要和业务对齐 。热窗口不是越短越好------如果你每天要看"近 45 天趋势",那就把
HOT_RETENTION_DAYS设成 45,而不是拍脑袋定 30。 - 代理层要做超时与重试封装 。隧道代理虽然自动换 IP,但单请求仍可能超时;务必给
requests加timeout和退避重试,避免把"半截脏数据"写进热层。 - 合规留存期优先于一切优化。金融、舆情类数据常有合规留存要求,冷热分离是"挪地方存",不是"少存",别为了省空间把该留的也清了。
八、总结
冷热分离不是什么高深架构,它只是承认了一个朴素事实:数据被访问的频率从来都不均匀。把"天天用"的和"三年用一次"的放在同一种存储里,既浪费钱又拖慢系统。
而它和代理的关系很微妙:像隧道代理这种"一次配置、自动换 IP"的产品,把采集门槛降到了极低,于是你天然会去跑长年累月的采集------数据堆得快,恰恰说明你更需要一套像样的冷热分层来接住它。