2020 年我写过两篇《前端监控系列》,一篇讲性能监控,一篇讲数据上报。那时候关心的是浏览器里怎么把指标算准、怎么把日志发出去。后来到了现在的公司,接手内部的前端监控平台扁鹊和配套的埋点 SDK 天启,才发现日志发出去之后的事,比发出去之前难得多。

扁鹊目前支撑公司内部 500 多个前端项目,H5、Web 和小程序都在里面,日均日志 10 亿条以上。链路本身没什么特别:天启 SDK 采集性能、错误、网络和自定义日志,经公司的数据网关进 Kafka,Flink 做清洗和实时处理,明细和聚合落在 ClickHouse,Node.js 服务负责查询编排,前面是一个 Vue 的平台页面。业界做前端监控,架构大同小异。

两年前有一段时间,随着公司业务的发展,新业务的增加,埋点数据的上涨,导致这条链路的状态很差。Flink 任务频繁重启,高峰期数据延迟五六十分钟是常态,最差接近 2 小时。业务同学线上出了问题,打开扁鹊看到的还是一个多小时前的数据,等于没有监控。这篇文章把这件事从头到尾讲一遍:怎么定位、怎么先止血、做了哪些治理、延迟怎么降到 2 分钟以内,以及哪些地方今天回头看还可以做得更好。

最早暴露的是 Flink。任务隔一阵就重启一次,重启后 Kafka 的消费积压(Lag)要很久才能追平,追平之前平台上的数据就是滞后的。最容易想到的判断是 Flink 算力不够,加并发,或者加机器。

先看堆积本身。下图是治理前一段时间的早高峰抽检,同一时段经常堆出几千万到几亿条,延迟 50 到 120 分钟都有;晚高峰同样会堆。

治理前高峰抽检:堆积量与延迟(分钟)

上报量也对得上。一天内按小时看,早晚两个高峰都能冲到七八千万量级,量一上来就开始堆。

单日按小时上报量:早晚高峰量级接近

但在堆积窗口里,Flink 的 CPU 没有打满,反而出现了波动。一个被算力压垮的任务,CPU 应该顶在高水位;现在是量在高位、任务却像闲下来了。

Flink TaskManager CPU:高峰窗口量上来后,处理侧并未打满

扩容解决不了「看起来闲着却追不上」这种状态,问题大概率不在 Flink 身上。

定位:把几张图放在一起看

做前端的人看监控,习惯上看单个组件的指标:Flink 看重启次数和 CPU,ClickHouse 看磁盘和查询耗时。这次排查最大的收获是,这些图必须按同一条时间轴叠在一起看。

我把三类数据放到一条时间线上:Flink 的写入失败日志和 Kafka Lag 曲线,ClickHouse 的查询日志(哪个时间点跑了什么 SQL、扫了多少行),以及 ClickHouse 的 CPU 曲线。对齐之后顺序很清楚:每一次 Lag 开始堆积之前,ClickHouse 都先出现一批大范围查询,CPU 随即冲到高水位并且下不来;接着 Flink 往 ClickHouse 的写入开始失败,Flink 处理不动,CPU 下降,Lag 开始涨。

Flink 的 CPU 下降,是因为下游把它堵住了。它被写入端的背压(Backpressure)拖着,能干的活变少,看起来像是闲了。重启 Flink 没用,重启完还是写不进去;早晚高峰都会堆也说得通,只要大查询和高峰写入撞上,哪一段高峰都会被堵住。

这个结论后来被反向验证过:有一次重启 ClickHouse,CPU 立刻回落,写入恢复,Lag 跟着回落,Flink 这边什么都没改。还有一个教训:只看基础设施监控的瞬时负载,ClickHouse 整机 CPU 有时并不显得打满,很容易误判成「下游没事」,把查询日志和写入失败叠在一起,才能看到大查询把资源占住的那一段。

根因:一条大查询能拖垮整条链路

根因有两层。

触发因素是前端页面发起的大范围查询。当时平台上有几类查询没有边界:应用概览要看长时间范围的趋势,网络分析页可以随手查 7 天的明细,自助分析里的高级查询想写什么 SQL 就写什么。早晚高峰本来就是写入峰值,大查询再叠上去,两边撞在一起就开始堆。

