49.Go避免大量并发访问DB、避免缓存击穿、缓存穿透、缓存雪崩以及使用延迟双删保证数据一致性


MySQLRedis是工作中最常见的两个组件,那么在使用过程中也有一些常见的问题和解决办法。本文通过 Go实现介绍它们。

一、在高并发下,如何避免大量请求直接访问数据库?

在高并发下,如果让大量请求直接访问数据库,可能会造成数据库压力过大,响应延迟上升,严重时甚至可能导致数据库崩溃。所以我们需要采取一些措施来保护数据库,避免大量请求直接访问数据库。以下是一些常见的策略:

  1. 使用缓存:缓存是最常见、最直接的方法,可以大大减少数据库的请求压力。对于读多写少的场景尤其有效。不仅如此,合理使用缓存,还可以提高系统的响应速度。但是在使用缓存时要考虑BigKeyHotKey、缓存击穿、缓存穿透、缓存雪崩以及数据一致性等众多问题。HotKeyBigKey可以看本人之前的两篇文章:
    HotKey29.Go处理Redis HotKey
    BigKey30.Go处理Redis BigKey

  2. 限流:使用限流算法(如漏桶算法、令牌桶算法)来对访问进行限制,保证系统在可接受的压力范围内运行,防止数据库被过多的请求冲垮。
    限流:13. Go中常见限流算法示例代码

  3. 熔断机制:引入熔断器,当侦测到某个服务的错误次数过多(如因数据库连接问题导致的错误)时,熔断该服务的所有请求,直到服务恢复。
    限流与熔断:48.Go简要实现令牌桶限流与熔断器并集成到Gin框架中

  4. 负载均衡:如果有足够的资源,可以采用负载均衡策略,如数据库读写分离,主从复制等,将读写压力分散到多个数据库节点上。
    负载均衡:15. Go实现负载均衡算法

  5. 使用队列:对于非实时性的数据请求,可以采用异步处理的方式,将请求先放入队列中,然后通过队列对数据库请求进行削峰填谷,避免大量请求同时涌向数据库。
    MQ:28.windows安装kafka,Go操作kafka示例

  6. 数据库优化:适当地对数据库进行优化,如合理的索引、合理的表结构设计、SQL 函数优化等,都可以提高数据库处理请求的能力。

以上就是一些避免数据库在高并发下被大量请求直接访问的策略,可以根据具体的场景和需求选择适合的策略。

二、避免缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的Key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击,即瞬时的高并发击穿了缓存去请求DB

缓存雪崩和缓存击穿的区别在于缓存击穿针对某一Key缓存,缓存雪崩则是很多Key。

由于缓存击穿是某个热点Key突然过期导致的,那么我们继续把它加载进缓存就可以了,但是不能让所有请求都去加载,只需要放行一个请求去加载,之后其他请求直接从缓存获取,避免高并发流量打挂DB,通过互斥锁即可实现。

互斥锁:在读取缓存的过程中,如果缓存未命中,则添加锁并从数据库中查询。这样可以避免在高并发下,大量的请求直接访问数据库。

注意:使用double check机制,此外,从DB查询后,如果DB也不存在,应该缓存一个空对象,否则这些高并发请求会继续请求DB。在设置空对象时,也可以设置一个较短的过期时间,避免长时间缓存空对象。

v, found := cache.Get(key)
if !found {
    lock.Lock()
    defer lock.Unlock()

    // double check
    v, found = cache.Get(key)
    if !found {
        v, err := db.Get(key)
        if err != nil {
            // handle error
            return nil, err
        }

        if v == nil {
            v = EmptyObject
        }

        cache.Set(key, v, cache.DefaultExpiration)
    }
}
return v, nil

二、避免缓存穿透

缓存穿透是指用户不断对一个缓存和数据库都不存在的数据进行访问,对于这种情况,由于既在缓存中查不到,也在数据库中查不到,于是每次都会对数据库进行一次查询,造成数据库压力增大,这种请求有可能是一个恶意攻击。

Go 语言中我们可以使用以下策略来避免缓存穿透:

缓存空对象:把空结果也进行缓存,当后续请求再次查询时,即使查不到数据,也会在缓存中得到一个空结果,而不会再对数据库进行查询。同时,为了避免未来的查询都返回空结果,需要对空结果设置一个较短的过期时间。

伪代码如下:

v, found := cache.Get(key)
if found {
    return v
}

v, err := db.Get(key)
if err != nil {
    // handle error
    return nil, err
}

if v == nil {
    v = EmptyObject
    cache.Set(key, v, cache.DefaultExpiration)
}
return v, nil

使用布隆过滤器:布隆过滤器(Bloom filter)是一种用于测试一个元素是否在一个集合中的数据结构。由于它的存储效率高且可以非常快速的查询,所以常常被用来过滤掉一部分肯定不存在的数据,避免了对数据库的无谓请求,在大数据量查找中有很高效率。

伪代码如下:

if !bloomFilter.Exists(key) {
    return nil
}

v, found := cache.Get(key)
if found {
    return v
}

v, err := db.Get(key)
if err != nil {
    // handle error
    return nil, err
}

if v == nil {
    v = EmptyObject
}

cache.Set(key, v, cache.DefaultExpiration)
return v, nil

在上面的示例中,如果布隆过滤器中不存在请求中的 key,则直接返回,不对数据库做查询。

上述两种方法可以有效应对缓存穿透,能够减轻数据库的压力,提升程序的响应速度。

三、避免缓存雪崩

缓存雪崩是指在缓存系统中,大量数据同时过期,在访问频率高的情况下可能会引起数据库的过载。

在Go语言中,我们可以采用以下措施来避免缓存雪崩:

1、设置缓存失效时间的随机性
使得每一个key的失效时间都是随机的,防止所有缓存在同一时刻全部失效。例如,我们可以在原有的失效时间基础上,增加一个随机的延长时间。

expires := baseExpires + time.Duration(rand.Intn(randExpires)) * time.Second
cache.Set(key, value, expires)

2、使用数据版本控制
通过在每次缓存数据时将数据打上版本(可以是时间戳、或是递增版本号等),每次访问时,先访问缓存,如果缓存不存在或者版本低于当前的版本,就更新缓存数据。这样,即使缓存失效,也可以由单一线程去做更新,其它请求只需要等待即可。

func Get(key string, currentVersion int) (string, error) {
    v, version, found := cache.GetWithVersion(key)
    if found && version >= currentVersion {
        return v, nil
    }

    // Single flight to load from the database and put to the cache.
    // Usually done with a lock or using sync.Once type of logic.
    // ...

    return v, nil
}

3、熔断机制和降级

在系统压力过大或者服务不可用的情况下,可以进行熔断降级,比如返回一些默认值,或者从备份缓存中读数据。

整体来说,防止缓存雪崩主要是预防工作以及在系统异常时的快速应对。每一个解决方案都有其应用场景,需要根据具体业务情况进行选择。

四、延迟双删保证数据一致性

使用到缓存,一般就需要考虑缓存与数据库的一致性。如更新时,是先更新缓存还是先更新DB,或者是先删除缓存还是先删除DB、或者是否要通过监听Binlog同步缓存,或者做一些其他旁路校验等。方案很多,需要针对当前业务是否能够容忍缓存与DB的不一致,以及容忍的程度如何来做具体涉设。但是最常用的还是延迟双删方案,成本低,容易实现,且能基本保障一致性。

延迟双删是解决缓存更新一致性问题的一个策略,具体策略如下:

  1. 先删除缓存
  2. 再更新数据库
  3. 最后延时删除缓存

这样做的目的是为了应对并发情况下缓存与数据库数据不一致的问题。

Go语言实现延迟双删的一个简单示例如下:

// 先删除缓存
cache.Delete(key)

// 更新数据库
err := db.Update(key, value)
if err != nil {
    fmt.Printf("DB update error: %v", err)
    return
}

// 延迟删除缓存
time.AfterFunc(time.Duration(delayMillisecond)*time.Millisecond, func() {
    cache.Delete(key)
})

上述代码的逻辑:

  • 首先,通过 cache.Delete(key) 删除旧的缓存数据。
  • 然后,通过 db.Update(key, value) 更新数据库数据。
  • 最后,使用 Gotime.AfterFunc 函数,实现一段时间后再次删除缓存。时间可以依据实际业务情况设定。

这种方式可以在大部分场景下确保缓存和数据库的数据一致,但还是存在极端情况下的问题,比如在第二次删除缓存之前,有其他请求把旧的数据加载到了缓存。这种情况的出现几率较小,如果业务对此有较高的要求,可能需要使用更严格的方案,如监听Binlog同步缓存。

五、在使用 Go 的 time.AfterFunc 函数时,如果删除缓存操作失败怎么办?

在使用time.AfterFunc时,如果删除缓存操作失败,最常见的处理办法是进行重试操作。不过,在设置重试次数和重试延迟时,应谨慎考虑以防止无效操作导致系统资源的浪费。

func deleteCacheWithRetry(key string, retryTimes int, delay time.Duration) {
    for i := 0; i < retryTimes; i++ {
        // 尝试删除缓存
        err := cache.Delete(key)
        if err == nil {
            // 删除成功
            return
        }
        // 如果删除失败,则等待一段时间再重试
        time.Sleep(delay)
    }
    // 在此处处理连续失败的情况,例如记录日志、发送告警等
    fmt.Printf("Failed to delete cache for key %s after %d attempts\n", key, retryTimes)
}

// 在 time.AfterFunc 中使用
time.AfterFunc(time.Duration(delayMillisecond)*time.Millisecond, func() {
    deleteCacheWithRetry(key, 3, 1*time.Second)
})

在以上代码中,我们定义了一个名为 deleteCacheWithRetry 的函数,它接受一个缓存键、重试次数和每次重试延迟的时间。它将尝试删除缓存,如果失败,将等待一段时间后重试,直到达到最大重试次数。如果超过重试次数仍未成功,将通过日志记录这个异常情况。
当然,具体的处理方式要根据项目具体需求和场景去判断,以上只是一个参考示例。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值