基本数据类型

Redis支持5种数据类型:string、hash、list、set、zset。

String

String是Redis最基础的类型,就是做简单的KV缓存,String可以缓存任何数据
底层是怎么存储的?
SDS:Redis没有直接使用C语言的字符串,而是自己构建了一个动态字符串(SDS)

  1. C字符串获取字符串长度时间复杂度为O(N),而SDS因为保存了长度信息,所以获取字符串长度的时间复杂度降低到了O(1)
  2. 因为C字符串不记录自身长度和空闲空间,很容易造成缓冲区溢出,而SDS在拼接字符串之前会通过free字段检测剩余空间能否满足需求,不能满足需求的就会扩容
  3. C字符串再增长或缩短时,需要进行一次内存重分配,而SDS通过len属性和free属性对内存分配做了优化,包括两种方式:空间预分配和惰性空间释放
    • 空间预分配:在对空间进行扩展的时候,除了会分配扩容时所需要的空间,还会额外多分配一些的空间
    • 惰性空间释放:在对字符串进行缩短操作的时候,程序并不会立刻回收缩短之后多出来的字节,而是使用free属性将这些字节的数量记录下来等以后使用

Redis - 图1
就好比这样的一个命令,其实我是在Redis创建了两个SDS,一个是名为aobing的Key SDS,另一个是名为cool的Value SDS,就算是字符类型的List,也是由很多的SDS构成的Key和Value罢了。
使用场景

  • 缓存功能:利用Redis缓存热点数据,可以提高系统并发能力、减轻后端数据库的压力。
  • 计数器:微博数、 粉丝数,可以快速实现计数和查询的功能
  • 计时器:用户下订单后,需要在10分钟内完成支付,否则订单关闭,结合键空间事件通知就行 ```python redis 127.0.0.1:6379> SET name “runoob” “OK” redis 127.0.0.1:6379> GET name “runoob”

incr计数器

redis 127.0.0.1:6379> set mycounter 99 OK redis 127.0.0.1:6379> get mycounter “99” redis 127.0.0.1:6379> incr mycounter (integer) 100 redis 127.0.0.1:6379> incrby mycounter 2 (integer) 102 redis 127.0.0.1:6379> incrby mycounter -2 (integer) 100

  1. <a name="fgYnc"></a>
  2. ## Hash
  3. **Hash**也是一种KV缓存,只不过它的Value是一个Map,适合存储对象<br />**底层是怎么存储的?**<br />底层使用**散列表**实现,散列表的好处就是如果散列函数选好,值的分布均匀,那么散列表可以在接近 O(1) 的时间内处理读写操作。散列表通过散列函数实现Key和数组下标的转换,使用链地址法来解决键冲突,被分配到同一个索引位置的多个键值对会形成一个单向链表。散列表设计的好坏决定了散列冲突的概率,也就决定散列表的性能,**当一个哈希只包含少量键值对,那么Redis就会使用压缩列表存储数据**<br />**使用场景**
  4. - **比如微博用户信息,**用户ID为key,value对象包含姓名,年龄,生日、爱好、所在地等信息,主要有以下2种存储方式:
  5. 1. **第一种方式将用户ID作为查找key,把其他信息序列化成一个string类型存储**,这种方式的缺点是,增加了序列化/反序列化的开销,并且在需要修改其中一项信息时,需要把整个对象取回,并且修改操作需要对并发进行保护,引入CAS等复杂问题。
  6. 1. **第二种方式将用户ID作为查找key**,**把其他信息序列化成一个Map类型存储**,这样对数据的修改和存取都可以直接通过其内部Map的Key(Redis里称内部Map的key为field),也就是通过 key(用户ID) + field(属性标签) 就可以操作对应属性数据了,既不需要重复存储数据,也不会带来序列化和并发修改控制的问题。
  7. ```python
  8. redis> HSET myhash field1 "Hello" field2 "World"
  9. "OK"
  10. redis> HGET myhash field1
  11. "Hello"
  12. redis> HGET myhash field2
  13. "World"

List