放大因素是当时使用的 ClickHouse 版本有一个问题:这类大查询跑完之后 CPU 无法正常释放,会持续停在高水位。查询本身可能几十秒就结束了,但它留下的 CPU 占用不走,后面的实时写入一直在跟一个已经结束的查询抢资源。这一层我最开始完全没想到,它也解释了为什么限流和重启都只能解决一半:光限流,已经卡住的 CPU 不会自己下来;光重启,下一条大查询来了照样卡住。

物化视图在里面也有份。为了让看板查得快,前几年陆陆续续建了不少物化视图,每建一个,一份基础写入就多出一份聚合写入。视图多了,写入放大就大,ClickHouse 留给实时写入和后台 Merge 的资源就更少。它没有直接引发事故,但让链路的余量变得很薄,一有大查询就顶不住。

回头看,这条链路上的每个组件单独看都没坏:Flink 只是被堵住,ClickHouse 只是版本有个已知问题,物化视图只是建得多了一点,前端查询只是没有边界。问题出在它们共享同一套资源,而我一直按单点指标在看它们。

先止损

根因清楚以后,先把血止住,治理放在后面。当时做了三件事,都不优雅,但都有效。

一是限制大查询。对跨度特别大的查询在服务端直接拦掉,先让高峰时段不再出现一次扫掉大半张表的 SQL。CPU 真的卡住的时候,只能重启 ClickHouse 释放,这个动作做了不止一次,每次都知道它只是救急。

二是下线没用的物化视图。把所有视图拉了一遍,看最近一段时间有没有页面在查它。有一批视图对应的功能早就下线了,或者当初为某个临时需求建的,之后再没人用过,但每天还在消耗写入。这批直接删掉。

三是清理 3 个月以上的明细日志。前端排障几乎不会翻 3 个月前的明细,但这些数据一直占着磁盘。清掉之后存储压力立刻缓了一大截。

止损做完,延迟的最差值明显收窄,但触发条件一个都没消除:数据还在按原来的速度涨,查询还在按原来的方式发。接下来才是治理。

治理一:设备级采样与比例还原

先说进入量。日志越多,写入越重,这是最直接的一层。

我把各类日志按数量、频率和实际用途梳理了一遍,占比最大的几类价值很低:正常返回的网络请求、小程序的一些通用 API 调用,还有很多业务早已下线却一直在上报的自定义日志。真正排障要看的错误和异常请求,在总量里占比不高。

第一反应是把没用的丢掉。但监控数据丢起来有个陷阱:错误率、成功率这类指标是分子除以分母算出来的。只保留异常、丢掉正常请求,分母就没了,错误率直接失真;每条日志独立按概率丢,同一个用户的一次操作可能一半被记录一半没有,串不起来。

最后落地的方案是:天启 SDK 在设备上生成一个随机数,只算一次,之后这台设备上所有普通日志都用这个随机数跟配置里的采样率比较,决定要不要上报。同一台设备要么整体进入样本,要么整体不进,一次用户操作产生的日志是完整的。关键错误、关键 API 和低频的高风险事件不走采样,全量保留。每条上报的日志带上当时生效的采样率,聚合时按采样率的倒数还原。配置放在 apmConfig 后台,经 CDN 下发,保留历史版本和回滚能力,业务不用重新发版。

用伪代码表示大概是这样:

// 天启 SDK 侧:设备级稳定采样(示意,非真实实现)
type SampleRule = { rate: number; exempt?: boolean };
type ApmConfig = {
  version: string;
  rules: Record<string, SampleRule>; // 按日志类型配置采样率
  blockedKeys: string[]; // 已失效的自定义日志 key
};

// 设备随机数只算一次并落本地存储,同一设备始终同进同出
const deviceBucket = hashToRange(getOrCreateDeviceId(), 10000); // 0 ~ 9999

function decide(log: RawLog, config: ApmConfig): SampleRule | null {
  if (config.blockedKeys.includes(log.key)) return null; // 直接过滤
  const rule = config.rules[log.type] ?? { rate: 1 };
  if (rule.exempt || log.level === 'error') return { rate: 1 }; // 关键日志不采样
  return deviceBucket < rule.rate * 10000 ? rule : null;
}

