事情其实是从一个很具体的需求开始的。 我们当时准备上一个资产推理模块,其中有一套逻辑,要根据 HTTP 流量里的特征去分析资产行为。最开始这套分析走的是 ClickHouse:HTTP 日志先落进去,再通过比较复杂的查询把需要的特征算出来。

问题很快就出来了。这个场景的流量本来就大,分析语句又重,一跑起来,ClickHouse 在查询阶段的解压和扫描压力都很高,磁盘占用也跟着往上冲。更麻烦的是,我们面对的还是本地化部署,中间件和业务基本都在同一个集群里。那段时间最让人烦的,不是某一条 SQL 在压测里慢了多少,而是 CK 一重,整个环境都开始难受:磁盘顶上去,查询抖,Mongo 这类中间件也会被一起带着受影响。

第一反应其实是先优化业务和 SQL,没想着动整条路。我们前后改了几版查询,希望把这套分析继续压在 ClickHouse 上跑稳。但做着做着我发现,这类优化并没有真的把问题拿掉。SQL 可以继续改,写法也可以继续压,可只要流量和复杂度继续往上走,CK 在解压、扫描和磁盘上的压力还是会回来。

优化能续命,但不治本。

也是从这里开始,我脑子里的问题变了。再来一条压不下去的 SQL 怎么办?再来一类更重的特征分析怎么办?难道还继续把稳定性押在 ClickHouse 上吗。

再往下想,事情就已经不是“怎么把 ClickHouse 查询再调快一点”了。我当时更在意的是另一件事:先把 ClickHouse 的稳定性守住。因为让我不安的,已经不只是某条查询慢多少的事——照这个趋势继续加压,CK 迟早会把整个环境一起拖重。

我当时卡住的点

如果只是单点性能差,大家通常会先想到调参数、提规格、改写入策略,或者把查询再压一压。这些动作当然不是完全没用,但它们有个默认前提:这条路径本身值得继续走。

我当时卡住的地方其实不在这里。几版 SQL 改下来,查询当然能局部好一点,但那种“整个环境被一起拖重”的感觉没有消失。

因为我们的业务能力本来就是建立在 ClickHouse 可稳定承接查询这件事上的。一旦本地化环境下做不到 IO 隔离,讨论的重点就会变:大家盯着的,不该再只是“还能不能把 ClickHouse 优化快一点”,而是“我们是不是还应该继续把关键能力压在它上面”。

如果风险是这样来的,那继续做局部优化,很多时候就只是把爆炸时间往后拖一拖。

我为什么开始想绕开 ClickHouse

我也是到这一步才认真回头看数据路径。因为数据其实早就不只在 ClickHouse 里。

Flink 本来就在这条链路下游,而且已经消费了同一份数据。更关键的是,数据到了 Flink 之后,很多必要的反序列化和解析动作其实已经做过一遍了。

这时我再回头看 ClickHouse 那条路,会发现它的代价不只是 IO:

  • 数据要再读一次。
  • 需要再做一轮反序列化和解析。
  • 后续处理还得再接一遍。

也就是说,这条路已经不只是“查库”了,它开始稳定地重复付费。前面已经为这份数据付过一次计算成本,后面又沿着另一条路再付一遍。

我后来问自己的,其实就是一句很直接的话:能不能把已经在 Flink 里付过的这笔成本复用起来,少依赖一点 ClickHouse?

绕开 ClickHouse 以后,麻烦也没有少

这个思路一出来,新的麻烦也跟着出来了。

Flink 原本只是既有流程的下游。如果把新的业务能力直接叠到现有 Flink 作业上,表面上看像是在复用处理结果,实际上等于把我们自己的迭代节奏、计算逻辑和稳定性风险一起挂到了原来的处理流程上。

在本地化环境里,这种耦合尤其危险。因为一旦我们为了自己的需求调整作业、加逻辑、改过滤规则,受影响的就不只是新能力,原来的 Flink 业务也会一起被带进去。

