使用redis

优点:

  1. redis的value支持多种类型,实现了很多操作在服务端就可以完成了,这个对客户端而言非常方便
  2. 基于内存运行,性能高效
  3. 支持分布式,理论上可以无限扩展
  4. key-value存储系统,C/S通讯模型
  5. 单进程单线程模型,高并发读写
  6. 丰富的数据结构
  7. 操作具有原子性
  8. 持久化
  9. 支持lua脚本

缺点:

  1. 由于redis时内存型的数据库,数据量存储量有限且分布式集群成本也会非常高(有很多公司开发了基于SSD的类Redis系统)

    简述redis常用的数据结构及其如何实现的

    数据结构

  • String 字符串
  • List 列表
  • Set 集合
  • Hash 哈希
  • Zset 有序集合

    之后又丰富了几种数据类型,Bitmaps,HyperLogLogs,GEO

由于Redis是基于标准C写的,只有最基础的数据类型,因此redis为了满足对外使用的5中数据类型,开发了署于自己独有的一套基础数据结构:

  • 简单动态数组SDS
  • 链表
  • 字典
  • 跳跃链表
  • 整数集合
  • 压缩列表
  • 对象

Redis为了平衡空间和时间效率,针对value的具体类型在底层会采用不同的数据结构来实现,其中哈希表和压缩列表是复用比较多的数据结构,如下图展示了对外数据类型和底层数据结构之间的映射关系:
image.png

从图中可以看到ziplist压缩列表可以作为ZsetHashList三种数据类型的底层实现,看来很强大,压缩列表是一种为了节约内存而开发的且经过特殊编码之后的连续内存块顺序型数据结构,底层结构还是比较复杂的。

场景

场景 选用的数据结构
会话缓存(Session Cache):网页访问,cookie,session记录等 Set(每个用户只能有一个会话)
全页缓存(FPC):网页 string
队列 list 和 set
排行榜/计数器 Set / string(自增)
发布 / 订阅 hash

Redis的SDS和C中字符串相比有什么优势

在C语言中使用N+1长度的字符数组来表示字符串,尾部使用’\0’作为结尾标志,对于此种实现无法满足Redis对于安全性、效率、丰富的功能的要求,因此Redis单独封装了SDS简单动态字符串结构。
image.png
从图中可以知道sds本质分为三部分:header、buf、null结尾符,其中header可以认为是整个sds的指引部分,给定了使用的空间大小、最大分配大小等信息
image.png
优势:

  • O(1)获取长度: C字符串需要遍历而sds中有len可以直接获得;
  • 防止缓冲区溢出bufferoverflow: 当sds需要对字符串进行修改时,首先借助于len和alloc检查空间是否满足修改所需的要求,如果空间不够的话,SDS会自动扩展空间,避免了像C字符串操作中的覆盖情况;
  • 有效降低内存分配次数:C字符串在涉及增加或者清除操作时会改变底层数组的大小造成重新分配、sds使用了空间预分配和惰性空间释放机制,说白了就是每次在扩展时是成倍的多分配的,在缩容是也是先留着并不正式归还给OS,这两个机制也是比较好理解的;
  • 二进制安全:C语言字符串只能保存ascii码,对于图片、音频等信息无法保存,sds是二进制安全的,写入什么读取就是什么,不做任何过滤和限制;

    Redis的Hash是怎么实现的,简述rehash过程

    字典是个层次非常明显的数据类型
    image.png
    image.png

    结构

    关于dictEntry

    dictEntry是哈希表节点,也就是我们存储数据地方,其保护的成员有:key,v,next指针。key保存着键值对中的键,v保存着键值对中的值,值可以是一个指针或者是uint64_t或者是int64_t。next是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决哈希冲突的问题。

    Redis 中的 Hash和 Java的 HashMap 更加相似,都是数组+链表的结构.当发生 hash 碰撞时将会把元素追加到链表上

image.png

关于dictht

