目 录CONTENT

文章目录

Redis 基础

小张的探险日记
2021-09-08 / 0 评论 / 0 点赞 / 702 阅读 / 10,260 字 / 正在检测是否收录...
温馨提示:
本文最后更新于 2021-12-16,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

1.Redis定位

大多数时候开发功能,我们第一时间想到的都是Mysql、Oracle 这种关系型数据库来储存数据。
关系型数据的特点:

1.二维的,它基于表格形式储存数据。
2.它的数据是结构化,所有的数据都需要按照它定义的结构来存储。
3.表和表之前有关联关系。
4.都可以通过SQl 来进行操作。
5.通过支持事务来达到 数据的一致性,保证数据的严谨性。

缺点:

1.水平扩展困难,只能向上扩展,不支持动态扩缩容,水平扩展需要复杂的技术支持,比如:分库分表。
2.表结构存储困难,存储数据格式收到限制🚫。
3.数据会持久化到磁盘,高并发场景下,磁盘压力大。

Redis 就不存在这种问题,Redis 是一种 NoSql 的数据库,同样类型的还有 MemcacheDb,NoSql 数据库还有很多:http://nosql-database.org

NoSql 优点:

1.没有结构化,很方便进行数据结构扩展
2.表和表之前没有关联,方便扩展
3.能够存储 图片、文件、视频 等二进制文件。
4.遵循Base 理论,Basically Availabel(基本可用);Soft-state;Eventually Consistent(最终一致性)
5.支持海量数据存储和高并发的读写。
6.支持分布式,能对数据进行分片处理,扩缩容简单。

除了这些NoSql 的特性外,redis 还提供了

1.丰富的数据类型,适用于各种场景;
2.单机和分布式提供支持;
3.持久化支持、过期策略支持;
4.高可用、可集群;
5.提供多种开发语言支持;

2.Redis的数据结构

redis 是基于Hashtable 来实现的,它有两层hash,外层的我们称为外层hash,内层的称为内层hash。

redis 是一种 key value 的数据库。

2.1 key 的数据编码

首先说一下 key 的数据类型,它是 string 类型,底层的数据编码是 SDS,
SDS 是redis 基于自己的应用场景,设计的一种支持动态扩容的字符串。

为什么不使用 c 语言的 string 呢?

c 语言的string 是基于 char[] 数组来实现的,不选它的原因:

  • 1.char[] 需要预先分配内存,可能造成内存溢出;
  • 2.char[] 获取长度需要遍历数组,性能消耗大;
  • 3.char[] 的结束是 以 \0 来判断的,这也导致 当它存储二进制数据的时候会造成数据不安全;
  • 4.长度变更需要对char[] 重新做内存分配;

为什么使用SDS?

  • 1.sds 支持内存预分配和内存的惰性释放,内存的操作都不是及时的,可以防止内存溢出和提高性能;
  • 2.sds 定义了 len 属性,可以直接获取长度;
  • 3.sds 遍历的结束 可以以 len 属性为标准。

3.Redis 的 RedisObject

redis 的value 都是以 RedisObject 对象进行存储。

3.1 string 类型

数据编码:

string 数据类型 的底层数据编码方式有三种:

  • 1.int 存储值为 int 类型时
  • 2.embstr 存储小于 44个字节的字符串时,大于或产生变动时数据编码会升级为 raw;
  • 3.raw 储存大于 44个字节的字符串;

应用场景:
1.缓存
2.计数
3.分布式锁
4.全局id
5.限流
6.位图统计

数据编码的升级时不可逆的

3.2 hash 类型

数据编码:
hash 数据类型的底层数据编码方式有两种:

  • 1.zipList
  • 2.hashtable

ziplist 是一种特殊的双向链表结构,它不存储 上一个节点和下一个节点的指针,只存储 上一个节点的 长度和当前节点的长度,是一种以时间换空间的数据接口,适用于小数据量的场景。

当key value 字符串长度都小于等于 64 byte 且 key value 小于512 对时使用。

