缓存中间件-缓存架构的实现(下)

前言

缓存架构,说白了就是利用各种手段,来实现缓存,从而降低服务器,乃至数据库的压力。

这里把之前提出的缓存架构的技术分类放出来:

  • 浏览器缓存
    • Cookie
    • LocalStorage
    • SessionStorage
  • CDN缓存
  • 负载层缓存
    • Nginx缓存模块
    • Squid缓存服务器
    • Lua扩展
  • 应用层缓存
    • Etag
    • ThreadLocal
    • Guava
  • 外部缓存
    • Redis
  • 数据库缓存
    • MySql缓存

前面的《缓存中间件-缓存架构的实现(上)》已经简单说明了浏览器缓存,CDN缓存,负载层缓存。这次将会继续阐述应用层缓存,外部缓存,数据库缓存。

应用层缓存

应用层的缓存,往往用户的请求最终达到了应用服务器,但是未达到数据库,其涉及应用服务器的具体开发。

Etag

之所以将Etag技术放在应用层缓存,是因为用户的请求必定达到应用层。

Etag的意思就是,如果连续两次请求的请求内容是一致的,那么两次响应也应该是一致的。那么第一次请求的响应,就可以充当第二次请求的响应。

当然实际业务中,也存在两次请求一致,但是响应不一致(如都是查询银行余额,但是并不一样,可能两次操作中间,工资到账了)。这就涉及到缓存的数据一致性问题,后面会提到。这里不再深入。

那么应用服务器怎么判断两次请求一致呢。它可以通过两次请求的hash,进行对比判断。其中涉及HTTP协议,如304状态码,请求协议头If-None-Match字段,响应协议头Etag字段。

请求流程

服务端已经做好了对应的开发与设置(如Spring的ShallowEtagHeaderFilter())。

第一次请求
  1. 客户端发出请求RequestA
  2. 服务端接收到客户端的请求RequestA,进行以下处理:
    1. 在应用中,根据请求RequestA计算对应的MD5值
    2. 在返回响应ResponseA的协议头中的Etag字段设置前面计算出来的MD5值
    3. 返回对应页面
  3. 客户端接收到响应ResponseA,在浏览器中展示。并在浏览器中缓存ResponseA
第二次请求
  1. 客户端再次发出请求RequestB,并且RequestB与RequestA请求内容相同(如都是请求同一个页面等)
  2. 服务端接收到客户端的请求RequestB,进行以下处理:
    1. 根据请求计算的新ETag,并判断是否与请求RequestB协议头中的If-None-Match字段对应的值(就是之前ResponseA的ETag字段的值)一致
      1. 如果没有超限, 在Response中设置协议状态为304,向客户端返回对应ReponseB
  3. 客户端接收到响应ReponseB,确认其协议状态为304,则直接使用之前缓存的响应ResponseA,作为请求RequestB的返回响应

上述其实是功能逻辑,如果按照代码逻辑,其实应该这样说:

客户端
  1. 客户端准备发送请求
  2. 浏览器检测该页面是否有对应的ETag字段的值
  3. 如果有对应的值,就置入请求的协议头
  4. 准备妥当后,浏览器想服务器发送请求
