赞
踩
关系型数据库系统给我们带来许多惊艳的特性,例如:ACID。但为了维护这些特性,数据库的性能在高负载下也会下降。为了提高性能,通常会在项目的应用层(处理业务逻辑)和存储层(持久化到数据库)之间添加一个缓存层。因为数据库的性能瓶颈通常是在硬盘(或二级存储)的读写(I/O)。所以缓存层通常使用内存来实现。当然,我们只会将一些“热数据”存储在缓存中。为识别“热数据”,通常会指定一个过期策略,如LFU(最少使用)和LRU(最近最少使用)。
将数据库中部分数据存储在内存缓存中,性能是有所提高,数据库的服务器压力也相应的减小,但一份数据分别存储在两个地方,那我们如何来保证两边的数据是一致?
接下来,我们来看下一些解决方案。
这是最简单的一种解决方案。这种方案的做法:将数据直接写入数据库,读取数据时先从缓存中读取,如果缓存数据不存在,再从数据库中读取并写回缓存。在写回缓存的时候为每个数据都添加一个过期时间。
假设缓存过期时间设置为30分钟,在这时候段内,A君更新了数据,而缓存中的数据还未过期,B君读取的数据为脏数据。那如果设置成一分钟或者更短,在大流量和高并发的情况下,将会有很多缓存未能命中,且系统的性能也会大大降低,那么缓存的数据将毫无价值。这就违背了使用缓存的最初目标。
缓存的过期时间设置过长,会导致脏读,会增加数据不一致的时间;设置过短,则缓存无效。所以,很难为过期时间设置一个合适的值。
显然,这种方案不合适高并发,且更改较频繁的数据。当然,我们可以在更新数据时候增加一个更新缓存的机制。
Cache Aside 就翻译成缓存备用模式。它有三种实现方式,三种方式都是围绕数据读取和写入(新增、修改、删除)。
读取
缓存命中:直接从缓存返回数据,不查询数据库
缓存没有命中:从只读数据库获取数据,再将数据保存到缓存中
写入
新增、修改、删除数据库中的数据
删除缓存(始终删除而不是更新,下次读取缓存未命中时再插入到缓存)
这种方式在理论情况下,基本上可以保证一致性。当然,也有例外的情况:
假设:A君已成功更新了数据库,在删除缓存前,B君获得了缓存命中的数据,因为该缓存还未删除。这里B君读取到为脏数据,但缓存还是会被删除。后面C君会得到更新后的值。
假设:A君已成功更新数据,但是在删除缓存时进程突然中止了,则该缓存将永远不会被删除,后续 C 君将继续读取旧数据。(进程中止的原因有:更新版本,老版本的程序被中止;中止多余的服务;应用程序崩溃。)
假设:A君未能缓存命中,从数据库中获取了数据。突然,A君出现了未知的故障卡了一下,这时,B君更新数据且删除了条目。之后,A君恢复了且将旧值保存到缓存。后续C君读取的都是脏数据。
当应用程序正确操作数据时,可以尽量减少情况1和情况3的发生。比如说,情况1在更新数据库,马上删除缓存,不要做其它任何事情。而情况2避免人为发生的可能性,但程序崩溃就没办法避免了。最后情况3在从数据库中读取数据后,尽快将结果写入缓存,不要做额外的格式转换。这样可以减少不一致性发生的概率。当然也有一些无法避免的情况,如垃圾回收产生的stop-the-word。
第二种方式是在第一种方式在写入时颠倒下顺序。先删除缓存,再新增、更新、删除数据到数据库。
这种方式虽然解决了第一种方式带来情况1和情况2的问题,但也会出现新的问题。假设A君在更新现有值,已成功清理了缓存,在更新数据库之前,B君来获取数据,此时缓存没能命中,然后就跑去数据库获取值并写入到缓存。但数据库里的值还未更新,这时不会再删除缓存,因此C君获取仍是旧的值。
若要说这种方式与第一种方式中情况1和情况2发生的概率,这种方式会小很多。但也没能有效的改善。因此,也不是一个很好的方案。
第三种方式也是在第一种方式可变操作中更改了缓存写入方式。具体方式:先新增、更新、删除数据库中的数据,再对应的创建、更新、删除缓存。
这种方式也存在一定的问题。假设B君在A君之前更新了数据库,而A君在B君之前更新了缓存。最终还是会导致数据库和缓存不一致。
在多服务部署的情况这种方式发生的概率极大。因此,这也是一种糟糕的方式。
总得来说,缓存备用模式也是一种简单的实现方式,但相比设置缓存过期时间还是比较可靠。如果想进一步提高一致性,这种方案也是不够看的。
Read Through 就翻译成只读模式。只读模式不做写入缓存,客户端始终简单从缓存中读取。缓存命中与否对客户端是透明的。如果未命中,缓存会自动从数据库中获取。
只读模式一个致命的缺点是许多缓存可能不支持。比如,Redis 就无法自动从 MySql 获取,除非自己写插件。还好 NCache 是支持,但支持的客户端还是有限。另外还有一些开源或付费版本,如果开源使用的人少,一旦出现问题就杯具。对于付费的企业版本,对于中小企业来说是一个不小的开销。因此可选择就很少了。
对此,我们还剩下自己造车轮的路子了。比如:我们可以把缓存打包为数据访问层,并通过内部API来协调缓存和数据库。这样我们不用关心缓存的类型,只要它足够快地为我们提供数据。当然这个对缓存内部API要很熟悉。
Write Through 就翻译成只写模式。只写模式不做读取,而且由 Read Through 来完成。只写模式仅为缓存写入数据,然后以原子方式将数据同步到数据库。
很明显这是把缓存当作了关系型数据库,但是这有个问题,许多数据库具有缓存所不具备的功能,比如:缓存有ACID的保证?再者缓存也不适合数据持久性,那怕 Redis 支持 RDB 和 AOF 的持久化,但也不建议这样用。因为缓存会由于“某种原因”而丢失数据,而这数据丢失了也就没法找回了。所以也就有了 Write Behind 模式了。
Write Behind 模式同样没有读取数据,只管写入,但它不是以原子方式同步数据库。而是通过内部消息队列将数据异步复制到数据库。这样做不仅提高了吞吐量,还不必等复制结果。
消息队列不仅有效的保证数据持久性,也保证了一定程度的原子性和隔离性。虽然没有像关系型数据库那样完整,但基本上是可靠的。
当然,消息队列的加入会使结构变得复杂,使用消息队列也需要相应的领域知识和详细的设计及实现。
此外,消息队列可以将零碎的更新合并到批处理中,例如:在 Redis 5.0 以后的版本中提供了 Redis Steam 的功能,可以合并更改并批量更新到数据库,从而进一步提高了性能。
使用消息队列还有一个值得注意的点,一定要确保消息队列中的执行顺序,比如:数据先更新再删除与先删除再更新是具有不同的含义。显然 Write Behind 的复杂性就高很多。
另外,这里做一个延伸。可以通过解析 MySql 的 binlog,将数据库中的数据同步到 Redis 。这个主要是利用 MySql 的复制原理,用一句话来说就是:从服务器读取主服务器 binlog 中的数据,从而同步到缓存中。当MySQL中有数据写入时,我们就解析MySQL的 binlog,然后将解析出来的数据写入到Redis中,从而达到同步的效果。这种方式实质上一个复制队列,它按顺序记录所有事务,不用担心顺序一致性的问题。
Double Delete 是在 Cache Aside 中第二种方式的伸延,在更新完数据库后让线程稍等片刻再次清除缓存。具体如下:
读取
缓存命中:直接从缓存返回数据,不查询数据库
缓存没有命中:从只读数据库获取数据,再将数据保存到缓存中
写入
首先删除缓存
新增、修改、删除数据库中的数据
等待片刻(如:500ms)
再次删除缓存
Double Delete 最大目的是减少读取旧值保存到缓存中,因此在第二次清除缓存有效清除脏数据,但在等待的过程难免也会有意外的情况发生,即在这等待的时间内可能存在不一致的情况。因此如何设置等待时间也是一个难题。
有人可能会提出,可以通过异步的方式执行,这无异于给自己增加难度。
虽然不完美,但这种发生不一致的可能性比较小吧。
以上这些都不能保证强一致性的要求。如果是为了强一致性,那我们必须在所有操作上实现 ACID。但这做会降低缓存的性能。这就违背我们使用缓存最初目标。当然我们可以根据自己业务情况选择合适的方案。
当一致性不是那么重要时,使用缓存过期就足够了,而且这个实现工作量比较低。比如:CDN使用的就是使用缓存过期之一。
随着一致性的需求越来越高,我们可以使用 Cache Aside 和 Double Delete。这两种基本上能满足大多数方案了。
但是,随着一致性要求的不断增加,就需要 Read Through、Write Through 和 Write Behind 这些模式来实现。虽然提高了一致性,但也要付出相应的代价。比如:需要人力去学习相应的知识来实施,且实施时间成本和之后的维护成本也会相应的增加。除些之外,还需要额外付出基础设施的费用。
当然,如果需要保证强一致,也只能使用更先进的技术了,比如:共识算法。至少目前我不会去掌握。因为我们要的是在100%正确和性能之间做权衡。也许99.9%正确就足够了,我们使用缓存的目的就是为了提高性能。
根据自己的情况选择可以实现的方案,即使是简单的方案,如果能正确实现它们,也能提高一致性。
好了,今天就到这。
祝大家学习愉快!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。