告别 ELK 账单刺客:用 ClickHouse + 隧道代理搭建百亿级爬虫观测底座

20 阅读10分钟

内容图片.jpg

去年年中,我们团队负责的一套分布式爬虫集群单日抓取量突破了 8000 万次。随之而来的不是业务增长的喜悦,而是基础设施层面的全面告警。

当时为了排查目标站的风控阻断和代理链路抖动,我们搭建了一套常规的 ELK 集群,同时将请求产生的 HTML 页面快照存放在对象存储中。跑了不到两个月,痛点集中爆发:

  1. Elasticsearch 成为账单刺客:海量爬虫日志把 8 节点 ES 集群的 JVM 堆内存和 SSD 磁盘迅速吃满,倒排索引膨胀严重,单月机器预算直接超标。
  2. 关系库写入性能见底:早期的部分核心元数据放在 MySQL,单表过亿后,高并发写入导致连接池经常被打满,B+ 树索引频繁分裂带来严重的 IO 抖动。
  3. 日志与页面快照割裂:排查一次反爬改版或解析异常,需要先在日志系统查到请求 ID,再去对象存储拉取对应的 HTML 快照,跨系统关联耗时费力。

在经历了两次线上复盘后,我们对整个采集链路的可观测架构做了重构,将日志度量数据与页面快照统一收拢至 ClickHouse。实测下来,整体存储成本下降了近 75%,单机写入吞吐和多维聚合查询性能也得到了数量级的提升。

本文结合这套方案的设计细节,分享我们在日志压缩、批量落盘、TTL 分层管理以及结合高并发隧道代理的完整实践。


一、主流存储方案的横向对标

在爬虫工程中,日志和快照数据具备非常典型的特征:写入吞吐要求极高、极少单条更新、强时间局部性、分析查询多为大范围聚合,且 HTML 快照包含大量重复的文本结构。

我们在选型阶段针对几种主流方案做了实测对比:

评估维度MySQL / PostgreSQLMongoDBElasticsearch (ELK)ClickHouse
单节点写入吞吐5k ~ 2w 行/秒2w ~ 5w 行/秒1w ~ 3w 行/秒(吃 CPU/内存)15w ~ 50w 行/秒(批量追加)
快照压缩比1.3:1 ~ 2:12:1 ~ 3:1约 1.5:1(索引开销大)6:1 ~ 10:1(ZSTD 列式压缩)
百亿级聚合延迟分钟级甚至超时秒级到十秒级秒级(受限于堆内存)亚秒级(SIMD 向量化执行)
冷热生命周期依赖业务分表与外置清理基础 TTL 索引(删除慢)ILM 生命周期策略原生表级/列级 TTL + 分层卷挂载
硬件成本较高(依赖高 IOPS)中等高昂(大内存 + SSD 标配)低(高压缩 + 支持 HDD/对象存储冷归档)

关系型数据库无法应付千万级以上的日志追加,Elasticsearch 的倒排索引在非全文检索场景下纯属资源浪费,而 ClickHouse 的列式存储与分析型引擎正好覆盖了爬虫监控的痛点。


二、ClickHouse 契合爬虫观测的关键特性

1. 列式存储与 ZSTD 高效压缩

爬虫日志中存在大量低基数或高重复字段,比如请求方法、状态码、目标域名、出口 IP、异常分类等。即便是 HTML 快照本身,也包含大量的重复标签(如 <div>、<span>、<script> 及 CSS 类名)。

行存数据库将整行数据打包在一起,无法利用字段间的数据相似性。ClickHouse 将同一列的数据连续存放:

  • 对于低基数字符串,使用 LowCardinality(String) 字典编码;
  • 对于时间戳与耗时浮点数,使用 DoubleDelta 和 Gorilla 编码;
  • 对于 HTML 快照长文本列,配置 CODEC(ZSTD(6))。

在我们真实的测试集中,1TB 原始抓取日志与 HTML 快照,落盘后仅占用约 130GB 空间,综合压缩比达到 7.7:1。

2. MergeTree 追加写支撑高并发数据流

