Redis
一、Redis基础
1. 概述
NoSQL(NoSQL = Not Only SQL),意思是不仅仅是SQL,泛指非关系型的数据库。
1.1 CAP理论
- C:Consistency(强一致性)
- A:Availability(可用性)
- P:Partion tolerance(分区容错性)
CAP理论图的核心是:一个分布式系统不可能同时很好的满足一致性,可用性和分区容错性这三个需求,最多只能同时较好的满足两个。因此,根据CAP原理将NoSQL数据库分成了满足CA原则、满足CP原则和满足AP原则三大类:
CA: 传统Oracle数据库
AP: 大多数网站架构的选择
CP: Redis、Mongodb
1.3 Redis特点
-
Redis支持数据的持久化
,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用 Redis不仅仅支持简单的key-value类型的数据,同时还提供list,set,zset,hash等数据结构的存储
-
Redis支持数据的备份
,即master-slave模式的数据备份。
2. Redis五大数据类型
操作指令参考: http://redisdoc.com/
所有的key都为String类型,讨论数据类型是说的value的类型
2.1 字符串(String)
//=====================基本操作=======================//
//设置String
set key value
mset key1 value1 key2 value2...
//设置生命周期
setex key seconds value
//得到String
get key
mget key1 key2...
//删除String
del key
//向字符串的后面追加字符,如果有就补在后面,如果没有就新建
append key value
//=================String作为数值的操作===================//
//增长指令,只有当value为数字时才能增长
incr key
incrby key increment
incrbyfloat key increment
//减少指令,有当value为数字时才能减少
decr key
decrby key increment
- string在redis内部存储默认就是一个字符串,当遇到增减类操作incr,decr时会转成数值型进行计算。
- redis所有的操作都是原子性的,采用单线程处理所有业务,命令是一个一个执行的,因此无需考虑并发带来的数据影响。
指定生命周期
//设置数据的生命周期,单位 秒
setex key seconds value
//设置数据的生命周期,单位 毫秒
psetex key milliseconds value
2.2 列表(List)
- 元素有序且可重
- List中保存的数据都是string类型的
//=====================基本操作=======================//
//添加修改数据,lpush为从左边添加,rpush为从右边添加
lpush key value1 value2 value3...
rpush key value1 value2 value3...
//查看数据, 从左边开始向右查看. 如果不知道list有多少个元素,end的值可以为-1,代表倒数第一个元素
//lpush先进的元素放在最后,rpush先进的元素放在最前面
lrange key start end
//得到长度
llen key
//取出对应索引的元素
lindex key index
//获取并移除元素(从list左边或者右边移除)
lpop key
rpop key
//规定时间内获取并移除数据,b=block,给定一个时间,如果在指定时间内放入了元素,就移除
blpop key1 key2... timeout
brpop key1 key2... timeout
//移除指定元素 count:移除的个数 value:移除的值。 移除多个相同元素时,从左边开始移除
lrem key count value
2.3 集合(Set)
- 不重复且无序
- string类型的无序集合。它是通过HashTable实现的
//添加元素
sadd key member1 member2...
//查看元素
smembers key
//移除元素
srem key member
//查看元素个数
scard key
//查看某个元素是否存在
sismember key member
//从set中任意选出count个元素
srandmember key count
//从set中任意选出count个元素并移除
spop key count
//求两个集合的交集、并集、差集
sinter key1 key2...
sunion key1 key2...
sdiff key1 key2...
//求两个set的交集、并集、差集,并放入另一个set中
sinterstore destination key1 key2...
sunionstore destination key1 key2...
sdiffstore destination key1 key2...
//求指定元素从原集合放入目标集合中
smove source destination key
2.4 哈希(Hash)
- 类似java里面的Map<String,Object>
- hash类型下的value只能存储字符串,不允许存储其他数据类型
//插入(如果已存在同名的field,会被覆盖)
hset key field value
hmset key field1 value1 field2 value2...
//插入(如果已存在同名的field,不会被覆盖)
hsetnx key field value
//取出
hget key field
hgetall key
//删除
hdel key field1 field2...
//获取field数量
hlen key
//查看是否存在
hexists key field
//获取哈希表中所有的字段名或字段值
hkeys key
hvals key
//设置指定字段的数值数据增加指定范围的值
hincrby key field increment
hdecrby key field increment
2.5 有序集合Zset(sorted set)
- 有序不重复
- zset和set一样也是string类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个double类型的分数
- 可进行排序
//插入元素, 需要指定score(用于排序)
zadd key score1 member1 score2 member2
//查看元素(score升序), 当末尾添加withscore时,会将元素的score一起打印出来
zrange key start end (withscore)
//查看元素(score降序), 当末尾添加withscore时,会将元素的score一起打印出来
zrevrange key start end (withscore)
//移除元素
zrem key member1 member2...
//按条件获取数据, 其中offset为索引开始位置,count为获取的数目
zrangebyscore key min max [withscore] [limit offset count]
zrevrangebyscore key max min [withscore] [limit offset count]
//按条件移除元素
zremrangebyrank key start end
zremrangebysocre key min max
//按照从大到小的顺序移除count个值
zpopmax key [count]
//按照从小到大的顺序移除count个值
zpopmin key [count]
//获得元素个数
zcard key
//获得元素在范围内的个数
zcount min max
//求交集、并集并放入destination中, 其中numkey1为要去交集或并集集合的数目
zinterstore destination numkeys key1 key2...
zunionstore destination numkeys key1 key2...
//查看某个元素的索引(排名)
zrank key member
zrevrank key member
//查看某个元素索引的值
zscore key member
//增加某个元素索引的值
zincrby key increment member
3. Redis持久化
3.1 简介
什么是持久化
利用永久性存储介质将数据进行保存,在特定的时间将保存的数据进行恢复的工作机制称为持久化。
为什么要持久化
防止数据的意外丢失,确保数据安全性
持久化过程保存什么
- 将当前数据状态进行保存,快照形式,存储数据结果,存储格式简单,关注点在数据
- 将数据的操作过程进行保存,日志形式,存储操作过程,存储格式复杂,关注点在数据的操作过程
3.2 RDB
RDB的触发有两种方式:
- 手动(使用命令save)
- 自动(save m n)
自动: 默认
的RDB快照触发机制(可以在redis.conf中的SNAPSHOTTING中查看)
1分钟内改动了1万次
5分钟内改动了10万次
15分钟内改动了1次
默认的名字为:dump.rdb
手动: 命令save或者是bgsave
如果出现某个数据非常重要,无法等到自动备份,这时候我们可以通过这
个命令来进行手动备份
save:save时只管保存,其它全部阻塞
bgsave:Redis会在后台异步进行快照操作
如何恢复RDB
将备份文件 (dump.rdb) 移动到启动Redis服务的目录即可
优缺点
优势
- 适合大规模的数据恢复
- 对数据完整性和一致性要求不高
劣势
- 在一定间隔时间做一次备份,所以如果Redis服务器意外down掉的话,就会丢失最后一次快照后的所有修改
- Fork的时候,内存中的数据被克隆了一份,会占用更多的内存空间
停止RDB
使用下面的命令:
CONFIG SET save ""
小结
- RDB是一个非常紧凑的文件。
- RDB在保存RDB文件时父进程唯一需要做的就是fork出一个子进程,接下来的工作全部由子进程来做,父进程不需要再做其他I0操作,所以RDB持久化方式可以最大化redis的性能。
- 与AOF相比,在恢复大的数据集的时候,RDB方式会更快一一些。
数据丢失风险大。 - RDB需要经常fork子进程来保存数据集到硬盘上,当数据集比较大的时候fork的过程是非常耗时间的,可能会导致Redis在一些毫秒级不能回应客户端请求
3.3 AOF
AOF概念
- AOF(append only file)持久化:以独立日志的方式记录每次写命令,重启时再重新执行AOF文件中命令,以达到恢复数据的目的。与RDB相比可以简单描述为改记录数据为记录数据产生的过程
- AOF的主要作用是解决了数据持久化的实时性,目前已经是Redis持久化的主流方式
AOF写数据三种策略
- always: 每次写入操作均同步到AOF文件中,数据零误差,性能较低,不建议使用
- everysec: 每秒将缓冲区中的指令同步到AOF文件中,数据准确性较高,性能较高 ,默认配置,在系统突然宕机的情况下丢失1秒内的数据
- no: 由操作系统控制每次同步到AOF文件的周期,整体过程不可控
AOF功能开启:
appendonly yes|no
appendfsync always|everysec|no
AOF自动重写
- 自动重写触发条件设置
//触发重写的最小大小
auto-aof-rewrite-min-size size
//触发重写须达到的最小百分比
auto-aof-rewrite-percentage percent
- 自动重写触发比对参数( 运行指令info Persistence获取具体信息 )
//当前.aof的文件大小
aof_current_size
//基础文件大小
aof_base_size
4. Redis事务
基于特定条件的事务执行
锁
-
对 key 添加监视锁,在执行exec前如果key发生了变化,终止事务执行
watch key1, key2....
-
取消对所有key的监视
unwatch
分布式锁
-
使用 setnx 设置一个公共锁
//上锁 setnx lock-key value //释放锁 del lock-key
- 利用setnx命令的返回值特征,有值(被上锁了)则返回设置失败,无值(没被上锁)则返回设置成功
- 操作完毕通过del操作释放锁
注意:上述解决方案是一种设计概念,依赖规范保障,具有风险性
分布式锁加强
-
使用 expire 为锁key添加时间限定,到时不释放,放弃锁
expire lock-key seconds pexpire lock-key milliseconds
-
由于操作通常都是微秒或毫秒级,因此该锁定时间不宜设置过大。具体时间需要业务测试后确认。
-- 例如:持有锁的操作最长执行时间127ms,最短执行时间7ms。
-- 测试百万次最长执行时间对应命令的最大耗时,测试百万次网络延迟平均耗时
-- 锁时间设定推荐:最大耗时120%+平均网络延迟110%
-- 如果业务最大耗时<<网络平均延迟,通常为2个数量级,取其中单个耗时较长即可
二、Redis应用
1. Redis分布式锁
商品超卖问题:
在多线程情况下会出现并发问题导致商品超卖问题。(多个线程同时获取到stock数量)
1)解决多线程并发问题
加锁解决多线程并发问题:
上述代码,在分布式环境情况下,每个环境的jvm不同(不同进程),synchronized锁不是同一把锁,此时还是会有并发问题。
2)解决多台服务部署应用导致并非问题
使用redis分布式锁解决,使用setnx + 超时时间:
其中:
- 使用try finally是为了在异常情况下依然可以释放锁;
- 设置锁的过期时间,防止程序运行期间jvm挂了导致finally块中代码也无法执行去释放锁;
- 第29行替换掉27、28行是为了原子操作。
上述代码还是有问题:
假设有3个客户端来请求这段代码,其中客户端A需要15秒钟才执行完,在执行第10秒的时候因为到了过期时间自动释放锁,此时客户端B拿到锁进来了,假设执行了5秒中B还正在执行中,此时客户端A执行到第43行去删除锁,此时删除的客户端拿的锁。那么,客户端C自然就可以再次拿锁进入。。。依次循环等于锁一直处于失效状态。
3)解决锁的误释放问题
问题的根本原因是自己设置的锁被别人释放掉了,可以设置一个客户端id来表示自己的锁。
4)释放锁的原子操作问题
当某一客户端a执行到46行时,卡了一下导致到了过期时间,客户端释放了锁,此时客户端B设置了锁进入程序,此时客户端A恢复,此时客户端执行47行删除的锁其实是客户端B的锁。
使用看门狗+锁续命来解决:弄一个监控线程轮训主线程是否还在执行,如果还在执行就给锁续命增加过期时间。
使用redisson框架来解决:
Redisson分布式锁实现原理:
Redlock实现原理(解决主从切换时锁失效的情况):
其实,分布式锁是和高并发是违背的,上述代码等同于串行执行。----分段加锁处理。
2. 缓存数据库双写不一致问题
情况1:
情况2:
解决方案:使用分布式锁,使得每个线程操作串行执行。
3. redis为什么这么快
1)内存数据库
2)单线程
3)底层的数据结构
4)全局维护了一个hash表。
三、Redis五大数据类型应用场景
1. String应用场景
2. Hash应用场景
3. List应用场景
比如:微信公共号的消息流。
4. Set应用场景
集合操作可实现共同关注模型、推荐模型(可能认识的人)。
5. Zset应用场景
四、Redis 底层的数据结构
Redis 底层的数据结构一共有 6 种,和数据类型对应关系如下图:
1.SDS
字符串在 Redis 中是很常用的,键值对中的键是字符串,值有时也是字符串。
Redis 使用 C 语言实现的,但是它没有直接使用 C 语言的 char* 字符数组来实现字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS) 的数据结构来表示字符串。
1.1 C 语言字符串的缺陷
C 语言的字符串其实就是一个字符数组,即数组中每个元素是字符串中的一个字符。
比如,下图就是字符串“xiaolin”的 char* 字符数组的结构:
在 C 语言里,对字符串操作时,char * 指针只是指向字符数组的起始位置,而字符数组的结尾位置就用“\0”表示,意思是指字符串的结束。
因此,C 语言标准库中字符串的操作函数,就通过判断字符是不是“\0”,如果不是说明字符串还没结束,可以继续操作,如果是则说明字符串结束了,停止操作。
举个例子,C 语言获取字符串长度的函数 strlen,就是通过字符数组中的每一个字符,并进行计数,等遇到字符为“\0”后,就会停止遍历,然后返回已经统计到的字符个数,即为字符串长度。下图显示了 strlen 函数的执行流程:
很明显,C 语言获取字符串长度操作的时间复杂度是 O(N)(*****这是一个可以改进的地方*)
C 语言的字符串用 “\0” 字符作为结尾标记有个缺陷。假设有个字符串中有个 “\0” 字符,这时在操作这个字符串时就会提早结束,比如 “xiao\0lin” 字符串,计算字符串长度的时候则会是 4。
还有,除了字符串中不能 “\0” 字符外,用 char* 字符串中的字符必须符合某种编码(比如ASCII)。
这些限制使得 C 语言的字符串只能保存文本数据,不能保存像图片、音频、视频文化这样的二进制数据(*****这也是一个可以改进的地方*****)
C 语言标准库中字符串的操作函数是很不安全的,对程序员很不友好,稍微一不注意,就会导致缓冲区溢出。
举个例子,strcat 函数是可以将两个字符串拼接在一起的。
c //将 src 字符串拼接到 dest 字符串后面 char *strcat(char dest, const char src);
C 语言的字符串是不会记录自身的缓冲区大小的,所以 strcat 函数假定程序员在执行这个函数时,已经为 dest 分配了足够多的内存,可以容纳 src 字符串中的所有内容,而一旦这个假定不成立,就会发生缓冲区溢出将可能会造成程序运行终止,(这是一个可以改进的地方)。
而且,strcat 函数和 strlen 函数类似,时间复杂度也很高,也都需要先通过遍历字符串才能得到目标字符串的末尾。然后对于 strcat 函数来说,还要再遍历源字符串才能完成追加,对字符串的操作效率不高。
通过以上的分析,可以得知 C 语言的字符串 不足之处以及可以改进的地方:
- 获取字符串长度的时间复杂度为 O(N);
- 字符串的结尾是以 “\0” 字符标识,而且字符必须符合某种编码(比如ASCII),只能保存文本数据,不能保存二进制数据;
- 字符串操作函数不高效且不安全,比如可能会发生缓冲区溢出,从而造成程序运行终止;
Redis 实现的 SDS 的结构就把上面这些问题解决了,接下来我们一起看看 Redis 是如何解决的。
1.2 SDS 结构设计
SDS 的数据结构:
结构中的每个成员变量分别介绍下:
- len,SDS 所保存的字符串长度。这样获取字符串长度的时候,只需要返回这个变量值就行,时间复杂度只需要 O(1)。
- alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算 出剩余的空间大小,然后用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区益处的问题。
- flags,SDS 类型,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面在说明区别之处。
- buf[],字节数组,用来保存实际数据。不需要用 “\0” 字符来标识字符串结尾了,而是直接将其作为二进制数据处理,可以用来保存图片等二进制数据。它即可以保存文本数据,也可以保存二进制数据,所以叫字节数组会更好点。
总的来说,Redis 的 SDS 结构在原本字符数组之上,增加了三个元数据:len、alloc、flags,用来解决 C 语言字符串的缺陷。
1.3 节省内存空间
SDS 结构中有个 flags 成员变量,表示的是 SDS 类型。
Redos 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。
这 5 种类型的主要区别就在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同,之所以 SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。
2. 压缩列表
压缩列表是 Redis 数据类型为 list 和 hash 的底层实现之一。
- 当一个列表键(list)只包含少量的列表项,并且每个列表项都是小整数值,或者长度比较短的字符串,那么 Redis 就会使用压缩列表作为列表键(list)的底层实现。
- 当一个哈希键(hash)只包含少量键值对,并且每个键值对的键和值都是小整数值,或者长度比较短的字符串,那么 Redis 就会使用压缩列表作为哈希键(hash)的底层实现。
压缩列表结构设计
压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。不作过多讲解。
在有序集合zset的实现里面,跳跃表和压缩列表都有用到。
根据上面都描述,压缩列表的使用场景一般是短字符串,且元素个数不能太多。所以在zset里面有几个配置:
zset-max-ziplist-entries: 默认值128, 当元素个数小于这个值都时候使用压缩列表。否则使用跳跃表。
zset-max-ziplist-value:默认值64,当每个元素的字符串长度小于这个值的时候使用压缩列表,否则使用跳跃表。
满足这两个条件中的任意一个条件,就会转换到跳跃表。而转换到跳跃表之后,即便把元素删除,也不好回退到压缩列表。
3. 跳跃表
什么是跳跃表 ?
对于一个单链表来说, 即便链表中存储的数据是有序的, 我们想要随机查找一个数据,那么也只能从头到尾遍历链表节点, 这样查找的效率就会很低,是件复杂度为 O(n)
image如果我们想要提高其查找效率, 可以考虑在链表上建索引的方式。每2个节点提取一个节点到上一级,我们把抽出来的那一级叫作索引。
可以看出, 加了一层索引以后, 查找一个节点需要遍历的节点数少了, 也就是说查找效率提升了,同理可以再加一层索引。
从图中我们可以看出,查找效率又有提升。在例子中我们的数据很少,当有大量的数据时,我们可以增加多级索引,其查找效率可以得到明显提升。
像这种链表加多级索引的结构,就是跳跃表!
Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时, Redis就会使用跳跃表来作为有序集合健的底层实现。
这里我们需要思考一个问题——为什么元素数量比较多或者成员是比较长的字符串的时候Redis要使用跳跃表来实现?
从上面我们可以知道,跳跃表在链表的基础上增加了多级索引以提升查找的效率,但其是一个空间换时间的方案,必然会带来一个问题——索引是占内存的。原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值值和几个指针,并不需要存储对象,因此当节点本身比较大或者元素数量比较多的时候,其优势必然会被放大,而缺点则可以忽略
Redis中跳跃表的实现
Redis的跳跃表由zskiplistNode和skiplist两个结构定义,其中 zskiplistNode结构用于表示跳跃表节点,而 zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等
-
header:指向跳跃表的表头节点,通过这个指针程序定位表头节点的时间复杂度就为O(1)
-
tail:指向跳跃表的表尾节点,通过这个指针程序定位表尾节点的时间复杂度就为O(1)
-
level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内),通过这个属性可以再O(1)的时间复杂度内获取层高最好的节点的层数。
-
length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内),通过这个属性,程序可以再O(1)的时间复杂度内返回跳跃表的长度。
结构右方的是四个 zskiplistNode结构,该结构包含以下属性:
-
层(level):
节点中用1、2、L3等字样标记节点的各个层,L1代表第一层,L代表第二层,以此类推。
每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离(跨度越大、距离越远)。在上图中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
每次创建一个新跳跃表节点的时候,程序都根据幂次定律(powerlaw,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”。 -
后退(backward)指针:
节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。与前进指针所不同的是每个节点只有一个后退指针,因此每次只能后退一个节点。 -
分值(score):
各个节点中的1.0、2.0和3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。 -
成员对象(oj):
各个节点中的o1、o2和o3是节点所保存的成员对象。在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)
在比较的时候 如果分值相同则会比较保存的对象。
五、Redis常见问题
1. 缓存穿透、缓存击穿、缓存雪崩 如何解决?
缓存穿透
当缓存与DB中都不存在该数据时,如果此时发起恶意大量请求,会导致这个不存在的数据每次请求都会查询DB,导致DB压力过大,直接挂掉。
解决方案:
(1)对空值缓存,并设置较短的过期时间。
(2)设置访问白名单,使用布隆过滤器,分别通过多个哈希函数生成多个哈希值,然后将这些哈希值存到一个足够大的bitmap中,此时一个一定不存在的数据就会被这个bitmap拦截,从而减少了数据库的查询压力。
(3)业务过滤。
缓存击穿
某个热点key对应的数据存在,但在redis中过期,此时若有大量并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
解决方案:
(1)设置热点数据永远不过期。
(2)使用分布式锁。
缓存雪崩
缓存中大批量的数据集中过期,从而导致查询数据量巨大,引起数据库压力过大甚至down机。
解决方案:
(1)将缓存失效时间分散开
(2)如果缓存数据库是分布式部署,将热点数据均匀分布在不同缓存中。
(3)设置热点数据永远不过期。
2. Redis为什么这么快?
(1)内存存储,没有磁盘IO上的开销
(2)单线程实现,避免了多个线程之间线程切换和锁资源争用的开销
(3)优化的数据结构
(4)非阻塞IO,Redis使用多路复用IO技术,在poll,epool,kqueue选择最优IO实现