List列表是简单的字符串列表,你可以向列表的头部或尾部插入元素,并且可以保证顺序。
常用命令:lpush,rpush,lpop,rpop,lrange(获取列表片段,LRANGE key start stop)
底层是怎么存储的?
List的实现为双向链表或压缩列表,因为双向链表占用的内存比压缩列表要多, 所以当创建新的元素时,列表会优先考虑使用压缩列表,并且在有需要的时候,才从压缩列表实现转换到双向链表实现。
使用场景

  • 微博上滑下滑的功能,如果用户下滑就不断向List中添加数据,如果性能高,滑的时候可以一次一页的添加
  • 微博我经常访问的人:记录前N个最新访问的用户Id列表,超出的范围可以从数据库中获得
  • 消息队列:这个最基本的了吧

    //把当前登录人添加到链表里
    ret = r.lpush("login:last_login_times", uid)
    //保持链表只有N位
    ret = redis.ltrim("login:last_login_times", 0, N-1)
    //获得前N个最新登陆的用户Id列表
    last_login_list = r.lrange("login:last_login_times", 0, N-1)
    

    Set

    Set是String类型的无序集合,可以交集,并集,差集,set中的元素是没有顺序的,所以添加,删除,查找的复杂度都是O(1)
    常用命令:sadd,spop,smembers,sunion
    底层是怎么存储的?
    底层使用了数组哈希表两种数据结构,如果存储的元素类型是哈希对象类型,就会被保存到哈希表里,如果是数组类型,会尝试能不能转成int对象类型,能的话就用数组保存,如果这个数组长度超出限制,那么多余的也会存储到哈希表。因为数组存储是有序的,并且可以二分查找和范围查找
    使用场景

  • 交集,并集,差集:Set与List类似,都是提供一个列表的功能,特殊之处在于Set是可以自动去重的,并且set提供了判断某个成员是否在一个Set集合内的重要接口

  • 微博共同关注、互相关注、共同喜好,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合
    //book表存储book名称
    set book:1:name    ”The Ruby Programming Language”
    set book:2:name     ”Ruby on rail”
    set book:3:name     ”Programming Erlang”
    //tag表使用集合来存储数据,因为集合擅长求交集、并集
    sadd tag:ruby 1
    sadd tag:ruby 2
    sadd tag:web 2
    sadd tag:erlang 3
    //即属于ruby又属于web的书?
    inter_list = redis.sinter("tag.web", "tag:ruby") 
    //即属于ruby,但不属于web的书?
    inter_list = redis.sdiff("tag.ruby", "tag:web") 
    //属于ruby和属于web的书的合集?
    inter_list = redis.sunion("tag.ruby", "tag:web")
    

    Zset

    Zset是排序的Set,去重而且排序,写进去的时候给一个分数,可以自动根据分数排序
    常用命令:zadd,zrange,zrem,zcard。zadd 命令:添加元素到集合,元素在集合中存在则更新对应score。
    底层是怎么存储的?
    Zset的内部使用散列表跳跃表来实现有序集合,散列表里放的是成员到score(si告尔)的映射,而跳跃表里存放的是所有的成员,排序依据是散列表里存的分数,使用跳跃表可以获得二分查找的效率

    跳表

    跳表是可以实现了二分查找的有序链表,就比如拿普通的链表的来说,如果想查找一个元素,那么只能从头开始找,时间复杂度为O(n),而跳表是通过在原始链表的基础上创建多层的索引结构,查找的某一个元素的时候就可以使用类似二分查找的方式从上到下查找元素。查询、插入、删除的时间复杂度都是O(log n),与平衡二叉树差不多,但是比平衡二叉树简单,范围查找时比红黑树速度更快为O(logn)。
    压缩列表
    当一个Zset只包含少量数据时,那么Redis就会使用压缩列表来做列表的底层实现。每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个节点保存元素的分值。并且压缩列表内的集合元素按分值从小到大的顺序进行排列,小的放置在靠近表头的位置,大的放置在靠近表尾的位置。
    说明:其实Zset单独使用字典或跳跃表其中一种数据结构都可以实现,但是这里使用两种数据结构组合起来,原因是假如我们单独使用字典,虽然能以 O(1) 的时间复杂度查找成员的分值,但是因为字典是以无序的方式元素,所以每次进行范围操作的时候都要进行排序;假如我们单独使用跳跃表来实现,虽然能执行范围操作,但是查找操作由 O(1)的复杂度变为了O(logN)。因此Redis使用了两种数据结构来共同实现有序集合。