ClickHouse 的 MergeTree 引擎只进行顺序追加写(Append-only),并在后台异步合并数据分片(Parts)。只要客户端做好批量聚合(例如每 10005000 条或每 23 秒提交一次),单台普通的 8 核 16G 机器即可稳定承载 20 万行/秒的写入压力,不会发生行级锁竞争。

3. 原生 TTL 自动化分层与数据生命周期

爬虫排障具有明显的时效特征:

  • 最近 3~7 天的数据:高频访问,用于线上告警排查、反爬拦截分析及解析模板纠错;
  • 7 天以上的数据:访问频率极低,仅用于离线统计与样本回溯。

ClickHouse 支持在建表时直接指定 TTL 策略,无需额外维护定时清理脚本:

ALTER TABLE crawler_dw.crawler_access_log
MODIFY TTL created_at + INTERVAL 7 DAY TO VOLUME 'cold_disks',
          created_at + INTERVAL 30 DAY DELETE;

三、生产实战:ClickHouse + 亿牛云隧道代理 采集与落盘

在大规模采集任务中,高并发网络请求通常依托于稳定的隧道代理服务。在本次方案中,我们选用 亿牛云爬虫代理(隧道版) 作为出口代理基础设施。

亿牛云隧道代理通过统一的网关入口自动在云端轮换高匿出口 IP,爬虫客户端无需维护本地 IP 池,直接在 Python 异步协程中发起请求,并同步完成链路耗时度量与 ClickHouse 批量落盘。

1. ClickHouse DDL 建表设计

CREATE DATABASE IF NOT EXISTS crawler_dw;