当数据不符合上诉要求时,数据编码结构会升级为 hashtable ,这个是我们常说的 内层 hash,它是一个 数组 + 链表 的数据结构。

dict 对象中定义了 两个hash 表,一张表用于使用,另一个用于扩容。

hash 表 的第一层数据结构是个数组,数组下挂一个链表

当一个数据push 进来,首先对数据进行hash,放在对应 数组的链表下,这里的链表使用的是单向链表使用next指针把各个节点连接起来,因为单向链表没有记录链尾的指针,所以新节点都是放在首位,这样速度也最快

如果出现 hash 碰撞就会出现 一个链表下出现多个节点的情况, 理论上,一个链表挂一个节点时 hash 表的性能是最优的,例如:hash 表下链表 都挂载到 5个节点时, hash 表就会退化为 链表, 那么就需要扩容(rehash)了,

image.png
hash表扩容:

第一步:给ht[1] 分配内存空间,空间大小通过 ht[0] 的节点数计算得来。
第二步:将ht[0] 的节点 重新 hash 后挂载到 ht[1] 上
第三步:将ht[0] 变为 ht[1] ht[1] 变为 ht[0],为下次 rehash 做准备,这一步 熟悉 JVM 的 肯定能看出一些端倪,是不是和 年轻带的 s0 和 s1 很像。

上面这种只适用于数据量比较小的情况,实际生产情况下数据量都是比较大,做不到一次性完成数据 从ht[0] 复制到 ht[1],所以就有了 渐进式rehash:

第一步:为 ht[1] 分配内存空间,让 字典同时拥有两个 hash 表。
第二步:创建一个计数器rehashIndex 并赋值为 0,表示rehash开始。
第三步:在rehash 期间中,每次crud 都会额外处理 把 ht[0]的数据复制到ht[1],如果客户端没有操作,Redis就会使用定时任务对数据主动搬迁.。
第四步:rehash 完成后 把rehashIndex 赋值为 -1 表示rehash 结束

这种分而治之的思想避免了 集中式处理带来的大量计算量 和 对其它操作带来干扰。
rehash 期间 所有新添加的数据 都会进入ht[1],查询、修改、删除 都会先在 ht[0]、ht[1] 中 一次查找。

那么究竟什么时候出发扩容呢?
这个redis 和 java 的HashMap 的设计类似,设置有负载因子 默认5,
当 使用比例达到 1:5,

负载因子=哈希表已保存节点数量 / 哈希表大小

load_factor = ht[0].used / ht[0].size

也就是hash表的链表下 挂满5个节点退化为 链表时触发扩容

应用场景:
1.购物车
2.对象存储

3.3 list 类型

实现原理:
原来储存方式 是 小数据量 ziplist 存储,不够后升级为 linklist。
但是考虑到 链表的附加空间较大,linklist 的pre 和 next 指针需要 16个字节(在 64位系统下),每个节点的内存都是单独分配的 不是连续的,这会加剧 内存的碎片化,影响内存的管理效率。

现在底层数据编码为 quicklist = (ziplist + linklist) 的混合体。

quicklist 实际上是 将 linklist 按段切分,每一段使用 ziplist 存储,这样的好处是:

1.减少了 内存的碎片化,因为每一段按ziplist 方式存储,它不依赖于指针,内存空间是连续的。
2.大量减少了pre 和 next 的指针空间消耗。

image.png

应用场景:
1.消息队列
2.时间线

3.4 set 类型

是一种存储 string 类型的无序集合。
image.png

数据编码:

当全存储是 int 数据时,使用 inset 数据编码存储。
当不满足第一种条件时或元素超过512个时,使用hashtable 存储。

应用场景:
1.关注
2.粉丝
3.点赞
4.等

3.5 zset

有序的 string 集合,根据 score 排序。

数据编码:

数据量少时使用ziplist(压缩表),超过阀值则会产生数据结构升级
升级条件:
1.元素数量小于128个
2.所有元素的长度都小于64字节

