缓存(三):如何解决缓存穿透、雪崩

86 阅读14分钟

之前的缓存文章,如有错误欢迎大家批评指正,另外有想看的内容,随时私信call我~

缓存(一):系统响应变慢?谈谈缓存的作用

缓存(二):剖析缓存模式的选择

我们在实际业务场景中,为了提升系统性能,提高响应效率,我们引入缓存,但谈到缓存离不开缓存命中率,若系统的缓存命中率低的话,一旦大量的查询请求到系统,这些请求会因为无法命中缓存而穿透到数据库中,而数据库承受并发的能力比较脆弱,一旦数据库承受不住这些并发请求,则可能会导致数据库查询变慢,请求阻塞到数据库查询中,甚至会出现资源被占满,数据库崩溃,从而导致服务崩溃不可用。

既然缓存穿透对系统的冲击那么大,我们就有必要知道了解这些缓存异常情况,面对这些情况下,该如何减少甚至避免穿透的发生,以及了解这些问题的对应处理手段。

一、缓存穿透

缓存穿透指当请求从缓存中没有查到需要返回的数据,需要到数据库中查询数据,而数据库也没有查询到数据,即请求访问缓存,发现缓存缺失,再去访问数据库发现数据库也没有需要的数据。

当并发请求持续发生缓存穿透的话,应用程序持续有大量请求访问数据,则会同时对缓存和数据库造成巨大压力。

image.png

当然,少量的缓存穿透是不可避免的,这些缓存穿透对于系统的影响也不会特别的大。

缓存的使用涉及到内存的占用,当数据量大的时候,由于缓存系统在容量上并不是无限的,我们无法将所有的数据都加载到缓存中,那么当请求查询未缓存的数据时,则会发生缓存穿透。

不过,有些事物是可以有主次之分的,一般来说最重要的部分通常只占20%,而其他的80%并没有那么重要,在我们的系统中,数据也同样可以遵循这样的原则,应用到数据访问的领域,就是我们会经常访问20%的热点数据,而另外的80%的数据则不会被经常访问。

因此,在缓存容量有限的情况下,我们可以根据实际的业务,将系统中大部分需要访问的热点数据加载到缓存中,这样就可以有效的避免大量并发请求达到数据库,从而达到保护系统。而大部分非热点数据,即使没有加载到缓存,也不用过于担心,这些数据即使发生缓存穿透,也只是少量的请求打入到数据库中,对系统是没有损害的。

什么样的缓存穿透对系统有害呢?答案显而易见,就是热点数据的缓存访问失效或者非法请求查询不存在的数据,大量的穿透请求超过了后端系统的承受范围,就会造成了后端系统的崩溃。

那么我们该如何解决缓存穿透呢?

1、缓存空值

缓存穿透的问题在于请求查询指定的数据但却查询不到,导致大量请求查询相同数据却始终查询不到,穿透持续发生。

那么,当我们从数据库查询数据时,如果没有查询到数据或者查询发生异常时,我们可以在缓存中加载一个空值。

nilValue := struct{}{}
valueFromDB, err := getFromDB(uid) //从数据库中查询数据
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
    return
}
if valueFromDB != nil {
    // 如果从数据库中查询到有效值,设置较长的超时时间
    err = cache.set(uid, valueFromDB, 1000)
} else {
    // 如果从数据库中查询到空值,就把空值写入缓存,设置较短的超时时间
    err = cache.set(uid, nilValue, 10)
}
if err != nil {
    return
}

虽然缓存空值能够抵挡大量的缓存穿透,但是如果大量的请求查询不存在的数据,则缓存会加载大量的空值,会造成缓存空间的浪费,如果缓存空间被占满,还有可能会淘汰掉一些已经缓存好的有效数据。

因此,使用这种方法应该评估一下实际业务场景的缓存容量是否能够支撑使用这种方案,同样也要监控缓存的内存占用情况。

2、限制非法请求

刚才我们聊到大量请求去请求不存在的数据,其实在我们的实际业务中,很少有正常请求去请求不存在的数据,因此很有可能这些请求是一些非法的请求,因此在请求入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。

3、布隆过滤器

如果你的系统容量无法支持大量空值的加载,无法通过回种空值的方式来解决,那么你可以考虑使用布隆过滤器。

什么是布隆过滤器?

布隆过滤器常用于判断一个元素是否在集合中,布隆过滤器通过二进制数组和Hash算法实现,它的基本思路在于将数据集合中的每一个元素,通过Hahs算法计算出Hash值,再对Hash值取模后计入二进制数组,将指定位置的值从0改为1。当判断一个元素是否在集合中时,同样也通过Hash算法并取模获取到指定位置,如果这个位置的值为 1 就认为这个元素在集合中,否则则认为不在集合中。

