分布式缓存

Siona

分布式缓存

一、What?

1. 什么是分布式缓存

分布式缓存:将应用系统和缓存组件进行分离的缓存机制,这样多个应用系统就可以共享一套缓存数据了。

特点:
    共享缓存服务和可集群部署
    为缓存系统提供了高可用的运行环境
    缓存共享的程序运行机制

2. 本地缓存 VS 分布式缓存

✅ 本地缓存:是应用系统中的缓存组件。
    `优点`
        应用和 cache 是在同一个进程内部,请求缓存非常快速,没有过多的网络开销等。
    `缺点`
        应用缓存跟应用程序耦合,多个应用程序无法共享缓存数据,各应用或集群的各节点都需要维护自己的单独缓存。
        【对内存是一种浪费】
    `应用场景`:在单应用不需要集群支持的场景。

✅ 分布式缓存:与应用分离的缓存组件或服务。
   分布式缓存系统是一个独立的缓存服务,与本地应用隔离,这使得多个应用系统之间可直接的共享缓存数据。
    
    📢 目前分布式缓存系统已经成为微服务架构的重要组成部分,活跃在成千上万的应用服务中。
        But,目前还没有一种缓存方案可以解决一切的业务场景或数据类型。
        So,我们需要根据自身的特殊场景和背景,选择最适合的缓存方案。

3. 特性

相对于本地应用缓存,分布式缓存具有如下特性:
   
1) 高性能
    当传统数据库面临大规模数据访问时,磁盘 I/O 往往成为性能瓶颈,从而导致过高的响应延迟。
    分布式缓存将高速内存作为数据对象的存储介质,数据以 key/value 形式存储。

2) 动态扩展性
    支持弹性扩展,通过动态增加或减少节点应对变化的数据访问负载,提供可预测的性能与扩展性;
    同时,最大限度地提高资源利用率;

3) 高可用性
    高可用性包含数据可用性与服务可用性两方面,故障的自动发现,自动转义。
    确保不会因服务器故障而导致缓存服务中断或数据丢失。

4) 易用性
    提供单一的数据与管理视图;
    API 接口简单,且与拓扑结构无关;
    动态扩展或失效恢复时无需人工配置;
    自动选取备份节点;
    多数缓存系统提供了图形化的管理控制台,便于统一维护。

4. 应用场景

分布式缓存的典型应用场景可分为以下几类:

1) 页面缓存
    用来缓存 Web 页面的内容片段,包括 HTMLCSS 和图片等,多应用于社交网站等。

2) 应用对象缓存
    缓存系统作为 ORM 框架的二级缓存对外提供服务,目的是减轻数据库的负载压力,加速应用访问。

3) 状态缓存
    缓存包括 Session 会话状态及应用横向扩展时的状态数据等。
    这类数据一般是难以恢复的,对可用性要求较高,多应用于高可用集群。

4) 并行处理
    通常涉及大量中间计算结果需要共享。

5) 事件处理
    分布式缓存提供了针对事件流的连续查询 (continuous query) 处理技术,满足实时性需求。

6) 极限事务处理
    分布式缓存为事务型应用提供高吞吐率、低延时的解决方案,支持高并发事务请求处理。
    多应用于铁路、金融服务和电信等领域。

二、 Why?

在传统的后端架构中,由于请求量以及响应时间要求不高,我们经常采用单一的数据库结构。
这种架构虽然简单,但随着请求量的增加,这种架构存在性能瓶颈导致无法继续稳定提供服务。

通过在应用服务与 DB 中间引入缓存层,我们可以得到如下三个好处:
1)读取速度得到提升。
2)系统扩展能力得到大幅增强。我们可以通过加缓存,来让系统的承载能力提升。
3)总成本下降,单台缓存即可承担原来的多台DB的请求量,大大节省了机器成本。

三、常用的缓存技术

目前最流行的分布式缓存技术有 redismemcached 两种。

Memcached

Memcached 是一个高性能,分布式内存对象缓存系统。
   通过在内存里维护一个统一的巨大的 Hash 表,它能够用来存储各种格式的数据,包括图像、视频、文件以及数据库检索的结果等。
   简单的说就是:将数据缓存到内存中,然后从内存中读取,从而大大提高读取速度。

Memcached 特性
1. 使用物理内存作为缓存区,可独立运行在服务器上。
   每个进程最大 2G,如果想缓存更多的数据,可以开辟更多的 Memcached 进程(不同端口)
   或者使用分布式 Memcached 进行缓存,将数据缓存到不同的物理机或者虚拟机上。

2. 使用 key-value 的方式来存储数据。
   这是一种单索引的结构化数据组织形式,可使数据项查询时间复杂度为 O(1)

3. 协议简单,基于文本行的协议。
   直接通过 telnet 在 Memcached 服务器上可进行存取数据操作。简单、方便多种缓存参考此协议。

4. 基于 libevent 高性能通信。
   Libevent 是一套利用 C 开发的程序库,它将 BSD 系统的 kqueue,Linux 系统的 epoll 等事件处理功能封装成一个接口,
   与传统的 select 相比,提高了性能。