skiplist(跳跃表)
当前节点对象可以有两个 指针,有一个指针可以指向 下下级节点,或下下下级节点,这个取决于算法,这种有点类似于二分法,减少对比的次数,快速获得数据范围得到最终的值。

image.png

应用场景;
1.排行榜
2.热门文章

3.6 BitMaps

位图数据结构,是 string 定义的位操作,一个字节由 8个二进制位 组成

image.png

用户一年(假设365天)打卡案例:
截屏20210914 下午9.54.26.png

应用场景:
1.用户访问统计
2.用户打卡
3.在线用户统计

3.7 Hyperloglogs

不太准确的基数统计法。

image.png

应用场景:
1.大数据量用户日活、月活
2.大数据量网站 UV

3.8 Streams

支持多播的可持久化的消息队列,用户实现发布订阅功能,类似 kafka。

3.9 数据结构总结

对象储存数据结构
stringint、embstr、raw
hashziplist、hashtable
listquicklist
setinset、hashtable
zsetziplist、skiplist

4.0 发布订阅模式

在讨论这个模式之前,其实 list 数据结构也可以实现 消息队列的功能,那么 发布订阅模式有什么不一样呢?
list 实现的队列 通过 lpush rpush lpop rpop 实现了队列的基本功能,问题点:

  • 1.消费者需要 不停调用 lpop 或 rpop 来获取消息。
  • 2.当消息的生产 大于 消息的消费时,内存的空间占用会很大。
  • 3.消息只能被一个 消费者消费。

list 也有 一个blpop 带阻塞的 命令,没有元素时会被阻塞。

发布订阅模式可以 有很多频道 和 很多订阅者,但是消息不回持久化,且订阅者只能收到 订阅之后的消息。

发布订阅相关命令:
publish 队列名称 内容 ---不支持一次发布多条消息
subscribe 队列名称

截屏20210908 下午11.09.28.png

还可以按规则订阅频道

支持?和*占位符。?代表一个字符,*代表 0 个或者多个字符。

案例:
c1 订阅 new*
c2 订阅 new-sport
c3 订阅 new-food

image.png

image.png

c4 发布 频道 new-a 内容:测试a
只有 c1 能收到
截屏20210908 下午11.23.04.png
c4 发布 频道 new-sport 内容:测试b
只有 c2和c1 能收到
截屏20210908 下午11.24.22.png
c4 发布 频道 new-food 内容:测试c
只有 c3和c1 能收到
截屏20210908 下午11.25.48.png

4.1 Redis 事务

文档:
https://redis.io/topics/transactions/ http://redisdoc.com/topic/transaction.html

相关命令:
multi 开启事务
exec 执行事务
discard 放弃事务
watch 监视

测试场景:
xiaoming 有 1000 元
xiaohei 有 500 元
xiaoming 转账 给 xiaohei 500 元

截屏20210908 下午11.47.32.png

watch 使用场景,它为 Redis 的事物提供了 CAS 乐观锁🔒机制,(Check and set/Compare and swap), 多个线程操作同一个数据时,会跟原值做对比,没有修改才会更新。

截屏20210908 下午11.55.33.png

exec 执行可能会遇到的问题:
1.语法错误,事务会直接拒绝执行。
2.运行时错误,事务不会回滚,这一点上不符合 事务的特性,但是 redis 的开发作者 也明确表示,这种问题是 程序员 开发不严谨导致的,开发应该在测试环境把这个问题处理完善,不处理。

4.2 Lua 脚本

lua 脚本类似 存储过程,是一种轻量级的脚本化语言,优势:

1.天生具有原子性。
2.可以编写复杂逻辑,并存储在服务器,减少网络传输

语法格式:

