前端性能监控 (RUM) 接入层服务高并发优化实践—缓存模型

2022-01-14 11:40:31 浏览数 (1)

作者:张翔,腾讯云监控高级工程师

听说上次 RUM 重构,还是上次了!

这次 RUM 重构的目的是?

RUM 要提升用户体验!!!

用户体验好了,用户就用的更好了!

真是听君一席话如听一席话

下面我们正式切入主题!

背景

为了更流畅的用户体验,RUM 的采集上报服务接入层正在经历着 2.0 版本的重构与升级。接入层的重构使用了性能更好的 Golang。而在重构升级的过程中也重新对以前的缓存模型进行设计与实现。

RUM 目前整体接入的项目超过数万个,每个项目都对应着不同的配置,如抽样率,域名校验信息,欠费数据与项目状态等等。这些数据在处理上报请求的时,都会被用到,这大量数据会持久化存在 RUM 控制台。为了能够快速地获取到对应数据,我们急需设计数据缓存机制。 

中心化缓存 本地缓存机制

结构图:

我们将接入层服务部署在 serverless 上,再将数据放置腾讯云数据库 Redis 中,中心化处理缓存数据。但是如果每一个请求都请求一次 Redis,这样不仅会影响请求的响应时间,增加一次网络 io,并且在面对监控上报如此大的请求量的时候,Redis 的读写能力也会受到极大的挑战。

虽然 Redis 的读写性能极高,Redis 的读性能在每秒 110000 次,但是面对服务端平均几十万 QPS 的压力还是会有点吃不消,如果 Redis 的 CPU 满载就会频繁出现 OOM 的错误。

[点击查看大图]

因此我们需要在本地建立缓存机制,缓解 Redis 的压力与降低服务端请求耗时。服务端会先去查找内存中的项目数据,避免了直接对 Redis 的读取 io,进而优化对于 Redis 的 io 压力,降低 Redis 的 CPU 压力,避免 OOM 错误。

避免缓存惊群效应

除了上面所说的本地缓存优化,我们还需要注意一个常见的缓存陷阱问题:缓存的惊群效应。

所谓惊群效应,即在多并发线程的场景下,爆发式地向缓存服务拿取数据,导致缓存服务无法处理瞬时大量请求导致服务崩溃的现象。

而在以前 Node 服务的场景下,我们使用 pm2 来进行进程守护并且使用 cluster 模式,单个 pod 起了 8 个进程,由于在 Node 里共享内存开发成本比较大,所以需要每个进程自行维护进程内的内存数据集,这样除了内存利用率不高以外,还有惊群效应的问题。接入层的 TKE 上的 pod 数量一般都在 300 个左右,我们设置的内存缓存过期为 2 分钟,这样的话其实每两分钟就会有 300*8N = 2400 * N 次以上的 Redis io 读操作(N 是需要执行的命令的个数)。

这种情况下,可以尽可能优化 N 的个数,在 RUM 接入层里,我们将这个数量优化成 1,所有项目的数据都放在 Redis 的 hash 表数据结构里面,可以通过 HGETALL 命令一次性将所有数据取出,而不是通过一个个的 cache key 重复执行拿到对应的项目数据。

除此之外,惊群效应还需要注意一点,就是对于内存中没有的数据,我们应该谨慎甚至是禁止服务直接调取缓存服务取用数据,看下以下代码例子:

代码语言:javascript复制
// 简单的代码例子
const projectHash = {}; // 存储项目数据,这里是异步并且服务启动前去 redis 中取数据的
app.use(async ctx => {    if (!Object.keys(projectHash) == 0 && projectHash[ctx.query.id]) {        projectHash[ctx.query.id] = await redis.hget(key, ctx.query.id);    }        return projectHash[ctx.query.id];});
代码语言:javascript复制

这样的一个即时获取数据的机制,在服务进程启动缓存数据还没有准备好的时候,服务突然收到大量的请求,会将瞬间的请求量直接打到缓存服务上,这样会发生缓存践踏,容易破防。

因此在新的接入层重构的时候,我们去掉了这样的即时获取数据机制,以防缓存服务的压力过大而造成服务雪崩。

那我们怎么更新内存数据呢?我们采取 refresh-ahead caching 策略。

refresh-ahead caching

即提前刷新缓存,这样的策略在微服务架构中非常常见,牺牲了一点数据的实时性而大幅度提升了服务的性能。缓存由微服务异步重新加载更新,客户端请求只访问内存缓存以达到快速响应的诉求,微服务中运行一个调度程序刷新缓存。

内存缓存性能优化

Golang 并发编程

Node 单进程模型在编程中,对于数据的修改是以函数为单位作为一个原子操作的,但是这样共享内存会相对麻烦,而在 Go 中,异步编程是使用了更为轻量的协程来达到更好的异步性能,而协程是会有并发操作的概念的,即会同时有多个协程对于某一个变量进行值修改操作。

而我们对于配置数据,项目数据等等都是放置在内存的一个 Map 里的,为了避免多协程下对于同一个变量读写出现错误,因此使用 Golang 重构的过程中使用了锁的机制。

