没有一项技术是完美的,任何一项技术都存在驳论。CAP 理论也告诉我们,没有完美无缺!所以缓存一致性问题,分布式架构一致性问题,缓存与数据库不一致问题等都没有完美的解决方案。只有最适合自己业务的解决方案!

参考我前面的那篇文章《电商系统高并发场景中的缓存 DB 数据一致性问题!》所提出的问题。总结下来,用一张图来说明缓存和数据库一致性问题!

解决这类问题主要有 4 种方案。参考国外的大厂的做法,Cache Aside Pattern(缓存旁路模式)、Read through Pattern(缓存通读模式)、Write through Pattern(缓存通写模式)、Write behind caching Pattern(写后缓存模式)。这 4 种模式网上都有对应的论文,至于国内的采用延时双删策略等都是在这种基础上的一种演变。

Cache Aside Pattern 缓存旁路模式 缓存旁路模式说白了就是“先更新数据库,再删缓存”这种套路。

简单的解释一下这种策略。

*** 失效**:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。

*** 命中**:应用程序从 cache 中取数据,取到后返回。

*** 更新**:先把数据存到数据库中,成功后,再让缓存失效。

说一下 Facebook 就是采用的这种策略。

你说的这种不就是上面的第三种情况吗?如果发生更新数据库成功,删除缓存失败呢?是不是还会有数据不一致问题。

另外并发情况呢?一个请求A做查询操作,一个请求B做更新操作。那么一定有下面的情形产生:

请求A去查缓存时,缓存刚好失效。所以 A 去查数据库,得到一个值;请求B将新值写入数据库,请求B删除缓存,请求A将查到的旧值写入缓存。

上面两个情况产生后,就会发生数据不一致问题吧。

是的,Facebook 也知道这个问题。但这个 case 理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

所以,这也就是 Quora (参考文末链接)上的那个答案里说的,要么通过 2PC 或是 Paxos 协议保证一致性,要么就是拼命的降低并发时脏数据的概率,而 Facebook 使用了这个降低概率的玩法,因为 2PC 太慢,而 Paxos 太复杂。当然,最好还是为缓存设置上过期时间。

总结一下处理流程如下:

  • 更新数据库数据

  • 缓存因为种种问题删除失败

  • 将需要删除的key发送至消息队列

  • 自己消费消息,获得需要删除的key

  • 继续重试删除操作,直到成功

上图虽然可以解决这类问题,但是需要借助消息队列等中间件,太复杂了。改动也太大了,本来概率就小,对于一些一致性要求不是特别高的就更没必要了。

还有一个方案就是借助阿里的 canal 中间件,启动一个订阅程序去订阅数据库的 binlog,获得需要操作的数据。

整个流程总结如下:

  • 更新数据库数据

  • 数据库会将操作信息写入 binlog 日志当中

  • 订阅程序提取出所需要的数据以及 key

  • 另起一段非业务代码,获得该信息

  • 尝试删除缓存操作,发现删除失败

  • 将这些信息发送至消息队列

  • 重新从消息队列中获得该数据,重试操作

这样的操作同样是操作太复杂,对于 Facebook 等巨头来说得不偿失。尤其是对于一些新闻类的,少几个阅读量,评论数量,点赞数量的影响不大。

Read through Pattern 缓存通读模式 通过上面的 Cache Aside 模式可以看出,我们的应用代码需要维护两个数据,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而 Read/Write Through 模式就是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的 Cache。

Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside 是由调用方负责把数据加载入缓存,而 Read Through 则用缓存服务自己来加载,从而对应用方是透明的。

Write through Pattern 缓存通写模式 Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由 Cache 自己更新数据库。

Read/Write Through Pattern 策略可以用上面这张图来表示。下面我们在通过一个场景来理解他们。

设想一个场景,老师在黑板上写字,全班在下面一起看,老师想要擦除,但是有人表示自己还没看完,于是老师等待大家看完才擦除黑板。

对于这样的情况就是Read-Write Pattern,线程读取实例状态的时候,实例的状态不会改变,而会使状态变化的只有对线程的写入。

这个模式就是将读和写区分开来进行处理,读之前需要获取读的锁,写的时候需要获取写的锁。读取的时候有多个线程同时读取没有关系,但是有人读取的时候不能进行写操作!

一般来说,进行共享互斥会导致程序性能变差,但是我们将写入的共享互斥和读取的共享互斥分开考虑就会提高程序的性能。

Write behind caching Pattern 写后缓存模式 Write Behind 又叫 Write Back。一些了解Linux操作系统内核的同学对write back应该非常熟悉,这不就是Linux文件系统的Page Cache的算法吗?是的,你看基础这玩意全都是相通的。所以,基础很重要,大道至简。

Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。

另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。

Write Behind Caching Pattern 的处理流程图如下:

最后,还是用上面的一句话来总结一下。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。

如果非要保证强一致性,那就采用 2PC、3PC 或是 Paxos 协议保证一致性