深入理解分布式缓存——使用Spring Boot+Redis实现分布式缓存解决方案
共 8033字,需浏览 17分钟
·
2022-07-05 01:38
在微服务飞速发展的今天,在高并发的分布式的系统中,缓存是提升系统性能的重要手段。没有缓存对后端请求的拦截,大量的请求将直接落到系统的底层数据库。系统是很难撑住高并发的冲击,下面就以Redis为例来聊聊分布式系统中关于缓存的设计以及过程中遇到的一些问题。
一、分布式缓存简介
1. 什么是分布式缓存
分布式缓存:指将应用系统和缓存组件进行分离的缓存机制,这样多个应用系统就可以共享一套缓存数据了,它的特点是共享缓存服务和可集群部署,为缓存系统提供了高可用的运行环境,以及缓存共享的程序运行机制。
2、本地缓存VS分布式缓存
本地缓存:是应用系统中的缓存组件,其最大的优点是应用和cache是在同一个进程内部,请求缓存非常快速,没有过多的网络开销等,在单应用不需要集群支持的场景下使用本地缓存较合适;但是,它的缺点也是缓存跟应用程序耦合,多个应用程序无法共享缓存数据,各应用或集群的各节点都需要维护自己的单独缓存。很显然,这对内存是一种浪费。
分布式缓存:与应用分离的缓存组件或服务,分布式缓存系统是一个独立的缓存服务,与本地应用隔离,这使得多个应用系统之间可直接的共享缓存数据。目前分布式缓存系统已经成为微服务架构的重要组成部分,活跃在成千上万的应用服务中。但是,目前还没有一种缓存方案可以解决一切的业务场景或数据类型,我们需要根据自身的特殊场景和背景,选择最适合的缓存方案。
3、分布式缓存的特性
相对于本地应用缓存,分布式缓存具有如下特性:
1) 高性能:当传统数据库面临大规模数据访问时,磁盘I/O 往往成为性能瓶颈,从而导致过高的响应延迟。分布式缓存将高速内存作为数据对象的存储介质,数据以key/value 形式存储。
2) 动态扩展性:支持弹性扩展,通过动态增加或减少节点应对变化的数据访问负载,提供可预测的性能与扩展性;同时,最大限度地提高资源利用率;
3) 高可用性:高可用性包含数据可用性与服务可用性两方面,故障的自动发现,自动转义。确保不会因服务器故障而导致缓存服务中断或数据丢失。
4) 易用性:提供单一的数据与管理视图;API 接口简单,且与拓扑结构无关;动态扩展或失效恢复时无需人工配置;自动选取备份节点;多数缓存系统提供了图形化的管理控制台,便于统一维护;
4、分布式缓存的应用场景
分布式缓存的典型应用场景可分为以下几类:
1) 页面缓存:用来缓存Web 页面的内容片段,包括HTML、CSS 和图片等,多应用于社交网站等;
2) 应用对象缓存:缓存系统作为ORM 框架的二级缓存对外提供服务,目的是减轻数据库的负载压力,加速应用访问;
3) 状态缓存:缓存包括Session 会话状态及应用横向扩展时的状态数据等,这类数据一般是难以恢复的,对可用性要求较高,多应用于高可用集群;
4) 并行处理:通常涉及大量中间计算结果需要共享;
5) 事件处理:分布式缓存提供了针对事件流的连续查询(continuous query)处理技术,满足实时性需求;
6) 极限事务处理:分布式缓存为事务型应用提供高吞吐率、低延时的解决方案,支持高并发事务请求处理,多应用于铁路、金融服务和电信等领域;
二、 为什么要用分布式缓存?
在传统的后端架构中,由于请求量以及响应时间要求不高,我们经常采用单一的数据库结构。这种架构虽然简单,但随着请求量的增加,这种架构存在性能瓶颈导致无法继续稳定提供服务。
通过在应用服务与DB中间引入缓存层,我们可以得到如下三个好处:
(1)读取速度得到提升。
(2)系统扩展能力得到大幅增强。我们可以通过加缓存,来让系统的承载能力提升。
(3)总成本下降,单台缓存即可承担原来的多台DB的请求量,大大节省了机器成本。
三、常用的缓存技术
目前最流行的分布式缓存技术有redis和memcached两种,
1. Memcache
Memcached 是一个高性能,分布式内存对象缓存系统,通过在内存里维护一个统一的巨大的 Hash 表,它能够用来存储各种格式的数据,包括图像、视频、文件以及数据库检索的结果等。简单来说就是:将数据缓存到内存中,然后从内存中读取,从而大大提高读取速度。
Memcached 特性:
使用物理内存作为缓存区,可独立运行在服务器上。每个进程最大 2G,如果想缓存更多的数据,可以开辟更多的 Memcached 进程(不同端口)或者使用分布式 Memcached 进行缓存,将数据缓存到不同的物理机或者虚拟机上。
使用 key-value 的方式来存储数据。这是一种单索引的结构化数据组织形式,可使数据项查询时间复杂度为 O(1)。
协议简单,基于文本行的协议。直接通过 telnet 在 Memcached 服务器上可进行存取数据操作,简单,方便多种缓存参考此协议;
基于 libevent 高性能通信。Libevent 是一套利用 C 开发的程序库,它将 BSD 系统的 kqueue,Linux 系统的 epoll 等事件处理功能封装成一个接口,与传统的 select 相比,提高了性能。
分布式能力取决于 Memcached 客户端,服务器之间互不通信。各个 Memcached 服务器之间互不通信,各自独立存取数据,不共享任何信息。服务器并不具有分布式功能,分布式部署取决于 Memcached 客户端。
采用 LRU 缓存淘汰策略。在 Memcached 内存储数据项时,可以指定它在缓存的失效时间,默认为永久。当 Memcached 服务器用完分配的内存时,失效的数据被首先替换,然后也是最近未使用的数据。在 LRU 中,Memcached 使用的是一种 Lazy Expiration 策略,自己不会监控存入的 key/vlue 对是否过期,而是在获取 key 值时查看记录的时间戳,检查 key/value 对空间是否过期,这样可减轻服务器的负载。
内置了一套高效的内存管理算法。这套内存管理效率很高,而且不会造成内存碎片,但是它最大的缺点就是会导致空间浪费。当内存满后,通过 LRU 算法自动删除不使用的缓存。
不支持持久化。Memcached 没有考虑数据的容灾问题,重启服务,所有数据会丢失。
2. Redis
Redis 是一个开源(BSD 许可),基于内存的,支持网络、可基于内存、分布式、可选持久性的键值对(Key-Value)存储数据库,并提供多种语言的 API。可以用作数据库、缓存和消息中间件。
Redis 支持多种数据类型 - string、Hash、list、set、sorted set。提供两种持久化方式 - RDB 和 AOF。通过 Redis cluster 提供集群模式。
Redis的优势:
性能极高 – Redis能读的速度是110000次/s,写的速度是81000次/s 。
丰富的数据类型 – Redis支持二进制案例的 Strings, Lists, Hashes, Sets 及 Ordered Sets 数据类型操作。
原子 – Redis的所有操作都是原子性的,同时Redis还支持对几个操作合并后的原子性执行。(事务)
丰富的特性 – Redis还支持 publish/subscribe, 通知, key 过期等等特性。
3. 分布式缓存技术对比
不同的分布式缓存功能特性和实现原理方面有很大的差异,因此他们所适应的场景也有所不同。
四、分布式缓存实现方案
缓存的目的是为了在高并发系统中有效降低DB的压力,高效的数据缓存可以极大地提高系统的访问速度和并发性能。分布式缓存有很多实现方案,下面将讲一讲分布式缓存实现方案。
1、缓存实现方案
如上图所示,系统会自动根据调用的方法缓存请求的数据。当再次调用该方法时,系统会首先从缓存中查找是否有相应的数据,如果命中缓存,则从缓存中读取数据并返回;如果没有命中,则请求数据库查询相应的数据并再次缓存。
如上图所示,每一个用户请求都会先查询缓存中的数据,如果缓存命中,则会返回缓存中的数据。这样能减少数据库查询,提高系统的响应速度。
2、使用Spring Boot+Redis实现分布式缓存解决方案
接下来,以用户信息管理模块为例演示使用Redis实现数据缓存框架。
1.添加Redis Cache的配置类
RedisConfig类为Redis设置了一些全局配置,比如配置主键的生产策略KeyGenerator()方法,此类继承CachingConfigurerSupport类,并重写方法keyGenerator(),如果不配置,就默认使用参数名作为主键。
@Configuration@EnableCachingpublic class RedisConfig extends CachingConfigurerSupport { / ** * 采用RedisCacheManager作为缓存管理器 * 为了处理高可用Redis,可以使用RedisSentinelConfiguration来支持Redis Sentinel */ @Bean public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory).build(); return redisCacheManager; } / ** * 自定义生成key的规则 */ @Override public KeyGenerator keyGenerator() { return new KeyGenerator() { @Override public Object generate(Object o, Method method, Object...objects) { // 格式化缓存key字符串 StringBuilder stringBuilder = new StringBuilder(); // 追加类名 stringBuilder.append(o.getClass().getName()); // 追加方法名 stringBuilder.append(method.getName()); // 遍历参数并且追加 for (Object obj :objects) { stringBuilder.append(obj.toString()); } System.out.println("调用Redis缓存Key: " + stringBuilder.toString()); return stringBuilder.toString(); } }; }}
在上面的示例中,主要是自定义配置RedisKey的生成规则,使用@EnableCaching注解和@Configuration注解。
@EnableCaching:开启基于注解的缓存,也可以写在启动类上。
@Configuration:标识它是配置类的注解。
2.添加@Cacheable注解
在读取数据的方法上添加@Cacheable注解,这样就会自动将该方法获取的数据结果放入缓存。
@Repositorypublic class UserRepository { / ** * @Cacheable应用到读取数据的方法上,先从缓存中读取,如果没有,再从DB获取数据,然后把数据添加到缓存中 * unless表示条件表达式成立的话不放入缓存 * @param username * @return */ @Cacheable(value = "user") public User getUserByName(String username) { User user = new User(); user.setName(username); user.setAge(30); user.setPassword("123456"); System.out.println("user info from database"); return user; }}
在上面的实例中,使用@Cacheable注解标注该方法要使用缓存。@Cacheable注解主要针对方法进行配置,能够根据方法的请求对参数及其结果进行缓存。
1)这里缓存key的规则为简单的字符串组合,如果不指定key参数,则自动通过keyGenerator生成对应的key。
2)Spring Cache提供了一些可以使用的SpEL上下文数据,通过#进行引用。
3.测试数据缓存
创建单元测试方法调用getUserByName()方法,测试代码如下:
@Testpublic void testGetUserByName() { User user = userRepository.getUserByName("weiz"); System.out.println("name: "+ user.getName()+",age:"+user.getAge()); user = userRepository.getUserByName("weiz"); System.out.println("name: "+ user.getName()+",age:"+user.getAge());}
上面的实例分别调用了两次getUserByName()方法,输出获取到的User信息。
最后,单击Run Test或在方法上右击,选择Run 'testGetUserByName',运行单元测试方法,结果如下图所示。
通过上面的日志输出可以看到,首次调用getPersonByName()方法请求User数据时,由于缓存中未保存该数据,因此从数据库中获取User信息并存入Redis缓存,再次调用会命中此缓存并直接返回。
五、常见问题及解决方案
1.缓存预热
缓存预热指在用户请求数据前先将数据加载到缓存系统中,用户查询 事先被预热的缓存数据,以提高系统查询效率。缓存预热一般有系统启动 加载、定时加载等方式。
5.热key问题
所谓热key问题就是,突然有大量的请求去访问redis上的某个特定key,导致请求过于集中,达到网络IO的上限,从而导致这台redis的服务器宕机引发雪崩。
针对热key的解决方案:
1. 提前把热key打散到不同的服务器,降低压力
2. 加二级缓存,提前加载热key数据到内存中,如果redis宕机,则内存查询
2.缓存击穿
缓存击穿是指大量请求缓存中过期的key,由于并发用户特别多,同时新的缓存还没读到数据,导致大量的请求数据库,引起数据库压力瞬间增大,造成过大压力。缓存击穿和热key的问题比较类似,只是说的点在于过期导致请求全部打到DB上而已。
解决方案:
1. 加锁更新,假设请求查询数据A,若发现缓存中没有,对A这个key加锁,同时去数据库查询数据,然后写入缓存,再返回给用户,这样后面的请求就可以从缓存中拿到数据了。
2. 将过期时间组合写在value中,通过异步的方式不断地刷新过期时间,防止此类现象发生。
3.缓存穿透
缓存穿透是指查询不存在缓存中的数据,每次请求都会打到DB,就像缓存不存在一样。
解决方案:
接口层增加参数校验,如用户鉴权校验,请求参数校验等,对 id<=0的请求直接拦截,一定不存在请求数据的不去查询数据库。
布隆过滤器:指将所有可能存在的Key通过Hash散列函数将它映射为一个位数组,在用户发起请求时首先经过布隆过滤器的拦截,一个一定不存在的数据会被这个布隆过滤器拦截,从而避免对底层存储系统带来查询上 的压力。
cache null策略:指如果一个查询返回的结果为null(可能是数据不存在,也可能是系统故障),我们仍然缓存这个null结果,但它的过期 时间会很短,通常不超过 5 分钟;在用户再次请求该数据时直接返回 null,而不会继续访问数据库,从而有效保障数据库的安全。其实cache null策略的核心原理是:在缓存中记录一个短暂的(数据过期时间内)数据在系统中是否存在的状态,如果不存在,则直接返回null,不再查询数据库,从而避免缓存穿透到数据库上。
布隆过滤器
布隆过滤器的原理是在保存数据的时候,会通过Hash散列函数将它映射为一个位数组中的K个点,同时把他的值置为1。这样当用户再次来查询A时,而A在布隆过滤器值为0,直接返回,就不会产生击穿请求打到DB了。
4.缓存雪崩
缓存雪崩指在同一时刻由于大量缓存失效,导致大量原本应该访问缓存的请求都去查询数据库,而对数据库的CPU和内存造成巨大压力,严重的话会导致数据库宕机,从而形成一系列连锁反应,使得整个系统崩溃。雪崩和击穿、热key的问题不太一样的是,缓存雪崩是指大规模的缓存都过期失效了。
针对雪崩的解决方案:
1. 针对不同key设置不同的过期时间,避免同时过期
2. 限流,如果redis宕机,可以限流,避免同时刻大量请求打崩DB
3. 二级缓存,同热key的方案。
六、缓存与数据库数据一致性
缓存与数据库的一致性问题分为两种情况,一是缓存中有数据,则必须与数据库中的数据一致;二是缓存中没数据,则数据库中的数据必须是最新的。
3.1删除和修改数据
第一种情况:我们先删除缓存,在更新数据库,潜在的问题:数据库更新失败了,get请求进来发现没缓存则请求数据库,导致缓存又刷入了旧的值。
第二种情况:我们先更新数据库,再删除缓存,潜在的问题:缓存删除失败,get请求进来缓存命中,导致读到的是旧值。
3.2先删除缓存再更新数据库
假设有2个线程A和B,A删除缓存之后,由于网络延迟,在更新数据库之前,B来访问了,发现缓存未命中,则去请求数据库然后把旧值刷入了缓存,这时候姗姗来迟的A,才把最新数据刷入数据库,导致了数据的不一致性。
解决方案
针对多线程的场景,可以采用延迟双删的解决方案,我们可以在A更新完数据库之后,线程A先休眠一段时间,再删除缓存。
需要注意的是:具体休眠的时间,需要评估自己的项目的读数据业务逻辑的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。当然这种策略还要考虑redis和数据库主从同步的耗时。
3.3先更新数据库再删除缓存
这种场景潜在的风险就是更新完数据库,删缓存之前,会有部分并发请求读到旧值,这种情况对业务影响较小,可以通过重试机制,保证缓存能得到删除。
最后
以上,就把分布式缓存介绍完了,并使用Spring Boot+Redis实现分布式缓存解决方案。缓存的使用是程序员、架构师的必备技能,好的程序员能根据数据类型、业务场景来准确判断使用何种类型的缓存,如何使用这种缓存,以最小的成本最快的效率达到最优的目的。