redis> eval lua-script key-num [key1 key2 key3 ....] [value1 value2 value3 ....]
  • eval 代表执行 Lua 语言的命令。
  • lua-script 代表 Lua 语言脚本内容。
  • key-num 表示参数中有多少个 key,需要注意的是 Redis 中 key 是从 1 开始的,如果没有 key 的参数,那么写 0。
  • [key1 key2 key3…]是 key 作为参数传递给 Lua 语言,也可以不填,但是需要和 key-num 的个数对应起来。
  • [value1 value2 value3 ….]这些参数传递给 Lua 语言,它们是可填可不填的。

执行操作案例:

127.0.0.1:6379> eval "return 'hello world'" 0
"hello world"
127.0.0.1:6379> eval "redis.call('set','zpr','123')" 0
(nil)
127.0.0.1:6379> get zpr
"123"
127.0.0.1:6379> eval "redis.call('set','zpr111','123')" 0
(nil)
127.0.0.1:6379> get zpr111
"123"
127.0.0.1:6379> eval "redis.call('set',KEYS[1],ARGV[1])" 1 sf 123
(nil)
127.0.0.1:6379> get sf
"123"

但这种在命令行或是代码里面写 lua 脚本的方式,不方便管理,最好能够写成文件 方便调用。
命令格式:
redis-cli --eval xxxx.lua

vi test.lua

redis.call('set','app','testApp')
return redis.call('get','app')

执行结果:

MacBook-Pro redisScript % redis-cli --eval test.lua 
"testApp"

案例:对IP 进行限流
ip_limit.lua -- IP 限流,对某个 IP 频率进行限制 ,6 秒钟访问 10 次
当 连续调用脚本10次后,返回 0 表示 ,限制🚫访问。

local num=redis.call('incr',KEYS[1]) 
if tonumber(num)==1 then 			 
	redis.call('expire',KEYS[1],ARGV[1]) 
	return 1 
elseif tonumber(num)>tonumber(ARGV[2]) then 
	return 0 
else
	return 1 
end

执行 lua 脚本

redis-cli --eval "ip_limit.lua" app_limit:192.168.1.113 , 6 10

出现 0 拒绝访问。
image.png

这里有一个小问题,因为lua 脚本可能会很大,每一次要传给服务端,会消耗带宽和性能。

redis 提供了 script load 和 evalsha 命令 可以缓存lua 脚本

自乘案例:
实现 对 某个 key value为数字 ,value 做乘法

缓存 脚本命令,需要在redis-cli 的命令行下执行

script load 'local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal'

测试效果

127.0.0.1:6379> set app 10
OK
127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 app 10
(integer) 100
127.0.0.1:6379> 

redis lua 脚本超时问题

因为 redis 时单线程的,所以 lua 脚本在执行时,其它工作都会停下,如果 lua 脚本出现死循环,会导致 redis 无法提供服务,

可以在配置文件中配置,默认 超时时间为 5s

lua-time-limit 5000(redis.conf 配置文件中)

这只能让 redis 接收命令,并不会支持,这是为了保证 lua 脚本的原子性。

Redis 提供一个 script kill 的命令来中止脚本的执行。新开一个客户端:

script kill

如果当前执行的 Lua 脚本对 Redis 的数据进行了修改(SET、DEL 等),那么通过 script kill 命令是不能终止脚本运行的。

127.0.0.1:6379> eval "redis.call('set','gupao','666') while true do end" 0 

因为要保证脚本运行的原子性,如果脚本执行了一部分终止,那就违背了脚本原子 性的要求。最终要保证脚本要么都执行,要么都不执行。

127.0.0.1:6379> script kill (error) UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script 12

遇到这种情况,只能通过 shutdown nosave 命令来强行终止 redis。 shutdown nosave 和 shutdown 的区别在于 shutdown nosave 不会进行持久化 操作,意味着发生在上一次快照后的数据库修改都会丢失。

总结:Lua 不适用存在耗时操作的场景。

4.3 Redis 为什么怎么快?

1.基于内存的数据库
2.单线程(注意这里是 请求单线程,6.0版本以后的多线程指的是IO多线程-性能翻一倍)
优势:
1.多线程存在线程上下文切换,单线程不存在。
2.单线程不需要处理 锁,线程竞争等问题。

