返回

Redis二三事(一)

想来既然决定了走Java这条路,那么一定是绕不开redis了,redis的使用以及其原理也算是一门艺术了,希望通过博文把一些常见的redis问题和操作记录下来。

前言

在当前的数据库体系中有两大类型的数据库,一类是关系型数据库,另一类为非关系型数据库。关系型数据库典型的数据结构是表,是由二维表及其之间的联系所组成的数据组。非关系型数据库严格上来说不是一种数据库,而是一种 数据结构化存储方法的集合,可以是文档或者键值对等。 两者的优缺点如下

关系型数据库

优点 缺点
使用方便,SQL语言较为通用,可以用于复杂查询 高并发读写性能较差,在海量数据的读写场景中性能较差,硬盘的IO是一个无法避免的瓶颈
易于维护,都是使用表结构,格式一致 灵活度较低,表结构固定,DDL修改对业务影响较大
复杂操作,可用于一个表以及多个表之间的复杂查询
支持事务控制

非关系型数据库

优点 缺点
读写速度快,可以存储在内存中,不依赖于硬盘 不支持join等复杂连接操作
数据格式灵活,可以是键值对,文档、图片等,扩展性强 事务处理能力弱
缺乏数据完整性约束
不提供SQL支持

redis简介

Redis(Remote Dictionary Server),即远程字典服务,是一个用C语言编写可基于内存亦可以持久化的日志型,Key-Value数据库。是非关系型数据库的一种解决方案,也是目前业界主流的缓存解决方案组件。

why redis

redis性能优秀,能够支持每秒大量的读写操作,还支持集群,分布式、主从同步等配置。同时支持一定的事务能力,保证了高并发场景下的数据的安全和一致性。还有一点,redis的社区十分活跃。其优点如下:

redis优点
redis优点

通常情况下,redis作为MySQL等数据库的缓存层使用。为什么要有缓存?如果在这样一个场景,当大量的数据请求访问MySQL,过多的请求可能会导致MySQL服务器压力过大,甚至会因为过量的请求将数据库击穿,数据服务也会因此中断。此时如果有缓存,那么数据访问请求将会先通过缓存再到达数据库,一旦请求在缓存中得到响应,将不会再查询数据库,这会很大的减少数据库的压力。简单描述如下图:

什么数据可以放在redis

这个问题其实一直萦绕在脑海里很久,在网上翻阅了一段时间也没有找到描述得较为详细的,更多的是应用的具体场景,但在我看来都不够抽象。看来这个问题也只有不断的在实践中去寻找答案了。这里就稍微简单描述一下

  • 不需要实时更新但是又极其消耗数据库的数据。例如网上的商品销售排行榜,这种数据只需要每隔一段时间统计即可,其实时性关注度并不高。
  • 更新频率不高,但是访问比较频繁的数据。这类数据如果放置于缓存能够一定程度上减少数据库的访问压力。如用户个人资料,设置完成后并不会频繁更新,但是为了个性化服务可能会频繁访问。
  • 需要实时更新,但是更新频率不高的数据。比如一个用户的订单列表,用户的订单显然是需要实时呈现的,但是频繁下单的情况又比较少。
  • 在某个时刻访问量极大而且更新也很频繁的数据。种数据有一个很典型的例子就是秒杀,在秒杀那一刻,可能有N倍于平时的流量进来,系统压力会很大。但是这种数据使用的缓存不能和普通缓存一样,这种缓存必须保证不丢失,否则会出现一致性等问题。

redis缓存问题

缓存雪崩

定义

大量或全部缓存数据突然失效或消失,导致所有请求都直接打到数据库上,数据库在巨大的压力下响应缓慢或宕机,应用性能急剧下降,就像雪崩一样。

触发原因

  1. 同步过期。如果你将大量缓存设置为在同一时间过期。突然间,所有数据都需要重新加载到缓存中,这时候所有的请求都会转到数据库上,导致瞬间流量激增。
  2. 系统重启。有时系统维护或意外的服务重启会导致所有缓存失效。当服务再次上线,所有的请求都会涌向空无一物的缓存,然后转向数据库。
  3. Redis服务宕机。硬件故障、网络问题或配置错误都可能导致Redis服务不可用。此时所有的请求都会打向数据库。
  4. 热点key消失。在某些情况下,特定的热点key(被大量频繁访问的key)如果失效或被删除,也会导致相应的大量请求直接落到数据库上,造成局部的雪崩效应。