服务端
  1. 根据请求的协议头,判断是否具备Last-Modified/If-None-Match字段
  2. 如果有对应字段,进行以下判断
    1. 根据请求计算的新ETag,并判断是否与请求协议头中的If-None-Match字段对应的值(就是之前ResponseA的ETag字段的值一致
      1. 如果没有超限,在Response中设置协议状态为304,向客户端返回对应Reponse
  3. 如果上述2中任一条件未满足,则执行以下逻辑:
    1. 在应用中,根据请求RequestA计算对应的MD5值,保存在应用中
    2. 返回对应页面
    3. 在返回响应ResponseA的协议头中的Etag字段设置前面计算出来的MD5值

准确地说,这应该是HTTP协议提供的缓存方案,而不仅仅只是ETag。因为ETag仅仅与HTTP协议的五大条件请求首部中的If-None-Match与If-Match两个首部相关。除此之外,还有If-Modified-Since,If-Unmodified-Since,If-Range三个条件请求首部。如果以后有机会专门写一篇有关HTTP协议的博客。迫切的小伙伴,也可以翻阅《HTTP权威指南》一书的第七章(尤其是7.8)。

优势

  • 降低数据库访问压力。如果ETag成功,则直接返回状态码304,没有数据库操作。
  • 降低应用服务器压力。如果ETag成功,则直接返回状态码304,无需业务操作等,如日志。
  • 降低带宽压力。根据统计表明,一般请求响应模型中,响应的报文大小远大于请求的保温大小。那么如果返回响应的主体为空,只有304状态码等协议头,则可以大大降低系统带宽压力。

缺点

  • 技术学习投入。如果想要较好利用 ,需要熟悉HTTP协议的缓存设计(包括理念,架构,步骤等)
  • 需要对现有的业务体系,进行一定的调整
  • 数据刷新问题的处理,确保数据的“新鲜度”
  • 应用系统的计算资源占用。有人提出ETag的MD5计算带来了对应的应用系统的CPU占用问题。这个需要说一下:
    • 这取决于具体请求本身是否有比MD5计算更大的CPU占用问题。
    • 合理的缓存架构设计一般不会有这样的问题(如静态资源等CPU占用少的请求,根本就在前面的浏览器,CDN,负载均衡层处理掉了)

实际应用

实际应用部分,主要有两点需要提及。

  • 由于If-None-Match的部分缺点,有需要的小伙伴最好引入Last-Modified-Since搭配使用
  • 实际开发方面,Spring提供了ShallowEtagHeaderFilter(),也可以自行扩展

PS:部分人认为只需要Last-Modified-Since即可,但是仅使用Last-Modified-Since存在以下问题:

  • 1s周期内的变化,无法处理(因为Last-Modified-Since记录的最小时间单位为秒)
  • 部分数据虽然发生了变化,但其实我们所需要的内容并没有变化(如周期性的重写等)
  • 部分应用系统的系统时间存在冲突(即集群内的应用服务器实例的绝对系统时间存在秒级差别。至于集群的时间统一相关的问题,日后有机会专门写一篇博客(感觉自己立下了无数flag))。

ThreadLocal

ThreadLocal是什么,我就不在此解释了。不了解的小伙伴,可以这样理解:ThreadLocal就是一个类中的静态Map,其key就是执行线程(调用类实例的线程)的name,而value就是调用位置设置的值。

优势

  • (核心)避免接口定义污染。如应用系统中(同一JVM中)存在A->B->C这样的操作链路。但只有A和C用到了特定参数(如用户信息),那么为了能够调用C,B也必须引入该特定参数(如用户参数),即使B没有用到该特定参数。这就造成了接口定义的污染(详见线程级缓存ThreadLocalCache
  • 数据缓存。由于ThreadLocal是通过栈封闭的理念实现了线程安全,所以其在一些场景下有着特定的使用。

缺点

  • ThreadLocal缓存设计与学习,及原有系统的改动
  • (核心)由于可能涉及多线程与调用链上多个调用节点,所以设计与问题排查会有较大的难度

实际应用

在我之前接收的IOT项目中,终端系统通过传感器数据读取程序与传感器配置,获得原始数据(包括原始监测值,以及配置表中对应配置(如硬件标识,报警阈值等))。但是原始数据采集后,会进行数据清洗,数据报警评估,数据保存等多个操作。但是其中的数据清洗并不涉及硬件标识,与报警阈值等。所以采用ThreadLocal来保存对应数据(硬件配置),避免方法接口的污染。当然,后来由于该流程并不都是有前后顺序要求,所以添加了事件监听,进行异步解耦,降低系统复杂度。

GuavaCache

Guava代表着应用级缓存,更准确说是单JVM实例缓存。在原单机系统时,我们往往并不是采用Redis这样的分布式缓存(除非是希望利用其数据处理,如GEO处理,集合处理等),而是采用GuavaCache或自定义缓存(自定义缓存的设计,后面会有一篇专门的博客)。

优势

  • 资源占用小。毕竟只是运行于单机的一种缓存工具
  • 实现了一种简便的缓存管理工具,满足了大多数单机系统对缓存的需求

劣势

  • 功能没有分布式缓存中间件完善(尤其是自定义的缓存工具)
  • 如果是采用Guava这样的第三方缓存工具,需要对工具的一定学习成本
  • 如果是自定义实现(为了更为精简,定制化),往往性能的提高对技术水平有着一定的需求(如SoftReference的利用等)
  • 对原有应用的改变

外部缓存

外部缓存的一个重要代表,就是Redis,Memcache这样的分布式缓存中间件。当然外部缓存,你要把文件系统等划分进来,也不是不行,只要可以满足对缓存的定义即可。

这里以Redis为例。

Redis

Redis作为当下最为流行的分布式缓存中间件,其应用可以说是非常广泛的,也是我非常喜欢使用的一种分布式缓存中间件。其是一个开源的,C语言编写的,基于内存,支持持久化的日志型,KV型的网络程序。

优点

  • 使用简单。Redis的单机使用不要太简单。即使是新人,也可以在很短的时间内上手,并在实际开发中应用(当然,如果项目中已经有了相关配置,并提供了相关Util就更方便了)
  • 性能强悍。即使是单机的Redis,也可以在一个普通性能的服务器上,提供每秒十万级的读写能力(当然影响的情况很多,详见redis的BenchMark
  • 功能强大。Redis提供了GEO的相关操作(计算两点距离等),集合相关操作(交集,并集等),流相关操作(类似消息队列)
  • 应用场景多。如Session服务器(分布式Session的优秀解决方案),计数器(Incr),分布式锁等

缺点

  • 需要部署Redis服务器。并且为了确保可用性,往往需要进行集群部署
  • 精通较难。
    • 功能方面。功能强大的Redis,其内部实现还是有不少东西的,包括其持久化机制,内存管理
    • 理论方面。如Redis内存管理方面,涉及LRU,LFU算法,以及其自定义简化版的实现。又或者其哨兵机制涉及的Raft分布式选举算法等
    • 部署方面。单机部署,以及多种集群部署(生产级部署,可以看我之前的博客-Redis安装(单机及各类集群,阿里云)

实际应用

在我之前接手过的某综合系统(涵盖社交,在线教育,直播等),其Session服务器是通过Redis进行支撑的。通过将<SessionId,Session>的方式,存储在Redis,而SeesionId会保存在用户的Cookie中(至于某些小伙伴担心的Cookie禁用问题,这就涉及Cookie的知识内容了。Cookie会保存在URL中)

再举一个例子(Redis的应用场景太多了)。之前负责的IOT项目中,其中控系统的报警模块有这么一个需求:同一个终端的同一个传感器在30min中,只报警一次,避免报警刷屏的现象。而中控系统已经采用了Redis(中控系统是可以集群部署,确保可用性,避免性能瓶颈),所以利用Redis的集合特性与expire特性,进行了对应的缓存设计。这个在之后会专门写一篇博客,进行阐述。

数据库缓存

这里说的数据库,是指Mysql,Oracle这样的数据库,而不是Redis这样的。

这里就以Mysql举例,这个大家应该是最熟悉的。

Mysql

Mysql缓存机制,就是缓存sql文本,及其对应的缓存结果,通过KV形式保存到Mysql服务器内存中。之后Mysql服务器,再次遇到同样的sql语句,就会从缓存中直接返回结果,而不需要再进行sql解析,优化,执行。

可能某些人担心,如果数据改变了,而请求的语句是select * from xxx,那不就一直拿到旧数据了嘛。放心,mysql有这方面的处理,当对应表的数据有所修改,那么使用了这个表的数据的缓存就全部失效。所以对于经常变动的数据表,缓存并没有太大价值。

优势

  • 提升性能。同样的语句,第一次执行可能需要1s,而第二次执行往往只需要几毫秒。
  • 避免索引时间。因为是通过请求的sql,直接从缓存中获取对应结果,所以没有进行索引查询操作。
  • 降低数据库磁盘操作。虽然请求到达了数据库,但如果没有进行硬盘操作(寻道,读取数据等),那么该次数据库操作对数据库的资源消耗就小了许多(因为在数据库中最消耗时间的就是索引操作与硬盘操作)
  • 降低数据库资源消耗,提高查询时间。因为其避免了数据库获得sql后的所有操作,取而代之的是从缓存获取数据(一个KV读取操作,资源消耗可以几乎可以忽略了)

缺点

  • mysql缓存的应用,及配置需要足够的专业知识(一般的后端并不会非常深入这个层次,往往需要专门的DBA进行处理)
  • mysql缓存的判断规则不够智能,提高了查询缓存的使用门槛,降低了其效率
  • mysql缓存的检查与清理需要占用一定资源
  • mysql缓存的内存管理不够完善,会产生一定内存碎片(貌似mysql并不是直接采用数据库的内存,就像JVM一样。如果有不同意见的,可以私信或@我。毕竟我并不擅长数据库,虽然刚接手的工作是进行数据库中间件开发。囧)

扩展

实际应用

在我之前接收的IOT项目中,无论是终端系统,还是中控系统,往往都存在大数据量的数据查询,单次的数据查询往往涉及万级,十万级数据的查询,并且可能频繁查询(就是多次刷新页面数据)。

一方面,我通过批量写入(降低数据库连接的占用频次),降低数据库对应数据表的修改频次(从原来的几秒一次,变为一分钟一次)。另一方面,进行数据库缓存相关配置,确保在一分钟内的数据库不需要进行索引操作与硬盘操作,直接返回内存内的结果。从而有效提高了前端页面数据展示效果。

当然后续,我为了针对这一特定业务场景与需求,对业务稍做了调整,从而大大提高了数据查询效果,大幅降低应用系统资源消耗(这个我会专门写一篇博客,甚至专门开一个系列,用来描写这种粒度的特定业务场景的方案设计)。

布隆过滤器

之前有人私信我,认为布隆过滤器应该归类于缓存架构的一部分。

我开始认为这有一定道理,因为布隆过滤器确实涉及数据的缓存,它需要以往数据的记录,来实现。但是后来我想了想,布隆过滤器并不应该划分为缓存中,因为布隆过滤器是基于缓存的,应用缓存的。就像你可以说Redis缓存属于缓存架构的一部分,但是你不可以说调用缓存的应用服务器属于缓存。所以最终,我并没有将布隆过滤器划分为缓存的一部分。而是将它作为一种非常有意思的过滤器,一种限流方式,一种安全手段等。

不过作为扩展,这里简单说一下布隆过滤器。说白了,就是利用Hash的散列映射特性,进行数据过滤。如我在应用中设置一个数组Array(其所有值都为0),其长度为固定的10W。我针对每个用户计算一个hash值,并将这个hasn值对10W进行取余操作,获得index值(如1000)。我将Array中第index位置的value设置为1。这样放在生产环境后,如果有一个用户,其计算出来的index在Array中对应位置的值为0,则说明这个用户在系统中不存在(当然,如果是1,也并不能就说明其就是系统的用户,毕竟存在哈希冲突与取余冲突,不过概率较低)。通过这样的手段,有效避免无效请求等。

后续可能会专门写一篇有关布隆过滤器的博客。

总结

以上就是缓存架构相关的知识了。当然,这些知识都是粒度比较大的,虽然我举了一些实际例子,但是需要大家针对具体应用场景,进行调整应用。另外,这些知识都是比较通用的。可能在特定业务场景下,还有一些方案没有列在这里。最后,没有最好的技术,只有最合适的技术。这里的许多技术都需要一定的业务规模(数据量,请求数,并发量等),采用比较好的性价比,需要大家仔细考虑。

如果有什么问题或者想法,可以私信或@我。

愿与诸君共进步。

参考

版权声明:本文为Tiancheng-Duan原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/Tiancheng-Duan/p/12185507.html