2

缓存中的穿透、并发及雪崩

简析

使用缓存的时候,最常见的场景莫过于查询缓存是否存在,存在则直接获取缓存并走业务流程,不存在则读取DB层并组装数据,组装完成后写入缓存,并返回业务层。

但这种做法在高并发的时候可能会遇到缓存穿透、缓存并发和缓存雪崩的问题,以下是一点笔记(真的只是一点)。


// 伪代码
if val := cache.Get("key"); val == nil{
        // 缓存不存在,重建缓存
        val = db.Read("key")
        cache.Set("key", val) // 一般还要设置一个过期时间,这里简化了
        return val
}else{
        // 缓存存在,直接返回结果
        return val
}

缓存穿透

行为简述

缓存穿透常见于客户端的恶意行为,一般场景下,查询DB的开支(各方面,CPUMEMTIMEBRANDWIDTH等等),我们都可以认为是远大于查询缓存的(这也是为啥会出现缓存的原因,引入了可靠性低的不一致因素,还是要用缓存),而恶意查询则是故意查询没有结果的东西(往往还是轮询),则造成大量的无效请求传到了DB层,最后引起DB层不堪重负挂掉,或者导致正常查询难以进行。

按照开头笔者提到的逻辑,我们在设计缓存的时候,有两个基本逻辑:

  1. 只会缓存已存在的数据,而不存在的数据是不缓存的。
  2. 查询某个键不存在,则在db中读取并重建缓存

但是这里有个问题,假设某个客户端查询的数据本身就是不存在的,则按照①,该数据永远不存在,则一定不会构建缓存;按照②,因为缓存不存在,每次都会查询DB以确认是否可以重新构建该缓存。

虽然笔者这里定义说是恶意行为,但也要区分是主观上恶意,还是过失性恶意,前者是我们要小心提防的,后者我们更多要在应用的设计(UI交互、业务培训等等)上防止用户无意中造成这样的结果。

当然,还有一种很常见的情况是客户端开发过程中,某些逻辑形成了死循环,不断向服务器发起请求引起的(表示笔者曾经折腾了一整天,才发现开发服务器不断宕机的原因是客户端写了一个while(true)循环……)

结果就是会导致该查询每次被执行的时候,都成功穿透了缓存层,并导致DB进行查询,而DB的查询要调用的资源是缓存层的十倍乃至百倍千倍,用缓存层尽可能的挡掉对DB的直接查询是我们设计缓存层的核心目的,然后在这个场景下,该盾牌被人恶意绕过了,此谓之缓存穿透。

注意,恶意绕过和由于业务流程绕过是两个问题。

解决思路

考虑到具体问题具体分析,缓存穿透的情况并不能一概而论,这里仅是一些思路:

请求参数检查
表示带实习生的时候总要强调一遍又一遍的一个原则:客户端传来的任何数据都是不可靠的,必须校验。

对客户端传入的查询参数提前进行检查。

例如,业务数据库设计上,所有的用户id都是正整数,则直接丢弃查询id为负数和小数乃至连数字都不是的查询。

延期失效

另一种场景是查询参数本身是合法的,但对于数据库没有意义。

例如,业务数据库设计上用户id是从1开始的自增长uint,当前系统有100个用户,而客户端不断传来查询id=10000的用户的请求,此时,id确实是合法的,然后却不符合数据库的实际情况,最后仍然是导致缓存层被穿透,DB要做一次没有结果的查询。

这种场景没有统一的解决思路,因为参数本身是合法的,必须考量业务情况及可靠性还有性能的平衡(也就是没有统一的答案)。

最简单的一种解决思路就是,每发生一次无效的缓存穿透,则让相同参数的缓存查询在一定时间内无法发生第二次,具体做法可以是:

// 毫无PS痕迹的伪代码