CREATE TABLE IF NOT EXISTS crawler_dw.crawler_access_log
(
    request_id       UUID DEFAULT generateUUIDv4(),
    task_name        LowCardinality(String),              -- 任务名称(字典编码)
    target_domain    LowCardinality(String),              -- 目标域名(字典编码)
    target_url       String,                              -- 完整URL
    proxy_provider   LowCardinality(String) DEFAULT 'yiniuyun', -- 代理商标识
    proxy_ip         String,                              -- 代理出口IP
    http_status      UInt16 CODEC(T64, ZSTD(1)),          -- HTTP状态码
    tcp_handshake_ms Float32 CODEC(Gorilla, ZSTD(1)),     -- TCP建连耗时
    ttfb_ms          Float32 CODEC(Gorilla, ZSTD(1)),     -- 首字节到达耗时
    total_cost_ms    Float32 CODEC(Gorilla, ZSTD(1)),     -- 请求总耗时
    retry_count      UInt8 CODEC(T64, ZSTD(1)),           -- 重试次数
    is_success       UInt8 CODEC(T64, ZSTD(1)),           -- 业务成功标记(1/0)
    error_reason     LowCardinality(String),              -- 错误类型(timeout/403/block等)
    html_snapshot    String CODEC(ZSTD(6)),               -- HTML快照(ZSTD Level 6)
    snapshot_size    UInt32,                              -- 快照字节数
    created_at       DateTime DEFAULT now() CODEC(DoubleDelta, LZ4)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(created_at)
ORDER BY (task_name, target_domain, is_success, created_at)
SETTINGS index_granularity = 8192;

2. 异步数据采集与批量写入 Pipeline

以下为结合 aiohttp 与 clickhouse-connect 的可执行完整代码:

# -*- coding: utf-8 -*-
"""
ClickHouse 爬虫日志与快照存储方案
集成亿牛云隧道代理与异步批量写入器
"""

import asyncio
import time
import uuid
import aiohttp
from typing import List, Dict, Any
from clickhouse_connect import get_client

# 亿牛云爬虫代理(隧道版)配置信息
PROXY_HOST = "proxy.16yun.cn"    # 代理服务器域名
PROXY_PORT = "3100"              # 隧道端口
PROXY_USER = "16YUN_USER_DEMO"   # 账号用户名
PROXY_PASS = "16YUN_PASS_DEMO"   # 账号密码

# 构建标准 HTTP Basic 认证代理连接串
PROXY_URL = f"http://{PROXY_USER}:{PROXY_PASS}@{PROXY_HOST}:{PROXY_PORT}"

# ClickHouse 实例连接配置
CH_HOST = "127.0.0.1"
CH_PORT = 8123
CH_USER = "default"
CH_PASS = ""
CH_DATABASE = "crawler_dw"


class ClickHouseBatchWriter:
    """ClickHouse 异步批量写入缓冲区,平滑突发写入峰值"""
    def __init__(self, batch_size: int = 500, flush_interval: float = 2.0):
        self.batch_size = batch_size
        self.flush_interval = flush_interval
        self.buffer: List[Dict[str, Any]] = []
        self.lock = asyncio.Lock()
        self.ch_client = get_client(
            host=CH_HOST, 
            port=CH_PORT, 
            username=CH_USER, 
            password=CH_PASS, 
            database=CH_DATABASE
        )
        self.is_running = True

    async def add_log(self, record: Dict[str, Any]):
        async with self.lock:
            self.buffer.append(record)
            if len(self.buffer) >= self.batch_size:
                await self._flush_unlocked()

    async def _flush_unlocked(self):
        if not self.buffer:
            return
        records_to_insert = self.buffer.copy()
        self.buffer.clear()
        
        columns = list(records_to_insert[0].keys())
        data = [[r[col] for col in columns] for r in records_to_insert]
        
        try:
            await asyncio.to_thread(
                self.ch_client.insert,
                table="crawler_access_log",
                data=data,
                column_names=columns
            )
            print(f"[{time.strftime('%X')}] 写入 ClickHouse 成功:{len(records_to_insert)} 条记录")
        except Exception as e:
            print(f"ClickHouse 批量写入异常: {e}")

    async def start_auto_flush(self):
        """定时刷盘协程"""
        while self.is_running:
            await asyncio.sleep(self.flush_interval)
            async with self.lock:
                await self._flush_unlocked()

    async def close(self):
        self.is_running = False
        async with self.lock:
            await self._flush_unlocked()
        self.ch_client.close()


async def fetch_and_record(
    session: aiohttp.ClientSession,
    url: str,
    task_name: str,
    writer: ClickHouseBatchWriter
):
    """通过亿牛云隧道代理发起请求,度量耗时并记录页面快照"""
    start_time = time.perf_counter()
    ttfb_ms, total_ms = 0.0, 0.0
    status_code = 0
    html_content = ""
    error_reason = "none"
    is_success = 0
    proxy_ip = "auto_tunnel"

    try:
        req_start = time.perf_counter()
        async with session.get(
            url,
            proxy=PROXY_URL,
            timeout=aiohttp.ClientTimeout(total=10, connect=3)
        ) as resp:
            ttfb_ms = (time.perf_counter() - req_start) * 1000
            status_code = resp.status
            proxy_ip = resp.headers.get("X-Forwarded-For", "tunnel_auto")
            
            # 读取快照(截取前 512KB 避免异常大体积响应)
            raw_bytes = await resp.content.read(512 * 1024)
            html_content = raw_bytes.decode("utf-8", errors="ignore")
            total_ms = (time.perf_counter() - start_time) * 1000

            # 业务内容有效性三层校验
            if status_code == 200:
                block_keywords = ["验证码", "访问限制", "waf", "robot check", "滑块验证"]
                if any(kw in html_content.lower() for kw in block_keywords):
                    is_success = 0
                    error_reason = "anti_spider_blocked"
                else:
                    is_success = 1
                    error_reason = "none"
            elif status_code in (403, 429):
                is_success = 0
                error_reason = f"http_{status_code}_rate_limit"
            else:
                is_success = 0
                error_reason = f"http_{status_code}"

    except asyncio.TimeoutError:
        error_reason = "network_timeout"
        total_ms = (time.perf_counter() - start_time) * 1000
    except Exception as exc:
        error_reason = f"exception_{type(exc).__name__}"
        total_ms = (time.perf_counter() - start_time) * 1000

    record = {
        "request_id": str(uuid.uuid4()),
        "task_name": task_name,
        "target_domain": url.split("//")[-1].split("/")[0],
        "target_url": url,
        "proxy_provider": "yiniuyun",
        "proxy_ip": proxy_ip,
        "http_status": status_code,
        "tcp_handshake_ms": 0.0,
        "ttfb_ms": round(ttfb_ms, 2),
        "total_cost_ms": round(total_ms, 2),
        "retry_count": 0,
        "is_success": is_success,
        "error_reason": error_reason,
        "html_snapshot": html_content,
        "snapshot_size": len(html_content.encode("utf-8")),
    }

    await writer.add_log(record)


async def main():
    writer = ClickHouseBatchWriter(batch_size=50, flush_interval=2.0)
    flush_task = asyncio.create_task(writer.start_auto_flush())

    target_urls = [
        "https://httpbin.org/ip",
        "https://httpbin.org/headers",
        "https://httpbin.org/user-agent",
    ] * 15

    connector = aiohttp.TCPConnector(limit=50, ssl=False)
    async with aiohttp.ClientSession(connector=connector) as session:
        tasks = [
            fetch_and_record(session, url, "news_crawling_job", writer)
            for url in target_urls
        ]
        await asyncio.gather(*tasks)

    await writer.close()
    flush_task.cancel()


if __name__ == "__main__":
    asyncio.run(main())

四、排障实战:常用分析查询

统一收敛到 ClickHouse 后,日常的集群治理与风控溯源可以通过直接执行 SQL 完成。

1. 实时分析各站点的抓取成功率与耗时分位数

SELECT 
    target_domain,
    count() AS total_requests,
    round(countIf(is_success = 1) * 100.0 / count(), 2) AS success_rate_pct,
    round(quantile(0.50)(ttfb_ms), 1) AS p50_ttfb_ms,
    round(quantile(0.95)(ttfb_ms), 1) AS p95_ttfb_ms,
    round(quantile(0.99)(total_cost_ms), 1) AS p99_total_ms
FROM crawler_dw.crawler_access_log
WHERE created_at >= now() - INTERVAL 1 HOUR
GROUP BY target_domain
ORDER BY total_requests DESC;

在千万级日志记录下,该聚合查询在单机环境下通常在 30~60 毫秒 内返回结果。

2. 定位风控拦截并提取现场快照

当特定目标站成功率出现异常滑落时,可以快速下钻异常分布,并调取被拦截时的 HTML 结构:

-- 1. 统计最近 30 分钟拦截类型分布
SELECT 
    error_reason,
    count() AS fail_count,
    topK(3)(http_status) AS top_statuses
FROM crawler_dw.crawler_access_log
WHERE target_domain = 'target-ecommerce.com' 
  AND is_success = 0
  AND created_at >= now() - INTERVAL 30 MINUTE
GROUP BY error_reason;

-- 2. 提取最新一条被拦截的真实页面快照
SELECT 
    request_id,
    http_status,
    html_snapshot
FROM crawler_dw.crawler_access_log
WHERE target_domain = 'target-ecommerce.com'
  AND error_reason = 'anti_spider_blocked'
ORDER BY created_at DESC
LIMIT 1;

五、总结

ClickHouse 在大规模爬虫可观测场景下的核心价值,在于同时兼顾了高吞吐日志度量与高压缩长文本快照两种不同类型的数据存储需求:

  1. 架构收敛:避免了在生产环境中同时运维 ES、MySQL 与对象存储的多组件负担,降低了系统运维复杂度和排障链路。
  2. 存储成本可控:依靠列式存储与 ZSTD 压缩编码,将原本庞大的 HTML 快照存储体积压缩至原来的 15% 左右,配合 TTL 机制实现冷热自动流转。
  3. 结合隧道代理提升效率:在底层配合亿牛云隧道代理这类自动轮换出口 IP 的网络基础设施后,爬虫侧可以将重心放在采集逻辑与风控对抗上,实时将质量指标沉淀至 ClickHouse,形成可快速回溯的观测闭环。