image.png

那么布隆过滤器是如何解决缓存穿透的?

首先我们可以初始化大容量数组与N个Hash函数,例如长度为10亿的数组。

写入数据时:

  • 通过N个Hash函数计算出N个Hash值
  • 将N个Hash值取模得到对应的N个数组下标
  • 将N个数组下标位置从0标记为1

读取数据时:

  • 通过N个Hash函数计算出N个Hash值
  • 将N个Hash值取模得到对应的N个数组下标
  • 检查这N个下标的值为否为1,如果都为1则表示数据可能存在,执行后续查询,如果不都为1,则说明数据不存在,直接返回。

image.png

我们可以简单实现一个布隆过滤器,但一般情况下我们会使用Redis等分布式缓存来实现布隆过滤器。

package BloomFilter

import (
    "hash/fnv"
    "sync"
)

// BloomFilter 布隆过滤器
type BloomFilter struct {
    bitset []uint8               // bit数组
    size   uint64                // 数组长度
    hashes []func([]byte) uint64 // 哈希函数
    mu     sync.RWMutex          // 读写锁,防止并发读写
}

// NewBloomFilter 初始化布隆过滤器对象
func NewBloomFilter(size uint64, numHashes int) *BloomFilter {
    bf := &BloomFilter{
       bitset: make([]uint8, 0, size),
       size:   size,
    }

    // 初始化Hash函数
    for i := 0; i < numHashes; i++ {
       bf.hashes = append(bf.hashes, bf.createHashFunc(uint64(i)))
    }

    return bf
}

// 创建Hash函数
// seed 是一个用于区分不同哈希函数的种子值。通过将种子值添加到哈希计算中,可以生成不同的哈希值,从而减少哈希碰撞的可能性。
func (bf *BloomFilter) createHashFunc(seed uint64) func([]byte) uint64 {
    return func(data []byte) uint64 {
       h := fnv.New64a()           // 创建了一个新的 FNV-1a 64 位哈希对象
       h.Write(data)               // 将输入数据(data)写入哈希对象 h
       h.Write([]byte{byte(seed)}) // 将种子值(seed)写入哈希对象 h
       // 取模返回
       return h.Sum64() % bf.size
    }
}

// Add 向布隆过滤器添加元素
func (bf *BloomFilter) Add(item string) {
    bf.mu.Lock()
    defer bf.mu.Unlock()

    for _, hash := range bf.hashes {
       index := hash([]byte(item))
       bf.bitset[index] = 1
    }
}

// Query 在布隆过滤器中查询元素是否存在
func (bf *BloomFilter) Query(item string) bool {
    bf.mu.RLock()
    defer bf.mu.RUnlock()
    for _, hash := range bf.hashes {
       index := hash([]byte(item))
       if bf.bitset[index] == 0 {
          return false
       }
    }
    return true
}

布隆过滤器拥有极高的性能,无论是写入操作还是读取操作,时间复杂度都是 O(1) 是常量值。当大量请求打到布隆过滤器时,大量请求只会查询布隆过滤器,只有数据存在才会查询数据库,保证了数据库能正常运行。

布隆过滤器固然有极高的性能,但是任何事物都有两面性,布隆过滤器也不例外,它主要有两个缺陷

  1. 布隆过滤器在判断数据是否存在时,可能存在误差,例如在查询不存在的数据时,也会通过布隆过滤器的筛选。
  2. 布隆过滤器不支持元素的删除。

造成关于第一种缺陷的主要原因在于Hash算法的问题,因为布隆过滤器是由一个二进制数组和一个 Hash 算法组成的,Hash 算法存在着一定的碰撞几率。Hash碰撞即不同的输入值,计算出来的Hash结果可能相同。

例如,当我们使用两个不同user_id参数AB查询用户时,由于Hash碰撞AB会计算出相同的Hash值,但可能数据库中只有A的数据而没有B的数据,因此导致了误判。

不过这种误判对于解决缓存穿透来说非常的适合。当布隆过滤器判断数据存在时,该数据可能存在于缓存或数据库中,但当布隆过滤器判断这个元素不存在时,则数据一定不在数据库中。

因此,布隆过滤器虽然存在误判的情况,但是能极大的减少缓存穿透的情况发生,我们只需要尽力减少误判,提高布隆过滤器的判断正确率,而减少误判的方法可以通过增加Hash算法,计算更多的Hash值与标记位。

