爬完别忘了存:社交媒体快照上 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 天滚动、月存储费稳定在个位数。方案不神秘,难的是一开始就把"存储"当成架构问题而不是事后补救。

相关文章
|
Kubernetes 网络协议 Linux
Softether VPN 打通 Kubernetes 调试网络
通过 Softether VPN 打通 Kubernetes 调试网络,其中包括无拆分隧道限制的版本,可以自定义推送路由
2823 2
|
网络安全 网络协议 网络架构
如何配置阿里云服务器防火墙?
虽然请求进去了,但是没有响应,我改了接口了,为何会这样,百思不得其解,幸好高人指点迷津。
29274 1
|
安全 Shell API
蜻蜓:GitLab结合fortify实现自动化代码扫描实践
在甲方做安全的同学可能会有一项代码审计的工作,通常需要从gitlab把代码拉取下来,然后使用代码审计工具进行扫描,然后对结果进行人工确认; 在这个流程中需要做的事情比较繁琐,比如说gitlab如何配置token、如何自动化把代码拉取到本地、如何调用fortify实现批量扫描等诸多繁琐问题。 本篇文章以甲方安全代码安全建设为主线,分享如何让代码审计工具自动化扫描gitlab仓库里的代码。并且提供了一个便捷的实验环境供大家测试。
1356 145
蜻蜓:GitLab结合fortify实现自动化代码扫描实践
|
XML 前端开发 小程序
PDF转Word完全指南:3大方法满足各种场景!
还不知道PDF怎么转Word吗,本文将提供完整的PDF转Word方案,包括离线、在线或者SDK API等各种方式,总有一款满足您的需求。
1407 0
|
SQL 运维 DataWorks
DataWorks数据服务介绍及最佳实践 | 《一站式大数据开发治理DataWorks使用宝典》
DataWorks作为一站式大数据开发治理平台,构建了从数据集成、数据开发、数据服务到应用开发的全链路解决方案。在整个大数据链路中,数据服务将数仓、数据库和数据应用进行串联,形成了一座数据与应用之间的桥梁。数据服务通过将数据封装成数据API的方式,可以为个人、团队及企业提供全面的数据开放及共享能力。借助这个平台,用户能够统一管理面向内外部的API服务。数据服务提供了向下对接数据源、向上支撑业务应用的有效连接。
4474 1
DataWorks数据服务介绍及最佳实践 | 《一站式大数据开发治理DataWorks使用宝典》
|
JavaScript 前端开发 CDN
vue项目中使用CDN引入
vue项目中使用CDN引入
1902 0
vue项目中使用CDN引入
|
Web App开发 文字识别 负载均衡
RPA 流程梳理和适用场景以及控制台功能展示(一)|学习笔记
快速学习 RPA 流程梳理和适用场景以及控制台功能展示(一)
1525 0
RPA 流程梳理和适用场景以及控制台功能展示(一)|学习笔记
|
SQL 缓存 监控
【巡检问题分析与最佳实践】RDS MySQL 内存使用问题
实例内存使用率和buffer pool命中率是RDS MySQL的关键指标之一,如果内存使用率过高会有OOM风险,如果buffer pool命中率低,大量的数据页无法命中buffer pool中缓存的数据页,需要从存储读取数据,造成IO吞吐增加和延迟增加。
【巡检问题分析与最佳实践】RDS MySQL 内存使用问题
|
存储 Kubernetes 文件存储
阿里云ACK服务使用Windows容器挂载NAS SMB最佳实践
前一篇容器文章《Windows容器使用阿里云NAS SMB文件系统做持久化存储目录》介绍了在Windows Docker容器中如何连接阿里云NAS SMB文件卷。本文则着重介绍如何使用K8S配置让阿里云ACK服务的Windows容器使用NAS SMB卷。 我们使用IIS应用作为演示应用,让IIS搭建的网站能够显示出NAS SMB卷的test目录下存储的index.html的内容。 用户可以举一反三,将自己的应用搭建在阿里云ACK上并使用NAS SMB卷。
4915 0
阿里云ACK服务使用Windows容器挂载NAS SMB最佳实践
优酷背后的大数据秘密
大家好,我是门德亮,现在在优酷数据中台做数据相关的事情。很荣幸,我正好见证了优酷从没有MaxCompute到有的这样一个历程,因为刚刚好我就是入职优酷差不多5年的时间,我们正好是在快到5年的时候,去做了从Hadoop到MaxCompute的这样一个升级。
23515 5