解决方案

  1. 过期策略改进
    • 随机过期时间。给缓存项设置随机的过期时间可以防止它们同时失效。例如,希望缓存大约在1小时后过期,可以设置过期时间为60±10分钟。
    • 细粒度过期。对于一些热点数据,可以使用更细粒度的过期时间。如使用不同的过期时间策略针对不同类型或频率访问。
  2. 预防措施
    • 合理设置缓存失效时间。根据应用的具体情况合理设置缓存的失效时间,避免大量缓存同时过期。对于不同的数据和业务场景,失效时间应该有所不同。
    • 持久化策略。利用Redis的RDB或AOF持久化机制,确保在系统重启后缓存可以被恢复,减少对数据库的压力。
    • 备份机制。确保有备份和灾难恢复计划,当缓存服务器出现问题时,可以快速恢复或切换到备份系统。
  3. 热点数据处理
    • 识别热点数据。监控和识别访问频率特别高的数据。这些数据是潜在的热点,需要特别关注。
    • 分布式锁。对于热点key的更新操作,可以使用分布式锁来确保同一时间只有一个请求去构建新的缓存,避免大量请求同时击中数据库。
    • 使用队列。对于高频更新的热点数据,可以使用消息队列来缓冲和序列化处理请求。
  4. 降级和限流
    • 服务降级。在缓存雪崩或其他系统异常时,可以暂时关闭一些非核心功能,保证核心功能的正常运作。
    • 请求限流。通过算法(如令牌桶、漏桶等)限制访问频率,确保系统在承受范围内。

缓存穿透

定义

当请求查询的数据在缓存中不存在时(也不存在于数据库中),请求便会“穿透”缓存层直接查询数据库。在正常情况下,缓存系统会减轻对数据库的访问压力,但在缓存穿透的情况下,大量的无效请求会直接落在数据库上,导致数据库负载激增,甚至可能导致服务瘫痪。

触发原因

  1. 恶意攻击

    攻击者可能会故意请求缓存中不存在的数据。这种攻击通常旨在使应用程序变慢或崩溃,从而达到拒绝服务的效果。

  2. 系统缺陷

    • 设计缺陷。如果系统没有妥善处理不存在的数据请求,例如未设置合理的默认行为或缓存策略,那么即使是正常的用户行为也可能导致缓存穿透。
    • 数据不一致。在有些情况下,缓存和数据库之间的数据不同步也可能导致缓存穿透。
  3. 错误的用户输入

    • 无效请求。用户错误的输入,如错误的ID或查询参数,如果没有妥善处理,也可能导致请求直接查询数据库。
    • 缺乏验证。系统未能验证输入的有效性,也可能导致大量无效查询穿透缓存层。

解决方案

  1. 布隆过滤器

    原理

    • 如何工作: 布隆过滤器是一种空间效率很高的数据结构,它可以告诉你某个元素是否在一个集合中。它使用多个哈希函数将元素映射到位数组中的几个点。如果检查时所有点都是1,那么元素可能存在;如果任何一个点是0,则元素一定不存在。
    • 适用场景: 对于缓存穿透问题,布隆过滤器可以作为请求的第一道防线,用来检查请求的数据是否有可能存在于数据库中。

    应用

    • 设置过程: 将所有可能的有效数据的标识(例如ID)添加到布隆过滤器中。然后,每次缓存查询前先查询布隆过滤器。
    • 效果: 如果布隆过滤器说数据一定不存在,就可以直接拒绝请求,避免查询数据库。
  2. 空值缓存

    策略

    • 核心思想: 即使一个查询的结果是空(即数据在数据库中不存在),也将这个“空”结果缓存起来。这样,下次相同的查询来时,直接从缓存中返回空结果,而不需要再次查询数据库。
    • 注意: 空结果设置一个较短的过期时间,避免长时间内有效数据被错误地判定为不存在。

    优势与考量

    • 减轻数据库压力。这种方法可以显著减少对数据库的无效查询。
    • 考量。过多的空结果缓存可能会占用大量缓存空间,应根据实际情况和业务需求适度使用。

缓存击穿

定义

指一个热门的缓存键在缓存中过期或不存在的情况下,大量请求同时访问该键所对应的数据,导致这些请求直接绕过缓存,直接访问数据库。

触发原因

  1. 缓存失效

    当一个热门的缓存键对应的数据在缓存中过期或者不存在时,如果此时有大量请求访问这个缓存键,就会导致缓存击穿。缓存失效可能是由于缓存策略设置的过期时间到期,或者手动删除缓存数据引起的。

  2. 大量并发请求

    缓存击穿通常不是由单一请求引起的,而是由大量并发请求集中在某个特定的热门数据上。这可能是由于系统设计的瓶颈、缓存数据的热度高、某个功能或数据点引起了极大的用户兴趣等原因。当大量请求同时访问一个缓存失效或者不存在的热门数据时,它们都会绕过缓存,直接访问数据库。