哈希表包括的成员有tablesizeusedsizemask。table是一个数组,数组中的每个元素都是一个指向dictEntry结构的指针, 每个dictEntry结构保存着一个键值对;size 属性记录了哈希表table的大小,而used属性则记录了哈希表目前已有节点的数量。sizemask等于size-1和哈希值计算一个键在table数组的索引,也就是计算index时用到的。
image.png

关于dict

dict结构体就是字典的定义,包含的成员有typeprivdatahtrehashidx。其中dictType指针类型的type指向了操作字典的api,理解为函数指针即可,ht是包含2个dictht的数组,也就是字典包含了2个哈希表,rehashidx进行rehash时使用的变量,privdata配合dictType指向的函数作为参数使用,这样就对字典的几个成员有了初步的认识。
image.png

字典的哈希算法

  1. //伪码:使用哈希函数,计算键key的哈希值
  2. hash = dict->type->hashFunction(key);
  3. //伪码:使用哈希表的sizemask和哈希值,计算出在ht[0]或ht[1]的索引值
  4. index = hash & dict->ht[x].sizemask;
  5. //源码定义
  6. #define dictHashKey(d, key) (d)->type->hashFunction(key)

普通rehash重新散列

哈希表保存的键值对数量是动态变化的,为了让哈希表的负载因子维持在一个合理的范围之内,就需要对哈希表进行扩缩容。

扩缩容是通过执行rehash重新散列来完成,对字典的哈希表执行普通rehash的基本步骤为**分配空间**->**逐个迁移**->**交换哈希表**,详细过程如下:

  1. 为字典的ht[1]哈希表分配空间,分配的空间大小取决于要执行的操作以及ht[0]当前包含的键值对数量:
    扩展操作时ht[1]的大小为第一个大于等于ht[0].used2的2^n;
    收缩操作时ht[1]的大小为第一个大于等于ht[0].used的2^n ;
    *扩展时比如h[0].used=200,那么需要选择大于400的第一个2的幂,也就是2^9=512。

  2. 将保存在ht[0]中的所有键值对重新计算键的哈希值和索引值rehash到ht[1]上;
  3. 重复rehash直到ht[0]包含的所有键值对全部迁移到了ht[1]之后释放 ht[0], 将ht[1]设置为 ht[0],并在ht[1]新创建一个空白哈希表, 为下一次rehash做准备。

    渐进rehash过程

    Redis的rehash动作并不是一次性完成的,而是分多次、渐进式地完成的,原因在于当哈希表里保存的键值对数量很大时, 一次性将这些键值对全部rehash到ht[1]可能会导致服务器在一段时间内停止服务,这个是无法接受的。

针对这种情况Redis采用了渐进式rehash,过程的详细步骤:

  1. 为ht[1]分配空间,这个过程和普通Rehash没有区别;
  2. 将rehashidx设置为0,表示rehash工作正式开始,同时这个rehashidx是递增的,从0开始表示从数组第一个元素开始rehash。
  3. 在rehash进行期间,每次对字典执行增删改查操作时,顺带将ht[0]哈希表在rehashidx索引上的键值对rehash到 ht[1],完成后将rehashidx加1,指向下一个需要rehash的键值对。
  4. 随着字典操作的不断执行,最终ht[0]的所有键值对都会被rehash至ht[1],再将rehashidx属性的值设为-1来表示 rehash操作已完成。

渐进式 rehash的思想在于将rehash键值对所需的计算工作分散到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的阻塞问题

讲讲4.0之前版本的redis的单线程运行模式

本质上Redis并不是单纯的单线程服务模型,一些辅助工作比如持久化刷盘惰性删除等任务是由BIO线程来完成的,这里说的单线程主要是说与客户端交互完成命令请求和回复的工作线程

单线程模式的考量

  • CPU并非瓶颈,内存才是瓶颈,redis所有操作都是基于内存的,IO少
  • 复杂的Value类型,如果使用多线程模式在进行相同key操作时就需要加锁进行同步,就可能造成死锁问题
  • 集群化扩展,使用集群化使得redis的单线程模式被集群化的处理所扩展
  • 单线程从开发和维护都比多线程容易且稳定

    redis的文件事件和时间事件

    Redis作为单线程服务要处理的工作一点也不少,Redis是事件驱动的服务器,主要的事件类型就是:文件事件类型和时间事件类型,其中时间事件是理解单线程逻辑模型的关键。

  • 时间事件

