耦合在业务中的解决方案
写缓存一般步骤: 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防止缓存击穿避免大量请求打入到数据库中
更新/增加
- 写入到数据库中
- 监听线程获得通知,更新缓存
删除
- 写入到数据库中
- 监听线程获得通知,删除缓存