缓存的基础

41次阅读

共计 5311 个字符,预计需要花费 14 分钟才能阅读完成。

缓存的基础

该文档编写的目的主要是让开发者明白缓存的相关概念,在使用缓存的时候清楚自己的在做什么事,避免盲目使用造成项目的可维护性变差。本文将从几个方面的来阐述缓存的相关基础概念,包括缓存解决的问题、缓存的弊端、缓存的相关概念、缓存的使用误区。

一、缓存解决的问题

互联网项目区分于传统的企业软件开发最大的不同点就是互联网项目需要应对更多的用户。这以为的应用必须提供更高的并发量支持、同时还需要有更高的性能来提高用户体验。这两个特点导致了互联网项目对缓存的依赖特别重。因为缓存恰恰就是为了解决这两个问题而诞生的。

1. 高并发

高并发需要解决的问题从 Web 系统来讲主要有两个方面。Web 服务器的的 IO 数和数据库的 IO 数。Web 服务器的 IO 数在设计良好的系统上可以通过简单的增加实例来解决,大多数情况主要是数据库的 IO 问题。一个请求发送到 Web 服务器上到数据库上可能就会放大到几倍,再加上数据库在一些场景上也很能通过增加实例来解决问题。所以大多数情况下只能通过减少对数据库的请求来解决问题。这里就是缓存的一个重要使用场景,通过直接读写缓存来减少对数据库的请求,提高系统的负载能力。

2. 高性能

高性能可以通过缓存解决可以从两个角度来思考。一个复杂计算的结果缓存。对于需要消耗很多的时间和资源计算,我们可以通过缓存结果来快速响应请求。另外一个角度就是通讯时间。很多时间,花费在计算上的时间并没有多少,更多的是数据通过网络传输太耗时。通过读取更近的缓存来减少请求的响应时间也是一个很重要的用途。

二、缓存的弊端

理想情况下我们肯定是期望缓存是对系统的一个补充,更多的提高系统的上限。但是现实情况是,缓存在系统设计之处就不得不认真考虑。因为互联网项目的需要面对的问题决定了我们的系统下限就已经容忍度很低了,在没有新技术诞生的情况下,现有的架构和中间件就难以达到下限。缓存成了系统中必不可少一部分,所以我们也要认清缓存会带来的问题,尽最大努力降低加缓存给开发带来的难度。

1. 缓存对业务逻辑的侵入

原来的单体应用,缓存都是存储在本地、需要缓存的内容大多也就是数据库的数据,这实际上是比较好搞定的一种缓存使用场景。而到了更复杂的系统中,服务都要求是无状态的、可分布式部署的,这导致原有的缓存理念实际上不能满足现在的使用场景。原有的缓存框架也不适应现在的开发面临的问题。很多时候我们需要在业务逻辑上手动操作缓存,而不单单依靠框架来解决问题。同时为了让缓存应用起来更简单,也要改变原来的一些开发理念,所以缓存也会影响我们的业务逻辑。

2. 缓存让架构更复杂

前文也提到了互联网应用的下限容忍度很低,很多时间一个系统如果没有缓存可能根本就无法提供服务。因此我们的架构上更加复杂了,需要加上更多的中间件,对中间件的可靠性要求也更高了。同时根据我们选择的缓存的策略的不同,整个系统可能就并非原来的页面、应用、数据库这三大件这么简单了。我们需要使用 MQ、Redis 来做缓存,计算过程更加复杂,调用链难以追踪,错误难以排查。

3. 牺牲了数据的一致性

使用缓存必然导致数据很难做到实时一致性,只能做到最终一致性。这也是很多单体架构进行服务拆分时碰到的问题。在使用缓存以后我们不得不考虑到数据不一致对业务逻辑的影响,甚至为此系统用户的使用体验。

三、缓存的相关概念

如果你只做业务逻辑开发,前面的内容可能对你可有可无,有其他人帮你考虑这些问题,但是对于缓存的分类是需要清楚的,这关系到具体的代码应该怎么写。

1. 缓存的概念

前面说了缓存的利弊、说了使用缓存的原因,但是并没有对缓存做一个定义。一方面是缓存的概念没那么复杂,就是存储计算结果从而让下次计算可以直接跳过计算步骤直接获取结果。

事实上在更多领域对缓存的使用其可能有更复杂的定义。但是从我们应用系统开发的层面来说,这种理解是足够的。更多的是我们需要了解缓存的一些具体的分类,这有助于我们更清楚自己做的事,更清楚自己应该使用那种缓存。

2. 缓存的分类