解决方案

  1. 热点数据预加载。在数据即将过期之前,提前异步加载新的数据到缓存中。通过定期或异步地预加载热门数据,可以避免缓存失效时大量请求同时访问。
  2. 互斥锁机制。在获取缓存数据之前,先尝试获取锁,只有一个线程能够从底层存储系统中加载数据,其他线程需要等待锁释放。这样可以避免多个线程同时访问存储系统,减轻了缓存击穿的可能性。
  3. 设置合理的缓存失效时间。缓存的过期时间应该设置得既不会导致数据过于陈旧,也不会过于频繁地触发缓存失效。合理的过期时间有助于平衡缓存的新鲜度和系统性能。
  4. 使用缓存穿透保护机制。在缓存中存储空对象或者特殊标记,当缓存中的值是空时,不再继续访问底层存储系统,而是直接返回空结果,从而防止大量请求穿透到存储系统。
  5. 分布式锁。在分布式系统中,使用分布式锁可以确保在集群环境中只有一个节点能够执行缓存失效时的数据加载操作,防止多个节点同时加载相同数据。

redis序列化问题

在使用Spring提供的Spring Data Redis操作redis必然需要使用Spring提供的模板类RedisTemplate。我们进入RedisTemplate类的源码中,如下图。红框框起来的部分即为RedisTemplate序列化器相关属性,都是为空的。这四个序列化属性主要是对key,value,hashkey,hashvalue进行序列化。我们经常需要将POJO对象存储到redis中,通常会使用JSON的方式序列化为字符串存储到redis中。

如果不设置这四个序列化器,那么RedisTemplate将会采用默认的JDK序列化。我们可以来看看JDK序列化的效果。我们有如下这样一个测试类和用户类,目的是为了向redis中写入对象user。得到的结果如图:


/*----User类----*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class User implements Serializable {
    private String name;
    private String password;
}
/*----测试类----*/
@SpringBootTest
@RunWith(SpringRunner.class)
public class SpringTestClass {

    @Resource
    private RedisTemplate redisTemplate;

    @Test
    public void testDefaultSerializer() {
        User user = new User("Tom", "123456");
        redisTemplate.opsForValue().set("user", user);
        
        redisTemplate.opsForValue().set("string", "abcd");
    }
}

使用默认序列化器插入对象
使用默认序列化器插入对象

可以看到,在redis的key不仅有我们指定的key,user。还有前导的一些类似16进制的数字,并且存入的 value内容也不够简洁,附带了一些不需要的属性。我们可以再来试试原始的字符串类型。结果如图。可以观察到,如果不知道你原始的值是什么,看着这些乱码根本无法识别。

使用默认序列化器插入字符串
使用默认序列化器插入字符串

为了解决这些问题,我们需要实现自己的序列化器,来达到简便且辨识度高的特点。自定义序列化器可以这样定义

@Configuration("redisConfig")
public class RedisConfig<V> {

    @Bean
    public RedisTemplate<String, V> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, V> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        // 设置key的序列化方式
        template.setKeySerializer(RedisSerializer.string());
        // 设置value的序列化方式
        template.setValueSerializer(RedisSerializer.json());
        // 设置hash的key的序列化方式
        template.setHashKeySerializer(RedisSerializer.string());
        // 设置hash的value的序列化方式
        template.setHashValueSerializer(RedisSerializer.json());
        template.afterPropertiesSet();
        return template;
    }
}

我们将原来默认的对象序列化方式改为了JSON的序列化方式,我们可以再来看看效果。如下图

自定义序列化器的字符串结果
自定义序列化器的字符串结果

自定义序列化器的类对象结果
自定义序列化器的类对象结果

至此,为什么要进行redis的自定义序列化就很明白了,自定义序列化的数据不仅存储的冗余减少了,也不再有乱码出现。在存储的key中也没有了乱码。既节省了空间,也让数据的表示更清楚了,方便排错。

写在最后

这篇文章简单介绍了一下redis,并对redis中会出现的三种缓存问题作了详细说明(也是因为易混淆,便于日后翻起来看看),至于实际操作和应用,有待日后精进了再实现一番。最后再简要介绍了redis自定义序列化器的问题,说明了为什么要使用自定义序列化。

算是给redis开了个头,之后嘛,应该是想参悟什么再写什么了。

参考

看完这篇大总结,彻底掌握Redis的使用! - 知乎 (zhihu.com)

什么时候使用Redis缓存_什么时候本地缓存什么时候用redis-CSDN博客

防弹防线:彻底击败Redis缓存穿透问题【redis问题 一】-CSDN博客

Redis缓存雪崩:预防、应对和解决方案【redis问题 二】-CSDN博客

Redis缓存保卫战:拒绝缓存击穿的进攻【redis问题 三】-CSDN博客

你要相信流星划过会带给我们幸运,就像现实告诉你我要心存感激
Built with Hugo
Theme Stack designed by Jimmy