Redis系列1:深刻理解高性能Redis的本质
Redis系列2:数据持久化提高可用性
Redis系列3:高可用之主从架构
Redis系列4:高可用之Sentinel(哨兵模式)
Redis系列5:深入分析Cluster 集群模式
追求性能极致:Redis6.0的多线程模型
追求性能极致:客户端缓存带来的革命
Redis系列8:Bitmap实现亿万级数据计算
Redis系列9:Geo 类型赋能亿级地图位置计算
Redis系列10:HyperLogLog实现海量数据基数统计
Redis系列11:内存淘汰策略
Redis系列12:Redis 的事务机制
Redis系列13:分布式锁实现
Redis系列14:使用List实现消息队列
Redis系列15:使用Stream实现消息队列
Redis系列16:聊聊布隆过滤器(原理篇)
Redis系列17:聊聊布隆过滤器(实践篇)
Redis系列18:过期数据的删除策略
Redis系列19:LRU淘汰内存淘汰算法分析
Redis系列20:LFU淘汰内存淘汰算法分析
缓存与数据库的数据一致性指的是,缓存中存储的数据与数据库中存储的数据需保持一致。
即缓存中存有数据,缓存的数据值 = 数据库中的值;缓存中没有该数据,数据库中的值 = 最新值。数据一致性主要包含以下两种情况:
如果存在以下情况,则说明存在不一致性情况:
缓存(Redis)和 数据库(MySQL)是两套系统,所以任何一方的数据改写,都需要另一方的协同来保证。但这种协同可能存在一定的失败率,如下:
为了保持缓存和数据库的数据一致性,需要采取适当的一致性策略(如引入 2PC 或 Paxos 等分布式一致性协议,或者分布式锁),并及时处理数据库更新和缓存刷新中的错误。同时,在实现并发请求时,需要合理控制操作的顺序和时机,以避免不一致的情况发生。
我们先来了解下缓存常用的执行策略,再分析下那种策略最适合一致性保障。
缓存旁路策略也是目前业务系统最常用的策略。在Cache-Aside机制中,系统将缓存视为一个辅助的存储介质,所有的读取缓存、读取数据库和更新缓存的操作都由应用服务来完成。
读取缓存数据的步骤如下:
程序实现如下(go 语言版本伪代码):
func main() {
// 尝试从缓存获取数据
cacheValue := getFromCache("testinfo")
if cacheValue != "" {
return cacheValue
} else {
// 如果缓存缺失,则从数据库获取数据
cacheValue = getDataFromDB()
// 将数据写入缓存
setInCache(cacheValue)
return cacheValue
}
}
更新数据的步骤如下:
可以看到数据库更新之后,让缓存与数据库的同步的方式有两种,一种是同步去更新缓存的value,一种是直接删除数据库。
因为高频模式下,更新够频繁,更新线程的执行先后可能导致脏数据情况,所以比较常用的方式是删除缓存使缓存数据失效来实现同步。具体优势如下:
程序实现如下(go 语言版本伪代码):
func main() {
_, err = db.Save(&user) // 保存更新后的数据到数据库
if err != nil {
log.Fatal(err)
} else {
// 更新成功之后,删除缓存
err = deleteCache(id)
}
}
整体优势:
Cache-Aside机制可以有效地提高系统的性能,因为缓存可以减少数据库等数据源的访问量,从而减少了系统的响应时间。同时,它还可以提高数据的可靠性和一致性,避免脏数据的出现。
当缓存未命中,从数据库读取数据,同时写到缓存中并返回给应用服务。
这个策略类似于Cache-Aside,但是它会自动从缓存中读取数据,而不需要先从数据库中读取数据。如果缓存中没有数据,则自动从数据库中读取数据,并将读取到的数据存储到缓存中。
值得注意的是,Read-Through 不对数据库与缓存的同步关注,代码只与缓存交互,由缓存组件来管理自身与数据库之间的数据同步。所以说这个策略虽然可以提高读取性能,但是可能会增加数据库的负载。
这个策略类似于Cache-Aside,但是在写入数据时,Write-Through 将写入责任转移到缓存系统,由缓存服务来执行更新,而不是先写入数据库再更新缓存。
将同步可能产生的故障处理和重试逻辑,交给缓存层来管理实现。
流程图如下:
这个策略可以提高写入性能,但是可能会降低缓存的利用率。
可以看出与Cache-Aside最大不通就是对调了顺序,更新数据时,数据先写缓存,接着由缓存组件将数据同步到数据库。
这个策略类似于Write-Through,但是在写入数据时,它会异步地更新缓存和数据库,而不是立即更新。这个策略可以提高写入性能,但是可能会增加数据库的负载和缓存的不一致性。
流程如下:
这种策略下,缓存与数据库存在一定程度的不一致性,对强要求一致的系统不建议使用。
我们在业务场景中最经常使用的是 Cache-Aside 策略。在该策略下,先从缓存中读取数据,如果缓存中没有数据,则从数据库中读取数据,并将读取到的数据存储到缓存中。在写入数据时,先写入数据库,然后更新缓存中的数据。
可以看出,读操作不会导致缓存与数据库的不一致。而写操作则存在风险,数据库和缓存毕竟是两套系统,如果都需要进行修改,它们的先后顺序可能导致数据不一致。
上面其实我们有讨论过,更新的时候有两种办法,一种是将数据库的修改更新到缓存;一种是直接删除缓存,等有需要调用的时候再去更新缓存。
所以规避一致性风险的时候,我们需要考虑的有:
在对这四种的方案的分析过程中,我们考虑两个点:
如图可以看出:如果缓存更新成功,数据库更新失败,就会导致数据库和缓存的数据不一致,那缓存就是脏数据了。
而查询的时候,会从缓存中查询到数据库不一致的数据,这样的数据是不正确的。
如图可以看出:
综上,高并发场景中,多线程同时写缓存写数据库时,很容易出现双值不一致的情况。
如图可以看出:
综上,这种情况也有很大缺陷,不论是异常情况还是高并发场景,都可能导致数据不一致。
如图可以看出:
可以看出,可能出现短暂的少量读取旧值的情况,但是很快缓存就会被删除,然后从数据库获取最新的值并更新到缓存。
之后的请求都能获取最新数据,这个方案比之前的三种都好很多。
延迟双删策略主要是是应对先删除缓存,再更新数据库的场景。
我们知道在这种场景中,很容易因为删除数据库太慢导致重新获取的缓存依旧读是数据库旧值,读完旧值之后,数据库才更新完毕。这时候缓存的数据就跟数据库不一致了。参考 3.3 节。
所以这边多加了一个步骤,就是在数据库更新完成之后,再删除一次缓存。所以步骤如下:
这时候唯一存在的一个问题就是,在(更新据库 + 休眠 n ms) 这个时间窗口中,依旧能读取到旧值,而这个短暂时间控制的好的话,是可以接受的。
无论是先更新数据库,再删除缓存;还是先删除缓存,在更新数据库。
保持事务性都是一种方案,如果删除缓存失败,则数据库更新会被回滚;如果更新数据库失败,则缓存也不会被删除。这个需要一致性策略接入。不过无论怎么做,这个都会在一定程度上影响执行完成的性能。
接着 4.1 的模式,如果双删还是失败呢,那可咋整,还是会产生缓存和数据库数据不一致的现象。
一般的做法是做一层兜底,比如记录日志,人工来处理;或者通过MQ来发布消息,然后开发一个独立的服务来订阅,专门用于数据清理,这就将操作异步化了。
我们知道,数据库有类似Binlog之类的东西,记录每次数据的更新。所以我们有另外一种方案,就是把同步缓存的操作交给独立的能力中,从应用层解耦。
图中我们可以看到步骤如下: