Redis全解析:一篇文章带你深入了解

发表时间: 2023-03-21 12:22

作为一个程序员,redis对于我们来说是一个必须熟悉的一个东西,今天就带大家一文读懂redis。

简介:我们老说redis,那么redis到底是什么,他有什么样的作用呢?简单来说, Redis是一个基于内存可持久化且支持网络、支持多种数据格式的kv存储系统,可用于缓存,发布订阅、消息队列等功能。

我们为什么要用Redis?(优点)

  1. 高性能(读写性能每秒能达10w余次)
  2. 支持多种数据类型--- 5种基本数据类型(string, list, set, zset, hash)3中特殊数据类型(HyperLogLogs基数统计, bitmap位图, 地理位置),Redis5.0后还引入了stream流数据类型
  3. 可持久化(支持rdb和aof两种持久化方式)
  4. 原子性(redis中所有的操作都是原子的)
  5. 丰富的特性(发布订阅,消息通知,key过期等)
  6. 分布式(redis-cluster)

既然它有这么多优点,那么我们到底能用它来干什么~~

首先我们最常见应用redis是用作于热点数据的缓存,这是redis应用最多最常用的一个功能,其次还可应用于消息队列,限流等方面。在我们的业务中就用redis实现了一个多级缓存的模块来减轻数据库的压力,而且我们的业务对带宽也有一定要求,所以也用redis及lua脚本实现了一个限流器,这些都是比较常见的功能。当然我们也能利用它的一些特性解决日常工作中的一些问题,比如点赞,访问量统计,限时秒杀等。

数据类型:redis中所有的键都是String类型, value可是redis支持的数据类型

  • string: 字符串类型,常见操作 set get del,常用于缓存
  • list: 列表,底层使用双端链表实现,常见操作 lpush, lpop, rpush, rpop等,常用于消息队列
  • set: string类型的无序去重的集合,常见操作 sadd, smembers等,常用于点赞,标签等
  • zset: 不同于set的是它是一个有序集合,且每一个值都会关联一个double类型的分数,通过这个分数对集合进行从小到大的排序,底层是基于压缩表、跳跃表实现的,压缩表是一种为了提高存储效率的特殊的双端队列,当存储成员数量小于128且每个成员的大小不超过64字节是采用压缩表反之则用跳跃表,跳跃表是一种随机链表,对于插入删除查找都能做到olog(n)的时间复杂度,常见操作zadd, zrem, zrange, 常用于排行榜等
  • hash: 是key string和value的一个映射,这种方式就特别适合存储对象,常见操作 hset, hget, hdel, 常用于缓存,比String更节省空间
  • bitmap: 位图,用二进制来存储,只有0和1两个状态,常用于统计用户信息,登录未登录,活跃不活跃等
  • HyperLogLogs:基数统计,常用于统计ip数,注册数等。
  • geospatial:地理位置
  • stream: Redis5.0后引入了stream流,其实就是对消息队列的完善(发布订阅模式是没有持久化的,宕机后消息就丢失了,而基于lpush命令等实现的队列是不支持多播分组消费等)

对于后几种数据类型应用较少,就不在做过多赘述,了解即可。那么对于以上的数据类型它到底是怎么实现的呢?

其实它的底层都是由对象结构(redisObject)与对应的编码数据结构组成的,在redis中,不同的命令涉及到不同的类型且要不同的处理方式,所以就设计了redisObject这种数据结构,其中包含了类型,编码方式,访问时间,引用计数及指向底层数据结构指针,通过这种方式实现了不同的数据类型。redis使用自己实现的对象机制(redisObject)来实现类型判断、命令多态和基于引用次数的垃圾回收,当然redis也会预分配一些常用的数据对象,并通过共享这些对象来减少内存占用,和避免频繁的为小对象分配内存。而真正底层数据结构如下所示:

  • 简单动态字符串 - sds
  • 压缩列表 - ZipList
  • 快表 - QuickList
  • 字典/哈希表 - Dict
  • 整数集 - IntSet
  • 跳表 - ZSkipList

Redis持久化:redis是一个基于内存的数据库,服务一旦宕机,内存中的数据就会丢失,这时候就只能从后端数据库中恢复数据,但是数据量较大的话会给数据库带来很大的压力,且数据库有性能瓶颈,导致程序响应较慢,故而需要持久化。

