去年帮一个客户做品牌舆情留存,要每天把 3 万条微博和小红书笔记的 HTML 原件全量存下来,保留 90 天,防止博主删帖改帖后证据丢失。第一版方案很简单:爬虫抓到什么就往 OSS 里塞什么。跑了三周我看了眼账单,然后看了眼代码,发现两件事都出了问题。这篇就聊怎么把"存网页快照"这件事的成本压下来,顺便把我们踩的坑和后来定的方案讲清楚。
一、先算一笔账:不优化的快照存储有多贵
问题不在抓取,在存储。我们第一版的逻辑是:原始 HTML 字符串直接 put_object,key 用 日期/平台/url_md5。看起来没问题,但一个月后复盘发现了三个很贵的细节。
第一,没压缩。一份小红书或微博笔记的 HTML 平均 250 KB(正文加图片占位再加内联评论 JSON,社交平台页面普遍偏重),3 万条一天就是 7.5 GB 原始文本,90 天滚动保留常驻 660 GB 以上。OSS 标准存储杭州区后付费单价约 ¥0.12/GB·月,光存储费一个月就 ¥79,还没算请求费和流量费。
第二,没有去重。笔记发布后正文和配图基本不动,但点赞、收藏、评论数每小时都在涨,导致完整 HTML 天天微变。我们对"内容原件"的定义是正文加图片,实时计数不算。抽掉计数节点后做哈希,全量日快照里约 41% 的笔记和前一天字节级完全一致(sha256 相同)。也就是说每天有 3.1 GB 是白存第二遍。
第三,存储类型选错了。快照是典型"写一次、几乎不读"的数据,只在舆情复盘或取证时翻出来。用标准存储等于给冷数据付热数据的钱。
把这三件事解决掉,月成本直接掉了一个量级。下面逐个说怎么改。
二、四个优化动作
1. 抓到就压缩,别等落盘再想起来
HTML 是高度冗余的文本,gzip 压缩比普遍在 5 倍以上,brotli 能到 7 倍。这一步零风险,因为 OSS/S3 都支持 Content-Encoding: gzip,下游读取时 SDK 自动解压,业务代码无感。
# 把 HTML 压成 gzip 字节流,压缩比通常 5~8 倍
import gzip
def compress_html(raw_html: str, level: int = 6) -> bytes:
"""level 取 6 是性价比拐点:再往上每加一级,体积只少一点点,CPU 却多烧不少。
一次性归档场景可以上 brotli -q11,日常抓取用 gzip -6 足够。"""
return gzip.compress(raw_html.encode("utf-8"), compresslevel=level)
2. 用内容哈希做 key,去重变成免费的
很多人的去重思路是"先查数据库有没有这条 URL 的记录",这要额外维护一张表。更干净的做法是内容寻址(content-addressable):对象的 key 直接取 HTML 的 sha256。相同内容永远落到同一个 key,上传前 object_exists 判断一下,存在就跳过。去重逻辑从"业务层"下沉到了"存储层"。
import hashlib
import time
def content_key(raw_html: str) -> str:
"""相同 HTML → 相同 key → 天然去重,连数据库都不用建。"""
h = hashlib.sha256(raw_html.encode("utf-8")).hexdigest()
# 分区前缀放前面,避免所有对象挤在同一个前缀下(见第 3 点)
day = time.strftime("%Y/%m/%d")
return f"snapshots/{day}/{h[:2]}/{h}"
3. key 分区,别让 OSS/S3 的单前缀热点坑了你
S3/OSS 底层按 key 的前缀做分区。如果你把所有快照都写成 snapshots/xxx,几亿个对象共享一个前缀,列表和批量操作会被限流。解决办法是在 key 里插入高基数的分布段,比如哈希前两位 {h[:2]},把对象均匀摊到 256 个逻辑分区。我们线上 90 天 270 万对象,用这个写法后列举操作的 503 错误从每天几十次降到零。
4. 冷热分层,快照本质是归档数据
快照 99% 的时间躺在存储里没人碰。OSS 的归档类型单价约 ¥0.033/GB·月,是标准存储的 1/4。但归档有取回延迟(分钟级到小时级),所以正确姿势是生命周期分层:热数据(最近 7 天)留标准,温数据(7~30 天)转低频,30 天以上的快照转归档。设置一次生命周期规则,平台自动迁移,不用代码干预。
# 阿里云 OSS 生命周期规则:按对象 age 自动降级存储类型
import oss2
def bind_lifecycle(bucket: oss2.Bucket):
"""配置后平台自动把老快照从标准→低频→归档,无需业务代码干预。"""
rule = oss2.models.LifecycleRule(
id="snapshot-tiering",
prefix="snapshots/",
status=oss2.models.LifecycleRule.ENABLED,
storage_transitions=[
oss2.models.StorageTransition(days=7, storage_class=oss2.BUCKET_STORAGE_CLASS_IA),
oss2.models.StorageTransition(days=30, storage_class=oss2.BUCKET_STORAGE_CLASS_ARCHIVE),
],
)
bucket.put_bucket_lifecycle(oss2.models.BucketLifecycle([rule]))
三、完整采集+存储骨架(隧道代理 + OSS)
下面是能直接跑的生产骨架。采集层走隧道代理,存储层走阿里云 OSS(换成 AWS S3 把 oss2 替成 boto3 即可,key 设计和压缩逻辑完全复用)。
# -*- coding: utf-8 -*-
"""网页快照采集存储一体化骨架
采集层:隧道代理(固定入口,云端自动调度出口 IP,IP 行为可控)
存储层:阿里云 OSS,gzip 压缩 + 内容哈希去重 + 生命周期冷热分层
场景:微博 / 小红书 笔记 HTML 原件留存,防删帖改帖导致证据丢失
"""
import asyncio
import gzip
import hashlib
import time
import aiohttp
import oss2
# ===== 1. 16YUN隧道代理配置 =====
# 固定入口地址,认证用「用户名(订单号):密码」,出口 IP 由云端 30 万+ 节点池自动调度
PROXY_HOST = "t.16yun.cn"
PROXY_PORT = "31111"
PROXY_USER = "your-username" # 控制台订单号
PROXY_PASS = "your-password" # 控制台密码
PROXY_URL = f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}"
# ===== 2. 阿里云 OSS 配置 =====
OSS_ENDPOINT = "https://oss-cn-hangzhou-internal.aliyuncs.com"
bucket = oss2.Bucket(oss2.Auth("ak-id", "ak-secret"), OSS_ENDPOINT, "web-snapshots")
def compress_html(raw_html: str, level: int = 6) -> bytes:
"""HTML 文本冗余度高,gzip -6 压缩比约 5.5 倍,下游 SDK 按 Content-Encoding 自动解压。"""
return gzip.compress(raw_html.encode("utf-8"), compresslevel=level)
def content_key(raw_html: str) -> str:
"""内容寻址 key:相同 HTML 恒等,自动去重;前缀插入哈希头两位做分区打散。
注意:社交平台笔记的实时计数(点赞/评论数)变动频繁,去重前应先抽掉计数节点,
只对「正文 + 图片」这部分稳定内容做哈希,否则几乎每条都会被判成『变了』。"""
h = hashlib.sha256(raw_html.encode("utf-8")).hexdigest()
day = time.strftime("%Y/%m/%d")
return f"snapshots/{day}/{h[:2]}/{h}"
async def fetch_and_store(url: str, session: aiohttp.ClientSession) -> str:
"""抓取单页 → 压缩 → 内容哈希去重 → 带元数据上传 OSS。"""
async with session.get(
url,
proxy=PROXY_URL,
# Proxy-Tunnel 绑定固定出口 IP:同博主/同平台快照采集 IP 稳定,避免触发风控导致缺页
proxy_headers={
"Proxy-Tunnel": "snapshot_crawler"},
timeout=aiohttp.ClientTimeout(total=20),
) as resp:
raw_html = await resp.text()
blob = compress_html(raw_html)
key = content_key(raw_html)
# 已存在说明内容没变,直接跳过,省一次存储写入和一份存储费
if bucket.object_exists(key):
return "dedup"
bucket.put_object(
key,
blob,
headers={
"Content-Encoding": "gzip", # 标记压缩,读取时自动解压
"x-oss-meta-source-url": url, # 原始 URL 写进元数据,回填时不用另建表
"x-oss-meta-fetched-at": str(int(time.time())),
},
)
return "stored"
async def crawl_all(urls: list, concurrency: int = 50):
"""异步高并发抓取。force_close 让每个请求断开 TCP,配合隧道代理自动换出口 IP。"""
sem = asyncio.Semaphore(concurrency)
connector = aiohttp.TCPConnector(limit=concurrency, force_close=True)
async def worker(u):
async with sem:
try:
return await fetch_and_store(u, session)
except Exception as e: # 单页失败不影响整体,缺页后续用差量补抓
print(f"失败 {u}: {e}")
return "error"
async with aiohttp.ClientSession(connector=connector) as session:
return await asyncio.gather(*[worker(u) for u in urls])
if __name__ == "__main__":
urls = [f"https://www.xiaohongshu.com/explore/{i}" for i in range(1000)]
results = asyncio.run(crawl_all(urls))
print(f"新增 {results.count('stored')} / 去重 {results.count('dedup')} / 失败 {results.count('error')}")
AWS S3 版本只需把存储部分替换:
import boto3
s3 = boto3.client("s3", endpoint_url="https://s3.amazonaws.com")
s3.put_object(
Bucket="web-snapshots",
Key=content_key(raw_html),
Body=compress_html(raw_html),
ContentEncoding="gzip",
Metadata={
"source-url": url, "fetched-at": str(int(time.time()))},
)
# 冷热分层用 S3 Lifecycle Configuration(Standard → Standard-IA → Glacier),逻辑与 OSS 一致
四、实测:压缩比和成本差多少
我们在生产环境抓了 10 万条社交平台笔记(原始 24 GB)做对比,结果如下。压缩和去重是真实测出来的,存储单价取阿里云 OSS 杭州区后付费公开参考价(会随官网调整,以控制台为准)。
压缩比实测(24 GB 原始 HTML,10 万条笔记样本):
| 算法 | 压缩后体积 | 压缩比 | 适用场景 |
|---|---|---|---|
| 不压缩 | 24.0 GB | 1.0x | 仅调试用,别上生产 |
| gzip -6 | 4.4 GB | 5.5x | 日常抓取,解压零依赖 |
| brotli -q11 | 3.4 GB | 7.0x | 一次性归档,压缩慢 3 倍但值得 |
90 天滚动常驻 120 GB(压缩后)的月存储费对比:
| 方案 | 常驻体积 | 月存储费 | 相对原始标准存储 |
|---|---|---|---|
| 原始 + 标准存储 | 660 GB | ¥79 | 基准 |
| gzip + 标准 | 120 GB | ¥14 | 省 82% |
| gzip + 归档(生命周期分层) | 120 GB | ¥4 | 省 95% |
再加去重:我们 41% 的日快照是重复内容,等于每天新增量再砍掉四成。综合下来,相比第一版"原样塞标准存储",月成本大约是原来的 1/20。省的不是小钱。
五、采集层:大规模快照为什么离不开隧道代理
存储优化解决的是"存得贵",但前提是"采得到"。日快照 10 万条以上,目标站的反爬会直接把你的出口 IP 按维度封掉,快照缺页比存储贵更致命,因为博主一旦删帖改帖,缺了的快照永远补不回来。
我们实测用的是16YUN隧道代理,选它的原因是这几项指标真的扛住了:
- 高去重率 IP 池。我们统计过,连续 1000 次请求里出口 IP 去重率 88.5%,意味着目标站很难靠 IP 维度锁定你。快照采集最怕的就是"同站几十万请求都从几个 IP 出去",那不是被封是迟早的事。
- 隧道架构省运维。固定入口
t.16yun.cn:31111,云端在 30 万+ 节点池里自动调度,失效 IP 毫秒级剔除。你不用自己写 IP 池健康检查、失效重试那套逻辑,代码量直接少一半。 - 并发韧性。我们压过:从 1 并发到 100 并发,成功率只掉 3 个百分点,100 并发下 TPS 42.8。日快照 10 万条,50~80 并发是性价比最高的区间。
- Proxy-Tunnel 固定 IP。文章开头代码里那个
Proxy-Tunnel: snapshot_crawler头就是干这个的:给快照采集绑定一个稳定出口,避免单页频繁换 IP 触发风控。多平台或多博主隔离时每个博主或每个平台一个 tunnel_id,故障爆炸半径恒等于 1。
如果你的量级还没到日 10 万条,普通动态住宅代理也够用;一旦要做长周期、大规模、不能缺页的快照留存,隧道代理的"固定入口加云端调度"基本是绕不开的架构。
结语
把网页快照存好,核心就三件事:抓到就压、用内容哈希去重、按冷热分层选存储类型。这三步不依赖任何高级组件,纯靠对象存储本身的能力就能把成本压到原来的二十分之一。采集层交给隧道代理处理 IP 调度,存储层交给 OSS/S3 的生命周期规则处理冷热迁移,业务代码反而最干净。
我们这套跑了大半年,3 万条笔记、90 天滚动、月存储费稳定在个位数。方案不神秘,难的是一开始就把"存储"当成架构问题而不是事后补救。