Redis 有哪些数据类型,及底层实现
底层数据类型可通过OBJECT encoding key查看,在安装包路径下的src目录下可查看具体的实现逻辑
- String(字符串)。主要由简单动态字符串实现,具体类型有,INT、EMBSTR、RAM。sdshdr保存了len、free、buf这三个参数属性。通过len 属性,sdshdr 可以实现复杂度为θ(1) 的长度计算操作。另一方面,通过对buf 分配一些额外的空间,并使用free 记录未使用空间的大小,sdshdr 可以让执行追加操作所需的内存重分配次数大大减少,还可以使用bitmap位图类型来实现用户登录天数统计等场景。
- Hash(哈希):键值对集合,具体类型有ZIPLIST压缩列表实现的哈希对象(默认)。和TH字典实现的哈希对象。在哈希表实现中,当两个不同的键拥有相同的哈希值时,我们称这两个键发生碰撞,一般会采用链地址法(使用链表将多个哈希值相同的节点串连在一起)。
为了在字典的键值对不断增多的情况下保持良好的性能,字典需要对所使用的哈希表(ht[0])进行rehash 操作,rehash被激活的条件:
自然rehash :ratio >= 1 ,且变量dict_can_resize 为真。
强制rehash : ratio 大于变量dict_force_resize_ratio
rehash详细步骤:
新分配一个ht[1]空间,同时拥有ht[0]和ht[1]两个哈希表
在字典中维持一个索引计数器变量rehashidx,初始值为0,表示rehash开始
在rehash期间,ht[0]在rehashidx上的键值rehash到ht[1]完成后,rehashidx增一
随着字典操作不断执行,ht[0]上所有的键值被rehash到ht[1]时,rehashidx被设为-1,表示rehash操作完成
渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需要的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash的庞大计算量。 - List(列表):redis列表使用两种数据结构作为底层实现:双向链表和压缩列表,双向列表还被很多redis内部模块所应用:
事务模块使用双向链表来按顺序保存输入的命令;
服务器模块使用双向链表来保存多个客户端;
订阅/发送模块使用双向链表来保存订阅模式的多个客户端;
时间模块使用双向链表来保存时间事件(time event)
双向链表主要由listNode和list组成,listNode是双向链表的节点,包含prev(前驱指针)、next(后继指针)和value(数值);list是双向链表本身,包含head(表头指针)、tail(表尾指针)、len(节点数量)、dup(复制函数)、free(释放函数)和match(对比函数) - Set(集合):具体类型有INTSET整数集合实现的集合对象和HT字典实现的集合对象。通过HashTable实现的String无序集合。
- zset(sorted set:有序集合):具体类型有ZIPLIST压缩列表和SKIPLIST跳跃表。通过OJBECT encoding key返回是ziplist类型,当元素超过128个或元素大小超过64byte时自动转换为skiplist
Redis 使用场景
- String:点赞、
统计(bitmap):任意用户、任意窗口登录天数,活跃用户数
限流 - List:评论列表
- hash:hashmap,详情页,聚合数据;统计值:点赞数,粉丝数等
- set:抽奖轮播数据的随机展示,并集差集可做推荐好友和共同好友
- zset:适用于简单的分页场景
Redis为什么会这么快
Redis是完全基于内存的数据库
处理网络请求使用的是单线程,避免了不必要的上下文切换和锁的竞争维护。
使用了I/O多路复用模型。
使用多路复用器解决什么问题
哪些IO可以读写,从6.x版本起,除了worker线程(参与计算)为单线程,其它的是多线程,单个线程,通过记录跟踪每一个Socket连接的I/O的状态来同时管理多个I/O流。
这里的I/O指的是网络I/O,多路指的是多个网络连接,复用指的是复用一个线程。
Redis 持久化机制,可参考《使用快照和AOF将Redis数据持久化到硬盘中》
- Snapshotting
快照是默认的持久化方式。这种方式是就是将内存中数据以快照的方式写入到二进制文件中,默认的文件名为dump.rdb。可以通过配置设置自动做快照持久化的方式。我们可以配置 redis在 n 秒内如果超过 m 个 key 被修改就自动做快照,下面是默认的快照保存配置:
save 900 1 #900 秒内如果超过 1 个 key 被修改,则发起快照保存
save 300 10 #300 秒内容如超过 10 个 key 被修改,则发起快照保存
save 60 10000 - AOF方式
由于快照方式是在一定间隔时间做一次的,所以如果 redis 意外 down 掉的话,就会丢失最后一次快照后的所有修改。如果应用要求不能丢失任何修改的话,可以采用 aof 持久化方式。下面介绍 Append-only file:aof 比快照方式有更好的持久化性,是由于在使用 aof 持久化方式时,redis 会将每一个收到的写命令都通过 write 函数追加到文件中(默认是 appendonly.aof)。当 redis 重启时会通过重新执行文件中保存的写命令来在内存中重建整个数据库的内容。当然由于 os 会在内核中缓存 write 做的修改,所以可能不是立即写到磁盘上。这样 aof 方式的持久化也还是有可能会丢失部分修改。不过我们可以通过配置文件告诉 redis 我们想要通过 fsync 函数强制 os 写入到磁盘的时机。有三种方式如下(默认是:每秒 fsync 一次)
appendonly yes //启用 aof 持久化方式
appendfsync always //收到写命令就立即写入磁盘,最慢,但是保证完全的持久化
appendfsync everysec //每秒钟写入磁盘一次,在性能和持久化方面做了很好的折中
appendfsync no //完全依赖 os,性能最好,持久化没保证
aof 的方式也同时带来了另一个问题。持久化文件会变的越来越大,redis 提供了 bgrewriteaof 命令。收到此命令 redis 将使用与快照类似的方式将内存中的数据以命令的方式保存到临时文件中,最后替换原来的文件。
Redis 集群方案与实现
- Redis官方集群方案 Redis Cluster
- Redis Sharding集群
- 利用代理中间件实现大规模Redis集群
Redis 为什么是单线程的?
因为CPU不是Redis的瓶颈。Redis的瓶颈最有可能是机器内存或者网络带宽。
总体来说快速的原因如下:
1)绝大部分请求是纯粹的内存操作(非常快速)
2)采用单线程,避免了不必要的上下文切换和竞争条件
3)非阻塞IO
缓存穿透、缓存击穿、缓存雪崩、缓存预热、缓存更新、缓存降级
一、缓存穿透
缓存中不存在,数据库也不存在
解决方案:缓存为空,数据库也为空,布隆过滤器;也可以在key中存储一个空值,过期时间设置很短
二、缓存击穿
缓存某个key过期,导致大量请求直接查询数据库(缓存为空,数据库不为空)
解决方案:热点数以永不过期;若缓存为空,则添加锁让一个线程查询数据库,不管得到的值是否为空,都添加到缓存;若数据库查询为空,则有效时间设置很短(5分钟左右),具体看业务需求。
二、缓存预热
目标就是在系统上线前,将数据加载到缓存中。
解决思路:
1,直接写个缓存刷新页面,上线时手工操作下。
2,数据量不大,可以在WEB系统启动的时候加载。
3,搞个定时器定时刷新缓存,或者由用户触发都行
三、缓存雪崩
可能是因为数据未加载到缓存中,或者缓存同一时间大面积的失效,从而导致所有请求都去查数据库,导致数据库CPU和内存负载过高,甚至宕机。
解决方案:
1.在缓存的过期时间上增加一个随机值,1~5秒,这样就不致于全部一起失效;
2.获取缓存为空时,不立即请求数据库,而是先使用缓存添加一个互斥锁,再去请求数据库将数据加载到缓存,期间其它请求暂时等待或直接返回。
四、缓存更新
给缓存添加一个过期状态(比缓存过期时间短),每次查询时判断过期状态是否改变,若已改变,则查询数据库更新缓存。
五、缓存降级
当访问量增加,拉低服务性能,或非核心服务影响到核心流程时,仍需保证服务是可用的这时候就需要进行缓存降级。将一些非核心的数据删除或设置过期,丢卒保帅的做法来保证服务的可用性。
六、缓存算法
FIFO算法:First in First out,先进先出。
LFU算法:Least Frequently Used,最不经常使用算法(使用次数)。
LRU算法:Least Recently Used,近期最少使用算法(很久未使用)。
如果某个key数据实在太热,海量请求,单个redis节点都可以撑不住怎么破
解决方案:数据散列开
将一份热点数据的key,做成N份,打个比方,将原本key分成key1,key2,key3,分别写入Redis-1,Redis-2,Redis-3,这样数据均匀打散到这三个redis节点上,分担了读压力,缓存就不至于压垮。
Redis常见的回收策略
- volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
- allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
- no-enviction(驱逐):禁止驱逐数据
redis持久化机制RDB与AOF的区别
RDB 持久化可以在指定的时间间隔内生成数据集的时间点快照
优势
RDB 是一个非常紧凑(compact)的文件,它保存了 Redis 在某个时间点上的数据集。 这种文件非常适合用于进行备份
RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快(因为其文件要比AOF的小)
RDB的性能更好
劣势
RDB的持久化不够及时
RDB持久化时如果文件过大可能会造成服务器的阻塞,停止客户端请求
AOF redis会将每一个收到的写命令都通过write函数追加到文件中(默认是 appendonly.aof)。
优势
AOF的持久性更加的耐久(可以每秒 或 每次操作保存一次)
AOF 文件有序地保存了对数据库执行的所有写入操作, 这些写入操作以 Redis 协议的格式保存, 因此 AOF 文件的内容非常容易被人读懂, 对文件进行分析(parse)也很轻松。
AOF是增量操作
劣势
对于相同的数据集来说,AOF 文件的体积通常要大于 RDB 文件的体积
根据所使用的 fsync 策略,AOF 的速度可能会慢于 RDB 。
AOF 在过去曾经发生过这样的 bug : 因为个别命令的原因,导致 AOF 文件在重新载入时,无法将数据集恢复成保存时的原样。 (举个例子,阻塞命令 BRPOPLPUSH 就曾经引起过这样的 bug 。)
当 Redis 启动时, 如果 RDB 持久化和 AOF 持久化都被打开了, 那么程序会优先使用 AOF 文件来恢复数据集, 因为 AOF 文件所保存的数据通常是最完整的。
redis过期键的删除策略
定时删除(内存最友好)
惰性删除(CPU时间最友好)
定期删除(是以上两种策略的一种折中)
Redis 使用的过期键删除策略是惰性删除加上定期删除, 这两个策略相互配合,可以很好地在合理利用 CPU 时间和节约内存空间之间取得平衡。
redis如何实现分布式锁,如果service还没执行完,分布式锁在redis中过期了该如何处理?
使用redission.getLock(“myLock”);获取分布式锁,默认有效期为30秒,通过wetch dog 监控(当前线程ID),每10秒查看service是否结束,若未结束则续期锁30秒,若service宕机,则无法续期锁,30秒后过期。
此锁是可重入锁,锁对象key的值是锁次数,每重入一次则次数加1,unLock一次则次数减1,到0时调用del删除锁
hset myLock
8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
myLock {
"8743c9c0-0795-4907-87fd-6c719a6b4586:1" : 1
}
什么是跳跃表
跳跃表是一种有序的数据结构,基于单链表加索引的方式实现,以空间换时间的方式提高了查找速度,它通过在每个节点中维持多个指向其他的节点指针,从而达到快速访问节点目的。跳跃表的效率可以和平衡树想媲美了,最关键是它的实现相对于平衡树来说,代码的实现上简单很多。
Redis中跳跃表的实现
Redis的跳跃表由zskiplistNode和skiplist两个结构定义,其中 zskiplistNode结构用于表示跳跃表节点,而 zskiplist结构则用于保存跳跃表节点的相关信息。
上图展示了一个跳跃表示例,其中最左边的是 skiplist 结构,该结构包含以下属性。
- header:指向跳跃表的表头节点,通过这个指针程序定位表头节点的时间复杂度就为O(1)
- tail:指向跳跃表的表尾节点,通过这个指针程序定位表尾节点的时间复杂度就为O(1)
- level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内),通过这个属性可以再O(1)的时间复杂度内获取层高最好的节点的层数。
- length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内),通过这个属性,程序可以再O(1)的时间复杂度内返回跳跃表的长度。
结构右方的是四个 zskiplistNode结构,该结构包含以下属性
- 层(level):
每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离(跨度越大、距离越远)。在上图中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
每次创建一个新跳跃表节点的时候,程序都根据幂次定律(powerlaw,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”。 - 后退(backward)指针:
节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。与前进指针所不同的是每个节点只有一个后退指针,因此每次只能后退一个节点。 - 分值(score):
各个节点中的1.0、2.0和3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。 - 成员对象(oj):
各个节点中的o1、o2和o3是节点所保存的成员对象。在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。
跳跃表的 level 是如何定义的?
Redis跳跃表 level 层级完全是随机的,每个跳跃表节点的层高都是1至32之间的随机数。一般来说,层级越多,访问节点的速度越快。
Redis String类型数据结构,什么是SDS
Redis默认并未直接使用C字符串(C字符串仅仅作为字符串字面量,用在一些无需对字符串进行修改的地方,如打印日志)。而是以Struct的形式构造了一个SDS的抽象类型。当Redis需要一个可以被修改的字符串时,就会使用SDS来表示。在Redis数据库里,包含字符串值的键值对都是由SDS实现的(Redis中所有的键都是由字符串对象实现的即底层是由SDS实现,Redis中所有的值对象中包含的字符串对象底层也是由SDS实现)。
struct sdshdr{
//int 记录buf数组中未使用字节的数量 如上图free为0代表未使用字节的数量为0
int free;
//int 记录buf数组中已使用字节的数量即sds的长度 如上图len为5代表未使用字节的数量为5
int len;
//字节数组用于保存字符串 sds遵循了c字符串以空字符结尾的惯例目的是为了重用c字符串函数库里的函数
char buf[];
}
为什么要使用SDS
SDS这种数据结构相对于C字符串有以下优点:
- 杜绝缓冲区溢出
- 减少字符串操作中的内存重分配次数
- 二进制安全
- 由于SDS遵循以空字符结尾的惯例,因此兼容部分C字符串函数
SDS空间分配策略
- 空间预分配
当执行字符串增长操作并且需要扩展内存时,程序不仅仅会给SDS分配必需的空间还会分配额外的未使用空间,其长度存到free属性中。其分配策略如下:
- 如果修改后len长度将小于1M,这时分配给free的大小和len一样,例如修改过后为10字节, 那么给free也是10字节,buf实际长度变成了10+10+1 = 21byte
- 如果修改后len长度将大于等于1M,这时分配给free的长度为1M,例如修改过后为30M,那么给free是1M.buf实际长度变成了30M+1M+1byte
- 惰性空间释放
惰性空间释放用于字符串缩短的操作。当字符串缩短是,程序并不是立即使用内存重分配来回收缩短出来的字节,而是使用free属性记录起来,并等待将来使用。
Redis通过空间预分配和惰性空间释放策略在字符串操作中一定程度上减少了内存重分配的次数。但这种策略同样会造成一定的内存浪费,因此Redis SDS API提供相应的API让我们在有需要的时候真正的释放SDS的未使用空间。
热点Key的重建
当前Key是一个热点Key,并发量非常大
- 互斥锁
只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可
可使用 Redis 的 setnx 命令实现该功能。 - 永不过期
物理”不过期:没有设置过期时间,所以不会出现热点 key 过期后产生的问题。
为每个 value 设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。 - 热点散列
Redis缓存与数据库双写不一致如何解决
1. 先更新数据库,后删除缓存。
存在问题:
- 数据库更新成功,缓存删除失败。导致数据库中的数据是最新的,但缓存中的是旧数据。
- 并发问题
- 读请求去查询缓存时,缓存刚好失效。
- 读请求去查询数据库,得到旧值。
- 写请求将新值写入数据库,写请求删除缓存。
- 读请求将旧值写入缓存。
改进:
提供一个保障的重试机制即可。目前有两种方案。
- 方案一:可以使用消息队列来保证缓存一致性。
- 方案二:启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。
2. 缓存延时双删策略
- 先删除缓存,再写数据库
- 异步等待一段时间后,再次淘汰缓存。(这里的时间设定主要是保证读请求结束,写请求可以删除读请求遭成的缓存脏数据,需要自行评估确定。)
该方案解决了高并发情况下,同时有读请求与写请求时导致的不一致问题。读取速度快,但是可能会出现短时间的脏数据。
如果第二次删除也删除失败,则需要添加重试机制保证一定删除成功。