RDB:快照,即将当前进程上的数据生成快照保存到磁盘的过程,支持手动触发和自动触发两种模式。手动触发可以使用save和bgsave命令,save命令会阻塞当前服务器直到完成操作,线上一般不建议进行此操作,而bgsave回fork出一个子进程,由子进程完成这个操作,阻塞只发生在fork阶段,通常时间很短,子进程会写一个临时的rdb文件,完成时会替换老的文件,同时通知主进程操作结束。自动触发会发生修改配置文件save修改,主从复制时从节点全量复制主节点数据,执行debug reload ,未开启aof执行shutdown命令时。RDB核心就是copy-on-write,在执行快照的过程中,不会停止对客户端的响应,主进程会fork出一个子进程来专门处理操作,而在这段时间中产生的数据操作会以副本的形式放在一个新的内存区域中,等操作完成时在同步到原来的区域中。

优点:

  1. RDB是某个时间点的快照,默认采用LZF压缩,压缩后的文件远远小于内存大小,适用于全量复制备份等场景
  2. RDB方式恢复数据要远远快于AOF方式

缺点:

  1. RDB的实时性很差,无法做到秒级持久化
  2. RDB文件是二进制的没有可读性,AOF在了解其结构可以手动修改补全
  3. RDB bgsave过程中要fork子进程,这是一个重量级操作,频繁执行成本太高了

AOF: 默认是关闭的,可通过修改配置文件参数启用,Redis是采用写后日志的方式,先写数据到内存,再写日志,由于redis高性能,采用这种方式可以避免命令检查开销,写日志是不会检查命令正确性的,同时它不会阻塞写操作。当然采用这种方式也有坏处,比如写数据成功,写日志时宕机了,会造成数据丢失,主进程写盘压力大,会阻塞后续操作。AOF日志记录为redis的命令,是以追加写的方式写入命令,步骤大致可以分为命令追加,命令写入,命令同步,当开启aof后,服务器执行命令成功后会将命令先写入aof_buf的缓冲区中,至于何时进行命令写入同步提供了三种写回策略

  1. always: 同步写回,redis的每个命令都要落盘可靠性高数据基本不丢失,但是性能低
  2. everySec: 每秒写回,性能适中,会丢失1s内的数据
  3. no:操作系统控制的写回,性能好,宕机时数据丢失的会多

AOF重写:AOF方式会记录每个命令,随着时间越来越长,写入的日志文件也会越来越大,恢复数据会变慢,影响性能,故而实现了AOF重写来解决这一问题,对文件进行瘦身操作。其实就是常见一个新的AOF文件替换老的文件,新旧文件保存数据相同,只是新文件中没有冗余操作。在发生AOF重写时,主进程会fork一个bgrewirte的子进程,fork会把住主进程的内存数据拷贝一份给子进程,然后子进程就会按照最新的数据重写文件,所以重写阻塞只会发生在fork阶段。触发重写的时机可由两个参数控制,一个是aof文件最小大小默认是64M,另一个是当前aof文件与上一文件增量比上次aof文件大小的比值。如果在发生AOF重写,有新的数据写入,此时会写入aofbuf缓冲区,fork子进程进行重写,写入完成后主进程追加AOF日志缓冲,最后替换旧的AOF文件即可。

Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。采用此种方式就不用频繁fork且减少了aof重写的次数

Redis发布订阅

  • 基于channel的发布订阅:发布者可以发送消息到指定频道,订阅者可以订阅一个或多个频道,当有消息到此频道时,所有的订阅者都能收到此消息。它的底层是基于字典实现的,字典的键是频道,而值是一个链表保存着所有订阅此频道的客户端
  • 基于pattern的发布订阅:如果有某个模式和这个频道匹配的话,那么订阅这些频道的客户端都将收到消息,底层是基于pubsubPattern节点的链表实现,链表中保存着关于模式相关的信息

通常在我们的业务中都是高并发,数据库就是一个薄弱的环节,所有就会引入redis作为缓冲来降低数据库的压力,但是引入redis就会带来一系列问题,那么我们来看看如何去解决这些问题。

缓存穿透:请求没有命中redis且数据库中没有,用户换不同key去打请求,造成穿透,如果有人恶意攻击会db就会出问题

  • 接口层做处理,对于无效的key直接进行拦截
  • 设置null缓存,可以解决用同一个key进行攻击的问题
  • 布隆过滤器,可以快速的判断key是否存在,不存在直接返回

