高并发之缓存
应用需要支撑大量并发量,但数据库的性能有限,所以使用缓存来减少数据库压力与提高访问性能。
、
缓存的使用可以出现在1到4的各个环节中,每个环节的方案他们都各有特点。
特征
- 命中率 = 命中数 / (命中数 + 没有命中数)
- 最大空间:缓存最大空间一旦缓存中元素数量超过这个值(或者缓存数据所占空间超过其最大支持空间),那么将会触发缓存启动清空策略根据不同的场景合理的设置最大元素值往往可以一定程度上提高缓存的命中率,从而更有效的时候缓存。
- 清空策略:FIFO/LFU/LRU/过期时间/随机
FIFO:最先进入缓存的数据,在缓存空间不足时被清除,为了保证最新数据可用,保证实时性
LFU(Least Frequently Used):最近最不常用,基于访问次数,去除命中次数最少的元素,保证高频数据有效性
LRU(Least Recently Used):最近最少使用,基于访问时间,在被访问过的元素中去除最久未使用的元素,保证热点数据的有效性
缓存算法(页面置换算法)-FIFO、LFU、LRU
影响缓存命中率的因素:
1.业务场景和业务需求
缓存通常适合读多写少的业务场景,反之的使用意义并不多,命中率会很低。业务需求也决定了实时性的要求,直接影响到过期时间和更新策略,实时性要求越低越适合缓存。
2.缓存的设计(策略和粒度)
通常情况下缓存的粒度越小,命中率越高。比如说缓存一个用户信息的对象,只有当这个用户的信息发生变化的时候才更新缓存,而如果是缓存一个集合的话,集合中任何一个对象发生变化都要重新更新缓存。
当数据发生变化时,直接更新缓存的值比移除缓存或者让缓存过期它的命中率更高,不过这个时候系统的复杂度过高。
3.缓存的容量和基础设施
缓存的容量有限就会容易引起缓存的失效和被淘汰。目前多数的缓存框架和中间件都采用LRU这个算法。同时采用缓存的技术选型也是至关重要的,比如采用本地内置的应用缓存,就比较容易出现单机瓶颈。而采用分布式缓存就更加容易扩展。所以需要做好系统容量规划,系统是否可扩展。
缓存介质
虽然从硬件介质上来看,无非就是内存和硬盘两种,但从技术上,可以分成内存、硬盘文件、数据库。
- 内存:将缓存存储于内存中是最快的选择,无需额外的I/O开销,但是内存的缺点是没有持久化落地物理磁盘,一旦应用异常break down而重新启动,数据很难或者无法复原。
- 硬盘:一般来说,很多缓存框架会结合使用内存和硬盘,在内存分配空间满了或是在异常的情况下,可以被动或主动的将内存空间数据持久化到硬盘中,达到释放空间或备份数据的目的。
- 数据库:前面有提到,增加缓存的策略的目的之一就是为了减少数据库的I/O压力。现在使用数据库做缓存介质是不是又回到了老问题上了?其实,数据库也有很多种类型,像那些不支持SQL,只是简单的key-value存储结构的特殊数据库(如BerkeleyDB和Redis),响应速度和吞吐量都远远高于我们常用的关系型数据库等。
根据缓存和应用的耦合度
本地缓存最大的优点在于它在应用进程的内部,请求缓存非常的快速,没有过多的网络开销。在单应用中不需要集群支持,集群的情况下各节点不需要互相通知的情况下使用本地缓存比较合适。
缺点是因为本地缓存跟应用程序耦合,多个应用程序无法直接共享缓存,各应用节点都需要维护自己单独的缓存,有时也是对内存的一种浪费。
分布式缓存指的是应用分离的缓存服务,最大的优点就是自身就是一个独立的应用,与本地应用是隔离的,多个应用直接共享缓存。
本地缓存Guava Cache
Guava Cache是Google开源的Java重用工具集库Guava里的一款缓存工具,它的设计灵感是CuncurentHashMap
Guava Cache继承了ConcurrentHashMap的思路,使用多个segments方式的细粒度锁,在保证线程安全的同时,支持高并发场景需求。Cache类似于Map,它是存储键值对的集合,不同的是它还需要处理evict、expire、dynamic load等算法逻辑,需要一些额外信息来实现这些操作。对此,根据面向对象思想,需要做方法与数据的关联封装.
其主要实现的缓存功能有:
- 自动将entry节点加载进缓存结构中;
- 当缓存的数据超过设置的最大值时,使用LRU算法移除;
- 具备根据entry节点上次被访问或者写入时间计算它的过期机制;
- 缓存的key被封装在WeakReference引用内;
- 缓存的Value被封装在WeakReference或SoftReference引用内;
- 统计缓存使用过程中命中率、异常率、未命中率等统计数据。
memcached是应用较广的开源分布式缓存产品之一,它本身其实不提供分布式解决方案。在服务端,memcached集群环境实际就是一个个memcached服务器的堆积,环境搭建较为简单;cache的分布式主要是在客户端实现,通过客户端的路由处理来达到分布式解决方案的目的。客户端做路由的原理非常简单,应用服务器在每次存取某key的value时,通过某种算法把key映射到某台memcached服务器nodeA上,因此这个key所有操作都在nodeA上,结构图如图6、图7所示。
![](https://tech.meituan.com/img/cache_about/memcached%E5%AE%A2%E6%88%B7%E7%AB%AF%E8%B7%AF%E7%94%B1%E5%9B%BE.png)
memcached客户端路由图
![](https://tech.meituan.com/img/cache_about/memcached%E4%B8%80%E8%87%B4%E6%80%A7hash%E7%A4%BA%E4%BE%8B%E5%9B%BE.png)
memcached一致性hash示例图
memcached客户端采用一致性hash算法作为路由策略,相对于一般hash(如简单取模)的算法,一致性hash算法除了计算key的hash值外,还会计算每个server对应的hash值,然后将这些hash值映射到一个有限的值域上(比如0~2^32)。通过寻找hash值大于hash(key)的最小server作为存储该key数据的目标server。如果找不到,则直接把具有最小hash值的server作为目标server。同时,一定程度上,解决了扩容问题,增加或删除单个节点,对于整个集群来说,不会有大的影响。最近版本,增加了虚拟节点的设计,进一步提升了可用性。
一致性哈希算法原理
memcached是一个高效的分布式内存cache,了解memcached的内存管理机制,才能更好的掌握memcached,让我们可以针对我们数据特点进行调优,让其更好的为我所用。我们知道memcached仅支持基础的key-value键值对类型数据存储。在memcached内存结构中有两个非常重要的概念:slab和chunk。
每个page的默认大小是1M,trunk是真正存放数据的地方,memcache会根据value值的大小找到接近大小的slab
Redis缓存
Redis是一个远程内存数据库(非关系型数据库),性能强劲,具有复制特性以及解决问题而生的独一无二的数据模型。它可以存储键值对与5种不同类型的值之间的映射,可以将存储在内存的键值对数据持久化到硬盘,可以使用复制特性来扩展读性能,还可以使用客户端分片来扩展写性能。
Redis具备以下特点:
- 异常快速: Redis数据库完全在内存中,因此处理速度非常快,每秒能执行约11万集合,每秒约81000+条记录。
- 数据持久化: redis支持数据持久化,可以将内存中的数据存储到磁盘上,方便在宕机等突发情况下快速恢复。
- 支持丰富的数据类型: 相比许多其他的键值对存储数据库,Redis拥有一套较为丰富的数据类型。
- 数据一致性: 所有Redis操作是原子的,这保证了如果两个客户端同时访问的Redis服务器将获得更新后的值。
- 多功能实用工具: Redis是一个多实用的工具,可以在多个用例如缓存,消息,队列使用(Redis原生支持发布/订阅),任何短暂的数据,应用程序,如 Web应用程序会话,网页命中计数等。
适用场景:
1.取最新N个数据的操作
2.排行榜类似的应用
3.精准设定过期时间的应用
4.计数器的应用
5.唯一性检查
6.实时系统,队列系统,最基础的缓存功能
1缓存一致性问题
当数据时效性要求很高时,需要保证缓存中的数据与数据库中的保持一致,而且需要保证缓存节点和副本中的数据也保持一致,不能出现差异现象。这就比较依赖缓存的过期和更新策略。一般会在数据发生更改的时,主动更新缓存中的数据或者移除对应的缓存。
2.缓存并发问题
缓存过期后将尝试从后端数据库获取数据,这是一个看似合理的流程。但是,在高并发场景下,有可能多个请求并发的去从数据库获取数据,对后端数据库造成极大的冲击,甚至导致 “雪崩”现象。此外,当某个缓存key在被更新时,同时也可能被大量请求在获取,这也会导致一致性的问题。那如何避免类似问题呢?我们会想到类似“锁”的机制,在缓存更新或者过期的情况下,先尝试获取到锁,当更新或者从数据库获取完成后再释放锁,其他的请求只需要牺牲一定的等待时间,即可直接从缓存中继续获取数据。
3.缓存穿透问题
缓存穿透在有些地方也称为“击穿”。很多朋友对缓存穿透的理解是:由于缓存故障或者缓存过期导致大量请求穿透到后端数据库服务器,从而对数据库造成巨大冲击。
这其实是一种误解。真正的缓存穿透应该是这样的:
在高并发场景下,如果某一个key被高并发访问,没有被命中,出于对容错性考虑,会尝试去从后端数据库中获取,从而导致了大量请求达到数据库,而当该key对应的数据本身就是空的情况下,这就导致数据库中并发的去执行了很多不必要的查询操作,从而导致巨大冲击和压力。
可以通过下面的几种常用方式来避免缓存传统问题:
缓存空对象
对查询结果为空的对象也进行缓存,如果是集合,可以缓存一个空的集合(非null),如果是缓存单个对象,可以通过字段标识来区分。这样避免请求穿透到后端数据库。同时,也需要保证缓存数据的时效性。这种方式实现起来成本较低,比较适合命中不高,但可能被频繁更新的数据。
单独过滤处理
对所有可能对应数据为空的key进行统一的存放,并在请求前做拦截,这样避免请求穿透到后端数据库。这种方式实现起来相对复杂,比较适合命中不高,但是更新不频繁的数据。
4.缓存颠簸问题
缓存的颠簸问题,有些地方可能被成为“缓存抖动”,可以看做是一种比“雪崩”更轻微的故障,但是也会在一段时间内对系统造成冲击和性能影响。一般是由于缓存节点故障导致。业内推荐的做法是通过一致性Hash算法来解决。这里不做过多阐述。
5.缓存的雪崩现象
缓存雪崩就是指由于缓存的原因,导致大量请求到达后端数据库,从而导致数据库崩溃,整个系统崩溃,发生灾难。导致这种现象的原因有很多种,上面提到的“缓存并发”,“缓存穿透”,“缓存颠簸”等问题,其实都可能会导致缓存雪崩现象发生。这些问题也可能会被恶意攻击者所利用。还有一种情况,例如某个时间点内,系统预加载的缓存周期性集中失效了,也可能会导致雪崩。为了避免这种周期性失效,可以通过设置不同的过期时间,来错开缓存过期,从而避免缓存集中失效。
从应用架构角度,我们可以通过限流、降级、熔断等手段来降低影响,也可以通过多级缓存来避免这种灾难。
此外,从整个研发体系流程的角度,应该加强压力测试,尽量模拟真实场景,尽早的暴露问题从而防范。