爬完别忘了存:社交媒体快照上 OSS/S3 的 4 个必做优化

简介: 本文分享网页快照低成本存储实战:针对大数据快照存储场景,通过HTML压缩(gzip)、内容哈希去重、Key分区防热点、冷热分层(标准→低频→归档)四大优化,将月存储成本从¥79降至¥4,降幅达95%,综合节省至原方案1/20。

去年帮一个客户做品牌舆情留存,要每天把 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 天滚动、月存储费稳定在个位数。方案不神秘,难的是一开始就把"存储"当成架构问题而不是事后补救。

相关文章
|
2月前
|
数据采集 机器学习/深度学习 运维
从“秒封”到“日爬十万”:谈谈5个风控机制
这篇文档讨论了Python爬虫常见问题和反爬策略。作者提出五个关键点:1. 控制请求频率;2. 轮换IP;3. 伪装请求头;4. 模拟真实访问路径;5. 使用高匿名代理。这些策略需综合运用,提高爬虫生存率。
404 5
|
8月前
|
数据采集 文字识别 JavaScript
基于文本检测的 Python 爬虫弹窗图片定位与拖动实现
基于文本检测的 Python 爬虫弹窗图片定位与拖动实现
|
9月前
|
数据采集 Web App开发 调度
我为什么彻底切到Playwright
本文分享从Puppeteer迁移到Playwright的实战经验,详解架构升级动因、模块重构与核心代码。Playwright凭借更强的隔离性、原生反检测支持、简洁代理配置及多浏览器兼容,彻底解决Puppeteer时代资源争抢、稳定性差等痛点,助力构建高可用、易维护的现代数据系统。
381 1
|
10月前
|
数据采集 JSON 文字识别
图像与视频页面的数据提取
随着小红书、抖音等视觉平台崛起,传统采集难以应对图像视频内容。本文详解多模态采集架构:通过OCR识别图文、关键帧抽取视频信息,结合元数据融合,实现对视觉内容的精准理解与结构化提取,推动数据采集从“抓取”迈向“认知”。
638 7
|
11月前
|
数据采集 弹性计算 Kubernetes
单机扛不住,我把爬虫搬上了 Kubernetes:弹性伸缩与成本优化的实战
本文讲述了作者在大规模爬虫项目中遇到的挑战,包括任务堆积、高失败率和成本失控。通过将爬虫项目迁移到Kubernetes并使用HPA自动伸缩、代理池隔离和Redis队列,作者成功解决了这些问题,提高了性能,降低了成本,并实现了系统的弹性伸缩。最终,作者通过这次改造学到了性能、代理隔离和成本控制的重要性。
333 2
单机扛不住,我把爬虫搬上了 Kubernetes:弹性伸缩与成本优化的实战
|
11月前
|
数据采集 NoSQL 数据可视化
用Playwright打造可靠的企业级采集方案--从单机验证到集群化落地
本项目将单机Playwright爬虫逐步演进为分布式集群,解决脚本不稳定、限速、维护难等问题。以招聘数据采集为例,实现从页面解析、代理IP轮换、Redis任务队列到多机并发的完整链路,结合MongoDB/Elasticsearch落库与可视化,形成可复用的生产级爬虫架构,适用于数据分析、岗位监控等场景。
748 0
用Playwright打造可靠的企业级采集方案--从单机验证到集群化落地
|
4月前
|
数据采集 Rust NoSQL
架构视角下的千万级分布式爬虫:Rust + Reqwest 与代理网关的全局设计
本文探讨如何用Rust重构分布式爬虫Worker节点,解决高并发下的内存泄漏、CPU瓶颈与代理调度难题;结合Tokio、Reqwest与企业级隧道代理,实现千万级实时抓取的稳定、安全与高效。
341 2
|
5月前
|
数据采集 网络协议 Java
爬虫踩坑实录:OkHttp 接入爬虫代理报 Too many tunnel connections attempted 深度解析
本文深入解析 OkHttp 使用隧道代理抓取 HTTPS 网站时频发的 `ProtocolException: Too many tunnel connections attempted: 21` 错误,揭示其根源在于风控触发 302 重定向后 OkHttp 盲目重试隧道连接。通过关闭 `followRedirects(false)` 和 `followSslRedirects(false)`,两行配置即可优雅破局,精准捕获拦截响应,提升爬虫稳定性与调试效率。
324 2
|
5月前
|
数据采集 Web App开发 监控
极速上手:Puppeteer + 原生代理IP (金融与突发新闻抓取 Cheat Sheet)
本文介绍金融与新闻高频爬虫的实战方案:用 `puppeteer-extra` + `stealth` 插件隐藏自动化指纹,结合高匿代理IP轮换,实现秒级资讯采集。含完整配置、优化代码及生产避坑指南。
312 4
|
5月前
|
数据采集 自然语言处理 监控
拒绝“数据断层”:高质量舆情分析背后的隐形功臣——动态节点池
在AI与大数据时代,社交媒体数据是舆情监控、情感分析的核心资产。但再精妙的NLP模型也难逃“垃圾进、垃圾出”——数据断层导致的幸存者偏差,常源于爬虫被限流封禁。本文揭示动态代理IP池如何保障数据时序完整性、提升并发吞吐、规避风控,附可落地的Python实战代码,强调:稳定的数据管道,才是最高级的ROI。
610 4