告别 ELK 账单刺客:用 ClickHouse + 隧道代理搭建百亿级爬虫观测底座
去年年中,我们团队负责的一套分布式爬虫集群单日抓取量突破了 8000 万次。随之而来的不是业务增长的喜悦,而是基础设施层面的全面告警。 当时为了排查目标站的风控阻断和代理链路抖动,我们搭建了一套常规的 ELK 集群,同时将请求产生的 HTML 页面快照存放在对象存储中。跑了不到两个月,痛点集中爆发: 1. **Elasticsearch 成为账单刺客**:海量爬虫日志把 8 节点 ES 集群的 JVM 堆内存和 SSD 磁盘迅速吃满,倒排索引膨胀严重,单月机器预算直接超标。 2. **关系库写入性能见底**:早期的部分核心元数据放在 MySQL,单表过亿后,高并发写入导致连接池经常被打满,B+ 树索引频繁分裂带来严重的 IO 抖动。 3. **日志与页面快照割裂**:排查一次反爬改版或解析异常,需要先在日志系统查到请求 ID,再去对象存储拉取对应的 HTML 快照,跨系统关联耗时费力。 在经历了两次线上复盘后,我们对整个采集链路的可观测架构做了重构,将日志度量数据与页面快照统一收拢至 **ClickHouse**。实测下来,整体存储成本下降了近 75%,单机写入吞吐和多维聚合查询性能也得到了数量级的提升。 本文结合这套方案的设计细节,分享我们在日志压缩、批量落盘、TTL 分层管理以及结合高并发隧道代理的完整实践。 ## 一、主流存储方案的横向对标 在爬虫工程中,日志和快照数据具备非常典型的特征:写入吞吐要求极高、极少单条更新、强时间局部性、分析查询多为大范围聚合,且 HTML 快照包含大量重复的文本结构。 我们在选型阶段针对几种主流方案做了实测对比: | 评估维度 | MySQL / PostgreSQL | MongoDB | Elasticsearch (ELK) | ClickHouse | | :--- | :--- | :--- | :--- | :--- | | **单节点写入吞吐** | 5k ~ 2w 行/秒 | 2w ~ 5w 行/秒 | 1w ~ 3w 行/秒(吃 CPU/内存) | **15w ~ 50w 行/秒(批量追加)** | | **快照压缩比** | 1.3:1 ~ 2:1 | 2: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 快照本身,也包含大量的重复标签(如 ``、``、`` 及 CSS 类名)。 行存数据库将整行数据打包在一起,无法利用字段间的数据相似性。ClickHouse 将同一列的数据连续存放: * 对于低基数字符串,使用 `LowCardinality(String)` 字典编码; * 对于时间戳与耗时浮点数,使用 `DoubleDelta` 和 `Gorilla` 编码; * 对于 HTML 快照长文本列,配置 `CODEC(ZSTD(6))`。 在我们真实的测试集中,1TB 原始抓取日志与 HTML 快照,落盘后仅占用约 130GB 空间,综合压缩比达到 7.7:1。 ### 2. MergeTree 追加写支撑高并发数据流 ClickHouse 的 `MergeTree` 引擎只进行顺序追加写(Append-only),并在后台异步合并数据分片(Parts)。只要客户端做好批量聚合(例如每 1000~5000 条或每 2~3 秒提交一次),单台普通的 8 核 16G 机器即可稳定承载 20 万行/秒的写入压力,不会发生行级锁竞争。 ### 3. 原生 TTL 自动化分层与数据生命周期 爬虫排障具有明显的时效特征: * 最近 3~7 天的数据:高频访问,用于线上告警排查、反爬拦截分析及解析模板纠错; * 7 天以上的数据:访问频率极低,仅用于离线统计与样本回溯。 ClickHouse 支持在建表时直接指定 TTL 策略,无需额外维护定时清理脚本: ```sql 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 建表设计 ```sql 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 的可执行完整代码: ```python # -*- 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. 实时分析各站点的抓取成功率与耗时分位数 ```sql 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 结构: ```sql -- 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,形成可快速回溯的观测闭环。