if val := cache.Get("id:14250"); val == nil{
        // 缓存不存在,重建缓存
        if val = db.Read("id:14250"); val == nil{
                // 数据库也不存在,则给该key赋一个180s过期的值
                cache.Set("id:14250",nil,180*time.Seconds)
                return nil
        }else{
                cache.Set("id:14250", val) // 一般还要设置一个过期时间,这里简化了
        }
        return val
}else{
        return val
}
一定量的数据库查询,是正常的,缓存层要解决的是异常的、多余的、没必要的查询。千万别矫枉过正,真理往前走一步,就可能变成了谬误。

也就是仍然向数据库写入该key,只是写入一个业务上表示空的值,代码中的nil仅供参考,具体业务中要结合自己的情况,设置合适的值。然后设置这个key在一定时间后会过期,这样在这个key过期前的一段时间里,缓存是不会被穿透的,其目的不是禁止缓存被穿透,而是在削平缓存被穿透产生的数据库峰值。

集合<Set>查询

同样是查询参数本身是合法的,但对于数据库没有意义的场景,还有另外一种思路,把所有合法的用户id存到一个Set中,在校验请求参数的同事,检查请求参数中的用户id是否存在于这个Set,如果存在则继续,不存在则直接返回。

这种实现需要注意如果查询的参数很多,要同步所有可用参数的结果,业务逻辑上做起来并不容易。

布隆在此:有一种布隆过滤器(Bloom Filter)可以处理这种BigSet的场景,就不细说了。

缓存并发

缓存并发其实某种程度也是缓存穿透的问题,但为了细化区分,前文中描述的缓存穿透,本质上是客户端发来的“意外”请求产生的,而这里缓存并发产生的穿透,则往往网站业务并发量大的时候产生的,我们来看看它产生的场景。

行为简述

按一般的行为,如果一个缓存key失效了,我们会在重新向DB层请求数据,并降新的数据存如缓存层,但想象一个这样的场景,如果服务的并发访问量很高,同时有1000个客户端向服务器请求查询用户1的信息,而此时凑巧由于某些原因(例如缓存服务刚重启、该缓存刚刚过期),则此时会有多个线程查询key不存在,然后调用数据库查询用户1的情况,则此时会凭空产生999个多余的数据库查询,因为这999个请求只要等一下,等第1个DB查询的请求完成并顺利写入缓存以后,就可以顺利从缓存拿数据了。

并发鸭梨过大很有可能瞬间打爆后端的DB服务。

解决思路

这跟做异步开发时遇到的问题其实是一样的,事实上WebServer基本都是一个并发程序,遇到并发的问题也不出奇,那么怎么解决这个问题呢?同样从并发式编程思路可以找到基本答案——锁。

基本思路就是利用缓存服务某些带成功操作返回值的操作,例如Redis的SetNX方法,用于模拟锁的实现。

SetNX的含义是“只有在 key 不存在时设置 key 的值。设置成功,返回 1 。 设置失败,返回 0 ”
// 仍然是毫无PS痕迹的伪代码

for {
        if val := redis.Get("id:14250"); val == nil{
                // 缓存不存在,重建缓存
                // 通过setNX设置锁,同设置超时,防止del失败
                if redis.SetNX("id:14250:mutex", 10*time.Second) == true{
                        // 只有能拿到锁的线程可以通过db重建缓存,其他线程应等待
                        if val = db.Read("id:14250"); val == nil{
                                // 数据库也不存在,则给该key赋一个180s过期的值
                                redis.set("id:14250",nil,180*time.Seconds)
                                // 释放锁
                                redis.del("id:14250:mutex")
                                return nil
                        }else{
                                // 一般还要设置一个过期时间,这里简化了
                                redis.set("id:14250", val)
                        }
                }else{
                        // 此时表示已经有其他现在重建缓存了,等待一个随机时间以后重试
                        wait := rand.Intn(1000)
                        time.Sleep(time.Duration(wait) * time.Millisecond)
                }
                return val
        }else{
                return val
        }
}