从几个不同的维度,我们对缓存做了一些分类。清楚自己的业务场景,选择合适的缓存方式可以降低应用的难度。

  • 从存储位置分类

    • 本地缓存

      存储在本地的缓存。一般是存储在内存中,仅供本进程读取的数据。这种技术使用的很普遍,传统的缓存框架已经针对这种缓存做了很好的封装。我们更多的需要考虑缓存的边界问题,缓存在本进程的共享范围、缓存的生命周期能否和进程保持一致。

    • 分布式缓存

      因为多实例部署的需求,只把数据缓存在本地已经很难满足我们的业务场景了。分布式缓存上我们花费了更多的精力。简单场景我们可以继续使用原来的缓存套路,把分布式缓存当做本地缓存来用。但是这么做其实并没有什么意义,这样的缓存应用模式很难被其他实例共享。更多的只是为了解决应用服务的内存占用问题。

      在使用分布式缓存时我们需要清楚使用分布式缓存的代价,将缓存内容放在 Redis 等中间件里我们需要更多的通讯时间、读取缓存的速度也下降了。放弃性能更好的本地缓存不用,肯定是因为这些缓存使用分布式存储性价比更高。这些缓存需要多实例共享、生命周期无法和进程保持一致、需要预加载等原因都是我们使用分布式缓存的原因。我们可以完全用缓存服务器代替本地缓存,但是要清楚代价。

  • 从更新方式分类

    • Cache Aside 更新模式

      这是我们接触比较多的一种模式,逻辑也比较简单。它的更新模式如下:

      失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
      命中:应用程序从 cache 中取数据,取到后返回。
      更新:先把数据存到数据库中,成功后,再让缓存失效。

      我们在自己写缓存操作逻辑的时候记住遵循这种模式就好了。具体为什么要这么做就不再赘述的,这种缓存更新模式的相关资料很多。

    • Read/Write Through 更新模式

      在上面的 Cache Aside 套路中,应用代码需要维护两个数据存储,一个是缓存(cache),一个是数据库(repository)。所以,应用程序比较啰嗦。而 Read/Write Through 套路是把更新数据库(repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的 Cache。
      Read Through
      Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或 LRU 换出),Cache Aside 是由调用方负责把数据加载入缓存,而 Read Through 则用缓存服务自己来加载,从而对应用方是透明的。
      Write Through
      Write Through 套路和 Read Through 相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后由 Cache 自己更新数据库(这是一个同步操作)。

      这种缓存使用场景我们可能使用的比较少,更多的是中间件自己提供的功能。

    • Write Behind Caching 更新模式

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

      这种缓存一般需要自己开发中间件来做,对性能和并发要求极高的场景可以考虑投入一定的精力来搭建缓存中间件。

  • 从缓存内容分类

    • Action 缓存

      这类缓存使用的很广泛。从运维的角度我们就有 CDN 缓存和反向代理缓存,我们主要讨论后端应用的缓存。Action 缓存是后端应用最早可以加缓存的地方,如果场景足够清晰完全可以在这一层就把缓存加上来。当然因为考虑到项目的分层问题,可能更多的只把相关逻辑挪到紧邻 controller 的 service 处。这里的缓存建议采用分布式缓存。

    • 方法缓存

      方法缓存在传统项目开发中使用的很普遍,到了互联网项目里虽然应用场景减少了,但是依然使用的很广泛。这种缓存的特征是缓存的 Key 是方法的入参,Value 是方法的结果。这是一个范围比较广的分类,和其他分类多有重合。但是实际上有本质的不同,可以自行体会一下。

    • 数据缓存

      这种缓存主要是缓存 repository 的数据。比较多的使用场景是为了较少 DB 的压力。在性能要求比较的高的场景可以关闭 repository 的缓存功能,完全用数据缓存来代替。这样 DB 需要的内存更多,响应速度也更快了。

    • 对象缓存

      把对象缓存放在最后讲不是因为对象缓存不重要。实际上对象缓存非常重要,一个系统如果有良好的对象缓存,那么它缓存应用难度会降低很多,读取和更新会非常简单。但是使用对象缓存的前提是开发是以面向对象的方式来架构系统的,对于对象的建模能力要求很高,这才是使用对象缓存最大的困难点。

四、缓存的使用误区