Redis的时间事件分为两类:

  1. 定时事件:任务在等待指定大小的等待时间之后就执行,执行完成就不再执行,只触发一次;
  2. 周期事件:任务每隔一定时间就执行,执行完成之后等待下一次执行,会周期性的触发;
  • 周期性时间事件

Redis中大部分是周期事件,周期事件主要是服务器定期对自身运行情况进行检测和调整,从而保证稳定性,这项工作主要是ServerCron函数来完成的,周期事件的内容主要包括:

  1. 删除数据库的key
  2. 触发RDB和AOF持久化
  3. 主从同步
  4. 集群化保活
  5. 关闭清理死客户端链接
  6. 统计更新服务器的内存、key数量等信息

可见 Redis的周期性事件虽然主要处理辅助任务,但是对整个服务的稳定运行,起到至关重要的作用。

  • 时间事件的无序链表

Redis的每个时间事件分为三个部分:

  1. 事件ID 全局唯一 依次递增
  2. 触发时间戳 ms级精度
  3. 事件处理函数 事件回调函数

image.png
选择无序链表也是适合Redis场景的,因为Redis中的时间事件数量并不多,即使进行O(N)遍历性能损失也微乎其微,也就不必每次插入新事件时进行链表重排。

事件调度

image.png

如果redis中的某个列表中的数据量非常大,如何实现循环显示每一个值?

可以尝试将对象分拆成几个key-value, 使用multiGet获取值,这样分拆的意义在于分拆单次操作的压力,将操作压力平摊到多个redis实例中,降低对单个redis的IO影响;

redis如何实现主从复制?以及数据同步机制?

Redis的主从同步机制可以确保redis的master和slave之间的数据同步
全备份过程中,在slave启动时,会向其master发送一条SYNC消息,
master收到slave的这条消息之后,将可能启动后台进程进行备份,备份完成之后就将备份的数据发送给slave

redis中的sentinel的作用?

用于监控redis集群中Master状态的工具

简述redis的有哪几种持久化策略及比较?

  • rdb 将数据库快照以二进制的方式保存到磁盘
  • aof 以协议文本方式,将所有数据库进行过写入的命令和参数记录到aof文件,从而记录数据库状态

RDB

RDB文件适合数据的容灾备份与恢复,通过RDB文件恢复数据库耗时较短,可以快速恢复数据。

RDB持久化只会周期性的保存数据,在未触发下一次存储时服务宕机,就会丢失增量数据。当数据量较大的情况下,fork子进程这个操作很消耗cpu,可能会发生长达秒级别的阻塞情况。

  • SAVE 阻塞式持久化,执行命令时Redis主进程把内存数据写入到RDB文件中直到创建完毕,期间Redis不能处理任何命令。
  • BGSAVE 非阻塞式持久化,创建一个子进程把内存中数据写入RDB文件里同时主进程处理命令请求。

image.png

AOF

将Redis执行的每次写命令记录到单独的日志文件中,当Redis重启时再次执行AOF文件中的命令来恢复数据

每当执行服务器(定时)任务或者函数时flushAppendOnlyFile 函数都会被调用,内容是redis通讯协议(RESP)格式的命令文本存储

  • always:服务器在每执行一个事件就把AOF缓冲区的内容强制性的写入硬盘上的AOF文件里,保证了数据持久化的完整性,效率是最慢的但最安全的;
  • everysec:服务端每隔一秒才会进行一次文件同步把内存缓冲区里的AOF缓存数据真正写入AOF文件里,兼顾了效率和完整性,极端情况服务器宕机只会丢失一秒内对Redis数据库的写操作;
  • no:表示默认系统的缓存区写入磁盘的机制,不做程序强制,数据安全性和完整性差一些。

服务端 — flushAppendOnlyFile —> 磁盘中的AOF文件,这个函数执行以下两个工作:

WRITE:根据条件,将aof_buf中的缓存写入到AOF文件
SAVE:根据条件,调用fsync或fdatasync函数,将AOF文件保存到磁盘中。

AOF比RDB文件更大,并且在存储命令的过程中增长更快,为了压缩AOF的持久化文件,Redis提供了重写机制以此来实现控制AOF文件的增长。

比较

  1. aof文件比rdb更新频率高,优先使用aof还原数据
  2. aof比rdb更安全也更大
  3. rdb性能比aof好
  4. 如果两个都配了优先加载AOF

    不使用redis做专门的持久化数据库存储

    redis时内存型的数据库,数据量存储量有限且分布式集群成本也会非常高

    RESP通讯协议

    实现简单,快速解析,可读性好
  • + 回复
  • - 错误
  • :数字
  • $ 字符串
  • * 数组

分布式锁

当多个进程不在同一个系统中,用分布式锁控制多个进程对资源的访问。
线程间并发问题和进程间并发问题都是可以通过分布式锁解决的,但是强烈不建议这样做!因为采用分布式锁解决这些小问题是非常消耗资源的!
分布式锁应该用来解决分布式情况下的多进程并发问题才是最合适的。
setnx(key, value):“set if not exits”,若该key-value不存在,则成功加入缓存并且返回1,否则返回0。
get(key):获得key对应的value值,若不存在则返回nil。
getset(key, value):先获取key对应的value值,若不存在则返回nil,然后将旧的value更新为新的value。
expire(key, seconds):设置key-value的有效期为seconds秒。
先拿setnx来争抢锁,抢到之后,再用expire给锁加一个过期时间防止锁忘记了释放。

流程:

  1. 首先建立一个redis连接池
  2. 对redis的api进行封装,封装一些实现分布式锁需要用到的操作
  3. 建立分布式锁工具类

在实现的时候要注意的几个关键点:

  1. 锁信息必须是会过期超时的,不能让一个线程长期占有一个锁而导致死锁;
  2. 同一时刻只能有一个线程获取到锁。

如果在setnx之后执行expire之前进程意外crash或者要重启维护了,那会怎么样?

set指令有非常复杂的参数,这个应该是可以同时把setnx和expire合成一条指令来用的!

使用过Redis做异步队列么,你是怎么用的?有什么缺点?

一般使用list结构作为队列,rpush生产消息,lpop消费消息。当lpop没有消息的时候,要适当sleep一会再重试。

缺点:

在消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列如rabbitmq等。

能不能生产一次消费多次呢?

使用pub/sub主题订阅者模式,可以实现1:N的消息队列。

什么是缓存穿透?如何避免?什么是缓存雪崩?何如避免?

缓存穿透

一般的缓存系统,都是按照key去缓存查询,如果不存在对应的value,就应该去后端系统查找(比如DB)。
一些恶意的请求会故意查询不存在的key,请求量很大,就会对后端系统造成很大的压力。这就叫做缓存穿透。

如何避免?

  1. 对查询结果为空的情况也进行缓存,缓存时间设置短一点,或者该key对应的数据insert了之后清理缓存。(缓存空对象)
  2. 对一定不存在的key进行过滤。可以把所有的可能存在的key放到一个大的Bitmap中,查询时通过该bitmap过滤。(布隆过滤器)

缓存雪崩

当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,会给后端系统带来很大压力。导致系统崩溃。

如何避免?

  1. 在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
  2. 做二级缓存,A1为原始缓存,A2为拷贝缓存,A1失效时,可以访问A2,A1缓存失效时间设置为短期,A2设置为长期
  3. 不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。

一个字符串类型的值能存储最大容量是多少?

512M

redis适合的场合

  • 会话缓存(Session Cache):网页访问,cookie,session记录等
  • 全页缓存(FPC):网页
  • 队列:Reids在内存存储引擎领域的一大优点是提供 list 和 set 操作,这使得Redis能作为一个很好的消息队列平台来使用。
    Redis作为队列使用的操作,就类似于本地程序语言(如Python)对 list 的 push/pop 操作。
  • 排行榜/计数器
  • 发布/订阅

Redis集群之间是如何复制的?

异步复制