function report(log: RawLog, config: ApmConfig) {
  const rule = decide(log, config);
  if (!rule) return;
  enqueue({ ...log, sample_rate: rule.rate }); // 采样率随日志一起上报
}

// 聚合侧:按采样率的倒数加权还原(示意)
const errorRateSql = `
  SELECT
    toStartOfMinute(ts)                        AS minute,
    sum(1 / sample_rate)                       AS est_requests, -- 估算总请求数
    sum(if(status >= 400, 1, 0) / sample_rate) AS est_errors,   -- 估算错误数
    est_errors / est_requests                  AS error_rate    -- 分子分母口径一致
  FROM network_log
  WHERE app_id = {app} AND ts BETWEEN {start} AND {end}
  GROUP BY minute
`;

上线时没有一次全量推,先挑流量最大的 20 个应用升级 SDK,看还原后的指标跟采样前对不对得上,再逐步扩大。代价也很明确:采样后的数据适合看趋势、比例和容量,不等于拥有完整明细,也不能拿去做审计。需要看单条明细时,页面上保留了跳转到公司日志平台的入口。另外,当时只做到了按比例还原,没有把采样误差做成产品能力,页面上看不到置信范围,这是后面会提到的欠账之一。

采样加过滤规则落地之后,上报量的变化很直观。稳定性专项期间,APM 上报峰值曾到过单日 28 亿以上;治理推进后,日上报量落到 10 亿量级。下图按日志类型拆开,网络类日志占大头,也是下降最明显的一块。注意 28 亿到 6 亿是专项窗口里的单日峰值变化,不等于今天的日均规模;今天的日常量级是前面说的日均 10 亿以上。

按日志类型拆开的上报量治理趋势

治理二:物化视图分时分场景

再说写入放大。

止损阶段删掉了明显没用的视图,剩下的视图也不该都按同一种方式存。一个分钟粒度的视图,业务真正会看分钟级曲线的场景,基本只在最近几天排障的时候;看一个月、一个季度的趋势,天粒度足够。可之前所有视图都是一个保留周期,分钟级数据也存很久,大部分时间没人查,却一直占着存储和 Merge。

于是把视图按时间粒度和使用场景重新拆了一遍:分钟级只保留 15 天,给近期排障用;小时级保留 30 天,给周报和近期趋势用;天级保留 90 天,给季度对比用。几个过大的聚合表按功能拆开,不再让一张表同时服务好几个页面。

道理很朴素:物化视图是拿写入成本换查询速度,它应该有预算。当时缺的就是这本账,每个视图值不值,建的时候几乎没人问,只看查询快不快。

治理三:按查询 SLA 分流

最后是查询竞争。这一块我觉得最值得写。

治理后的数据链路:采样在端上,固定长周期查询走 PostgreSQL 预聚合

把大查询和实时写入分开,常见做法是再放一个库。最后也确实用了 PostgreSQL,但分流依据选的是查询的服务目标,也就是 SLA:新鲜度要求多高、维度能不能临时变。

平台上的查询分成两类。一类是实时排障:错误明细、网络请求明细,要求分钟级新鲜度,维度是临时的,今天按城市钻,明天按版本钻。这类查询必须留在 ClickHouse,换库帮不上忙。另一类是应用概览、周报、历史趋势:查询模型固定,时间跨度长,重复度高,但对新鲜度要求不高,晚一天也没关系。高峰时段把 ClickHouse 打满的,几乎都是第二类。

于是第二类查询被整个搬走。一个定时任务在低峰期把每天的聚合结果算好,写进 PostgreSQL;应用概览这类高频页面先查 PG,PG 里没有的(比如今天的实时数据)再回 ClickHouse 补。高峰时段业务同学再怎么刷概览页,打到 ClickHouse 上的只剩当天那一小段。

如果一律按「明细库 / 汇总库」去搬,临时维度的排障查询也会被赶到 PG,查不到再绕回 ClickHouse,多一跳。按 SLA 划分是用查询目标换资源稳定,代价是两条路径的数据一致性要自己维护:同步任务失败、口径变了、聚合窗口没对齐,PG 里的数就会跟 ClickHouse 对不上。这是至今仍要小心的地方。