3.多路复用(基于 系统底层的多路复用支持,实现Redis 的多路复用程序)
多路复用,指 多个socket 请求 复用 一个线程(或多个线程) 监听文件描述符。
image.png

Redis 6.0 后支持多线程,但是默认不开启,配置核心数要小于 CPU 数,小于 4核CPu 或 大于8核CPU 不建议使用。 多线程模式的推出主要解决 大型项目的业务量需求,常规项目不推荐使用,Redis 单线程的性能已经足够优秀。

严格来说 Redis 4.0 后就已经不是单线程了,如:大Key 删除、无用🔗释放、清理脏数据 等操作 都是后台线程操作。

4.4 Redis 的过期策略

1.定时过期
每设置一个key 的过期,就生成一个定时器扫描。 非常消耗CPU资源。对内存友好。

2.惰性过期
每次 访问一个key,判断是否过期,过期则删除,消费内存,对CPU 友好。

3.定期过期
每隔一段时间 执行一次过期操作。

4.5 Redis 的淘汰策略

Redis 默认提供了很多种淘汰机制:

# volatile-lru -> Evict using approximated LRU among the keys with an expire set. 
# allkeys-lru -> Evict any key using approximated LRU. 
# volatile-lfu -> Evict using approximated LFU among the keys with an expire set. 
# allkeys-lfu -> Evict any key using approximated LFU. 
# volatile-random -> Remove a random key among the ones with an expire set. 
# allkeys-random -> Remove a random key, any key. 
# volatile-ttl -> Remove the key with the nearest expire time (minor TTL) # noeviction -> Don't evict anything, just return an error on write operations.

lru 和 lfu 分别代表了不同的算法:
lru: 最近最少使用-按时间戳计算
lfu: 最不常用-按时间和使用频率计算(含频率衰减)

volatile-lru : 根据 LRU 算法删除设置了超时属性(expire)的键,直到腾出足够内存为止。如果没有 可删除的键对象,回退到 noeviction 策略。

allkeys-lru 根据 LRU 算法删除键,不管数据有没有设置超时属性,直到腾出足够内存为止。 volatile-lfu 在带有过期时间的键中选择最不常用的。

allkeys-lfu 在所有的键中选择最不常用的,不管数据有没有设置超时属性。

volatile-random 在带有过期时间的键中随机选择。 allkeys-random 随机删除所有键,直到腾出足够内存为止。 volatile-ttl 根据键值对象的 ttl 属性,删除最近将要过期数据。如果没有,回退到 noeviction 策略。

noeviction 默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error)OOM command not allowed when used memory,此时 Redis 只响应读操作。

建议使用 valatile-lru: 从设置了 过期时间的集合中删除 最近不常用的。

LRU 算法是基于 RedisObject 对象的 lru属性来计算的,不需要额外的数据结构来处理,通过获取全局的 时间戳(server.clock) 来每次访问时 更新 lru 字段,计算与server.clock 的差值来判断(差值越大,对象越久没有更新),但是因为lru 只有24位只够表示 194天,可能出现 lru 比 server.clock 大的情况,这时候就不是通过相减来计算而是通过相加来计算,配合 设置的采样值(默认5)获取随机数据,淘汰最小的lru的数据,算法采样值越大精准度越高,但相应消耗的性能越大。
3.0版本加入了pool 进一步优化LRU ,第一次机选随机数时初始化一个pool,放入随机选择的 16 个数据(默认16个),之后放入的数据 lru 值必须比 其中最小的数据小,当满了之后把最大的数据 提出,每次淘汰时 淘汰最小的数据,进一步优化了性能。

lru 算法本质上是基于概率的猜测,Redis 通过这种方式模拟lru算法,在某些条件下 可以达到lru 算法理想的状态。官方测试结果如下:

截屏20210917 下午3.38.13.png

