作者:李振,腾讯云前端性能监控负责人
什么是前端性能监控(RUM)
腾讯云前端性能监控 (RUM) 是一站式前端监控解决方案,用户只需要安装 SDK 到自己的项目中,通过简单配置化,即可实现对用户页面质量的全方位守护,真正做到了低成本使用和无侵入监控。前端性能监控专注于 Web,小程序等大前端领域,主要关注用户页面性能(页面测速,接口测速,CDN 测速等)、质量(JS 错误,Ajax 错误等),并且通过联动腾讯云应用性能监控实现对前后端监控一体化的打通。
前端性能监控技术架构历史
前端性能监控主要做日志和指标的综合处理,主要开发功能是 SDK + 接入层 + API 层 + 可视化。这看似简单的功能,会有什么难点呢?前端性能监控是如何实现的?
前端性能监控技术难点:高维数据的处理和高并发请求的处理。
高并发想必大家已经耳熟能详了,我们面对的是无数 C 端用户的数据收集和上报,业务数据的保障也会影响我们服务的稳定性。
高维数据可能大家都比较陌生,那么什么是高维数据呢?
比如你要计算某个页面在某个地区,某个机型,在某个运营商下的平均耗时,如果采用离线计算的方式,你需要提前把每个维度下的值计算出来,这样轻松就是几十亿上百亿的维度分布!
前端性能监控最初架构
最早选择了前端同学最熟悉的 Node + MongoDB + MySQL 的模式进行开发,底层是参考 BadJS(在线收集 JavaScript error 信息的插件) 的实现,并对其进行了无状态处理和容器化托管。
这套架构最主要的问题是性能瓶颈明显,而且没有办法进行指标计算。性能瓶颈主要在 MongoDB ,在写并发超过一定量级后,MongoDB 就无法承受数据写入了,即使做了读写分离。计算瓶颈主要存在于架构设计,主要实现流程如下:
这套架构强撑了我们很长一段时间,中间也做了无数优化,比如通过 “Node 分布式定时任务” 来增加计算能力,但是面对前端监控这种动辄几十亿上百亿的维度计算,根本无济于事。
穷则思变,既然现有架构没办法满足我们的诉求,就寻求新的技术方案。
分析日志存储问题
前端性能监控收集了很多用户的质量日志,包括:JS 错误,Promise 错误,AJAX 异常等,这些日志可以协助开发者快速定位问题,并且分析页面质量。将日志存储在 MongoDB 中的,好处是读写简单,搜索速度较快。但是日志上报量大了,使用 MongoDB 也成了性能瓶颈。
解决 MongoDB 使用问题
使用 MongoDB 我们最早遇到的是满容的问题,用户日志上报量大了,就把日志写满了。解决方案:把每个项目的数据单独存一个 Collection,把 Collection 设置为 “capped” 即为固定集合,每个集合的数量固定,只允许用户保留数千万条日志,这样就可以解决了满容的问题,而且可以保证 MongoDB 的存储增长不会有突增和异常的情况,但是毕竟对于大项目来说,会面临查不到历史日志的情况。
除此之外还是存在很多运维问题,除了满容以外,还遇到过连接池占满,session 数异常,CPU 异常等等一系列问题。而且随着业务数逐渐增多,MongoDB 这种数据库存储的模式已经不能满足前端性能监控的业务诉求了。
引进腾讯云日志服务(CLS)
经过了缜密的分析,我们引入了腾讯云日志服务。在使用一段时间后,果断把日志全量迁移到了腾讯云日志服务。
切换到腾讯云日志服务后也是遇到了一系列问题,比如最常见的 “CLS 查不到日志了”,但是 CLS 背后有团队支撑,而且有稳定的迭代和运维,问题嗖的一声就解决了。
优化指标计算
解决了日志一大难题,指标计算问题就显得有些“孤单只影”了。在前端性能监控中,我们会帮用户计算各种维度值的平均值和分位数。指标该如何做优化呢?
指标计算和日志不同,除了读和写,还涉及到一些对业务的理解。我们一直陷入了自建的困扰,尝试了多种方式,也没有效果。主要原因是无法摆脱陈旧的技术框架。一次次闭门造车,却一次次被打败。
偶然间听说内部团队,有成熟的指标处理方案。使用腾讯云流计算 Oceanus+ 腾讯云时序数据库 CTSDB ,从此为前端性能监控指标处理开辟了一条新的道路。
改进后的前端性能监控整体架构
整体来看,SDK 做数据采集后,通过腾讯云 API 网关+ 腾讯云负载均衡的方式,把数据负载均衡发送到已部署在 K8S 集群上的日志接收层和测速计算层。这两个模块对用户数据进行限流和抽样,再调用微服务补齐字段,比如城市,运营商等,然后把日志数据通过 API 接口写入到腾讯云日志服务 CLS,再把性能数据通过 Telegraf (收集和报告指标和数据的代理)方式上报到腾讯云监控中台,最终存储在 Clickhouse(一个开源、高性能的列式 OLAP 数据库管理系统) 里。
其中我们主要精力在开发 SDK 还有日志接收层、测速处理层和查询数据的 API 层以及数据可视化部分。测速的逻辑稍显复杂,下面我将详细的讲解其中的设计和实现。
测速架构介绍
首先我们收集数据是通过 StatsD SDK,把用户上报的数据汇聚成我们想要的指标数据,其中主要是:
- count :累计值,包括 PV,自定义事件访问量。
- set :去重值,包括 UV,自定事件访问用户数。
- histogram : 直方图,用来计算性能指标数据。
- summary :统计数据,用户计算性能和指标数据(与 histogram类似,虽然也上报了,但是我们主要使用 histogram)
StatsD 收集完指标数据后,通过部署在 Sidecar 上的 Telegraf agent 把数据上报给云监控中台。
Telegraf:数据收集 agent,可编写插件对接监控中台协议上报鉴权,流量限制等。
使用 Sidecar 模式的优势:
- 通过抽象出与功能相关的共同基础设施到一个不同层降低了微服务代码的复杂度
- 能够降低微服务架构中的代码重复度和代码耦合度
- 节约 Node 多进程模型对 telegraf 的重复使用
微服务抽离
从上面的架构图中还可以看到,我们使用 Golang 做了一些微服务,其中包括
- Ipcity:把用户ip信息解析为城市信息和运营商信息的服务。
- Restful:提供独立的剪枝微服务。
- kafka:把数据旁路到用户提供的 Kafka 中的服务。
当然这样的微服务架构也不是一蹴而就的,也是我们经过长时间的摸索和失败得出来的经验。
拿 ipcity 举例。有一段时间发现服务器内存总是非常高,而且服务时不时崩溃,通过 heapdump 抓取了服务崩溃时候的内存 dump。
发现内存中存在一个超大的 Object,来自 ipcity。我们把 ipcity 的进程单独跑起来,发现就已经占有了 1.38G 的内存,而且这个内存会随着数据源进化不断变大。
这对于 Node 多进程模型来说,几乎是致命的,因为每个进程都会启用一个 ipcity 的服务。
我们用 pm2 在 8 核 pod 上启动了 8 个 Node 进程,这样一算,仅是 ipcity 就占有了 12G 的内存,内存的增长也随之带来 CPU 的增长。
于是考虑到我们应该把 ipcity 服务单独抽离出来,接入层通过 rpc 的方式进行调用,而不是每个进程中引入 ipcity 模块。
最后把 ipcity 的服务放在 trpc-go 里,接入层通过 rpc 通信完成 ip 转化为地区和运营商的工作。
这样做的效果非常显著:
- 数据接入层功能更纯粹,数据转发,Node 服务做自己擅长的事情;
- 彻底解决 mode 多进程模型重复耗费内存的问题;
- 把一些密集型 CPU 计算放在 trpc-go 中完成;
- Ipcity 独立升级,独立维护,不影响整个接入层。
由于 Golang 语言本身性能就远远好于 Node,并且微服务改造也减少了接入层 CPU 抢占的一些问题。所以这个优化给我们成本带来了巨大的提升。 广州区集群 pod 数下降了 65.75%。
欣喜之余,把之前困扰我们的 Kafka 连接池的问题也用相同的方式改造了。业务上我们允许用户在平台上录入一个 Kafka topic,可以把原始数据旁路给用户。
由于整个服务是无状态的,并且会随着业务上报量平行扩容,用户上报的数据会随机分配到任意一个 Node 节点上,因此需要我们每个 Node 节点都跟用户提供的 Kafka 保持连接。显而易见,这样对用户 Kafka 的连接数是一个挑战。解决办法也是通过微服务改造,把全部数据集中在几个节点上,再旁路给用户。完美解决,后续又陆续改造了告警服务,剪枝服务。
RUM 发展遇到的问题以及解决思路
流量激增
随着业务的发展,我们首先面临的挑战是用户上报数据流量激增。
业务请求一段时间内突增 650%。想必这种情况对于大多数企业来说都是非常致命的。鉴于之前的经验,我们使用单机限流 + 按项目令牌桶抽样解决流量突增问题。
单机限流:准确来说,是 Node 单进程内存限流,这个主要为了防止一些非常突发和异常的流量进入我们的数据后台。
令牌桶抽样:主要针对测速数据,实行按项目和接口级别的抽样控制,可以保证每个项目,每种类型的接口最大入库不超过某个预设值。
流量整型后的效果也非常显著,无论是部署我们服务的 stke 节点,还是用来限流的 Redis ,资源都大幅下降。Stke 广州节点数下降40%,Redis CPU 从 57% 下降到 17%。
UV 计算
早期使用Redis hash-set 计算 uv ,根据规则对用户唯一值(aid)取模后分布在 60个不同的 Redis key 中,随着业务增长,尤其是 “小程序垂搜” 加入,UV数据显著上涨,hash-set 占有内存大,计算消耗大的问题凸显。旧版本当时宽口径 UV 约几十亿每天,用这种方式 UV 数据使用的 hash-set 最大占有较大的内存。
这种方案显然是行不通的,重新整理需求,UV 计算是基于一天用户去重的,本质上是 DAU,我们需要一个可以实现去重算法的方案。当时考虑的有布隆过滤器和 HyperLogLog 算法,还有业界比较流行的离线计算的方案。
HyperLogLog 是什么?
- HLL 是一种近似的去重算法
- HLL 使用极少存储空间计算不重复元素的个数
- 多个使用 HLL 统计出的维度基数可以重聚合
HyperLogLog 对比 Redis hash 还有 HLL 算法以及离线计算的方式。
对比来说,离线计算和 hash 的方式,可以提供一个高精度的 UV 计算方式,但是 hash 的方式带来极大的内存占用,离线计算的方式需要我们引入额外的计算框架,成本较高。所以最后选择了 HLL 算法,HLL 算法优势是可以高效计算去重元素个数,占有空间极少,缺点是存在少量误差。下面是我们用 1亿 条不重合 aid 存储使用 hash 和 HLL 中内存占用的区别。
使用 hash 的方式,占用了 4.37G 的内存,使用 HLL 的方式,只占用了 12k 的内存,数据对比惊人。
当然 HLL 算法的误差也不是永远这么大的,随着其内部分桶个数的不同,占用内存大小和误差也在变化。
不过 Redis 中 HLL 默认选择了 14 分桶,因此显示误差是 0.81%。那有没有办法减少误差呢?唯一的办法就是自己实现 HLL 算法,目前我们的方案是通过 Clickhouse 实现了 HLL 算法,并且使用 16分桶,这样精度更高,而且存储空间的增长也在可接受范围内。
页面自动剪枝
页面剪枝主要面对的是具有 Restful 风格页面和 API 的分析和统计功能,以下图为例。
从截图中可以看出来,单个页面地址和单条 API 测速无意义,也无法聚合。即使服务端有充足的算力,Web 侧页面展示也是一个问题。那么 Restful 风格 的页面地址和 API 如何聚合呢?
我们给出的方案是在服务端自动帮用户把数据做汇聚,把上面 URL 里面的变量汇聚成为一个 *。
首先根据 / 把整个 URL 地址绘制成为一个树,这样的话,变量节点就会变成一个有超级多兄弟节点的叶子结点。那我们只需要判断某个节点的子节点个数超过一定的阈值就可以得出,这个 URL 可能是属于 Restful 风格的发散节点。
所以这个问题就简化成了,当某个节点的子节点数大于 N,子节点可能就是一个变量,我们就可以把其子节点汇聚成为 * 以满足我们的诉求。唯一的变量就变成了 N,我们只需要确定 N 的值就可以了。这是一个典型的数据分析模型转化为数学公式的问题。最终根据经验和真实用户数据,我们给了一个 N 的大概值用来解决这个问题。
当然这个算法并不是完美的,比如构建剪枝树需要消耗比较多内存,剪枝数有新的节点加入的时候需要对各个节点做对比,因为剪枝树需要放在 Redis 中,所以还有 Redis 高并发读写的问题。
但是随着我们计算能力的增强,尤其是引入 ClickHouse 作为性能数据的存储引擎后,基本没有再遇到高维度的问题。
### 使用动态网关解决流量瞬间突增
使用动态网关
前面我们讲到通过流量整型来解决突发流量的问题,这个是建立在流量的增长是在一段时间内完成的。
而真实情况我们遇到了一些流量会在一瞬间增长到某个值的情况,比如新年的红包活动,游戏的抽奖,购物的抢购,热门视频的开播等(这些都是血泪的教训)。这种情况若不及时扩容,服务将会崩溃。
对于这种案例,解决方案只有一种,就是在网关层限制,可是我们使用的网关却又无法满足这种自定义的限制。
面对突发流量,一般性自动扩所容的架构无论如何都无法完全满足需求,唯一的解决方案就是在平时就做冗余。比如我们接入层平时 CPU 占用大概在 30 - 50% 左右,当 CPU 达到 70% 的时候,接口就会有一些异常。那可想而知,如果某个时间点有超过原并发 2 倍请求量时候,服务器在扩容之前就已经大量报错了。
但是将整个接入层做冗余成本太高了,于是我们还是需要一层网关,于是选择自研一套业务网关。
在这个背景下,动态网关项目应运而生,希望把所有跟流量相关的请求都放在网关中进行,以减少业务的压力。
目前动态网关基础功能已经上线,细心的用户可能也发现了,前端性能监控的上报接口更稳定更高效了,基本可以在 30-50ms 内完成响应。
动态网关的架构如下
动态网关的核心功能在于拿到用户请求后,直接返回了 204 给用户,然后再异步请求真正的接入层,把用户数据转发给接入层。所以无论后台服务部署在哪里,并发如何,都可以保证用户上报侧不会受到影响。只要网关服务异地多活,就可以保证用户上报接口的性能,随后也可以充分利用边缘计算节点来部署网关服务,效果更加显著。
总结
随着流量不断递增,整体架构面临着流量和维度爆炸的双重压力。下列为处理方案总结:
- 服务无状态化处理后,整个服务的瓶颈就变成了依赖的第三方服务;
- 通过流量整型解决突发流量和异常流量的问题;
- 通过把部分服务微服务化降低整个服务负载,并且解决连接池占有的问题;
- 对数据上报方式进行架构优化,来解决高维的问题;
- 通过使用 Redis Hyperloglog 优化 UV 计算;
- 通过剪枝算法来降低页面地址和接口地址的维度;
- 引入 openrestry 做业务测网关,把用户请求异步处理,加快服务器响应速度,也解决突发流量的问题。