使用场景

  • 微博热搜:比如微博以关注度作为score(si告尔)来存储,这样获取时就是自动按关注度排好序的
  • 弹幕系统:比如B站的视频回放的时候需要播放用户发的弹幕,通过Zadd添加消息,其中score记录消息的相对视频的开始时间,视频播放过程中通过ZrangeByScore两秒一次轮询,播放弹幕

    redis 127.0.0.1:6379> zadd runoob 0 redis
    (integer) 1
    redis 127.0.0.1:6379> zadd runoob 0 mongodb
    (integer) 1
    redis 127.0.0.1:6379> zadd runoob 0 rabitmq
    (integer) 1
    redis 127.0.0.1:6379> zadd runoob 0 rabitmq
    (integer) 0
    redis 127.0.0.1:6379> > ZrangeByScore runoob 0 1000
    1) "mongodb"
    2) "rabitmq"
    3) "redis"
    

    压缩列表

    首先应该知道数组存储时其实是一堆连续存放大小相同的格子,如果要存储的字符串大小不一,那么就需要申请格子的时候就需要申请更大的格子,会浪费部分存储空间。压缩列表继承了数组连续存放的特点,然后对每一个元素添加一个lenght的属性,我们在遍历节点的之后就知道每个节点的长度,就可以很容易计算出下一个节点的位置
    image.png

    持久化策略

    Redis 提供了 RDB 和 AOF 两种持久化方式,RDB是定时fork子进程对数据做快照,缺点是间隔太大;AOF以类似记录增量日志的方式记录每一次写操作,发生异常重启的时候也是优先加载AOF,缺点是AOF 文件比 RDB 文件大,且恢复速度慢
    RDB:手动触发自动触发

  • 手动触发

    • save命令:会阻塞当前服务器,直到RDB完成为止,如果数据量大的话会造成长时间的阻塞,线上环境一般禁止使用 bgsave命令:就是background save,执行bgsave命令时Redis主进程会fork一个子进程来完成RDB的过程,完成后自动结束(操作系统的多进程Copy On Write机制,简称COW)。所以Redis主进程阻塞时间只有fork阶段的那一下。相对于save,阻塞时间很短。
  • 自动触发
    • 配置redis.conf,定时定量触发执行
    • 执行shutdown命令关闭服务器时,如果没有开启AOF持久化功能,那么会自动执行一次bgsave
    • 主从同步(slave和master建立同步机制)

AOF:手动触发自动触发
AOF日志是持续增量的备份,是基于写命令存储的可读的文本文件。AOF日志会在持续运行中持续增大,由于Redis重启过程需要优先加载AOF日志进行指令重放以恢复数据,恢复时间会无比漫长。所以需要定期进行AOF重写,对AOF日志进行瘦身。目前AOF是Redis持久化的主流方式。

key过期策略

两种过期策略(redis使用惰性删除+定期删除)

  • 惰性删除:下一次查询读到已经过期的key时,才会被删除
  • 定期删除:redis会把设置了过期时间的key放在单独的字典中,定时遍历来删除到期的key

    淘汰策略

    | 策略 | 描述 | | —- | —- | | volatile-lru | 从已设置过期时间的 KV 集中优先对最近最少使用(less recently used)的数据淘汰 | | volitile-ttl | 从已设置过期时间的 KV 集中优先对剩余时间短(time to live)的数据淘汰 | | volitile-random | 从已设置过期时间的 KV 集中随机选择数据淘汰 | | allkeys-lru | 从所有 KV 集中优先对最近最少使用(less recently used)的数据淘汰 | | allKeys-random | 从所有 KV 集中随机选择数据淘汰 | | noeviction | 不淘汰策略,若超过最大内存,返回错误信息 |

数据一致性

方案一延迟双删,不严谨,在修改mysql前后都进行del(key)操作,并且第二次删除通过延迟的方式进行。

  1. 先删除缓存
  2. 再写数据库
  3. 休眠500毫秒(根据具体的业务时间来定,这点不稳定)
  4. 再次删除缓存

方案二MQ异步延迟双删(高并发)

  1. 先删除缓存
  2. 再写数据库
  3. 触发异步写入串行化MQ(也可以采取一种key+version的分布式锁)
  4. MQ接受再次删除缓存

方案三异步更新缓存(基于binlog的同步机制,主从复制中获得的灵感)

  1. 先修改数据库
  2. 再使用阿里的Canal订阅Binlog日志,并推送MQ消息队列,Redis根据消息更新数据

缓存击穿