另外,布隆过滤器不支持删除元素,这也与Hash算法有关,因为如果AB两个数据计算出来的Hash值相同,那么当从布隆过滤器删除A数据时,也会导致B数据在布隆过滤器的标记也清除,这样请求B数据时,很可能会被布隆过滤器拦下来,导致误判,查询不到B数据。

要解决布隆过滤器不支持删除元素的问题,可以将布隆过滤器数组中不存储01两个值,而是存储一个计数值,比如如果AB同时命中了一个数组的索引,那么这个位置的值就是2,如果A被删除了就把这个值从2改为1,当然这种方式也会导致布隆过滤器占用更多的内存空间。所以,你要依据业务场景来选择是否能够使用布隆过滤器。

布隆过滤器的使用总结下来,可以有两点:

  • 如果需要减少布隆过滤器的误判几率,增加正确性,可以增加Hash函数,计算多个Hash值与数组下标位置。
  • 布隆过滤器会占用一定的内存空间,因此在实际使用时,需要评估数据量与布隆过滤器占用的内存空间,衡量存储成本。

二、缓存雪崩

通常我们在使用缓存,都会为缓存设置过期时间,但缓存过期后,系统会重新加载新的缓存数据。

缓存雪崩指的是缓存中大量的缓存数据在同一时间过期,大量的应用访问请求无法在缓存中进行处理,发生缓存缺失,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增,从而影响到系统。

image.png

对于大量数据同时失效带来的缓存雪崩问题,可以通过两种办法解决

  • 我们可以在给缓存数据设置过期时间时,应该避免将大量数据设置成同一过期时间,例如给这些数据的过期时间增加一个随机数,打乱这些过期时间,让不同的数据过期时间有所差别,避免大量数据同时过期。
  • 另一种方法可以采取服务降级,当发生缓存雪崩时,针对不同的数据采取不同的处理方法。例如业务中的热点数据,当缓存失效时,允许继续执行查询数据库,而非热点数据,可以拦截数据库查询,转而返回预定义信息或默认值。

另外一种缓存雪崩的情况则是Redis缓存实例发生故障宕机,无法处理大量请求,从而导致数据库压力激增。

面对这种缓存雪崩情况,同样也有两种办法解决

1、在系统中实现服务熔断请求限流。服务熔断指的是当发生缓存雪崩时,为了防止大量请求压垮数据库导致整个系统崩溃,暂停应用程序对于缓存组件的访问,转而直接返回响应,等待Redis实例恢复后,再重新访问缓存组件。

服务熔断虽然可以防止数据库因缓存雪崩而被压垮,暂停了整个缓存组件的访问,对业务的影响也会比较大,在实际业务系统运行时,同样也需要我们实时监控缓存与数据库的负载指标(cpu利用率、内存占用、每秒请求数)。

image.png

另外,为了尽可能减少这种影响,我们也可以进行请求限流。简单来说,就是减少请求进入我们的系统,从而减少数据库的压力,我们可以在业务系统的请求入口控制每秒进入系统的请求数,避免过多的请求被发送到数据库。

2、通过搭建主从节点构建Redis缓存高可用集群,当Redis主节点故障宕机时,会通过选举的方式将从节点切换成主节点,继续提供缓存服务,避免实例宕机从而导致缓存服务不可用,从而解决缓存雪崩的问题。

三、缓存击穿

缓存击穿其实属于缓存雪崩的变种,通常系统都会存在部分经常被访问的数据,即我们所谓的热点数据,而缓存击穿实际上就是针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,当大量请求访问该热点数据,缓存无法处理,导致了数据库压力激增,会影响数据库处理其他请求。

解决缓存击穿同样也可以通过两种办法来解决:

  • 当业务程序发现某个热点缓存数据失效后,启动后台线程加载热点数据到缓存中,在缓存加载完成热点数据期间,所有访问该热点数据的请求都直接返回。
  • 另一种办法,则是通过设置分布式锁的方式,当缓存失效时,只有获取到分布式锁的请求,才能够继续执行查询数据库,并将数据加载到缓存,利用锁的方式来防止大量的热点数据访问请求冲击数据库。

四、总结

当你发现系统中缓存的命中率有所下降时,则需要考虑是否发生了缓存失效的问题,当出现缓存穿透、缓存雪崩以及缓存击穿等问题时,核心的思想在于减少对于数据库访问的冲击,减少数据库的并发请求,防止冲垮数据库从而导致服务不可用。