告别ELK:用ClickHouse+隧道代理打造低成本高并发爬虫观测底座 爬虫量级一旦冲上去最先崩溃的往往不是爬虫本身而是你用来“看”爬虫的那套系统。我这边集群从每天几百万请求涨到几亿之后第一波挨刀的就是 ELK。ES 集群天天报警磁盘从 3T 加到 8T内存顶着 90% 跑月底一看账单直接肉疼和“刺客”没什么区别。那段时间热词里全是“ELK 多少钱”“Loki 能不能替代”这类问题我自己的结论是别在 ELK 上死磕把链路换成 ClickHouse 隧道代理做观测底座存储成本降一个量级查询还更快。这篇文章就围绕这套方案展开内容包括架构怎么设计、隧道代理日志如何纳入观测、ClickHouse 表结构怎么写、从 ELK 迁移时踩了哪些坑以及最后的效果对比。适合正在被 ELK 成本和数据量双重折磨的爬虫工程师、数据平台开发以及所有想把日志成本打下来的朋友参考。1. 为什么告别 ELK账单刺客的三刀1.1 爬虫日志场景的 ELK 成本解剖很多团队上 ELK 的时候其实并没有认真算过爬虫日志这种数据的“性格”。爬虫产生的观测日志有几个明显特征条目数极大单条很小价值密度很低但保留周期又很长。这三条正好全部撞在 Elasticsearch 的枪口上。ES 本质是倒排索引 全文检索的引擎它为了支持快速搜索会把文本做分词、建倒排、存 fielddata这些操作会带来 2 到 4 倍的存储膨胀。加上默认至少一个副本实际物理存储往往是原始数据的 4 到 8 倍。做个简单计算每天 5 亿条访问日志单条约 300 字节原始数据 150GB。在 ES 里走一圈两个副本 倒排膨胀落到磁盘上就是 600GB 到 1TB。一个月下来 20TB 级别哪怕用冷存储也得掂量掂量。内存就更不用说了。ES 官方建议 JVM 堆不要超过 32GB但爬虫日志这种高基数场景keyword 字段一多堆外内存和 segment memory 照样吃满。我见过一台 64GB 内存的节点索引一多直接 OOM最后只能靠频繁重启续命。这不是 ES 不行是场景不合适。1.2 为什么替代品不是 Loki 而是 ClickHouse热词里很多人问“ELK 是否能使用 Loki 采集日志”我也认真试过 Loki。Loki 的设计哲学是“少索引只存日志内容”配合 Promtail 采集成本确实比 ES 低很多。但问题在于爬虫观测底座不只是查日志它要做状态码分布、按目标站点聚合成功率、按代理 IP 段统计延迟——这些都是典型的多维聚合分析。Loki 的 LogQL 在这方面非常吃力尤其是多维度 group by 加时间窗口聚合查一次慢得让人怀疑人生。它更适合 Kubernetes 容器日志的排查场景不太适合当分析型底座。ClickHouse 完全不一样。它是真正的列式存储每一列独立压缩ZSTD 压日志文本通常能到 5 到 10 倍压缩比。写入用的是 LSM-Tree 思路批量插入吞吐极高单机每秒轻松写几十万行。查询方面针对聚合场景做了向量化执行十亿行做一次 group by 也就几秒钟的事。简单说ES 是给“我要搜到那条日志”用的ClickHouse 是给“我要算这批日志的规律”用的。爬虫观测底座的核心是后者。1.3 一个便宜又够用的底座应该满足什么我说三个标准缺一不可。第一存储成本必须低因为爬虫日志要长期留档做回溯分析压缩比直接决定月底账单第二写入吞吐必须高爬虫高峰期的请求洪峰不能被日志链路拖死第三查询要能扛住多维聚合因为观测不只是翻日志还要按域名、代理、状态码、时段各种维度切。ClickHouse 在这三点上几乎是碾压级的适配。单机配置不用太高32GB 内存 2TB NVMe 就能扛住每天几亿条日志和 ES 动辄一个集群相比运维负担和硬件成本都降了一个量级。2. 整体架构设计从采集到观测2.1 观测底座的五层链路整套底座我把它拆成五层采集、缓冲、存储、计算、展示。采集层跑在每个爬虫实例上通过一个轻量级 sidecar 进程把请求日志、代理日志统一收集起来。缓冲层用 Kafka虽然有些人觉得小规模直接写 ClickHouse 也行但我坚持上 Kafka 的原因很简单它是天然的流量整形器爬虫高峰期几百万 QPS 的毛刺打过来ClickHouse 即使能扛住也不划算Kafka 缓冲一下让写入更平滑。存储层就是 ClickHouse 集群最初只有单机后来加了两个副本做高可用。计算层靠物化视图实时预聚合把原始明细数据变成分钟级、小时级的统计结果。展示层用 Grafana 接 ClickHouse 数据源再配一个简单的自研看板用于日常运营。这套链路看起来普通但每一层都有值得说的选择逻辑。比如采集层为什么要 sidecar 而不是直接打日志文件因为爬虫是 Python 多进程模型直接写文件容易互相干扰sidecar 通过 UDP 接收本地日志再批量转发对业务代码侵入极小。2.2 为什么数据要分两条流水线原始的请求日志、代理日志是第一条流水线也就是明细流水线。它的特点是海量、低频查询、高保留价值。第二条流水线是指标流水线把明细实时聚合出各种维度统计比如每个目标站点的请求量、成功率、平均响应时间。分两条流水线的原因很直接明细数据拿来排查单次问题指标数据拿来日常观察趋势。如果所有看板都直接查明细大表每次刷新都要扫几十亿行ClickHouse 再快也扛不住高并发看板。物化视图在 ClickHouse 里是写入时触发实时计算的数据进来就更新聚合表查询走预聚合表秒出结果这才是正确用法。我自己最初的失败经验是把所有看板都直接怼在明细表上导入历史数据后 Grafana 的 dashboard 一打开查询直接把 CPU 拉满还拖慢了正常写入。后来把核心指标全部迁移到物化视图聚合表时间从几十秒降到几百毫秒变化立竿见影。2.3 隧道代理在底座里的位置隧道代理做爬虫的人应该都熟它不是给每个请求换一个短暂 IP而是通过一个代理网关统一出口网关背后自动轮换短暂 IP。这种模式对目标站点来说看起来是一个固定出口 IP 段在访问但实际每次请求出口 IP 都在变。观测底座必须把这个环节当成一等公民对待因为代理的健康度直接决定爬虫的存活率。代理网关本身会产出一份非常宝贵的访问日志每一条都记录了端口、目标域名、出口 IP、返回状态码、延迟、字节数、响应时间。这份日志以前是散落在各台代理网关机器的目录里出了问题才去 grep效率极低。我们把代理网关日志通过 syslog 实时转发到 Kafka再汇入 ClickHouse从此代理质量完全透明。3. 隧道代理观测让每一笔代理费用都透明3.1 隧道代理的工作原理与核心指标隧道代理的本质是一个 L4/L7 层的转发服务。爬虫把请求发给隧道代理网关网关根据自身的 IP 池调度策略选择一个当前可用的短效 IP 作为出口访问目标站点拿到响应后再原路返回。这个过程中隧道服务商手里握着 IP 池的健康状态但作为使用者我们看到的只是一个网关地址对池子内部一无所知。所以观测的落点就得放在“我们能感知到的指标”上成功率请求有没有成功拿到响应、首字节时间代理链路延迟高不高、封禁率目标站点返回 403/429 的比例、会话复用率短效 IP 是否频繁断开、还有按目标站点拆分的错误分布。这些指标过去只能靠“感觉”——爬虫成功率掉了就换一批代理延迟高了就骂服务商。引入 ClickHouse 之后我可以直接拉一张盘某个代理端口在最近 15 分钟里针对不同目标域名的成功率和延迟曲线一眼定位是目标站点风控升级了还是代理 IP 池质量下降了。3.2 代理日志如何落进 ClickHouse代理日志的采集格式我要求统一为 JSON 行每个字段都是结构化的。采集端用 Vector 或者 Fluent Bit 都行只要能把日志解析成 JSON 后投递到 Kafka。Kafka 的主题按日志类型分proxy_access_log、crawler_request_log、ban_event_log。ClickHouse 建表的时候直接对应这几个主题建 MergeTree 表。核心字段大概是这样的timestamp 用 DateTime64(3)proxy_id 是网关端口标识target_domain 是目标站点域名exit_ip 是实际出口 IPstatus_code 是返回码latency_ms 是延迟毫秒数request_id 贯穿整个链路用于追踪region 是代理出口地区。把 region 加进去之后按地区维度排查质量问题方便很多因为某些地区的 IP 池就是不稳定。Python 爬虫侧接代理时我用的是 requests 加自定义适配器响应里会带上代理网关返回的 X-Proxy-IP 头把它取出放进日志字段里。这样可以精确知道每次请求走了哪个出口 IP排查封禁时非常有价值。import requests from requests.adapters import HTTPAdapter class ProxyAdapter(HTTPAdapter): def send(self, request, **kwargs): response super().send(request, **kwargs) request.extended_attrs { proxy_exit_ip: response.headers.get(X-Proxy-IP, ), latency_ms: round(response.elapsed.total_seconds() * 1000, 2), } return response采集侧把 request_id 和这些扩展属性拼成一行 JSON发到本地 sidecarsidecar 批量投递到 Kafka。整个过程对爬虫业务代码的改动很小却让每条请求都带上了代理出口 IP 和延迟数据这为后面“按代理维度分析”打下了基础。3.3 反爬事件的实时发现隧道代理有个特点IP 池是共享的一个目标站点如果同时对整个代理网段的 IP 段启用风控那么这一波 403/429 会同时出现在很多条记录里。这种事件如果靠人工巡检往往滞后十几分钟爬虫那边已经大面积失败了。用 ClickHouse 做实时反爬事件检测很简单。我建了一个物化视图按 1 分钟窗口聚合每个 target_domain 的状态码分布然后写一条查询去拉“最近 5 分钟某域名 403 占比超过 30%”的记录一旦有结果就触发告警SELECT target_domain, toStartOfMinute(timestamp) AS minute, countIf(status_code 403) AS cnt_403, count() AS total, countIf(status_code 403) / count() AS ban_ratio FROM proxy_access_log WHERE timestamp now() - INTERVAL 5 MINUTE GROUP BY target_domain, minute HAVING ban_ratio 0.3 ORDER BY ban_ratio DESC LIMIT 20;这个查询在几亿行的明细表上跑大约一两秒出结果。告警接入飞书机器人风控事件发生后几十秒内就能感知到然后人工决定切换代理策略、调整请求频率或者直接换目标数据源。这个能力在只有 ELK 的时期很难做到因为同样的查询在 ES 上要写很长的 DSL而且聚合性能差很多。4. ClickHouse 写入与存储优化实践4.1 表结构设计ClickHouse 的表设计决定了后面一年的运维体验这点怎么强调都不过分。我最终采用的明细表结构如下几个关键点值得展开说。CREATE TABLE crawler.request_log ( request_id String, ts DateTime64(3, Asia/Shanghai), crawler_name String, target_domain String, proxy_id String, exit_ip String, region String, status_code UInt16, latency_ms UInt32, bytes_sent UInt32, referer String, user_agent String ) ENGINE MergeTree PARTITION BY toYYYYMMDD(ts) ORDER BY (target_domain, ts, request_id) TTL ts INTERVAL 90 DAY, ts INTERVAL 60 DAY TO VOLUME cold SETTINGS index_granularity 4096;分区键用 toYYYYMMDD(ts) 是按照天分区好处是清理数据只需删分区不用慢速 DELETE。排序键我用 (target_domain, ts, request_id)这是根据最常用查询模式设计的按域名和时间范围检索所以域名必须放在最前面。如果排序键设计反了查询性能会差好几倍。字段类型尽量收紧。status_code 用 UInt16 而不是 Stringlatency_ms 用 UInt32省空间也快。user_agent 这种字段不用建索引但压缩比很高ZSTD 一顿压几百字符的 UA 变成几十字节一点问题没有。TTL 设置 90 天冷数据 60 天后转到冷存储卷用对象存储或者慢速磁盘。4.2 Part 与写入节奏ClickHouse 的 part 命名困扰过很多人。热词里就有“clickhouse 的 part 命名”这里顺便说透。每次 INSERT 都会生成一个新的 data part命名格式一般是partition_id_minBlockNum_maxBlockNum_level比如20250501_0_0_0。其中 min/max block num 是这张表全局自增的块编号level 是合并代数每次合并之后 level 会加一。part 数量对查询影响很大。太多的小 part 会导致查询时要读取大量文件描述符合并线程也忙不过来。ClickHouse 在后台会自动合并但合并速度跟不上极端频繁的小批量插入就会出现 “Too many parts” 错误。我实际测试下来写入节奏控制在每批 5 万到 10 万行左右最好。一条爬虫请求日志大约 300 字节10 万行就是 30MB 左右这个体量的 part 合并效率很高也不会太碎。批量插入时如果使用的是 Kafka 消费者可以用缓冲区攒批攒够 10 万行或者 3 秒钟就 flush 一次。一个重要提示ClickHouse 不擅长大批量的小事务插入宁可攒一批也不要一条条写。一次插入一万行和一万次插入性能差距是数量级的。4.3 聚合与去重的物化视图明细表虽然能扛住查询但看板要的是秒级响应所以必须上物化视图做预聚合。我建了几类物化视图请求量按分钟聚合一维表、成功率按域名加分钟聚合表、状态码分布表。其中最有价值的是按 target_domain crawler_name 分钟聚合的表日常看板 90% 的查询都打在这张表上。CREATE MATERIALIZED VIEW crawler.request_log_1m ENGINE SummingMergeTree PARTITION BY toYYYYMMDD(ts) ORDER BY (target_domain, crawler_name, ts) AS SELECT target_domain, crawler_name, toStartOfMinute(ts) AS ts, count() AS request_count, sumIf(latency_ms, latency_ms 0) AS total_latency, countIf(status_code 400) AS error_count, countIf(status_code 429) AS rate_limited_count FROM crawler.request_log GROUP BY target_domain, crawler_name, ts;SummingMergeTree 会把相同排序键的行在后台合并时自动求和。聚合逻辑在数据写入时实时触发查询时直接 SELECT 这一层速度极快。需要说明的是SummingMergeTree 的合并是异步的有时会残留部分重复行查询时用GROUP BY把同 key 的行归并一下最保险。去重场景另说。如果业务上有“同一 request_id 可能重复上报”的情况可以用 ReplacingMergeTree 配合 version 字段做最终一致去重但代价是写入时多一个版本号查询时会有多版本残留。爬虫日志场景我建议能容忍少量重复就容忍彻底去重用最终表的聚合逻辑来兜底别在写入时做重活。4.4 查询优化三板斧ClickHouse 查询优化的核心就三个词分区裁剪、PREWHERE、SAMPLE。分区裁剪是最基本的查任何数据都先带上时间范围让 ClickHouse 只读对应分区目录。不写时间范围的查询等于全表扫描几亿行的表想不慢都难。这个道理简单但实际中我看到太多人因为 Grafana 模板变量没传时间参数导致每次查询全表扫描。PREWHERE 适合过滤条件作用在大字段上的场景。比如我要筛user_agent LIKE %HeadlessChrome%这个字段长且被压缩如果放 WHEREClickHouse 得先把整列解压出来再过滤。改成 PREWHERE 以后引擎会在读取阶段先过滤掉不匹配的行再解压需要的列。实测对于长字符串过滤性能提升 2 到 3 倍。SAMPLE 则适用于大体量数据探索场景。ORDER BY 键里如果有随机数或者 request_id可以直接做抽样。比如SELECT target_domain, count() AS cnt FROM request_log SAMPLE 0.1 WHERE ts today() GROUP BY target_domain ORDER BY cnt DESC LIMIT 20;抽样查询在十亿行级别做趋势分析时非常香秒级出结果误差也在可接受范围内。5. 从 ELK 迁移的实战记录5.1 存量日志怎么搬迁移最麻烦的不是新链路搭建而是历史数据怎么从 ES 搬到 ClickHouse。ES 里存了三个月的爬虫日志直接丢掉太可惜但也没人愿意天天写脚本慢慢导。我的做法分三步第一步用 elasticdump 把 ES 索引导出成 JSON 文件第二步把 JSON 转成 Parquet 格式这一步可以用 Python 的 pandas 或者直接使用 clickhouse-local 自带的功能第三步用 clickhouse-local 加载 Parquet 文件并 INSERT INTO SELECT 导入 ClickHouse。clickhouse-local --query INSERT INTO FUNCTION remote(localhost:9000, crawler.request_log, default, password) SELECT * FROM file(logs.parquet, Parquet) 这一步有几个坑。ES 的字段类型和 ClickHouse 不兼容比如 ES 的text类型在 ClickHouse 里是无意义的导入时统一按原始字符串存即可反正我们不靠倒排索引搜日志。日期格式也要注意ES 时间戳往往是毫秒级 long导入时直接用fromUnixTimestamp64Milli转成 DateTime64。分页导出的量控制在一批 5 万条左右避免一次性加载撑爆内存。5.2 查询接口怎么兼容老的 ELK 体系下业务方可能已经习惯了 Kibana 的查询界面和一些内部工具调用的 ES API。迁移到 ClickHouse 后这些不能全推翻重写否则业务方会炸。我的方案是保留一个薄的查询封装层。对于 Kibana 侧直接部署 Grafana数据源切到 ClickHouse把以前常用的几个 dashboard 用 ClickHouse SQL 重新实现一遍。对于内部 API用 Python FastAPI 写一个薄服务接收原来的查询参数翻译成 SQL 后查 ClickHouse返回 JSON 格式尽量与原来对齐。好消息是爬虫观测底座的绝大多数查询都是“按时间和域名过滤 聚合”这类查询翻译成 SQL 非常自然。真正麻烦的全文搜索场景比如“找 user_agent 里包含某关键字的请求”在 ClickHouse 里也有办法用LIKE加PREWHERE或者创建布隆过滤器索引虽然比 ES 的全文检索弱但对日志场景足够了。5.3 省钱到什么程度迁移完一个月后我拉了一下账单做对比。原 ES 集群3 节点、每节点 64GB 内存、8TB SSD每月只保留 30 天日志机器和存储成本大概占了基础设施账单的大头。换 ClickHouse 后2 节点、每节点 32GB 内存、4TB NVMe保留 90 天日志硬件成本直接降到原来的三分之一还不到。存储压缩比方面原始日志如果以 JSON 文本形式落盘一天大约 120GB。在 ClickHouse 列式存储 ZSTD 压缩下一天实际占用 12GB 到 15GB压缩比接近 8 到 10 倍。同样是 30 天数据ES 要 8TBClickHouse 用不到 500GB这个差异是雪崩级的。查询性能就更不用说了最常用的一张看板 SQL在旧 ES 上跑要 8 到 15 秒在 ClickHouse 里 500 毫秒以内出结果刷新 dashboard 的体验从“卡顿怀疑人生”变成“秒开”。6. 常见问题与排查技巧实录6.1 经典故障速查表下面这些坑都是我实际踩过的整理成一张表直接对照排查能省不少琢磨时间。现象可能原因解决方式报错 Too many parts批量太小、插入太频繁后台合并跟不上提高单批行数到 5 万以上降低插入频率临时提高 parts_to_delay_insert 参数查询走全表扫描WHERE 没用时间分区字段所有查询强制带时间范围Grafana 模板变量正确传时间参数物化视图数据重复SummingMergeTree 异步合并同 key 行未合并完成查询时 GROUP BY 同 key 字段或使用 FINAL 关键字视数据量谨慎使用写入内存飙升单批数据量过大或 max_insert_block_size 配置过高控制单批 10 万行以内调低 max_memory_usageTTL 到期数据没删TTL 任务执行频率太低或冷热卷配置错误检查 storage_policy 配置调低 background_common_pool_size 或等待触发周期压缩比远低于预期字段全是高基数字符串且无压缩或低压缩级别使用 ZSTD(3) CODEC考虑 Dictionary 编码处理低基数字段某目标域名数据倾斜大站流量集中part 大小不均排序键增加分桶字段针对大域名单独建表或使用分布式表 weight 路由代理 IP 段被封后查询定位慢缺少 exit_ip 到运营商/地区的映射建 IP 归属地字典表查询时 JOIN 映射6.2 关于 Too many parts 的实战处理Too many parts 是 ClickHouse 新手最容易撞上的墙而且报错的时候往往已经在生产环境了。最直接的诱因是 Kafka 消费者端设置不当比如每条消息都执行一次 INSERT或者一批数据太少导致每秒产生几十个 part。遇到这个报错第一步不是调parts_to_delay_insert而是改写入代码改成攒批。我的经验是消费者循环里攒够 10 万行再 flush如果写入间隔超过 3 秒即使不够也要 flush。这样既能保证 part 粒度合适又能控制实时性不至于太差。如果已经积压了大量小 parts可以手动触发一次OPTIMIZE TABLE ... FINAL强制合并但这个操作在高负载期间要慎用因为会吃 CPU 和 IO建议放在低峰期执行。6.3 慢查询的定位与治理ClickHouse 的慢查询定位相对简单。system.query_log表会记录所有查询的执行时间、读取行数、内存占用。我养成了一个习惯每天扫一眼 query_log 里超过 5 秒的查询逐条分析。最常见的慢查询场景有两个。一是排序键设计不匹配导致的数据扫描量过大比如 ORDER BY 键是(crawler_name, ts)但查询大量按target_domain过滤这时候 ClickHouse 只能走全分区扫描。二是聚合基数爆炸比如 group by 的字段是高基数字符串又没参与排序键统计时会产生大量临时数据。治理办法也很直接要么调整表排序键代价是重写表要么把该查询纳入物化视图做预聚合。日常运维中我倾向于后者多一些因为改排序键需要对全表重写停机窗口不好找而物化视图是增量维护的不影响现有数据。6.4 代理维度数据的倾斜问题隧道代理日志有个独特问题某些大目标站点比如电商平台、社交平台的请求量可能是其他小站点的几十倍聚合查询时出现严重的倾斜单个分片处理的数据量远超其他分片。我在处理时做了一层改造在表结构里新增domain_hash字段由cityHash64(target_domain) % 16生成。排序键改成(domain_hash, target_domain, ts)这样同样域名的数据被分散到 16 个区间聚合查询时并行度更高。代价是查询同域名数据时多了 hash 过滤条件但实测收益远超开销。如果数据量已经到了单集群扛不住的程度可以引分布式表 分片分片键就用 domain_hash。需要注意分布式表查询会有网络开销小集群不建议一上来就上先把单机优化做到极致再说。6.5 关于历史数据回溯的体会热词里有个场景是“历史数据回溯”我正好做过一次。当时业务方要求把三个月前的爬虫成功率与某次代理服务商故障做关联分析。这套方案因为数据保存 90 天ClickHouse 直接可用SQL 一拉就出来了。换做以前 ES 存 30 天就得抓瞎。但这里也有个教训默认 TTL 不要设太短尤其当你的业务周期是按月结算、按月复盘时。我当时把 TTL 设成 60 天结果月底分析时发现月初的数据已经被删了一半只能重新补采集。后来我把明细表设成 90 天聚合表保留 180 天成本增加不多但分析窗口从容了很多。结尾就不再讲套话了最后分享一点个人的实际体会这套 ClickHouse 隧道代理的观测底座不是把 ELK 完全踩到脚下而是在合适的地方用合适的工具。ES 在日志全文检索方面依然有优势但爬虫观测这个场景数据量大、多维聚合多、存储成本敏感ClickHouse 的综合性价比确实明显更高。如果你正被 ELK 账单追着跑建议先小范围验证一下建一张表接一条代理日志流跑一周对比看看存储占用和查询速度再决定要不要全面迁移。数据不会骗人。