所以问题很快就从“要不要用 Flink”变成了另一种更麻烦的问题:怎么用 Flink,才能既拿到复用价值,又不把隔离做丢。

后面反复比较的,也就是两条路。

不过这里得把阶段边界说清楚,不然很容易被读成“我们当时已经把整套路都走完了”。实际并没有。我们最先落下去的,只是第一步:先把原来压在 CK 那边的一部分逻辑往 Flink 挪,先把链路跑起来,先证明这条路确实有价值。后面这些关于隔离、复用、分发层和引擎化的比较,是在第一步落地以后才继续往下想清楚的;当时为了尽快验证价值,我们也没有一上来就把它独立长成一套能力引擎。

先只看原始路径,问题会更清楚一点:

flowchart LR
    MQ["MQ"] --> DL["数据湖"]
    DL --> CK["ClickHouse"]
    DL --> FL["Flink"]
    CK --> Q["业务查询"]
    ENV["本地化部署:共享磁盘,IO 隔离有限"] -. "放大冲突" .-> CK
    ENV -. "放大冲突" .-> Q
    CK --> RISK["CK 路径开始变成系统风险"]
    Q --> RISK

也正因为这里出的问题已经不是单点调优能解的,我后面才会开始在两条替代路径之间反复权衡。

方案一:自己从 MQ 重新消费

这条路最直接,也最好理解。

它的结构大概是这样:

flowchart LR
    MQ["MQ"] --> SELF["独立消费链路"]
    SELF --> ENG["我们的引擎"]

    SELF --> COST1["重新消费消息"]
    SELF --> COST2["重新解析和计算"]
    SELF --> COST3["重新维护运行时治理"]
    SELF --> GAIN1["隔离最强"]

我们自己拉一条独立消费链路,消费、解析、计算、输出都自己负责。这样做最大的好处就是隔离够硬:不影响现有 Flink 作业,出问题也是自己的问题,迭代节奏也完全掌握在自己手里。

但代价同样直接。前面那套已经付过的钱,基本又得重新付一遍:

  • 重新消费消息。
  • 重新做反序列化和字段解析。
  • 重新承担网络和链路成本。
  • 重新养一套监控、排障和运行时治理。

如果只是为了换掉 ClickHouse 风险,这条路当然能走,但它会把复用价值基本清空。

方案二:复用 Flink,再做一层分发

另一条路,是承认 Flink 已经吃过那轮解析成本,然后在它后面再加一层过滤和分发,把需要的数据吐给我们的引擎。

如果压成图,更接近下面这种关系:

flowchart LR
    MQ["MQ"] --> DL["数据湖"]
    DL --> FL["Flink"]
    FL --> DIST["过滤 / 分发层"]
    DIST --> ENG["我们的引擎"]

    FL -. "已做过一轮解析" .-> DIST
    DIST --> GAIN2["保留已付成本"]
    DIST --> COST4["新增耦合和边界治理"]

这样做的好处很明显。已经完成的反序列化、字段提取、部分预处理都可以沿用,流程里最重的那几步不需要再完整重来一次。

不过这里的代价没有消失,只是从资源成本换成了耦合成本。

一旦选择复用,你就得补下面这些现实问题:

  • 哪一层逻辑还算公共逻辑,哪一层已经是新业务自己的逻辑。
  • 过滤和分发规则由谁维护,变更怎么发。
  • 一边要复用,另一边又不能把原有 Flink 任务一起拖重时,边界怎么卡。
  • 出问题以后,责任算在原链路,还是算在新加的分发层。

所以这两个方案放在一起看,差别不只是“哪条更省资源”。换句话说,你是在拿重复成本换隔离,还是拿新增耦合换复用。

后面真正值得继续抠的,是链路里那些重复成本

