缓存是高并发零碎架构中的利器,通过利用缓存,零碎能够轻而易举的扛住成千上万的并发拜访申请,但在享受缓存带来的便当的同时,如何保障数据库与缓存的数据一致性,始终是一个难题,在本篇文章中分享如何在零碎架构中保障缓存一致性问题。
概述
在介绍如何解决数据库与缓存的一致性问题前,先来理解一下两个问题——什么是数据库和缓存的一致性问题(What)和为什么会呈现数据库和缓存的数据一致性问题(Why)。
什么是数据库和缓存的数据一致性问题
首先来理解下咱们始终在说的数据一致性问题到底是什么。CAP 实践置信大家都曾经耳熟能详了,只有是做分布式系统开发的应该根本都据说过,C 示意一致性(Consistency)、A 示意可用性(Availability)、P 示意分区容错性(Partition tolerance),CAP 实践论述了这三个元素最多只能同时实现两个,不可能三者兼顾。这里对一致性的定义是——在分布式系统中的所有数据备份,在同一时刻是否同样的值。
因而,咱们能够把数据库和缓存中的数据了解为两份数据正本,数据库与缓存的数据一致性问题等同于如何保障数据库与缓存中的两份数据正本的数据一致性问题。
为什么会呈现数据库和缓存的数据一致性问题
在业务开发中咱们个别通过数据库事务的四大个性(ACID)来保证数据的一致性。到了分布式环境中,因为没有相似事务的保障,因而容易呈现局部失败的状况,比方数据库更新胜利,缓存更新失败,或者缓存更新胜利,数据库更新失败的状况等等,总结一下会导致数据库和缓存的数据不一致性的起因。
网络
在分布式系统中默认网络是不稳固的。因而在 CAP 实践下,个别认为网络起因导致的失败是无奈防止的,零碎的设计个别会抉择 CP 或者 AP,就是这个起因。操作数据库和缓存都波及到网络 I /O,很容易因为网络不稳固导致局部申请的失败,从而导致数据不统一。
并发
在分布式环境下,如果不显式同步的话,申请是会被多个服务器结点并发解决的。看上面这个例子,假如有两个并发申请同时更新数据库中的字段 A,过程 1 先更新字段 A 为 1 并更新缓存为 1,过程 2 更新字段 A 为 2 并更新缓存为 2,因为在并发的状况下无奈保障时序,就会呈现上面的这种状况,最终的后果就是数据库中字段 A 的值为 2,缓存中的值为 1,数据库与缓存的数据不统一。
过程 1 | 过程 2 | |
---|---|---|
工夫点 T1 | 更新数据库字段 A = 1 | |
工夫点 T2 | 更新数据库字段 A = 2 | |
工夫点 T3 | 更新缓存 KEY A = 2 | |
工夫点 T4 | 更新缓存 KEY A = 1 |
读写缓存的模式
在工程实际中,读写缓存有几种通用的模式。
Cache Aside
Cache Aside 应该是最罕用的模式了,在许多的业务代码中都是通过这种模式来更新数据库和缓存的。它的次要逻辑如下图所示。
先判断申请的类型,针对读申请和写申请别离做不同的解决:
写申请 :先更新数据库,胜利后再生效缓存。
读申请:先查问缓存中是否命中数据,如果命中则间接返回数据,未命中则查询数据库,胜利后更新缓存,最初返回数据。
这种模式实现起来比较简单,在逻辑上看起来也没什么问题,读申请逻辑的实现在 Java 中为了防止代码反复,个别会通过 AOP 的形式。
Cache Aside 模式在并发环境下是会存在数据一致性问题的,比方上面表格形容的这种读写并发的场景。
读申请 | 写申请 | |
---|---|---|
工夫点 T1 | 查问缓存的字段 A 的值未命中 | |
工夫点 T2 | 查询数据库失去字段 A =1 | |
工夫点 T3 | 更新数据库字段 A = 2 | |
工夫点 T4 | 生效缓存 | |
工夫点 T5 | 设置缓存 A 的值为 1 |
读申请查问字段 A 的缓存,然而未能命中,而后查询数据库失去字段 A 的值为 1,同时写申请将字段 A 的值更新为 2,因为是并发的申请,写申请中生效缓存的操作先于读申请中设置缓存的操作,导致在读申请中设置了字段 A 的缓存值为 1 且未能被正确生效,造成了缓存脏数据,如果这里还不设置缓存过期工夫的话,那么数据就始终是错的。
Read Through
Read Through 模式和 Cache Aside 模式十分相似,区别在于在 Cache Aside 模式中,如果读申请中未能命中缓存,须要咱们本人实现查询数据库再更新缓存的逻辑,而在 Read Through 模式中,则不须要关怀这些逻辑,咱们只跟缓存服务打交道,由缓存服务来实现缓存的加载,举个 Java 中罕用的 Guava Cache 的例子来阐明,看上面这段代码。
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
.maximumSize(1000)
.build(new CacheLoader<Key, Graph>() {public Graph load(Key key) throws AnyException {return createExpensiveGraph(key);
}
});
...
try {return graphs.get(key);
} catch (ExecutionException e) {throw new OtherException(e.getCause());
}
在这段代码中咱们应用了 Guava 中的 CacheLoader 来替咱们加载缓存。在读申请中,当调用 get 办法时,如果产生 Cache Miss,那么由 CacheLoader 来负责加载缓存,而咱们的代码只跟 graphs 这个对象打交道,不必关怀底层加载缓存的细节,这就是 Read Through 模式。
Read Through 模式与 Cache Aside 模式在逻辑上没有本质区别,只不过 Read Through 模式在实现上代码会更简洁,因而同样的,Read Through 模式也会呈现 Cache Aside 模式中的并发导数据库和缓存数据不统一的问题。
Write Through
Write Through 模式的逻辑与 Read Through 模式有点相似,在 Write Through 模式下所有的写操作都要通过缓存,而后依据写的时候是否命中缓存再执行后续逻辑。
Write-through: write is done synchronously both to the cache and to the backing store.
在 Wikipedia 上对 Write Through 模式的定义强调了该模式下在写申请中,会同步写缓存和数据库,只有缓存和数据库都写胜利了才算胜利。次要逻辑如下图所示。
Write Through 模式在产生 Cache Miss 的时候,只会在读申请中更新缓存。写申请在产生 Cache Miss 的时候不会更新缓存,而是间接写入数据库,如果命中缓存则先更新缓存,由缓存本人再将数据写回到数据库中。怎么了解由缓存本人将数据写回到数据库中呢,这里举个 Ehcache 应用的例子。在 Ehcache 中,CacheLoaderWriter 接口实现了 Write Through 模式,在这接口中定义了一系列的 Cache 生命周期的钩子函数,其中有两个办法如下:
public interface CacheLoaderWriter<K, V> {void write(K var1, V var2) throws Exception;
void writeAll(Iterable<? extends Entry<? extends K, ? extends V>> var1) throws BulkCacheWritingException, Exception;
}
只须要实现这两个 Write 相干的办法,即能够实现在更新缓存时,将数据写入底层的数据库,也就是说在代码中只须要跟 CacheLoaderWriter 交互即可,不须要同时实现更新缓存和写入数据库的逻辑。
回过头来再看下 Write Through 模式的逻辑,发现在读申请的解决上跟 Read Through 模式根本是一样的,所以 Read Through 模式和 Write Through 模式能够配合应用。
那么 Write Through 模式有没有 Read Through 模式在并发的场景下的一致性问题呢?显然是有的,而且产生不统一问题的起因跟 Read Through 模式也是相似的,都是因为更新数据库和更新缓存的时序在并发场景下无奈保障导致的。
Write Back
Write-back (also called write-behind): initially, writing is done only to the cache. The write to the backing store is postponed until the modified content is about to be replaced by another cache block.
还是先来看下 Wikipedia 上对 Write Back 模式的定义——该模式在写申请中只会写入缓存,之后只有在缓存中的数据要被替换出内存的时候,才会写入底层的数据库。Write Back 模式与 Write Through 模式的次要区别有两点:
- Write Through 模式是同步写入缓存和数据库,而 Write Back 模式则是异步的,在写申请中只写入缓存,后续会异步地将数据从缓存再写入底层数据库,而且是批量的。
- Write Back 模式在写申请中产生 Cache Miss 时,会将数据从新写入到缓存中,这点是与 Write Through 模式也是不同的。因而,Write Back 模式的 Read Cache Miss 和 Write Cache Miss 的解决是相似的。
Write Back 模式的实现逻辑比较复杂,次要起因是该模式须要 track 哪些是“脏”数据,在必要的时候写入底层存储,且如果有屡次更新的话,还须要做批量的合并写入,Write Back 模式实现逻辑的图这里就不贴了,如果有趣味的话,能够参考 Wikipedia 上的图。
既然是异步的,那 Write Back 模式的益处就是高性能,不足之处在于无奈保障缓存和数据库的数据一致性。
思考
通过观察以上三种模式的实现,能够看出一些在实现上的差别点——到底是删除缓存还是更新缓存,先操作缓存还是先更新数据库。上面的表格中列举了所有可能呈现的状况,其中 1 示意缓存与数据库中的数据统一,0 则示意不统一。
缓存操作失败 | 数据库操作失败 | |
---|---|---|
先更新缓存,再更新数据库 | 1 | 0 |
先更新数据库,再更新缓存 | 0 | 1 |
先删除缓存,再更新数据库 | 1 | 1 |
先更新数据库,再删除缓存 | 0 | 1 |
以上状况都是在 不思考 将缓存的操作放到数据库事务中(个别不倡议将非数据库操作放到事务中,比方 RPC 调用、Redis 操作等等,起因是这些内部操作往往会依赖网络等不牢靠因素,一旦呈现问题,容易导致数据库事务无奈提交或者造成“长事务”问题)。
能够看到,只有“先删除缓存,再更新数据库”这种模式在局部失败的状况下能保证数据的一致性,因而咱们能够得出结论 1——“先删除缓存,再更新数据库”是最优的计划。然而,先删除后更新的模式,容易造成缓存击穿的问题,对于这个问题会在前面细说。
除此之外,咱们还能够察看到,Cache Aside/Read Through/Write Through 三种模式在并发场景下都存在缓存与数据库数据不统一的问题,且起因都是在并发场景下,无奈保障更新数据库与更新缓存的时序,导致更新数据库先于写入缓存产生,而写入的缓存是旧数据,从而产生数据不统一问题。基于此,咱们能够得出结论 2——只有某种模式能解决这个问题,那么这种模式就能够在并发环境下保障缓存与数据库的数据一致性。
察看以上三种模式之后,发现的最初一点是一旦产生缓存和数据库的数据不统一问题之后,如果数据不再更新,那么缓存中的数据始终是错的,不足一种补救的机制,因而能够得出结论 3——须要有某种缓存主动刷新的机制。最简略的形式是给缓存设置上过期工夫,这是一种兜底伎俩,避免万一产生数据不统一的时候数据始终是谬误的。
基于以上三个论断,再来介绍上面两种模式。
延时双删
提早双删模式能够认为是 Cache Aside 模式的优化版本,次要实现逻辑如下图所示。
在写申请中,首先依照下面咱们得出的最佳实际论断,先删除缓存,再更新数据库,而后发送 MQ 音讯,这里的 MQ 音讯能够由服务本人来发送,也能够通过一些中间件来监听 DB 的 binlog 变动的音讯来实现,监听音讯后须要提早一段时间,提早的实现形式能够应用音讯队列的提早音讯性能,或者在生产端接管到音讯后自行 sleep 一段时间,而后再次删除缓存。伪代码如下。
// 删除缓存
redis.delKey(key);
// 更新数据库
db.update(x);
// 发送提早音讯,提早 1s
mq.sendDelayMessage(msg, 1s);
...
// 生产提早音讯
mq.consumeMessage(msg);
// 再次删除缓存
redis.delKey(key);
读申请的实现逻辑同 Cache Aside 模式,当发现未命中缓存时,将在读申请中从新加载缓存,同时要设置给缓存设置上正当的过期工夫。
相比 Cache Aside 模式,这种模式肯定水平上升高了呈现缓存和数据库数据不统一问题的可能性,但也仅仅是升高,问题仍然还是存在的,只不过呈现的条件更严苛了,看上面这种状况。
读申请 | 写申请 | |
---|---|---|
工夫点 T1 | 查问缓存的字段 A 的值未命中 | |
工夫点 T2 | 查询数据库失去字段 A =1 | |
工夫点 T3 | 更新数据库字段 A = 2 | |
工夫点 T4 | 生效缓存 | |
工夫点 T5 | 发送提早音讯 | |
工夫点 T6 | 生产提早音讯并生效缓存 | |
工夫点 T7 | 设置缓存 A 的值为 1 |
因为音讯的生产和读申请是并发产生的,因而生产提早音讯后生效缓存和读申请中设置缓存的时序仍然是无奈保障的,还是会呈现数据不统一的可能性,只不过概率变得更低了。
同步生效并更新
联合以上几种模式的劣势和有余,在理论的我的项目实际中自己采纳了另外一种模式,我把它命名为“同步生效并更新模式”,次要实现逻辑如下图。
这种模式的思路是在读申请中只读缓存,把操作缓存和数据库都放在写申请中,并且这些操作都是同步的,同时为了避免写申请的并发,在写操作上须要减少分布式锁,获取到锁之后能力进行后续的操作,这样一来,就打消了所有可能因为并发而导致呈现数据不统一问题的可能性。
这里的分布式锁能够依据缓存的维度来确定,不须要应用全局锁,比方缓存是订单纬度的,那么锁也能够是订单纬度的,如果缓存是用户纬度的,那么分布式锁就能够是用户纬度的。这里以订单为例,写申请的实现伪代码如下:
// 获取订单纬度的分布式锁
lock(orderID) {
// 先删除缓存
redis.delKey(key);
// 再更新数据库
db.update(x);
// 最初从新更新缓存
redis.setEx(key, 60s);
}
这种模式的益处是根本能保障缓存和数据库的数据一致性,在性能方面,读申请根本是性能无损的,写申请因为须要同步写数据库和缓存,会有肯定的影响,然而因为互联网大部分业务都是读多写少,相对来说影响也不是很大。当然,这种模式同样也有有余,次要有以下两点:
写申请强依赖分布式锁
在这种模式下写申请是强依赖分布式锁的,如果第一步获取分布式锁失败,那么整个申请都失败了。在失常的业务流程中个别是通过数据库的事务来保障一致性的,在某些要害业务场景,除了事务,还会应用分布式锁来保障一致性,所以这么来看分布式锁原本就有许多的业务场景下会应用,并不能齐全算是额定的依赖。而且大厂根本都有成熟的分布式锁服务或者组件,即便没有,应用 Redis 或 ZK 简略实现一个分布式锁的老本也并不高,稳定性根本也有肯定的保障。在我集体应用这种模式的我的项目实际中,根本没呈现过因为分布式锁而导致的问题。
写申请更新缓存失败会导致缓存击穿
为了谋求缓存和数据库的数据一致性,因而同步生效并更新模式将缓存和数据库的写操作都放在的写申请中,这样防止了在并发环境下,因为多处操作缓存和数据库而导致的数据不统一问题,读申请中对缓存是只读的,即便产生缓存 Miss 也不会从新加载缓存。
但也正是因为这种设计,万一产生在写申请中更新缓存失败的状况,那么如果没有后续的写申请,缓存中的数据就不会再被加载,后续所有的读申请会间接到 DB,造成缓存击穿问题。基于互联网业务的特点是读多写少,因而这种缓存击穿的可能性还是比拟大的。
解决这个问题的计划是能够应用弥补的形式,比方定时工作弥补或者 MQ 音讯弥补,能够是增量的弥补,也能够是全量的弥补,集体的教训是倡议最好要加上弥补。
其它一些须要关注的问题
有了正当的缓存读写模式后,再来看看为了保障缓存和数据库的数据一致性须要关注的一些其余问题。
防止其余问题导致缓存服务器解体,从而导致数据不统一问题
- 缓存穿透、缓存击穿和缓存雪崩
后面也提到了在“先删除缓存,再更新数据库”的模式下会有缓存击穿问题,除了缓存击穿,相干的问题还有缓存穿透和缓存雪崩问题,这些问题都会导致缓存服务器奔溃,从而导致数据不统一,先来看看这些问题的定义和一些惯例的解决方案。
问题 | 形容 | 解决方案 |
---|---|---|
缓存穿透 | 查问一个不存在的 key,不可能命中缓存,导致每次申请都到 DB 中,从而导致数据库奔溃 | 1. 缓存空对象 2. 布隆过滤器 |
缓存击穿 | 对于设置了过期工夫的缓存 key,在某个工夫点过期的时候,恰好有大量对这个 key 的并发申请,可能导致霎时大量并发的申请把数据库压垮 | 1. 应用互斥锁(分布式锁):每次只有 1 个申请能抢到锁并从新加载缓存 2. 永远不过期:物理不过期,但逻辑上会过期(比方后台任务定时刷新等等) |
缓存雪崩 | 设置缓存的过期工夫时采纳了雷同的值,缓存在某一时刻大量过期,导致大量申请拜访数据库。缓存雪崩和缓存击穿的区别是:缓存击穿时针对单个 key,缓存雪崩是针对多个 key | 1. 扩散设置缓存过期工夫,比方减少随机数等等。2. 应用互斥锁(分布式锁):每次只有 1 个申请能抢到锁并从新加载缓存 |
在理论的我的项目实际中,个别不会谋求 100% 的缓存命中率,其次在应用“先删除缓存,再更新数据库”的模式时,失常状况下两步操作相隔工夫是很短的,不会有大量申请击穿到数据库中,因而有一些缓存击穿也是可承受的。但如果是在秒杀等并发量特地高的零碎,齐全没方法承受缓存击穿的时候,那能够应用抢占互斥锁更新或者把缓存操作放到数据库事务中,这样就能够应用“先更新数据库,再更新缓存”的模式,防止缓存穿透问题。
- 大 key/ 热 key
大 key 和热 key 问题根本都是业务设计上的问题,须要从业务设计的角度来解决。大 key 更多影响的是性能,解决大 key 的思路是将大 key 拆分为多个 key,这样能无效升高一次网络传输数据量的大小,进而晋升性能。
热 key 容易造成缓存服务器单点负载过高,从而导致服务器解体。热 key 的解决形式是减少正本数量,或者将一个热 key 拆分 多个 key 的形式来解决。
总结
须要阐明的是,下面介绍的这些模式都不是齐全的数据 强一致性 的,只能说是尽量做到业务意义上的数据最终一致性,如果肯定要强一致性保障,那么须要应用 2PC、3PC、Paxos、Raft 这一类分布式一致性算法。
最初来对下面介绍的这几种模式来个总结。
- 并发量不大或者能承受肯定工夫内缓存与数据库数据不统一的零碎:Cache Aside/Read Through/Write Through 模式。
- 有肯定的并发量或者对缓存与数据库数据一致性要求中等的零碎:提早双删模式。
- 并发量高或者对缓存与数据库数据一致性要求较高的零碎:同步生效并更新模式。
- 对数据库数据一致性要求强一致性的零碎:2PC、3PC、Paxos、Raft 等分布式一致性算法。
综上能够看到,还是那句话,架构没有银弹,在做架构设计的时候须要做各种取舍,因而在抉择和设计缓存读写模式时,须要联合具体的业务场景,比方并发量大还是小、数据一致性级别要求高还是低等等,灵活运用这些模式,必要时能够做一些变通,确定大的方向之后,再来补充细节,能力有一个好的架构设计。
参考
- 缓存更新的套路
- Cache(computing))
- Read-Through, Write-Through, Write-Behind, and Refresh-Ahead Caching
- 缓存模式(Cache Aside、Read Through、Write Through)
- 意识 MySQL 和 Redis 的数据一致性问题