wwqdrh

耦合在业务中的解决方案

写缓存一般步骤: 1、缓存key设置失效时间,2、DB操作,再缓存失效,3、写操作都走主库,4、监听到变化数据删除缓存

读缓存一般步骤:1、判断是否走主库,是的话直接查主库,2、查询缓存,3、缓存没有去查数据库,4、防止缓存穿透可以配置个零值,5、可能由于网络延时或者应用阻塞问题导致读取到的数据是脏数据(通过分布式锁进行处理)

缓存操作主要分为更新策略、删除策略、延迟删除三种策略

方案 问题 问题出现概率 推荐程度
更新缓存 -> 更新数据库 为了保证数据准确性,数据必须以数据库更新结果为准,所以该方案绝不可行 不推荐
更新数据库 -> 更新缓存 并发更新数据库场景下,会将脏数据刷到缓存 并发写场景,概率一般 写请求较多时会出现不一致问题,不推荐使用。
更新数据库 -> 删除缓存 在更新数据库之前有查询请求,并且缓存失效了,会查询数据库,然后更新缓存。如果在查询数据库和更新缓存之间进行了数据库更新的操作,那么就会把脏数据刷到缓存 并发读场景&读操作慢于写操作,概率最小 读操作比写操作更慢的情况较少,相比于其他方式出错的概率小一些。勉强推荐。
延迟双删:淘汰缓存->写数据库->休眠1秒再次淘汰缓存(避免在写数据库的时候又被其他线程读取到了) 第二次如果阻塞删除会导致吞吐量下降(使用异步任务); 删除失败添加重试机制或者加入消息队列;极端情况下在第一次删除以及修改数据库之间,其他线程读取并写入缓存的操作由于网络问题阻塞了,第二次删除完成之后才对缓存缓存更新,这时候缓存中的数据其实是旧的数据 -- 推荐

删除缓存的时候,为了防止缓存击穿,通用的做法是使用分布式 Redis 锁保证只有一个请求到数据库,等缓存生成之后,其他请求进行共享。这种方案能够适合很多的场景,但有些场景却不适合。

  • 例如有一个重要的热点数据,计算代价比较高,需要3s才能够获得结果,那么上述方案在删除一个这种热点数据之后,就会在这个时刻,有大量请求3s才返回结果,一方面可能造成大量请求超时,另一方面3s没有释放链接,会导致并发连接数量突然升高,可能造成系统不稳定。
  • 另外使用 Redis 锁时,未获得锁的这部分用户,通常会定时轮询,而这个睡眠时间不好设定。如果设定比较大的睡眠时间1s,那么对于10ms就计算出结果的缓存数据,返回太慢了;如果设定的睡眠时间太短,那么很消耗 CPU 和 Redis 性能

防止缓存击穿

在高并发的情况下,如果删除了一个热点数据,那么此时会有大量请求会无法命中缓存,产生缓存击穿。

分布式锁

为了防止缓存击穿,通用的做法是使用分布式 Redis 锁保证只有一个请求到数据库,等缓存生成之后,其他请求进行共享。

singleflight

或者使用singleflight,加锁等待处理完成

import "sync"

type Group struct {
    mu sync.Mutex
    m map[string]*Call // 对于每一个需要获取的key有一个对应的call
}

// call代表需要被执行的函数
type Call struct {
    wg sync.WaitGroup // 用于阻塞这个调用call的其他请求
    val interface{} // 函数执行后的结果
    err error         // 函数执行后的error
}

func (g *Group) Do(key string, fn func()(interface{}, error)) (interface{}, error) {

    g.mu.Lock()
    if g.m == nil {
        g.m = make(map[string]*Call)
    }
    
    // 如果获取当前key的函数正在被执行,则阻塞等待执行中的,等待其执行完毕后获取它的执行结果
    if c, ok := g.m[key]; ok {
        g.mu.Unlock()
        c.wg.Wait()
        return c.val, c.err
    }

    // 初始化一个call,往map中写后就解
    c := new(Call)
    c.wg.Add(1)
    g.m[key] = c
    g.mu.Unlock()
    
  // 执行获取key的函数,并将结果赋值给这个Call
    c.val, c.err = fn()
    c.wg.Done()
    
    // 重新上锁删除key
    g.mu.Lock()
    delete(g.m, key)
    g.mu.Unlock()

    return c.val, c.err

}

rockscache中的策略

延迟删除法中,如果缓存中的数据不存在,那么会锁定缓存中的这条数据,因此避免了多个请求打到后端数据库(其实跟singleflight中的锁定数据是同一个道理)

防止缓存穿透

对于缓存以及数据库中都不存在的数据使用空key并且加上一定时间的过期时间

防止缓存雪崩

对缓存的过期时间加上随机值避免同时过期

解耦合的解决方案

使用消息队列保证一致

这种做法可以保证数据库更新之后,缓存一定会被更新。但这种这种架构方案很重,这几个部分开发维护成本都不低:消息队列的维护;高效轮询任务的开发与维护。

订阅操作日志

这种方案也可以保证数据库更新之后,缓存一定会被更新,但是这种架构方案跟前面的消息队列方案一样,也非常重。一方面 canal 的学习维护成本不低,另一方面,开发者可能只需要少量数据更新缓存,通过订阅所有的 binlog 来做这个事情,浪费了很多资源。

可以使用个人维护的库,wwqdrh/datamanager,具体实现示例https://github.com/wwqdrh/datamanager/blob/main/examples/cacheupdate/main.go

datamanager目前支持postgres的订阅,后续会加上MySQL

启动后台线程并监听自定义的配置策略,当获取到所关注的数据修改后去修改缓存中的数据

由于这里只是单个线程去处理数据的变更以及去修改缓存中的数据,并且读数据时都是直接读取缓存中的数据,不进行降级直接读取db数据,所以是不会存在并发读写的问题的

读取

  • 应用启动时缓存预热
  • 读取的时候从缓存中读取,不存在就手动触发监听的模块去读取数据处理并返回缓存,使用singleflight防止缓存击穿避免大量请求打入到数据库中

更新/增加

  • 写入到数据库中
  • 监听线程获得通知,更新缓存

删除

  • 写入到数据库中
  • 监听线程获得通知,删除缓存