指的是单个key非常热点,当这个key在失效的瞬间,大量的请求直接打到数据库上

解决办法

  1. 设置互斥锁:在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。
  2. 热点数据不过期:直接将缓存设置为不过期,然后由定时任务去异步加载数据,更新缓存。使用时需要考虑能接受数据不一致的问题

缓存穿透

产生这个问题的原因可能是外部的恶意攻击,比如我们对用户信息进行了缓存,但是攻击者使用不存在的用户id频繁请求接口,导致查询缓存不命中,然后穿透 DB 查询依然不命中。

解决办法

  1. 缓存空对象:从数据库找不到时,将这个空对象设置到缓存里边去,下次再请求的时候,就可以从缓存里边获取了,但是需要将空对象设置一个较短的过期时间。
  2. 布隆过滤器
  • 将所有的数据使用散列函数的方法映射到一个足够大的数组中,如果这个在这个数组中查不到,不存在的 key 直接被过滤,存在的 key 则再进一步查询缓存和数据库;不直接存储到散列表的是因为随着数据量增多,占据的内存会很大。

使用场景:

  1. 网页爬虫对URL的去重:避免爬取相同的URL地址
  2. 反垃圾邮件:从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱(同理,垃圾短信)
  3. 大数据判断是否存在:这就可以实现出上述的去重功能,如果你的服务器内存足够大的话,那么使用 HashMap 可能是一个不错的解决方案,理论上时间复杂度可以达到 O(1 的级别,但是当数据量起来之后,还是只能考虑布隆过滤器

缓存雪崩

指的是多个key非常热点,当这些key在失效的瞬间,大量的请求直接打到数据库上
出现原因:

  1. key同时失效
  2. redis本身崩溃了

    解决办法

  3. 设置互斥锁:在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。(缓存重建期间key是锁着的,这是过来1000个请求999个都在阻塞的。同样会导致用户等待超时,这是个治标不治本的方法!)

  4. 热点数据不过期:直接将缓存设置为不过期,然后由定时任务去异步加载数据,更新缓存。使用时需要考虑能接受数据不一致的问题
  5. 设置不同的过期时间:可以给缓存的过期时间再加上一个随机值,让每个 key 的过期时间分散开,不会集中在同一时刻失效。
  6. 高可用:使用主从模式和集群模式来尽量保证缓存服务的高可用。

主从模式

首先slave连接到master,master会启动一个线程,并把最新的RDB文件发送给slave的,slave拿到之后做的第一件事情就是写进本地的磁盘,然后加载进内存。这个过程可能有新的写请求,master会通过AOF增量日志的方式将新数据同步给slave,就像MySQLBinlog一样,把日志增量同步给slave服务

哨兵模式

基于主从方案的缺点还是很明显的,无法保证高可用,master挂了,整个服务挂了。而哨兵可以同时监视多个主从服务器,假如master挂了,可以Raft协议做选举将某个slave提升为master,然后由新的master继续接收命令

集群模式

依靠哨兵可以实现Redis的高可用,如果还想在支容纳海量的数据,那就需要Redis集群

  1. 无中心架构(不存在哪个节点影响性能瓶颈)
  2. 数据按照哈希槽(hash slot)分布存储在多个节点, 一个Redis集群包含16384个哈希槽
  3. 集群中的每个节点负责维护一部分哈希槽。 比如一个集群可以有三个节点:
  • 节点 A 负责处理 0 号至 5500 号哈希槽
  • 节点 B 负责处理 5501 号至 11000 号哈希槽
  • 节点 C 负责处理 11001 号至 16384 号哈希槽
  1. 如果节点A向节点B发送ping消息,节点B没有在规定的时间内响应,那么节点A会标记节点B为疑似下线状态,同时把B的状态通过消息的方式发送给其他节点,如果超过半数以上的节点都标记B为疑似下线状态,B就会被标踢出集群,然后将会发生故障转移,优先从复制数据较多的从节点选择一个成为主节点,整个过程和哨兵非常类似,都是基于Raft协议做选举。

为什么是单线程

  1. 纯内存操作
  2. 单线程也避免了CPU频繁的上下文切换,所以CPU不是瓶颈
  3. 使用基于非阻塞的IO多路复用机制,一个线程可以跟踪多个客户端Socket状态,哪个就绪,就处理哪个
  4. 使用基于跳跃表数据增删改效率非常高