但如果把这件事只理解成“复用 Flink 以后可以少做一遍解析”,还是不够。让我继续往下看的,是一旦决定把这类能力从 CK 上挪开,整条路径上那些之前被忽略的重复成本就会一下子全冒出来。

其中最刺眼的一类,就是序列化和反序列化成本。

它属于高频、反复、会被整条路径持续放大的那类成本。尤其当一份数据会被多次读取、多次转换、多次筛选时,这种成本很容易被误以为只是“正常开销”,最后谁都不把它当主问题。

但真往下拆,会发现这部分代价比想象中更可怕。它会连着放大 CPU、内存、网络和下游处理压力,影响面远不止某一台机器上的一次计算。

后面那些“性能优化”,其实都更像是在这条新路径里,一笔一笔把那些没必要继续付的钱砍掉,而不是回头继续给 ClickHouse 补血。

如果只按我们这次的 HTTP 日志场景看,后面见效的动作更像一条递进过程。

第一步,先减字段。HTTP 日志字段很多,但不是每个字段都对资产推理有用。只要某些字段既不参与判断,也不参与后续计算,却还一路跟着被读取、解压、解析和搬运,它们就在稳定制造固定成本。所以第一步该问的是:哪些 HTTP 字段根本不该继续留在这条热路径上。

第二步,再拆 body。字段减完以后,最重的点其实就更显眼了:body 很大,但下游真正要用的,往往只是里面少数几个能支撑资产推理的特征。我们实际做的事情也很朴素:把 body 解析开,拿到有用的那部分信息,再把这部分结果给下游,而不是把完整 body 一路整包带着跑。只要 body 还默认跟主流程绑在一起,后面的过滤、传输、缓存和处理就都会被它抬重。

第三步,把过滤和特征抽取前移。字段已经减过,body 也拆过,接下来最该做的是尽量在前面就把无效 HTTP 日志挡掉,把真正有用的特征先抽出来。因为这一步一旦前移,后面不管是传输、缓存、下游消费还是继续计算,成本都会一起往下掉。这里的改变在于这条路径从“整批日志先往后堆,再慢慢处理”,改成了“越早筛、越早抽,后面越轻”。

第四步,再把已经抽出来的结果重新组织成轻量表示给下游。前面三步解决的是哪些数据别再进主流程、哪些内容别再整包解析、哪些特征要更早抽;但如果做到这里为止,下游拿到的还是接近原始日志形态的内容,前面的减法还是会被吃掉一截。所以最后还得多做一步:把这些已经抽出来的特征重新归类、编码,必要时转成更小的数字化结果,然后只把这份结构化特征继续传给下游。这样下游消费的就变成一份压缩过的、明确服务资产推理的结构化特征,不再是整份原始 HTTP 日志或完整 body

这些动作都属于优化,但它们已经不是文章主线了。前提还是得先把路径选对。路径没选对,后面再多优化也只是在给一个歪掉的结构擦屁股。

后来我会先这么看

这件事没有什么完美方案,但它把我的判断顺序改了不少。

我后来再遇到类似问题,通常不会先盯参数,也不会先盯某条 SQL 还能不能继续压。我会先看现在最该保的是什么。如果眼前最要命的是隔离,那就先别让不同能力在同一条路径上继续互相拖死;如果已经付过的成本有机会复用,再去想怎么把这部分留住;等这两层站住了,局部优化才值得往下做。

而且这个顺序也不是一开始就要全做完。我们当时的真实推进方式,其实就是先把最小可落地的一步跑起来:先把 CK 那边的一部分逻辑往 Flink 挪,先验证这条路确实能减掉风险、减掉重复成本。等这一步站住了,再继续判断后面怎么补隔离、怎么做分发、要不要再往更独立的形态演进。

CK 当然有它自己的性能边界。但在我们当时那个客户环境里,让我下决心改路的,不是“它还能不能再快一点”,而是“我还该不该继续把这类能力压在它上面”。