缓存击穿:redis没有命中,但是数据库中有,若有大量并发会将数据库打挂

  • 设置热点数据永不过期
  • 接口限流降级熔断

缓存雪崩:同一时间大量的缓存失效,导致请求都透到数据库,造成数据库压力过大的问题

  • 设置缓存过期的时间随机
  • 设置热点数据用不过期
  • 如果分布式业务可以将热点数据分布在不同的数据库中,避免都在同一库中造成压力过大

缓存污染:缓存满了,比如一些冷数据我们访问一次就不在访问了,这种数据越堆越多就会影响性能(设置淘汰策略--8种淘汰策略)

  1. noeviction

该策略是Redis的默认策略。在这种策略下,一旦缓存被写满了,再有写请求来时,Redis 不再提供服务,而是直接返回错误。这种策略不会淘汰数据,所以无法解决缓存污染问题。一般生产环境不建议使用。

其他七种规则都会根据自己相应的规则来选择数据进行删除操作。

  1. volatile-random

这个算法比较简单,在设置了过期时间的键值对中,进行随机删除。因为是随机删除,无法把不再访问的数据筛选出来,所以可能依然会存在缓存污染现象,无法解决缓存污染问题。

  1. volatile-ttl

这种算法判断淘汰数据时参考的指标比随机删除时多进行一步过期时间的排序。Redis在筛选需删除的数据时,越早过期的数据越优先被选择。

  1. volatile-lru

LRU算法:LRU 算法的全称是 Least Recently Used,按照最近最少使用的原则来筛选数据。这种模式下会使用 LRU 算法筛选设置了过期时间的键值对。

Redis优化的LRU算法实现

Redis会记录每个数据的最近一次被访问的时间戳。在Redis在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。通过随机读取待删除集合,可以让Redis不用维护一个巨大的链表,也不用操作链表,进而提升性能。

Redis 选出的数据个数 N,通过 配置参数 maxmemory-samples 进行配置。个数N越大,则候选集合越大,选择到的最久未被使用的就更准确,N越小,选择到最久未被使用的数据的概率也会随之减小。

  1. volatile-lfu

会使用 LFU 算法选择设置了过期时间的键值对。

LFU 算法:LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。 Redis的LFU算法实现:

当 LFU 策略筛选数据时,Redis 会在候选集合中,根据数据 lru 字段的后 8bit 选择访问次数最少的数据进行淘汰。当访问次数相同时,再根据 lru 字段的前 16bit 值大小,选择访问时间最久远的数据进行淘汰。

Redis 只使用了 8bit 记录数据的访问次数,而 8bit 记录的最大值是 255,这样在访问快速的情况下,如果每次被访问就将访问次数加一,很快某条数据就达到最大值255,可能很多数据都是255,那么退化成LRU算法了。所以Redis为了解决这个问题,实现了一个更优的计数规则,并可以通过配置项,来控制计数器增加的速度。

  1. allkeys-lru

使用 LRU 算法在所有数据中进行筛选。具体LFU算法跟上述 volatile-lru 中介绍的一致,只是筛选的数据范围是全部缓存,这里就不在重复。

  1. allkeys-random

从所有键值对中随机选择并删除数据。volatile-random 跟 allkeys-random算法一样,随机删除就无法解决缓存污染问题。

  1. allkeys-lfu 使用 LFU 算法在所有数据中进行筛选。具体LFU算法跟上述 volatile-lfu 中介绍的一致,只是筛选的数据范围是全部缓存,这里就不在重复。

引入Redis作为业务缓存可以很好的解决数据库压力过大的问题,但是缓存和数据库是两个组件,写数据库和写redis就会带来数据不一致的问题,那么让我们来分析一下。

如果我们业务体量较小,我们可以直接全量缓存,同时可以起一个定时任务定时将数据库中数据同步到redis中,但是这样对缓存的利用率很低,可能会存在冷数据,浪费资源,再有是定时任务去更新缓存,也会在一定时间内存在缓存不一致的问题,这个其实就看取舍了,之前在我们的业务中也存在这样的场景,用户恶意的采用不同的自定义域名攻击我们的服务,造成了缓存穿透,后来我们调研了一下,可以采用布隆过滤器解决这一问题,但是也没有必要,因为我们的那个服务体量很小,所以当时就采用了全量缓存的方式,缓存如果没有直接就返回了,不过没有使用定时任务,因为访问没有那么频繁,所以也只是在查询的时候全量缓存一次,更新的时候清理掉缓存。