通过模拟锁的设计,我们可以尽可能的削平重建缓存时产生高并发请求,把鸭梨拦截在DB层之外了,当然,这里其实完全可以绕过缓存层,在业务层根据语言特性自己设计锁、信号量等等也是完全可以的,好处就是把锁的业务鸭梨从缓存层直接拆走,缺点是,锁的开发并不容易,用不好容易出问题。

缓存雪崩

雪崩效应说的都是一个小因素的变化,却往往有着无比强大的力量,以至于最后改变整体结构、产生意想不到的结果。

行为简述

而缓存雪崩也是来自于设计缓存时一个看起来很合理的设计——缓存失效。

大多数情况下,缓存都是存放在内存中的,而内存的成本要原大于硬盘,所以出于成本考虑,我们一般会把热数据放在缓存中,而冷数据放在硬盘中。

那么怎么区分热数据和冷数据呢?最常见的做法莫过于给缓存设置一个失效时间,当某个缓存寿终正寝时,则将该缓存逐出缓存服务,以空出空间给更多的用户;而如果又有用户需要访问这个缓存,则重建缓存。这样,既保持了缓存中数据的时效性,同时也防止冷门缓存一直占着宝贵的内存却无人问津。

这是一个很伟大的思路,简单,实用!

但,在高并发的场景下,这里又会产生新的问题,在上一节的“缓存并发”中我们见识了同一个key在失效时被高发访问引起DB层崩溃的场景,那么这里我们就要考虑不同的key刚好在同一个时间点失效的问题了。

记得一位伟人说过,任何小问题,乘以13亿都是一个大问题

经常我们设置缓存失效时间的时候,往往给出的是一个固定值,特别是系统刚启动时,在段时间内会构建大量的缓存。

而这些缓存愉快地工作了一段时间后,又会在一个较短的时间内大量的失效,这就会导致时段性的大量不同业务的请求穿透缓存层直面DB层。

解决思路

上述两个时间点之间的较长时间内(即缓存都处于生效状态是),DB层的时间片又比较空闲,空闲不是问题,但如果忙起来的时候,会出现忙不过来的现象时,就又要考虑削峰平谷的问题了。

尽可能把不同的缓存重构时机分摊到整个系统的运行周期中,而不是在某个时间段集中处理。

简单的处理方式是给缓存的失效时间增加一个不影响大局的随机数:

// 仍然是毫无PS痕迹的伪代码

for {
        if val := redis.Get("id:14250"); val == nil{
                // 缓存不存在,重建缓存
                // 通过setNX设置锁,同设置超时,防止del失败
                if redis.SetNX("id:14250:mutex", 10*time.Second) == true{
                        // 只有能拿到锁的线程可以通过db重建缓存,其他线程应等待
                        if val = db.Read("id:14250"); val == nil{
                                // 给失效时间增加一个随机数
                                randExpire := rand.Intn(180)
                                // 数据库也不存在,则给该key赋一个180s~360s过期的值
                                redis.set("id:14250",nil,time.Duration(randExpire+180)*time.Seconds)
                                // 释放锁
                                redis.del("id:14250:mutex")
                                return nil
                        }else{
                                // 一般还要设置一个过期时间,这里简化了
                                redis.set("id:14250", val)
                        }
                }else{
                // 此时表示已经有其他现在重建缓存了,等待一个随机时间以后重试
                        wait := rand.Intn(1000)
                        time.Sleep(time.Duration(wait) * time.Millisecond)
                }
                return val
        }else{
                return val
        }
}

小结

在本文中我们简单介绍了使用缓存时遇到缓存穿透、缓存并发及缓存雪崩的问题,三种问题本质面对的都是通过缓存保护DB时,可能遇到的常见的失效原因,产生问题的方式各不相同,解决的方式看起来也都不一样,但其实核心的思路都是一样的,就是“大而化之,削而分之”。

虽然笔者这里整理了几种解决方案,但切记的一个问题是,缓存是直面业务的服务,所以没有通用的解决方案,而是要根据业务情况具体分析再处理,本文仅供抛砖引玉。

Alt text


天高愉悦
8 声望1 粉丝

引用和评论

0 条评论