防复发:给查询画边界,给治理定节奏

治理做完,延迟降下来了,但复发条件还剩一个:自助分析里的高级查询。业务同学可以写任意 SQL,谁也不能保证下一条不会再把 ClickHouse 顶到高水位。

给自助查询加了几条硬边界:时间范围有上限,超过直接拒绝;返回行数有上限;同一用户的并发查询数有限制;单条查询有超时,到点取消。这些都在 Node.js 服务端做,前端只把规则提示出来。长跨度的大查询不再允许随手发,收口到固定入口,走预聚合结果。边界也要说清楚:当时只做到资源层面的准入,完整的 SQL AST 白名单没有做,语法合法但代价高的查询主要靠这几条限制兜住。

另一件事跟技术关系不大,但同样重要。链路稳定之后,平台终于能持续产出可信的性能数据,我把它接进了业务治理:重构应用管理,把每个应用归到明确的业务线和负责人;统一秒开率等指标的口径,通过大盘和排行榜把项目之间的差距摆出来;再做性能日报和周报,定期把指标变化推给各业务线。低指标的项目进入业务线后,由业务线开发逐项定位和改页面,我协助拆问题、对口径、沉淀常见方案。巡检有了固定节奏,专项做完也不会散。

结果,以及今天会怎么重做

高峰期的数据链路延迟,从最差接近 2 小时、常态五六十分钟,降到 2 分钟以内。治理前后的早高峰抽检可以对照:前期同一时段延迟常见 50 到 120 分钟,专项推进后稳定在约 2 分钟。

治理后早高峰抽检:延迟稳定在约 2 分钟

链路稳下来之后,其他几个数字也跟着变了:按 8 个月窗口统计,Flink 任务重启次数从 61 次降到 5 次;网络分析页查 7 天数据,从约 28 秒降到约 7 秒;ClickHouse 磁盘使用率从治理前的高位回落,长期稳定在 55% 左右。

数字之外,有几件事当时没做好,今天重做会换种做法。这部分都还是想法,没有落地。

第一,ClickHouse 的版本问题拖得太久。大查询后 CPU 不释放,是一个可以通过升级解决的问题,但当时把重启当成了常规操作,升级一直排不上优先级。今天重做,我会把 ClickHouse 版本和已知问题的治理当成一条独立事项,灰度压测过了就升,不长期依赖重启恢复。

第二,先评估 ClickHouse 原生的工作负载隔离,再决定要不要跨库。当时的版本没法证明有这类能力,所以选了预聚合到 PostgreSQL 的路。较新的 ClickHouse 版本已经开始提供工作负载调度能力(I/O 已有,CPU 与查询并发的隔离也在跟进),如果一个集群内就能把实时写入、看板查询、自助 SQL 和离线聚合的资源预算隔开,跨库的一致性成本就不一定值得付。

第三,物化视图应该有一本成本账。每个视图在创建时就该记下 Owner、命中频率、额外写入量、存储和到期时间,定期看收益是否还能覆盖成本,不够就标成低价值。当时是出了事才回头做一次性清理,这个账本一直没建起来。

第四,采样误差要做成看得见的能力。现在页面上只有按比例还原后的数字,读者看不到采样率、策略版本和置信范围,也不知道哪些数据不能用于明细审计。数据版本和采样信息应该从采集到展示每一层都带着,SDK 里存一个采样率不够。

回到开头。监控链路上的每个组件单独看都可能是正常的,Kafka Lag、Flink 背压和 ClickHouse 写入延迟要放在一条时间轴上一起看,才能找到真正堵住的那个点。采样要保留统计口径:采样率跟着数据走,分子分母一致,还原出来的数字才可比。查询分流看的是新鲜度和自由度这类 SLA,先定服务目标,再选落库路径。

这次治理花了不少时间,中间也走过把重启 ClickHouse 当日常的弯路。写下来,一方面是给 2020 年那两篇监控文章补一个「数据发出去之后」的续篇,另一方面也提醒自己,平台的稳定性要整条链路一起算账。文中的方案和判断都带着当时的条件和限制,不一定适合别的团队,有不对的地方欢迎指出。