5. 分布式能力取决于 Memcached 客户端,服务器之间互不通信。
   各个 Memcached 服务器之间互不通信,各自独立存取数据,不共享任何信息。
   服务器并不具有分布式功能,分布式部署取决于 Memcached 客户端。

6. 采用 LRU 缓存淘汰策略。
Memcached 内存储数据项时,可以指定它在缓存的失效时间,默认为永久。
Memcached 服务器用完分配的内时,失效的数据被首先替换,然后也是最近未使用的数据。
LRU 中,Memcached 使用的是一种 Lazy Expiration 策略,自己不会监控存入的 key/vlue 对是否过期,
   而是在获取 key 值时查看记录的时间戳,检查 key/value 对空间是否过期,这样可减轻服务器的负载。

7. 内置了一套高效的内存管理算法。这套内存管理效率很高,而且不会造成内存碎片,但是它最大的缺点就是会导致空间浪费。
   当内存满后,通过 LRU 算法自动删除不使用的缓存。

8. 不支持持久化。Memcached 没有考虑数据的容灾问题,重启服务,所有数据会丢失。

Redis

Redis 是一个开源(BSD 许可)数据库。
   基于内存的,支持网络、可基于内存、分布式、可选持久性的键值对 (Key-Value) 存储数据库。
   提供多种语言的 API
   可以用作数据库、缓存和消息中间件。

Redis 支持多种数据类型 - string、Hash、list、set、sorted set。
   提供两种持久化方式 - RDBAOF
   通过 Redis cluster 提供集群模式。

Redis的优势:
1. 性能极高
   Redis 能读的速度是110000/s,写的速度是81000/s

2. 丰富的数据类型
   Redis 支持二进制案例的 Strings, Lists, Hashes, SetsOrdered Sets 数据类型操作。

3. 原子
   Redis 的所有操作都是原子性的,同时 Redis 还支持对几个操作合并后的原子性执行。(事务)

4. 丰富的特性
   Redis 还支持 publish/subscribe、通知、key 过期等特性。

分布式缓存技术对比

基于内存数据结构虚拟内存过期策略数据持久灾难恢复性能
RedisString、Hash、List、
Set、Sorted Set
物理内存用完,可以将
不用的数据交换到磁盘
MemcachedK/V

四、实现方案

缓存的目的:为了在高并发系统中,有效降低 DB 的压力,高效的数据缓存可以极大地提高系统的访问速度和并发性能。

1、缓存实现方案

如上图所示,系统会自动根据调用的方法缓存请求的数据。
当再次调用该方法时,系统会首先从缓存中查找是否有相应的数据,
If 命中缓存,则从缓存中读取数据并返回;
If 没有命中,则请求数据库查询相应的数据并再次缓存。

2. SpringBoot + Redis 实现

以用户信息管理模块为例演示使用 Redis 实现数据缓存框架。

1️⃣ 添加 Redis Cache 配置类

RedisConfig 类为 Redis 设置了一些全局配置,比如配置主键的生产策略 KeyGenerator(), 此类继承 CachingConfigurerSupport 类,并重写方法 keyGenerator(),如果不配置,就默认使用参数名作为主键。

@Configuration
@EnableCaching
public 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 注解,这样就会自动将该方法获取的数据结果放入缓存。

@Repository
public 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(),测试代码如下:

@Test
public void testGetUserByName() {
    User user = userRepository.getUserByName("siona");
   System.out.println("name: "+ user.getName()+",age:"+user.getAge());
 
    user = userRepository.getUserByName("siona");
   System.out.println("name: "+ user.getName()+",age:"+user.getAge());
}

上面的实例分别调用了两次 getUserByName(),输出获取到的 User 信息。 最后,单击 Run Test 或在方法上右击,选择 Run 'testGetUserByName',运行单元测试方法,结果如下图所示。

通过上面的日志输出可以看到,首次调用 getPersonByName() 请求 User 数据时, 由于缓存中未保存该数据,因此从数据库中获取 User 信息并存入 Redis 缓存,再次调用会命中此缓存并直接返回。

五、常见问题及解决方案

1. 缓存预热

缓存预热:在用户请求数据前先将数据加载到缓存系统中,用户查询事先被预热的缓存数据,以提高系统查询效率。
缓存预热一般有系统启动加载、定时加载等方式。

2. 热点 key 问题

热点 key 问题:
    突然有大量的请求去访问 Redis 上的某个特定 key,导致请求过于集中,达到网络 IO 的上限,
    从而导致这台 Redis 的服务器宕机引发雪崩。
    
    
解决方案:
1️⃣ 提前把热点 key 打散到不同的服务器,降低压力
2️⃣ 加二级缓存,提前加载热点 key 数据到内存中,如果 Redis 宕机,则内存查询

3. 缓存击穿

缓存击穿:大量请求缓存中过期的 key,由于并发用户特别多,同时新的缓存还没读到数据,
        导致大量的请求数据库,引起数据库压力瞬间增大,造成过大压力。
        
        缓存击穿和热 key 的问题比较类似,区别在于过期导致请求全部打到 DB 上而已。

