1 前言
这里准备讲的一致性是指最终一致性,因为在分布式条件下,要想实现CAP里面的强一致性,就无法保证缓存可用性。在现代系统中,在保证实时性的前提下,不存在两者(DB和缓存)完全保存一致的方案,只有最终一致性方案。
2 缓存设计模式
内存是很珍贵的,容量有限且读写速度很快。怎么样利用有限的资源完成最大限度的存取是缓存设计模式要考虑的。目前流行的模式有四种:Cache aside
, Read through
, Write through
, Write behind caching
,我们一一看下。
2.1 Cache aside
即旁路缓存,在这种模式中,读取缓存
、读取数据库
和更新缓存
的操作都是在应用程序
中完成。此模式是业务系统最常用的缓存策略。
又分为读模式
和写模式
。
2.1.1 读模式
大概步骤如下:
- 应用程序接受用户端请求,先查询缓存中是否有需要的数据;
- 若缓存中没有,则需要查询数据源(一般是数据库),并将结果缓存,再返回给应用程序、用户;
- 若命中,直接返回。
2.1.2 写模式
步骤如下:
- 先更新数据库;
- 再让缓存失效。
这里更新数据时,并没有先删除缓存,再更新数据库。主要原因是这种情况存在读操作始终获取的是旧数据。
试想一下,现在有一个读操作
,一个写操作
,并发情况下,写操作删除缓存后,读操作没有命中缓存,就先把老数据读出来后放到缓存中,然后写操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。
而这种模式下,写操作完成后让缓存失效,之后的读操作没有读到数据,就从数据库中获取,此时的数据是崭新的,不会有上述的情况发生。
但是,仍然会有数据不一致的情况,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。
但是这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效
,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。
我们可以拿网上流行的解决方案瞅瞅,看看各自的优劣。
2.1.3 更新缓存
2.1.3.1 先更新缓存,再更新数据库
无并发时,如果缓存更新成功了,但数据库更新失败,那么此时缓存中是最新值
,但数据库中是「旧值」
。
虽然此时读请求可以命中缓存,拿到正确的值,但是,一旦缓存「失效」
,就会从数据库中读取到「旧值」
,重建缓存也是这个旧值
。
这时用户会发现自己之前修改的数据又「变回去」了,对业务造成影响。
并发情况下,有线程 A
和线程 B
两个线程,需要更新「同一条」数据,会发生这样的场景:
- 线程 A 更新数据库(X = 1);
- 线程 B 更新数据库(X = 2);
- 线程 B 更新缓存(X = 2);
- 线程 A 更新缓存(X = 1);
最终X
的值在缓存中是 1
,在数据库中是 2
,发生不一致。
也就是说,A 虽然先于 B 发生,但 B 操作数据库和缓存的时间,却要比 A 的时间短,执行时序发生「错乱」
,最终这条数据结果是不符合预期的。
2.1.3.2 先更新数据库,再更新缓存
同理。
2.1.4 删除缓存
2.1.4.1 先删除缓存,后更新数据库
如果有 2 个线程要并发「读写」
数据,可能会发生以下场景:
- 线程 A 要更新 X = 2(原值 X = 1);
- 线程 A 先删除缓存;
- 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1);
- 线程 A 将新值写入数据库(X = 2);
- 线程 B 将旧值写入缓存(X = 1)。
最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。
可见,先删除缓存,后更新数据库,当发生「读+写」并发时,还是存在数据不一致的情况。在并发读的情况下尤其严重,因为缓存中的值可能始终都是X=1
。
2.1.4.2 先更新数据库,后删除缓存
2.1.1、2.1.2节已解释了一些,这里举个例子。
依旧是 2 个线程并发「读写」
数据,缓存中 X
不存在(数据库 X = 1)
- 线程 A 读取数据库,得到旧值(X = 1);
- 线程 B 更新数据库(X = 2);
- 线程 B 删除缓存;
- 线程 A 将旧值写入缓存(X = 1);
最终 X
的值在缓存中是 1
(旧值),在数据库中是 2
(新值),也发生不一致。
这种情况「理论」来说是可能发生的,但实际真的有可能发生吗?其实概率「很低」,这是因为它必须满足 3 个条件
:
- 缓存刚好已失效
- 读请求 + 写请求并发
- 更新数据库 + 删除缓存的时间(步骤 2-3),要比
读数据库 + 写缓存
时间短(步骤 1 和 4)
仔细想一下,条件 3
发生的概率其实是非常低的。因为写数据库一般会先「加锁」,所以写数据库,通常是要比读数据库的时间更长的。
这么来看,「先更新数据库 + 再删除缓存」
的方案,是可以保证数据一致性的。
所以,我们应该采用这种方案,来操作数据库和缓存。生产上一般采用这种模式。只是删除缓存过程有不同的解决方案,即保证删除能够成功进行是着重解决的地方。
有两种方法:
- 重试机制
- 如果应用
删除缓存失败
,可以从消息队列中重新读取数据,然后再次删除缓存
,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。 - 如果
删除缓存成功
,就要把数据从消息队列中移除
,避免重复操作,否则就继续重试。 缺点
:代码侵入性强。
- 如果应用
流程如下所示:
- 更新数据库数据;
- 缓存因为种种问题删除失败;
- 将需要删除的key发送至消息队列;
- 自己消费消息,获得需要删除的key;
- 继续重试删除操作,直到成功。
为了保证高可靠的删除Cache记录,这里引入高可用的独立组件——Rocketmq消息队列。需要注意的是,这里引入的RocketMq消息队列是
高可用类型消息队列
,不是单节点的类型消息队列,从而保障消息记录的高可用,保障Cache的删除操作
只要没有被执行成功,就不会丢失。
- 订阅 MySQL binlog,再操作缓存。
canal [kə’næl],译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库
增量日志
解析,提供增量数据订阅和消费
。canal工作原理:canal是一个伪装成
slave
,订阅mysql的binlog
,实现数据同步
的中间件
。
2.2 Read/Write Through(读/写直通)
相比于Cache aside
需要明显地
更新两个数据源,Read/Write Through
就相当于黑盒子一样去操作了。对于应用层来说,我就只知道一个数据源即可,至于怎么更新缓存,那是你自己的事情。
2.2.1 Read Through
Read Through
套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside
是由调用方
负责把数据加载入缓存,而Read Through
则用缓存服务自己来加载。
2.2.2 Write Through
套路和Read Through
相仿,不过是在更新数据时发生。当有数据更新
的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)。
2.3 Write Behind Cache
又叫Write back
,看着挺陌生的,如果我提一嘴page cache
,你可能就恍然大悟了。Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量地
更新数据库。
这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write back
还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。
但是,其带来的问题是,数据不是强一致性的
,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性能是有冲突的。软件设计从来都是取舍Trade-Off
。
另外,Write Back
实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back
会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write
。
3 总结
这里的一致性都是最终一致性,既然用到了缓存,就得有这样的共识:肯定存在误差,不会始终完全一致,得有容忍度。如果要实现强一致性,那就得考虑一致性相关的协议,算法。比如两阶段、三阶段提交。可参考【Zookeeper学习】2.一致性协议一文。