首先加入n个key并顺序访问这n个key,之后加入n/2个key(假设redis中只能保存n个key,于是会有n/2个key被逐出).上图中浅灰色为被逐出的key,淡蓝色是新增加的key,灰色的为最近被访问的key(即不会被lru逐出的key)

Redis3.0增加了pool后效果好一些(右上角的图)。当Redis3.0增加了pool并且将采样key增加到10个后,基本等同于理想中的LRU(虽然还是有一点差距)

LFU 算法也是基于 RedisObject 对象的 lru 属性来计算,高16位存储访问时间,低8位存储访问频率,对象被访问时,lru 会持续更新,但不是每次更新,通过设置的增长速率(lfu-log-factor) 如5,那么访问5次增加一次,减少性能消耗,同时为了解决长时间没有访问,对象热度也应该对应衰减的问题,可以设置对应的衰减因子
lfu-decay-time(分钟)N分钟频率减少N次。

4.6 Redis 的持久化

RDB:默认开启,定时存储写入磁盘,生成快照文件 rdb,Redis 重启时会通过加载 rdb 文件来 重新加载数据。

一、优势
1.RDB 是一个非常紧凑(compact)的文件,它保存了 redis 在某个时间点上的数据 集。这种文件非常适合用于进行备份和灾难恢复。
2.生成 RDB 文件的时候,redis 主进程会 fork()一个子进程来处理所有保存工作,主 进程不需要进行任何磁盘 IO 操作。
3.RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。
二、劣势
1、RDB 方式数据没办法做到实时持久化/秒级持久化。因为 bgsave 每次运行都要 执行 fork 操作创建子进程,频繁执行成本过高。
2、在一定间隔时间做一次备份,所以如果 redis 意外 down 掉的话,就会丢失最后 一次快照之后的所有修改(数据有丢失)。 如果数据相对来说比较重要,希望将损失降到最小,则可以使用 AOF 方式进行持久 化。

AOF:默认不开启,以日志的形式追加到文件中,Redis 重启时 会把 “写操作” 相关的记录 从头到尾执行一次。

配置redis.conf

appendonly no //配置为 yes 启用
appendfilename "appendonly.aof"

由于操作系统的缓存机制,数据并不是实时写入磁盘,而是进入了硬盘缓存,那什么时候把 数据写入磁盘呢? 提供了三种策略:
appendfsync everysec/no/always

1.no 不执行fsync 操作,由系统执行同步操作,不安全。
2.always 每次执行都执行异步写入操作。
3.everysec(推荐)每秒执行一次,保证性能于安全性,但是可能丢失这一秒的数据。

但是这样还是有问题,如果 aof 的文件逐渐变得很大怎么办?

redis 提供了 bgrewirteof 命令来重写 aof 的指令集,重新读去 key value 数据,用一条命令 代替多条命令,覆盖原来的 aof 文件。可以设置 覆盖的阀值。

1、AOF 持久化的方法提供了多种的同步频率,即使使用默认的同步频率每秒同步 一次,Redis 最多也就丢失 1 秒的数据而已。
缺点:
1、对于具有相同数据的的 Redis,AOF 文件通常会比 RDF 文件体积更大(RDB 存的是数据快照)。
2、虽然 AOF 提供了多种同步的频率,默认情况下,每秒同步一次的频率也具有较 高的性能。在高并发的情况下,RDB 比 AOF 具好更好的性能保证

两种方案比较:

那么对于 AOF 和 RDB 两种持久化方式,我们应该如何选择呢?
如果可以忍受一小段时间内数据的丢失,毫无疑问使用 RDB 是最好的,定时生成 RDB 快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要 比 AOF 恢复的速度要快。 否则就使用 AOF 重写。但是一般情况下建议不要单独使用某一种持久化机制,而 是应该两种一起用,在这种情况下,当 redis 重启的时候会优先载入 AOF 文件来恢复原始 的数据,因为在通常情况下 AOF 文件保存的数据集要比 RDB 文件保存的数据集要完整。

0

评论区