这里我们援引了一篇博客( 使用缓存的 9 大误区 )上总结的误区,在这个基础上我补充了自己对这些使用误区的理解。

  • 过于依赖默认的缓存机制

    因为直接使用语言提供的序列化方式比较简单,所以很多时候我们直接使用这种序列化方式并没有什么问题。但是很多场景我们可以自己定制序列化的方式,或者是使用的数据量更少、又或者是这种结构让我们更新的时候比较方便。一个很典型的场景,我们可以使用 Redis 的 Hash 结构来缓存对象,这样如果我们想更新缓存的某一条属性的时候是非常方便的。

  • 缓存大对象

    这个更多是开发者没有正确的认知到自己缓存的对象到底是什么样的数据结构。比如缓存 Spring 管理的 Bean,世界上我们在使用这些 Bean 时操作的更多是自己对象的代理类,这里面可能有很多框架附加上去的信息。我们以为自己的对象结构很简单就直接缓存了,等实际序列化以后是很庞大的。缓存毕竟还是很消耗资源的,对于自己到底缓存了什么东西要心里有数。

  • 使用缓存机制在不同服务间共享数据

    这里我对原作者的内容做了一些修改,加上了不同服务间的前提。因为微服务架构中一个服务部署多个实例是一件很正常的事情,同一个服务间共享同一份缓存并没有什么问题。但是不同服务间共享缓存就问题很多了,这让原来辛辛苦苦拆分出来的服务一下子又被耦合到一起了,同时可能不同服务直接不了解对方的逻辑,还可能导致缓存的内容被修改成错误的值。这是绝对需要避免的错误。但是这么做又是极具诱惑力的,缓存的良好性能让、方便的操作让通过缓存共享数据很省事,但是这对于架构的破坏性非常大。

  • 认为调用缓存 API 之后,数据会被立刻缓存起来

    这个误区更多的是出现在使用了 MQ 做缓存的场景,或者是并发量极大的场景。并没有什么很好的方式来避免这种错误,更多的是在写缓存逻辑的时候注意到这种情况是有可能发生的。

  • 缓存大量的数据集合,而读取其中一部分

    也是一个比较常见的误区。很多时候是因为从数据库获取的数据就是一个集合,所以直接缓存了这个集合。等到要读取数据的时候不得不反序列化整个集合的数据再从集合里找自己想要的内容。这也导致自己的缓存更新非常麻烦,明明只是更新了集合中某一个缓存就不能不让整个集合的缓存失效,缓存的命中率大大降低了。

    这种情况下试试将集合中的缓存一条一条的存储,也就是将大的缓存对象拆分成多个缓存。

  • 缓存大量具有图结构的对象导致内存浪费

    在面向对象的编程思维中,我们很容易就会设计出层级比较多的对象。ORM 框架一般会帮我们做懒加载,这其实可以很好的利用到数据库的缓存机制。但是这却不利于我们做分布式缓存。所以说原来的架构并没有很好的贴合分布式系统的使用场景。这里 MyBatis 框架因为它的灵活性倒是提供了一些方法来做缓存。

  • 缓存应用程序的配置信息

    这也是一种极具诱惑力的使用方式。在缓存服务器中存储配置,这样当需要更新配置时只要修改缓存的值就可以统一修改所有实例的配置。但是这样做风险很大。缓存服务器虽然已经做了很多可靠性保障,但是其本意并不是像数据库这类中间件一样必须 100% 可用,缓存服务器是允许挂掉(理论上)的。如果我们把配置放在缓存服务器上,这导致我们不得不把缓存服务器的可靠性也提高到 100%。在分布式架构中,配置的分发更推荐使用专业的中间件,例如 zookeeper、etcd 等。它们在设计上就是要做到 100% 可靠,同时也提供了推送机制,配置更新更及时。

  • 使用很多不同的键指向相同的缓存项

    这个是在使用方法缓存的时候比较常犯的错误,使用不同的参数进行计算但是结果其实是同一个。参考数据库里的索引设计,其实是一样的道理。如果缓存的 Key 比较复杂,其实可以通过维护一份 Key 的缓存,最后都指向缓存的唯一性标示,类型数据库的主键的设计。这样数据我们只需要维护一份,只需要维护 Key 的缓存就好了。

  • 没有及时的更新或者删除再缓存中已经过期或者失效的数据

    这个是在使用缓存的时候很少注意到的问题。因为各种缓存框架、缓存服务器一般会帮我们做一些缓存剔除操作。但是如果需要自己操作缓存的时候就需要特别注意这个问题。一旦缓存里出现了非预期的脏数据,不但清理起来很麻烦,找到出现问题的地方有时候也很难。对于这点更多的是想清楚自己缓存的生命周期,在生命周期结束上记得加上清理逻辑。比如服务关闭的时候进行缓存清理。

正文完
 0