解决方案:
1️⃣ 加锁更新,假设请求查询数据 A,
   若发现缓存中没有,对 A 这个 key 加锁,同时去数据库查询数据,然后写入缓存,
   再返回给用户,这样后面的请求就可以从缓存中拿到数据了。
2️⃣ 将过期时间组合写在 value 中,通过异步的方式不断的刷新过期时间,防止此类现象发生。

4. 缓存穿透

缓存穿透:查询不存在缓存中的数据,每次请求都会打到 DB,就像缓存不存在一样。


解决方案:
1️⃣ 接口层增加参数校验。
   如用户鉴权校验,请求参数校验等,对 id<=0 的请求直接拦截,一定不存在请求数据的不去查询数据库。
2️⃣ 布隆过滤器。
   指将所有可能存在的 Key 通过 Hash 散列函数将它映射为一个位数组,在用户发起请求时首先经过布隆过滤器的拦截,
   一个一定不存在的数据会被这个布隆过滤器拦截,从而避免对底层存储系统带来查询上的压力。
3️⃣ cache null 策略。
   如果一个查询返回的结果为 null(可能是数据不存在,也可能是系统故障),
   我们仍然缓存这个 null 结果,但它的过期时间会很短,通常不超过 5 分钟;
   在用户再次请求该数据时直接返回 null,而不会继续访问数据库,从而有效保障数据库的安全。
   
   cache null 策略的核心原理:
        在缓存中记录一个短暂的(数据过期时间内)数据在系统中是否存在的状态,
        如果不存在,则直接返回 null,不再查询数据库,从而避免缓存穿透到数据库上。

布隆过滤器

原理:
    在保存数据的时候,会通过 Hash 散列函数将它映射为一个位数组中的 K 个点,同时把他的值置为 1
    这样当用户再次来查询 A 时,而 A 在布隆过滤器值为 0,直接返回,就不会产生击穿请求打到 DB 了。

5. 缓存雪崩

缓存雪崩:
    在同一时刻由于大量缓存失效,导致大量原本应该访问缓存的请求都去查询数据库,而对数据库的 CPU 和内存造成巨大压力,
    严重的话会导致数据库宕机,从而形成一系列连锁反应,使得整个系统崩溃。
    雪崩和击穿、热 key 的问题不太一样的是,缓存雪崩是指大规模的缓存都过期失效了。
    
    
解决方案:
1. 针对不同 key 设置不同的过期时间,避免同时过期
2. 限流,如果 redis 宕机,可以限流,避免同时刻大量请求打崩 DB
3. 二级缓存,同热 key 的方案。

六、数据一致性

缓存与数据库的一致性问题分为两种情况:

  • 缓存中有数据,则必须与数据库中的数据一致;
  • 缓存中没数据,则数据库中的数据必须是最新的。
1️⃣ 删除和修改数据
情况 1:先删除缓存,再更新数据库。
    潜在的问题:数据库更新失败了,get 请求进来发现没缓存则请求数据库,导致缓存又刷入了旧的值。

情况 2:先更新数据库,再删除缓存。
    潜在的问题:缓存删除失败,get 请求进来缓存命中,导致读到的是旧值。

2️⃣ 先删除缓存再更新数据库
假设有 2 个线程 AB
A 删除缓存之后,由于网络延迟,在更新数据库之前,B 来访问了,发现缓存未命中,则去请求数据库然后把旧值刷入了缓存,
这时候姗姗来迟的 A,才把最新数据刷入数据库,导致了数据的不一致性。

解决方案:
针对多线程的场景,可以采用延迟双删的解决方案,我们可以在 A 更新完数据库之后,线程 A 先休眠一段时间,再删除缓存。
需要注意的是:具体休眠的时间,需要评估自己的项目的读数据业务逻辑的耗时。
            这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
            当然这种策略还要考虑 redis 和数据库主从同步的耗时。

3️⃣ 先更新数据库再删除缓存
这种场景潜在的风险就是更新完数据库,删缓存之前,会有部分并发请求读到旧值,
这种情况对业务影响较小,可以通过重试机制,保证缓存能得到删除。

应用场景:大部分的高并发场景,都是读多写少,要想提高数据的访问速度,系统必须加缓存。

原因:缓存的读写效率,远远大于数据库的读写效率。

So,一般会采用分布式缓存来提升系统性能。

常见的分布式缓存系统,包括:

方案
Redis
⭐️⭐️⭐️⭐️⭐️
基于内存的键值存储系统。
支持多种数据结构,如字符串、哈希、列表等。
场景:快速读取和写入
Memcached基于内存的键值存储系统。
场景:分布式缓存和缓存共享。
Hazelcast开源的分布式数据存储和计算平台。
场景:支持分布式缓存、分布式计算等。
Couchhase一个分布式缓存和数据库系统。
结合了缓存和文档存储的功能。
Ehcache一个 Java 缓存库。
场景:作为本地缓存 or 分布式缓存使用。

实现 Redis 分布式缓存(代码)

相关方案:MyBatis 二级缓存 + Redis 实现分布式缓存 --- 不好用😢open in new window

Last Updated 8/29/2024, 3:09:35 AM