Fork me on GitHub

分布式缓存--缓存与数据库一致性方案

1. 概述

缓存设计是应用系统设计中重要的一环,是通过空间换取时间的一种策略,达到高性能访问数据的目的;但是缓存的数据并不是时刻存在内存中,当数据发生变化时,如何与数据库中的数据保持一致,以满足业务系统要求,本篇将给出具体分析。

2. 强一致与最终一致性

所谓强一致,就是指系统在对外提供服务的过程中,时刻让缓存数据与数据库保持一致,这种情况比如秒杀系统,商家后台,他会设置秒杀商品,参与秒杀活动,一旦说他参与了秒杀活动,商品的库存本来是在数据库里的,此时必须直接被加载到缓存里,缓存立马就要可以被使用。最终一致性,就是允许缓存与数据库在中间一小段时间中有不一致的情况,但是最终两者是一致的,比如微博的粉丝数,页面每天的访问数。本篇重点讲最终一致性,强一致的情况后续分析。

3. 缓存与数据库一致性

3.1 缓存的更新机制

缓存的更新,一般分为被动更新与主动更新,被动更新是指缓存在有效期到后,被淘汰。
被动更新如下步骤:
step1: 发起方查数据,缓存中没有,从数据库中获取,并写入缓存,同时设置过期时间t;
step2: 在t内,所有的查询,都由缓存提供,所有的写,直接写数据库;
step3: 当缓存数据到过期时间t后,缓存数据失效。后面的查询,回到了第1步。

主动更新,一般为调用方发起缓存与数据库同时更新,缓存分为删除、更新,数据库分为更新,通过组合与先后顺序,分为如下四种情况:
更新缓存、更新数据库更新数据库,更新缓存删除缓存,更新数据库更新数据库,删除缓存,下面逐一分析。

3.2 更新缓存、更新数据库

这种情况,当缓存更新成功,数据库更新不成功时,数据不一致的风险比较高,所以一般不采用

3.2 更新数据库、更新缓存

当更新完数据库,缓存的加载前需要通过大量复杂计算才能得出缓存的值,不仅让发起方阻塞,影响性能;而且如果缓存命中率不高,很少使用,更浪费前期的复杂计算成本与缓存空间,这里就不符合懒加载的设计思想,故一般也不采用

3.3 删除缓存、更新数据库

如图所示,当两个调用方线程高并发访问的情况下,A线程先删除缓存,再更新数据库,此过程时间较长,B线程在A删除缓存后,迅速读取缓存,因缓存每命中,从数据库中读取再加载缓存,此时缓存还是旧值,等A线程更新完数据库后,发现又出现数据不一致的现象。

一般大概率情况下,出现此根源的原因是读比写快,所以这种一般也不采用,如果非得采用,需要在写完数据库之后延迟一段时间再删除一次缓存,也称延时双删,延迟多久呢,一般看数据库的更新时长来决定,此做法也会带来系统吞吐量下降

3.4 更新数据库,删除缓存

该方案是比较经典的cache-aside模式,虽然这种方式也会带来不一致的情况,比如如下场景:

前提:缓存无数据,数据库有数据。
A:查询,B:更新
过程如下:
step1: A查缓存,无数据,去读数据库,旧值;
step2: B更新数据库为新值;
step3: B删除缓存;
step4: A将旧值写入缓存。

该场景最终也会出现不一致,产生的根源是是读比写慢,这种是小概率事件,一般很少出现,如果非要解决这种情况,可以采用延迟双删,再删除一次缓存。

3.5 Read/Write Through

上面的方式,数据库是缓存的来源,主导是数据库,而 Read/Write Through模式,相当于缓存占主导。在cache-aside模式中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。而Read/Write Through做法是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。

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

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

值得注意的是,该方案在实现过程中,程序启动时,需将数据库的数据, 提前放到缓存中,不能等启动完成,再放缓存中。

3.5 Write Behind

Write Behind 又叫 Write Back。一些了解Linux操作系统内核的同学对write back应该非常熟悉,这不就是Linux文件系统的Page Cache的算法吗?是的,你看基础这玩意全都是相通的。所以,底层思想很重要,我已经不是一次说过底层很重要这事了。

Write Behind 思想,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作速度飞快(因为是直接操作内存),同时带来吞吐量大幅上升;因为异步,Write Behind 还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。如果说软件功能模块的思维是逻辑与实现,那么软件架构设计的思维是权衡与取舍

4. 总结

(1)上面讲的一些模式,具体在实际设计过程中,需要根据场景做权衡,这些东西都是计算机体系结构里的设计,比如CPU的缓存,硬盘文件系统中的缓存,硬盘上的缓存,数据库中的缓存。基本上来说,这些缓存更新的设计模式都是非常经典的,而且历经长时间考验的策略,所以这也就是,工程学上所谓的最佳实践。
(2)有时候,我们觉得能做宏观的系统架构的人一定是很有经验的,其实,宏观系统架构中的很多设计都来源于这些微观的东西。比如,云计算中的很多虚拟化技术的原理,和传统的虚拟内存不是很像么?Unix下的那些I/O模型,也放大到了架构里的同步异步的模型,还有Unix发明的管道不就是数据流式计算架构吗?如果你要做好架构,首先你得把计算机体系结构以及很多底层的技术吃透了,应用层的架构一定能从底层找到原型或者影子。

3)在软件开发或设计中,我非常建议在之前先去参考一下底层软件已有的设计和思路,比如操作系统、编译原理、计算机组成原理以及网络,找到相应的经典设计思路与最佳实践,吃透了已有的这些东西,再决定是否要重新发明轮子。千万不要似是而非地,想当然的做软件设计。

4)上面,我们没有考虑缓存(Cache)和持久层(Repository)的整体事务的问题。比如,更新Cache成功,更新数据库失败了怎么吗?或是反过来。关于这个事,如果你需要强一致性,你需要使用两阶段提交协议——prepare, commit/rollback,比如Java 7 的XAResource,还有MySQL 5.7的 XA Transaction,有些cache也支持XA,比如EhCache,关于事务问题后续再分析。

posted @ 2022-04-11 09:07  小猪爸爸  阅读(1324)  评论(0编辑  收藏  举报