如果业务体量较大这种方式就不合适了,一般我们只是缓存热点数据,查询时先查询缓存,缓存中有直接返回,缓存中没有去查询数据库并写回缓存。 那么在这种方式下到底是先更新数据库再更新缓存还是先更新缓存再更新数据库呢?

  • 先更新缓存:如果先更新缓存,更新数据库失败了,缓存中是新数据,数据库中还是旧的数据,一旦缓存失效后,回写缓存就会写入旧的数据造成不一致
  • 先更新数据库:如果先更新数据库,而更新缓存失败了,那么在这段时间内缓存中的数据还是旧数据造成不一致的现象

由此可见,无论是那种方式都会造成不一致的现象,那么怎么去解决呢,别急,我们接着往下分析,除了这种更新失败的方式,那么并发访问是不是也会有问题呢?

  • 先更新缓存:如果有两个请求并发访问,请求一先更新缓存,还未更新数据库时,请求二更新缓存,并且更新数据库,最后请求一又更新了数据库,此时数据库是请求一的数据,缓存是请求二的数据造成了不一致的问题
  • 先更新数据库:如果有两个请求并发访问:请求一先更新数据库还未更新缓存时,请求二更新了数据库并且更新了缓存,请求一又更新了缓存,此时数据库时请求二的数据,缓存是请求一的数据造成了数据不一致的问题

综上无论是更新失败还是并发问题都会带有缓存与数据库不一致的问题,那么到底该如何解决这种问题呢?

针对这种方式常见的解决方式就是加分布式锁,保证同一时刻只有一个线程去操作,但是这种方式会带来缓存资源浪费和机器性能的问题。所以我们就要考虑另一种方式,删除缓存。

删除缓存我们仔细想想跟上面所述在读写并发时会有同样的问题

  • 先删缓存:写请求删除缓存后还未更新数据库,读请求查询发现没有缓存去查数据库并写回缓存,写请求后更新数据库,此时缓存中还是旧数据造成不一致
  • 后删缓存:缓存中没有,读请求查询拿到旧数据还未写入缓存,写请求更新数据库并删除缓存,读请求又写入旧数据到缓存,造成不一致

后一种发生的概率较低,针对这种方式其实加锁就可以解决,所以通常采用更新数据库+删除缓存这种方式就可以了,那么之前更新失败的方式如何处理呢?对于失败的问题我们很容易就想到了重试机制,但是怎么重试,重试的策略怎么设置呢?

  1. 重试多少次?
  2. 立即重试很大概率又会失败
  3. 重试会占用线程资源,无法提供服务

针对以上问题,那我们很容易就想到了异步,让其异步重试,将重试请求放入消息队列或者直接将操作缓存哪一步放入消息队列中,由消费者来操作。 但是引入消息队列又会带来一些问题,维护成本,写入消息队列失败等问题,但是其实也还好,消息队列也是我们经常使用的组件,开发和维护成本也在可控范围内。

那么还有没有更好的方式呢,答案是有的,那就是mysql的binlog日志,数据库每条操作变更都会产生一条binlog日志,我们订阅这个日志然后根据这个日志去操作缓存即可。

至此,我们可以得出结论,想要保证数据库和缓存一致性,推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来做

那么到底能不能做到强一致呢,其实是很难,像常见的一致性协议2PC, 3PC, Raft等很复杂且要考虑容错等,完全没有必要,回到最初的问题,我们引入缓存的就是为了解决性能这一问题,在实际开发过程中,我们是完全可以容忍短时间的不一致情况。

总结

好了,总结一下这篇文章的重点。

1、想要提高应用的性能,可以引入「缓存」来解决

2、引入缓存后,需要考虑缓存和数据库一致性问题,可选的方案有:「更新数据库 + 更新缓存」、「更新数据库 + 删除缓存」

3、更新数据库 + 更新缓存方案,在「并发」场景下无法保证缓存和数据一致性,解决方案是加「分布锁」,但这种方案存在「缓存资源浪费」和「机器性能浪费」的情况

4、采用「先删除缓存,再更新数据库」方案,在「并发」场景下依旧有不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估

5、采用「先更新数据库,再删除缓存」方案,为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据最终一致

6、采用「先更新数据库,再删除缓存」方案,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率