为什么 node 中不需要锁而 golang 中需要锁呢?—— Memory Model

这里关于异步编程的概念特别有意思,一定要圈起来!

为什么 Node 中不需要锁而 Golang 中需要,原因可以深究到 Golang 中的 Memory Model。

所谓 Memory Model 即内存模型,也就是它指定了一个协程(goroutinue)在什么条件下可以保证读取某个变量的时候观察到其他协程(goroutinue)写入的值。

那就会有一个很有意思的问题出现:为什么会出现某个协程看不到某个变量被写进来的值呢?这个涉及 CPU 的结构与内存重排机制。

内存重排

所谓内存重排,也就是所写的高级语言转化为汇编即对内存的读写指令时,CPU 设计者为了提高性能,会使用类似分支预测等手段优化对于内存的读写性能,来看一个伪代码例子:

代码语言:javascript复制
// code1x := 0for i := 0; i < 100; i   {    x = 1    fmt.Println(x)}
// code2x := 1for i := 0; i < 100; i   {    fmt.Println(x)}

编译器重排下,有可能将代码逻辑从 code1 变成 code2 的形式,code1 和 code2 的结果在同一个协程中是一致的,若如果同时有另外一个线程对于 x 这个变量进行 x=0 赋值操作,code1 的打印结果可能就是 1100011 而 code2 的打印结果可能就是 1111000000,在多核情况下是很难断定这两个程序是等价的。这就是编译器重排,即编译器会改变变量的赋值顺序。

除此之外,由于 CPU 为了提高运算性能与读写性能,抚平内核,内存,硬盘之间的速度差异,建立了三级缓存,比如让如下图的 (2) 行代码的执行无需等待 (1) 行代码的执行结果”可见”,所以将 (1) 的结果存到 store buffer 中:

store buffer 这样的机制在单线程是完美的,但是在多线程里面,比如先执行了 (1) 和 (3) 的指令,写入了 store buffer,再执行 (2) 和 (4),此时由于每次执行顺序不同,打印的结果不尽相同,最坏情况是 A/B 两个变量打印出来都是 0:

所以,对于多线程程序,CPU 都会支持锁机制,也就是内存屏障(memory barrier),barrier 指令要求所有对内存的操作必须扩散到内存之后才能执行其他对于内存的操作,这也就是锁的本质。因此 Golang 里面需要使用到锁机制。

明确了需要使用锁之后,我们第一版的代码很快就出来了,我们使用了 sync.Map 来在内存中保存项目数据。

代码语言:javascript复制
// 伪代码
type ProjectRepo struct {    data sync.Map}
var projectRepo ProjectRepo
func (p *ProjectRepo) LoadData() {    rawData := redis.HGetAll(key)    p.data.Store('data', rawData)}
func (p *ProjectRepo) Get(projectId string) {    rawData, _ := p.data.Load('data')return rawData[projectId]}

锁机制的优化

写下了第一版代码完成了功能,回看上面的内存模型,其实可以轻松认识到锁是有巨大开销的。所以继续反思一下我们是否真的需要锁?

sync.Map 里面存在一个互斥锁,不管是读是写都共用了一把锁,显而易见这样的效率是非常低的。

但是如果不用锁又会存在并发问题,所以思考一下,能否进一步优化呢?

Golang 里面有 RWMutex 即读写锁机制,将读锁和写锁进行分离,读写互相不打扰,但是这个机制给了我们一个灵感,Local cache 这种数据属于读多写少的数据,并没有太多并发写的诉求,是否能直接连写锁这样的机制都省略掉呢?答案是可以的!

COW — Copy On Write 技术

copy-on-write 技术即写时复制,是微服务降级与微服务 local cache 中优化的常见思路。对于内存配置数据等等这些读多写少的数据,非常适合这种方式。

所谓写时复制,就是写操作时复制全量老数据到一个新对象中,并携带本次新的数据,最后利用原子替换,更新读取方的数据,以达到无锁访问共享数据。

可以看下面的 benchmark 数据,atomic 原子替换的读写性能比锁机制强出好几个数量级,操作时间和比原生的 Map 数据相差无几。

[点击查看大图]

实际 projectRepo benchmark 对比:

[点击查看大图]

使用 cow 之后:

[点击查看大图]

读性能比以前多出了至少 500 万次以上的次数。

并且 atomic 有一个优势就是它是 Go 并发同步原语之一,即使后面需要多协程去写,这里也能满足无缝切换。

因此最终我们优化项目数据的数据层内存中使用 atomic 原子替换来存储我们的内存数据已达到最佳性能。

总结

以上就是RUM 接入层 2.0 对于服务的缓存模型优化的全部内容,整个优化的过程我最大的收获就是:对于每一个细节都有优化的空间,我们需要尽可能地深挖个中原理,计算机的底层知识依然在需要性能优化的时候起到帮助,要明白语言的底层与操作的原理,才能从根本上发现并针对性地进行优化。

点击文末「阅读原文」即可了解前端性能监控(RUM)。

联系我们

扫码加云监控小助手,回复“Rum”

加入前端性能监控技术交流群

RUM 相关文章:


关注我们,了解腾讯云监控的最新动态

0 人点赞