Redis深度历险 学习笔记

一、Redis基本数据结构类型

1、string

实现方式是数组,类似于java的ArrayList。单个字符串足底啊512M,字符串需要扩容时:小于1M扩容一倍;大于1M,最多只扩容1M。

使用场景:
1)键值对:set,get

127.0.0.1:6379> set aa 11
OK
127.0.0.1:6379> get aa
"11"

2)批量键值对:mset, mget

127.0.0.1:6379> get aa
"11"
127.0.0.1:6379> set bb 22
OK
127.0.0.1:6379> mget aa bb
1) "11"
2) "22"
127.0.0.1:6379> mset a1 aa a2 bb a3 cc
OK
127.0.0.1:6379> mget a1 a2 a3
1) "aa"
2) "bb"
3) "cc"

3)设置过期时间

127.0.0.1:6379> mget a1 a2 a3
1) "aa"
2) "bb"
3) "cc"
127.0.0.1:6379> expire a1 3
(integer) 1
127.0.0.1:6379> mget a1 a2 a3
1) (nil)
2) "bb"
3) "cc"

4)计数:

127.0.0.1:6379> set age 30
OK
127.0.0.1:6379> incr age
(integer) 31
127.0.0.1:6379> get age
"31"

说明:字符串由字节数组组成,每个字节由8个bit组成,这就是bitmap"位图”数据结构。关于位图数据结构后面详细讲。

2、list:列表

实现方式是链表,类似于java的LinkedList。所以他的插入、删除快,时间复杂度O(1);但查询慢,时间复杂度O(n)。
当链表弹出最后一个元素后,链表会被删除,内存被回收。

redis的list常用来做 异步队列使用:

1)队列:

127.0.0.1:6379> rpush books java go python
(integer) 3
127.0.0.1:6379> llen books
(integer) 3
127.0.0.1:6379> lpop books
"java"
127.0.0.1:6379> lpop books
"go"
127.0.0.1:6379> lpop books
"python"
127.0.0.1:6379> lpop books
(nil)

2)栈:

127.0.0.1:6379> rpush books java python go
(integer) 3
127.0.0.1:6379> rpop books
"go"
127.0.0.1:6379> rpop books
"python"
127.0.0.1:6379> rpop books
"java"
127.0.0.1:6379> rpop books
(nil)

3)慢操作

  • lindex:相当于java LinkedList的get(int index)方法,要逐个元素遍历,时间复杂度O(n)
  • ltrim:根据两个参数start_index和end_index定义了一个区间,把区间内的值保留,区间外的值删除,时间复杂度O(n)
  • lrange:获取区间内的值,时间复杂度O(n)
127.0.0.1:6379> rpush books python java go
(integer) 3
127.0.0.1:6379> lindex books 1
"java"
127.0.0.1:6379> lrange books 0 -1
1) "python"
2) "java"
3) "go"
127.0.0.1:6379> ltrim books 1 -1
OK
127.0.0.1:6379> lrange books 0 -1
1) "java"
2) "go"

快速列表

在这里插入图片描述
实际上,redis对list的底层存储并不是简单的LinkedList, 而是称为快速链表 quicklist 的结构:
1)在列表元素较少时,会使用一块连续的内存存储,这个结构是 ziplist,即压缩列表
2)在列表元素较多时,会改成 quicklist,因为普通链表需要附加指针的空间太大,且内存碎片多,浪费空间。
所以redis把链表和ziplist结合起来,组成quicklist,也就是将多个ziplist使用双向指针串起来使用。这样既满足了快速插入删除的性能,又不会出现太大的空间冗余。

3、hash(字典)

字典相当于java的HashMap。内部实现同java,数组+链表。
不同的是,redis的字典的值只能是字符串,另外,他们rehash的方式不一样,因为java的HashMap在字典很大时,rehash是个耗时的操作,需要一次全部rehash。Redis为了高性能,采用了渐进式rehash策略。
在这里插入图片描述
渐进式rehash在rehash的同时,保留新旧两个hash结构,查询时,会同时查询两个hash结构,然后在后续的定时任务中以及hash的子指令中,循序渐进地将旧hash的内容一点点迁移到新的hash结构中。
当hash移除最后一个元素之后,该数据结构会被自动删除,内存被回收。

127.0.0.1:6379> hset books java "thinking in java"
(integer) 1
127.0.0.1:6379> hset books go "concurrency in go"
(integer) 1
127.0.0.1:6379> hset books python "python cookbook"
(integer) 1
127.0.0.1:6379> hgetall books
1) "java"
2) "thinking in java"
3) "go"
4) "concurrency in go"
5) "python"
6) "python cookbook"
127.0.0.1:6379> hlen books
(integer) 3
127.0.0.1:6379> hget books java
"thinking in java"
127.0.0.1:6379> hget books go
"concurrency in go"
127.0.0.1:6379> hget books python
"python cookbook"
127.0.0.1:6379> 
127.0.0.1:6379> hset books go "learning go programming"
(integer) 0
127.0.0.1:6379> hget books go
"learning go programming"

也可以对Hash的key做加法计数:

127.0.0.1:6379> hset rose age 20
(integer) 1
127.0.0.1:6379> hincrby rose age 1
(integer) 21
127.0.0.1:6379> hget rose age
"21"

4、set:无序集合

在Redis中,set就相当于java的HashSet。他相当于特殊的Hash, 所有value都是NULL。
set可用来做去重功能

127.0.0.1:6379> sadd books python
(integer) 1
127.0.0.1:6379> sadd books java go
(integer) 2
127.0.0.1:6379> smembers books
1) "python"
2) "java"
3) "go"
127.0.0.1:6379> sismember books java
(integer) 1
127.0.0.1:6379> sismember books rust
(integer) 0
127.0.0.1:6379> scard books
(integer) 3
127.0.0.1:6379> spop books
"java"
127.0.0.1:6379> scard books
(integer) 2

5、zset:可对value排序集合

zset是面试中考的频率最高的数据结构,他类似于java的SortedSet和HashMap的结合体。他一方面是set, 另一方面他可以给每个value赋予一个score,代表这个value的排序权重。他的内部实现是一种叫做“跳表”的数据结构。

zset使用示例:value是书名,score是评分,可以通过成绩进行排名:
zadd key score value

127.0.0.1:6379> zadd books 9.0 "think in java"
(integer) 1
127.0.0.1:6379> zadd books 8.9 "java concurrency"
(integer) 1
127.0.0.1:6379> zadd books 8.6 "java cookbook"
(integer) 1
127.0.0.1:6379> zrange books 0 -1
1) "java cookbook"
2) "java concurrency"
3) "think in java"
127.0.0.1:6379> zrevrange books 0 -1
1) "think in java"
2) "java concurrency"
3) "java cookbook"
127.0.0.1:6379> zcard books
(integer) 3
127.0.0.1:6379> zscore books "java concurrency"
"8.9000000000000004"
127.0.0.1:6379> zrank books "java concurrency"
(integer) 1
127.0.0.1:6379> zrangebyscore books 0 8.91
1) "java cookbook"
2) "java concurrency"
127.0.0.1:6379> zrem books "java concurrency"
(integer) 1
127.0.0.1:6379> zrange books 0 -1
1) "java cookbook"
2) "think in java"

容器行数据结构的通用规则

1、create if not exists
2、drop if not elements

二、Redis的各种应用场景

应用1、分布式锁

分布式锁本质上就是在redis里占一个“茅坑”,当别的进程也要来占时,发现已经有人蹲在那里了,只好放弃或者稍后再试。

占坑一般使用setnx(set if not exists),只允许一个客户端占坑,用完了,再调用del指令释放茅坑。

避免死锁

为了避免死锁,让setnx和expire指令成为原子操作,在redis 2.8版本中,加入了set治理的扩展参数

可能产生死锁的方式:

setnx mykey 111
expire mykey 30

此时如果执行expire指令前,程序崩溃了,那么其他进程就再无法获取锁。

新的方式:

127.0.0.1:6379> set mykey 11 ex 20 nx
OK

这样把setnx和expire合成到了同一个指令里,变成了原子操作。

超时问题

对于分布式锁指令“set mykey 11 ex 20 nx”,假设执行set操作时间在过期时间“20s”内没有完成,这时,过期时间到了,第二个线程获取到了这把锁,然后执行逻辑过程中,第一个指令执行完了,这时它会释放到第二个线程正在执行的锁,然后第三个线程可能会获取到锁,但此时第二个线程还没有执行完:这就是“超时问题”。

为了避免这个问题,redis的分布式锁,不要执行较长的时间任务,万一出现了问题,就要人工介入解决了。要彻底解决这个问题,就需要Lua脚本介入了。

应用2、延时队列

redis相比于kafka当消息队列,使用更简单。

redis的list数据结构用来做异步的消息队列,使用rpush/lpush操作入队,用lpop, rpop操作出队列。

如果队列空了怎么办

如果队列空了,客户端就会陷入pop的死循环,不停pop,空轮训,不但拉高了客户端的CPU,redis的qps也会被拉高,如果这样空轮训的客户端有几十个,redis的慢查就会多出很多。

通常我们使用sleep来解决,如随眠1s。但是,睡眠会导致消息延迟增大,有没有更好的解决方案呢?有,就是blpop/brpop
这两个指令的b代表的是blocking,也就是阻塞读。
阻塞读,在队列没有数据的时候,会立即进入休眠状态;一旦数据道理,就会立即醒过来。消息的言之几乎为零。

空闲连接自动端口

上述方案还有问题:就是空闲连接的问题,如果线程一直阻塞在哪,redis客户端就成了空闲连接,闲置过久,服务器一般会主动断开连接,减少闲置的资源占用,这时blpop/brpop会抛出异常来。所以编写客户端的消费者要小心,注意捕获异常,还要重试。

锁冲突处理

对于分布式锁,加入处理请求时获取锁失败怎么办?有下面三种处理策略:
1)直接抛出异常,通知用户稍后重试
2)sleep一会再重试
3)将请求转移到延时队列,过一会再重试。

延时队列的实现

延时队列,可通过redis的zset来实现。我们将消息序列化为一个字符串作为zset的value,这个消息的到期处理时间作为score, 然后用多个线程轮训zset获取到期的任务进行处理,这里设计多个线程是为了保证可用性,万一挂了一个线程,还有其他线程继续处理,但因为有多个线程,需要考虑并发场景,确保任务不被重复执行。

应用3、位图

应用场景:假设用户要记录365天每天的签到记录,签到是1,没有签到是0,如果用key/value, 需要365个记录。当用户上亿的时候,可以用位图。

redis提供了位图的数据结构,这样每天签到记录只占一个byte,这样就大大结节省了存储空间:
在这里插入图片描述
位图不是特殊的数据结构,实际上就是字符串,也就是byte数组。我们可以使用get/set直接获取和设置整个位图的内容,也可以使用位图操作getbit/setbit等把byte数组看成“位数组”来处理。

应用4、HyperLogLog

如果要统计PV(访问数),就很容易,给每个网页用一个Redis计数器就行了,这个计数器的key后缀加上当天的日期。这样每来一个请求,incrby一次。

怎样统计每个网页每天的UV(访问数去重)数据呢?
UV要求同一个用户每天的多次请求也只计数一次,这就要求每个网页请求都带上userId。
对于排查,可以用set, 但如果页面访问量非常大, set中的元素将会非常大的,例如有上千万用户,这样用set就非常浪费空间。这里,Redis提供了HyperLogLog数据结构,用于解决这种统计问题。HyperLogLog提供不精确的去重计数方案,标准误差是0.81%,这样的精确度已经可以满足上面的UV统计需求了。

使用方法:
HyperLogLog提供了两个指令pfadd,pfcount,一个增加计数,一个获取计数。

127.0.0.1:6379> pfadd c user1
(integer) 1
127.0.0.1:6379> pfcount c
(integer) 1
127.0.0.1:6379> pfadd c user2
(integer) 1
127.0.0.1:6379> pfcount c
(integer) 2
127.0.0.1:6379> pfadd c user3 user4 user5
(integer) 1
127.0.0.1:6379> pfcount c
(integer) 5

但高并发跑,就会有稍许的误差,且有去重功能。

pf 是什么意思呢?他是HyperLogLog这个数据结构的发明人Philippe Flajolet首字母缩写。
redis对HpyerLogLog的存储进行了优化,采用了稀疏矩阵存储。

HyperLogLog实现原理

在这里插入图片描述

应用5、布隆过滤器

我们在使用新闻客户端看新闻时,他会给我们不停的推送内容,怎样过滤掉已经推送过的内容呢?
用户看过的历史记录存在关系数据库里,去重就需要频繁地去数据库中做exists查询,并发高时数据库扛不住。

这种场景适合用布隆过滤器。

布隆过滤器可以理解为不精确的set:当布隆过滤器说某个值不存在时,一定不存在;但说存在时,却不一定。

在redis4.0版本时,提供了布隆过滤器,作为一个插件加载到Redis Server中。

应用6、简单限流

简单的限流策略:单位时间周期内某个行为只能执行n次。
redis怎样做呢?可以用zset的score来圈出时间窗口来,我们只需要圈出这个时间窗口,在窗口之外的数据都可以砍掉,那么value填什么呢?这里只需要保证他的唯一即可,可以用毫秒时间戳,因为用uuid太费时间了。

应用7、漏斗限流

Redis4.0 提供了一个限流的Redis模块,叫做redis-cell,使用了漏斗算法,并提供了原子的限流指令。

cl.throttle reply 15 30 60

表示每60秒最多30次,一开始可以先做15次,然后才开始漏斗的限制。

应用8、GeoHash

Redis 3.2 版本增加了地理位置GEO模块,可以使用redis来实现“附近的人”,“附近的餐厅”这样的功能。

GeoHash算法将二维的经纬度数据映射到了一维的整数,这样所有的元素都讲挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间的距离也会很接近。

redis提供的GEO指令有6个。使用时,我们要知道,他只是一个普通的zset结构

录入:

127.0.0.1:6379> geoadd company 116.48105 39.996794 juejin
(integer) 1
127.0.0.1:6379> geoadd company 116.514203 39.905409 ireader
(integer) 1
127.0.0.1:6379> geoadd company 116.489033 40.007669 meituan
(integer) 1
127.0.0.1:6379> geoadd company 116.562108 39.787602 jd 116.334255 40.027400 xiaomi
(integer) 2

查找距离:

127.0.0.1:6379> geodist company juejin ireader km
"10.5501"
127.0.0.1:6379> geodist company juejin meituan km
"1.3878"
127.0.0.1:6379> geodist company juejin jd km
"24.2739"
127.0.0.1:6379> geodist company juejin xiaomi km
"12.9606"
127.0.0.1:6379> geodist company juejin juejin km
"0.0000"

可以看出,掘金离美团最近。

获取元素位置:

127.0.0.1:6379> geopos company juejin
1) 1) "116.48104995489120483"
   2) "39.99679348858259686"

获取元素hash值:

127.0.0.1:6379> geohash company ireader
1) "wx4g52e1ce0"

根据这个值可以打开地图,定位到具体的位置:
在这里插入图片描述
附近的公司:

127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 asc
1) "ireader"
2) "juejin"
3) "meituan"

根据坐标值查附近的公司:

127.0.0.1:6379> georadius company 116.514202 39.905409 20 km withdist count 3 asc
1) 1) "ireader"
   2) "0.0000"
2) 1) "juejin"
   2) "10.5501"
3) 1) "meituan"
   2) "11.5748"

应用9、Scan

当需要从redis实例中成千上万的key中找出特定前缀的key的列表来手动处理数据,可能是修改他的值,也可能是删除这个key,那么,怎样从海量的key中找出满足特定前缀的key列表呢?

redis提供了简单粗暴的key*指令, 例如:

127.0.0.1:6379> set codehole1 a
OK
127.0.0.1:6379> set codehole2 b
OK
127.0.0.1:6379> set codehole3 c
OK
127.0.0.1:6379> set code1hole a
OK
127.0.0.1:6379> set code2hole b
OK
127.0.0.1:6379> set code3hole b
OK
127.0.0.1:6379> keys *
 1) "bb"
 2) "rose"
 3) "a2"
 4) "codehole2"
 5) "age"
 6) "code2hole"
 7) "Jack"
 8) "c"
 9) "aa"
10) "a3"
11) "codehole3"
12) "code1hole"
13) "books"
14) "code3hole"
15) "qq"
16) "company"
17) "codehole1"
127.0.0.1:6379> keys codehole*
1) "codehole2"
2) "codehole3"
3) "codehole1"
127.0.0.1:6379> keys code*hole
1) "code2hole"
2) "code1hole"
3) "code3hole"

这样的指令简单粗暴,一次性返回所有满足条件的key,很可能key非常多,性能不好,时间复杂度O(n),可能会导致redis卡顿,甚至会block其他redis线程请求(redis是单线程)

redis为了解决这个问题,在2.8版本中加入了 scan 命令。相比keys,有如下特点:
1)时间复杂度虽然也为O(n),但他是通过游标分步进行的,不会阻塞线程
2)提供Limit参数,可以控制每次返回结果的最大条数
3)也支持模式匹配功能
4)返回的结果可能会重复:这点非常重要,需要客户端自己去重
5)遍历过程中如果数据有修改,修改后的数据能否遍历到是不确定的
6)单次返回的结果是空,并不意味着遍历节省,而是要看返回的游标值是否为0

127.0.0.1:6379> scan 0 match code*hole count 200
1) "0"
2) 1) "code2hole"
   2) "code1hole"
   3) "code3hole"

字典的结构

redis中的所有key,都存储在一个很大的字典中,这个字典和java的HashMap类似,是数组+链表结构,第一维数组的大小是2^n(n>=0),扩容一次数组大小空间加倍,也就是n++
在这里插入图片描述
scan指令返回的游标就是第一维数组的位置索引,我们将位置索引称为槽(slot),如果不考虑字典的扩容缩容,直接按数组下标逐个遍历就行了,Limit参数就表示需要遍历的槽位数,返回的结果不一定几个,因为每个槽位后挂的链表长度不确定,所以每次遍历都会将limit数量的槽位上挂的所有链表元素进行模式匹配过滤后,一次性返回给客户端。

用scan命令可以用来扫描bigkey,当然redis客户端已经提供了这样的指令,也可以通过客户端监控来获取bigkey。

三、Redis底层原理部分

原理1、线程IO模型

首先,Redis是个单线程程序,必须牢记。

对于高性能服务器,不单redis是单线程,nginx也是单线程,并不是说单线程就性能不好。

1、Redis单线程为什么还这么快?

因为redis的所有数据都在内存中,所有的运算都是内存级别的。
正因为redis是单线程的,所以要小心时间复杂度是O(n)的指令,避免造成redis卡顿。

2、Redis单线程如何处理那么多并发的客户端连接?

1)非阻塞IO & 多路复用

在这里插入图片描述
即epoll & NIO

原理2、通信协议

redis的作者认为数据库系统的瓶颈一般不在于网络流量,而是数据库自身内部逻辑处理上。所以即使redis使用了浪费流量的文本协议,依然可以取得极高的访问性能。
redis将所有数据都放在内存,用一个单线程对外提供服务,单个节点在跑满一个CPU核的情况下,qps可以达到10w/s

1、RESP(Redis Serialization Protocol)

RESP是一种直观的文本协议,优势在于实现异常简单,解析性能极好。

Redis协议将传输的结构数据分为5中最小单元类型,单元结束时统一加上回车换行符号\r\n。
1)单行字符串:以 + 符号开头,如:+hello world\r\n
2)多行字符串:以 $ 符号开头,后跟字符串长度,如:$11\r\nhello world\r\n
3)整数值:以 :符号开头,后跟整数的字符串形式,如::1024\r\n
4)错误消息:以 - 符号开头,如:-WRONGTYPE Operation against a key holding the wrong kind of value
5)数组:以 * 号开头,后跟数组的长度,如:

*3\r\n:1\r\n:2\r\n:3\r\n

6)NULL:用多行字符串表示,不过长度写成-1,如:$-1\r\n
7)空串:用多行字符串表示,长度填0,如:$0\r\n\r\n

2、服务器 --> 客户端

服务器向客户端发送消息支持多种数据结构,但在复杂也是这5种疾病类型的组合,例如:

127.0.0.1:6379> set author codehole
OK

这里OK就是单行响应:

+OK

原理3、持久化

Redis的持久化机制分两种:
一种是RDB(快照):全量备份,即内存数据的二进制序列化形式,在存储上很紧凑
一种是AOF(写日志):连续增量备份,运行中文件会逐渐庞大,所以需要定期进行AOF重写,给AOF日志瘦身。

1、RDB 快照原理

我们知道redis是单进程,RDB执行时,redis除了要处理客户端请求,还要做RDB内存快照做文件IO操作,可文件IO操作不能使用IO多路复用API。

所以,Redis使用了操作系统的多进程COW(copy on write)机制来实现快照的持久化。

2、AOF原理

AOF日志存储的是Redis服务器的顺序指令序列,这样可通过一个新的redis实例顺序执行所有的指令,也就是“重放”,来恢复redis当前实例的内存数据结构的状态。

Redis服务端在收到客户端修改redis内存数据的指令后,先进行参数校验,没问题后,先把指令文本存储到AOF日志中落盘,然后再执行指令。这样即使遇到突发宕机,已经存储到AOF日志的治理重放一下就可以恢复到宕机前的状态。

Redis运行中,AOF日志会越来越大,所以如果实力宕机重启,重放整个AOF日志会非常耗时,导致长时间redis无法对外提供服务,所以需要对AOF日志瘦身。

AOF重写

redis提供了bgrewriteaof指令,用于对AOF日志进行瘦身。其原理就是开辟一个子进程,对内存进行遍历,转换成一系列redis的操作指令,序列化到一个新的AOF日志文件中。序列化完毕后,再将操作期间发生的增量AOF日志追加到这个新的AOF日志文件中,追加完毕后,就立即替代旧的AOF日志文件,瘦身工作就完成了。

fsync

AOF日志已文件形式存在,当redis写AOF时,是将内存写到了OS内核为文件描述符分配的一个内存缓存中,然后OS内核会异步地将数据刷会到磁盘。

但如果突然宕机,AOF日志还没来得及刷盘,会出现日志丢失,怎么办呢?
Linux的glibc提供了 fsync(int fd)函数可以将指定文件强制从内核缓存刷到磁盘。只要Redis进程实时调用fsync函数,就可保证AOF日志不服,但这样做耗费性能。所以在生产环境中,通常redis每秒执行一次fsync操作,周期可配置,即在数据安全和性能直接做了个折中。

通常,redis的主节点不会做持久化操作,而是在从节点上进行。

3、redis 4.0 混合持久化

redis服务器重启后,都是通过AOF来恢复内存数据,不会通过RDB来恢复数据(因为RDB数据不全,会丢数据),但重放AOF比重放RDB慢许多。

Redis 4.0为了解决这个问题,引入了一个新的持久化选项【混合持久化】。将RDB的内容和增量AOF的日志文件存在一起。这里的AOF日志不是权力的,而是从RDB持久化开始到持久化结束这段时间发生的增量AOF日志,这部分AOF日志铜绿很小。

于是在Redis重启的时候,先加载RDB内容,然后再重放AOF日志,这样重启效率大幅提升。

原理4、管道(Pipeline)

pipeline操作,就是把多次读写合并成一次批量读写,降低网络IO次数。

我们深入分析一个请求交互的流程:
在这里插入图片描述
1)客户端进程调用write将消息写入到操作系统内核为socket分配的发送缓冲send buffer
2)客户端操作系统内核将发送缓存的内容发送到网卡,网卡硬件将数据通过“网际路由”送到服务器网卡。
3)服务器操作系统内核将网卡的数据放到内核为socket分配的接收缓冲recv buffer
4)服务器进程调用Read从接收缓冲中取出消息进行处理
5)服务器进程调用write将相应消息写到内核为socket分配的发送缓冲send buffer
6)服务器操作系统内核将发送缓冲的内容发送到网卡,网卡硬件将数据通过“网际路由”送到客户端的网卡
7)客户端操作系统内核将网卡的数据放到内核为socket分配的接收缓冲recv buffer
8)客户端进程调用read从接收缓冲中取出消息,返回给上层业务逻辑进行处理

这里要注意:
1)write操作并不是要等到对方收到消息才会返回,而是只讲数据写入到本地操作系统内核的发送缓冲然后就返回了,剩下的事交给操作系统异步讲数据发送到目标机器。但如果发送缓冲满了,那么就需要等待缓冲空出空闲空间来,这就是写操作IO的真正耗时。

2)read操作也不是从目标机器拉取数据,而是只负责将数据从本地操作系统内核的接收缓冲中取出来。但如果缓冲是空的,那么就要等待数据到来,这就是读IO操作耗时的地方。

所以,对于“value=redis.get(key)"这样一个简单的请求,write操作基本没有耗时,直接写入到发送缓冲就返回了。而read比较耗时,他需要等待消息经过网际路由到目标处理后的响应消息,再会写到当前的内核读缓冲才可以返回。

对于pipeline来说,连续的write操作根本就没有耗时,之后第一个read操作会等待一个网络的来回开销。

管道操作并不是服务器的特性,而是客户端通过改变了读写的顺序带来的性能的巨大提升。

原理5、事务

1、redis事务的基本使用

java的事务操作:

begin(); 
try {
    
    
	command1(); 
	command2(); 
	....
	commit();
} catch(Exception e) {
    
     
	rollback();
}

Redis在形式上差不多,分别是multi/exec/discard:

127.0.0.1:6379> set books 1
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr books
QUEUED
127.0.0.1:6379> incr books
QUEUED
127.0.0.1:6379> exec
1) (integer) 2
2) (integer) 3
127.0.0.1:6379> get books
"3"

可以通过pipeline优化:

pipe = redis.pipeline(transaction=true) pipe.multi()
pipe.incr("books")
pipe.incr("books")
values = pipe.execute()

2、Watch机制

Watch机制是乐观锁,而redis的分布式锁是悲观锁。
Watch的使用方式如下:

while True: 
	do_watch()
	commands() 
	multi() 
	send_commands() 
	try:
		exec()
		break
	except WatchError:
		continue

watch会在事务开始之前盯住一个或多个关键变量,当事务执行时,也就是服务器收到了exec指令要顺序执行缓存的事务队列时,redis会检测关键变量自watch之后,是否被修改了。如果被改,exec指令就会返回null告知客户端事务执行失败,这时候客户端一般会选择重试。

127.0.0.1:6379> get books
"3"
127.0.0.1:6379> watch books
OK
127.0.0.1:6379> incr books
(integer) 4
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr books
QUEUED
127.0.0.1:6379> exec
(nil)
127.0.0.1:6379> get books
"4"

在watch books和incr books中间,另一个线程执行”incr books“,执行exec返回nil,说明执行失败。

注意:redis禁止在multi和exec之间执行指令,而必须在multi之前做好盯住关键变量,否则会出错。

redis为什么不支持事务回滚呢?
redis在事务失败时,不会进行回滚,而是继续执行余下的命令

这样做的优点是:

  • redis命令只会因为错误的语法而失败,这些错误应该在开发过程中被发现,不应该出现在生产环境中
  • redis不需要对回滚进行支持,内部可以保持简单且快速

原理6、sub/pub

Redis的消息队列机制,缺点是不支持广播机制。

为此,redis专门提供了sub/pub机制来支持广播消息。

订阅:

127.0.0.1:6379> subscribe image text blog
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "image"
3) (integer) 1
1) "subscribe"
2) "text"
3) (integer) 2
1) "subscribe"
2) "blog"
3) (integer) 3


1) "message"
2) "image"
3) "www.google.com/dudo.png"

发布:

127.0.0.1:6379> publish image www.google.com/dudo.png
(integer) 1

注意:redis的sub/pub机制,不会持久化,如果发送消息后,一个订阅者都没有,那么这条消息会被丢弃。

原理7、小对象压缩

1、32bit vs 64bit

如果redis使用内存不超过4G,就可以考虑用32bit进行编译。

2、小对象压缩存储ziplist

如果redis内部管理的集合数据结构很小,他会使用紧凑存储形式压缩存储:这就好比HashMap,本来是数组+链表二维结构,但如果元素较少,使用二维结构反而浪费空间,还不如使用一维数组。

Redis的ziplist是一个紧凑的字节数组结构,每个元素都是紧挨着的
在这里插入图片描述
如果他的存储结构是hash结构,那么key和value会作为两个entry相邻存在一起。
如果他存储的是zset,那么value和score会作为两个entry相邻存在一起。

3、内存回收机制

redis并不总是可以将空闲内存立即归还给操作系统。如果当前redis内存有10G,当你删除了1G的key后,再去观察内存,会发现内存变化不大,原因是操作系统回收内存是以页为单位,如果这个页上只要有一个key还在使用,那么他就不会被回收。redis虽然删除了1GB的Key,但他们分散到了很多页中,每个页都还有其他的key存在,这就导致了内存不会立即被回收。

但如果你执行flushdb,就会马上回收内存了。

redis虽然无法保证立即回收已经删除的key的内存,但他会重用那些尚未回收的空闲内存,就好比电影院里人虽然走了,但座位还在,下一波观众来了,直接坐就行了。而操作系统回收内存,就好比把座位给搬走了。

4、内存分配算法

redis为了保持自身结构的简单性,在内存分配这直接做了甩手掌柜,讲内存分配的细节丢给了第三方内存分配库去实现,目前redis可以使用 jemalloc(facebook) 来管理内存,也可以切换到 tcmalloc(google)。默认是jemalloc。

原理8、主从同步

1、增量同步

redis会把修改内存的指令存到本地内存buffer中,然后异步将buffer种的指令同步到从节点,从节点在接收同步指令流的同时,会反馈给主节点自己同步到哪了(偏移量)

因为内存buffer是有限的,所以redis主库不能将所有的指令都记录在内存buffer中。redis复制内存buffer是一个定长的环形数组,如果数组内容慢了,就会从头开始覆盖前面的内容。
在这里插入图片描述
如果网络状况不好,从节点在短时间内无法和主节点进行同步,那么当网络恢复后,redis主节点中哪些没有同步的指令在buffer中有可能已经被后续的指令覆盖掉了,从节点将无法直接通过指令流来进行同步,这时候就要增加更加复杂的同步机制–快照同步。

2、快照同步

快照同步,是一个很耗费资源的操作,他先要在主库上执行bgsave,将当前内存的数据全部快照到磁盘文件中,然后再将快照文件的内容全部传送到从节点。从节点将快照文件接收完毕后,立即执行一次全量加载,加载之前先要将当前内存的数据清空。加载完后通知主节点继续进行增量同步。

在整个快照同步进行过程中,主节点的复制buffer还在不停的往前移动,如果快照同步的世界过长,或者复制的buffer过小,都会导致同步期间的增量指令在复制buffer中被覆盖,这样就会导致快照同步完成后无法进行增量的复制,然后会再次发起快照同步,这样可能会死循环。

所以务必配置一个合适的复制buffer大小参数,避免快照复制死循环。

3、新增从节点

当新增从节点后,必须要先进行一次快照同步,同步完成后在进行增量同步。

4、无盘复制

主节点在执行快照备份时,会进行很重的文件IO操作,会对系统负载产生较大影响。所以从redis 2.8.18版本开始,支持无盘复制。就是指主服务器直接通过套接字将快照内容发送到从节点,生成快照是一个遍历的过程,主节点会一遍遍历内存,一遍将序列化的内存发送到从节点,从节点先将接收到的内容存到磁盘文件中,再进行一次性加载。

5、wait指令

redis的主从同步复制是异步的,wait指令可以让异步复制变成同步复制,确保系统的强一致性(不严格)。wait指令时redis3.0版本后才出现的。

wait提供两个参数,第一个参数是从库的数量n, 第二个参数是时间t,以毫秒为单位。他表示等待 wait 指令之前的所有写操作同步到n个从库(也就是确保n个从库的同步没有滞后),最多等待时间t。如果t=0, 表示无限等待直到n个从库同步完成达成一致。

假设此时出现了网络分区,wait指令第二个参数时间 t=0,主从同步无法继续进行,wait指令会永远阻塞,redis服务器将失去可用性。

四、redis集群

集群1、Sentinel

1、Sentinel哨兵模式的引入

对于redis主从方案,假设凌晨redis宕机,运维需要起床做人工切换,从才能顶替主,而不可用时间较长,无法接受。
在这里插入图片描述
我们可以将redis sentinel集群看成是一个zookeeper集群,他是集群高可用的心脏,他一般由3~5个节点组成,这样挂了个别节点,集群和可以正常运行。

他负责持续监控主从节点的健康,当主节点挂掉时,自动选择一个最优的从节点,切换为主节点。客户端来连接集群时,会首先连接sentinel,通过sentinel来查询主节点的地址,然后子啊去连接主节点进行数据交互。

当主节点故障时,客户端会重新向sentinel要地址,sentinel会将最新的主节点地址告诉客户端。如此应用程序将无需重启即可自动完成节点切换,比如上图的主节点挂掉,集群将可能自动调整为下图:
在这里插入图片描述
之后sentinel会持续监控已经挂掉的主节点,待他恢复后,集群会调整为下图:
在这里插入图片描述
此时原先挂掉的主节点现在变成了从节点,再从新的主节点那同步数据。

消息丢失

Redis主从异步复制,意味着主节点挂掉,从节点可能没有收到全部的消息同步,这部分未同步的消息就丢失了。sentinel不能保证消息完全不丢,但能尽可能保证消息少丢失。他有两个配置项可以限制主从延迟过大:

min-slaves-to-write 1 
min-slaves-max-lag 10

第一个参数表示主节点必须至少有一个从节点在进行正常复制,否者就停止对外写服务,丧失可用性。

何为正常复制?何为异常复制?就是由第二个参数控制的,他的单位是秒,表示如果10s没有收到从节点的反馈,就意味着从节点同步不正常,要么网络断开了,要么一直没有给反馈。

集群2、Codis

在数据量非常大时,单个redis实例数据不宜过大,例如RDB文件过大。
这时,就需要redis集群,codis是redis的集群方案之一,是中国人开发并开源的,来自豌豆荚中间件团队。

从redis的广泛流行到RedisCluster的广泛使用,之间间隔了好多年,codis就是在这段时间发展起来的。
在这里插入图片描述
codis使用Go语言开发,他是一个代理中间件,他和redis一样,也使用了redis协议对外提供服务,当客户端向codis发送指令时,codis负责将指令转发到后面的redis实例来执行,并将返回结果再返回给客户端。

codis上挂接的所有redis实例构成了一个redis集群,当集群空间不足时,可以通过动态增加redis实例来扩容。

客户端操作codis和操纵redis几乎没有区别,还可以使用相同的客户端SDK。

因为codis是无状态的,他只是一个转发代理中间件,这意味着我们可以启动多个codis实例,供客户端使用,每个codis节点都是对等的。因为单个codis代理能支撑的qps比较有限,通过启动多个codis代理可以显著增加整体的qps需求,还能启动容灾功能,挂掉一个codis代理没关系,还有很多codis代理可以继续服务。
在这里插入图片描述

1、codis分片原理

codis要负责将特定的key转发到特定的redis实例,那么这种对应关系codis是如何管理的呢?codis将所有的key默认分为1024个槽位(slot),他首先对客户端传过来的key进行 crc32 运算计算hash值,再将hash后的整数值对1024这个整数进行取模,得到一个余数,这个余数就是对应key的槽位。

每个槽位都会唯一映射到后面的多个Redis实例之一,codis会在内存维护槽位和redis实例的映射关系。这样就知道转发到哪个redis实例了。

hash = crc32(command.key) 
slot_index = hash % 1024 
redis = slots[slot_index].redis 
redis.do(command)

槽位默认是1024,他是可配置的

2、不同codis实例之间的槽位关系如何同步?

如果codis的槽位的映射关系只存在内存里,那么不同的codis实例之间的槽位关系就无法同步了,所以codis还需要一个分布式配置数据库,专门用来持久化槽位关系。codis开始使用了zookeeper,后来连etcd也一块支持了。
在这里插入图片描述
codis将槽位关系存在zk中,并且提供了一个Dashboard可以用来观察和修改槽位关系,当槽位变化时,codis proxy会监听到变化,并且重新同步槽位关系,从而实现多个codis proxy之间共享相同额槽位关系配置。

3、扩容

当codis后端的redis实例扩容时,就需要做槽位的迁移:
codis对redis进行了改造,增加了SLOTSSCAN指令,可以遍历指定slot下所有到的Key,然后逐个迁移每个key到新的redis节点。

在迁移过程中,codis还会接收到新的请求,打到当前正在迁移的slot上,因为当前slot的数据同时存在于新旧两个槽位中,codis如何判断将请求转发到后面的那个具体的实例中呢?
codis无法判定迁移过程中的Key到底在哪个redis实例中,所以他采用了不同的思路:当codis接收到位于正在迁移槽位中的Key后,会立即强制对当前的单个key进行迁移,迁移完成后,再将请求转发到新的redis实例

4、自动均衡

redis新增实例,人工手动均衡slot太麻烦,codis提供了自动均衡的功能:codis会在系统空闲的时候,观察每个Redis实例对应的slot数量,如果不平衡,就会自动进行迁移。

5、codis的代价

codis中所有的key分撒在不同的redis实例中,就不支持事务了。事务只能在单个Redis实例中完成。同样rename也很危险,因为他的参数有两个key,如果这两个key在不同的redis实例中,rename操作是无法正确完成的。

同样为了支持扩容,单个key对应的value不宜过大。

codis因为增加了proxy作为中转层,所以网络开销上要比单个redis大,毕竟数据包夺走了一个网络节点,整体性能比redis性能下降,但损耗不是太明显。

codis的集群配置中心使用zk来实现,意味着在部署上增加了zk运维的代价。

6、codis优点

codis在设计上相比Redis Cluster官方方案要简单很多,因为他将分布式问题交给了第三方zk/etcd去做,自己就省去了复杂的分布式一致性代码的编写维护工作。而Redis Cluster的内部实现非常复杂,他为了实现去中心化,混合使用了raft和gossip协议,还有大量需要调优的配置参数。

集群3、Redis Cluster

Redis Cluster是Redis官方提供的方案,与codis不同的是,他是去中心化的,如下图,该集群有三个redis节点组成,每个节点负责整个集群的一部分数据,每个节点负责的数据多少可能不一样。三个节点相互连接组成一个对等的集群,他们之间通过一种特殊的二进制协议互相交互集群的信息:
在这里插入图片描述
Redis CLuster将所有数据划分为16384个slot,每个节点负责其中一部分槽位。槽位的信息存储在每个redis节点中,他与codis不同,他不错另外的分布式存在来存储节点操作信息。

当Redis Cluster的客户端来连接集群时,他也会得到一份集群的槽位配置信息。这样当客户端要查找某个key时,可以直接定位到目标节点。

另外,Redis CLuster会将集群的配置信息持久化到配置文件中,所以必须要确保配置文件是可写的。

1、跳转

当客户端向一个错误的节点发出了指令,该节点会发现指令的key所在的槽位并不归自己管理,这时它会想客户端发送一个特殊的跳转指令,并携带目标节点地址,告诉客户端去连这个节点来获取数据:

GET x
-MOVED 3999 127.0.0.1:6381

MOVED指令的第一个参数3999是key对应的槽位编号,后面是目标节点地址。
MOVED指令前面带一个剑豪,表示这个指令时一个错误消息。

客户端收到MOVED指令后,要立即纠正本地的槽位映射表。后续所有的key将使用新的槽位映射表。

2、迁移

Redis Cluster提供了工具redis-trib可以让运维人员手动调整槽位分配情况,他使用Ruby语言进行开发,通过组合各种原生的Redis Cluster指令来实现。这点Codis做得更加人性化,提供了UI界面。

迁移过程

在这里插入图片描述
Redis迁移的单位是槽,Redis一个槽一个槽进行迁移,当一个槽正在迁移时,这个槽就处于中间过渡状态,这个槽在源节点的状态为migrating,在目标节点的状态为importing,表示数据正在从源节点流向目标节点。

迁移工具redis-trib,首先会在源节点和母节点设置好中间过渡状态,然后一次性获取源节点槽位的所有key列表(keysinslot指令,可以部分获取),再挨个key进行迁移。每个key的迁移过程是以源节点作为目标节点的客户端,源节点对当前的key执行dump指令得到序列化内容,然后通过客户端向目标节点发送指令restore携带序列化的内容作为参数,目标节点再进行反序列化就可以将内容恢复到目标节点的内存中,然后返回客户端OK,源节点客户端收到后再把当前节点的key删除掉,就完成了单个key迁移的整个过程。

从源节点获取内容–>存到目标节点–> 从源节点删除内容
注意这里的迁移过程是同步的,在目标节点执行restore指令到源节点删除key之间,源节点的主线程会处于阻塞状态,直到key被成功删除。

如果迁移过程中突然出现网络故障,整个slot的迁移只进行了一半,这两个节点依旧处于中间果断状态,待下次迁移工具重新连上时,会提示用户继续进行迁移。

在迁移过程中,每个key对应的value都很小,migrate指令会执行的很快,他并不影响客户端的访问,如果key的内容很大,因为migrate指令是阻塞指令,会同时导致源节点和目标节点请求返回失败,所以要避免bigkey。

在迁移过程中,客户端访问的流程如下:
首先,新旧两个节点对应的槽位,都存在部分的key数据。客户端先尝试访问旧节点,如果对应的数据还在旧节点里面,那么旧节点正常处理。如果对应的数据不再旧节点里面那么有两种可能,要么在新节点里,要么根本不存在。旧节点不知道是哪种情况,所以他会向客户端返回一个 "-ASK targetNodeAddr” 的重定向指令。客户端收到这个重定向指令后,先去目标节点执行一个不带任何参数的asking指令,然后再目标节点再重新执行原先的操作指令。

为什么需要执行一个不带参数的asking指令呢?
因为在迁移没有完成之前,按说这个槽位还是不归新节点管的,如果这时候向目标节点发送该槽位的指令,节点是不认的,他会向客户端返回一个“-MOVED”重点县指令,告诉他去源节点去执行,如此会形成 重定向循环。 asking指令的目标就是打开目标节点的选项,告诉他下一条指令不能不理,而要当成自己的槽位来处理。

从以上过程可以看出,迁移是会影响服务效率的,同样的指令在正常情况下,一个ttl就能完成,而在迁移中,得要3个ttl才能搞定。

容错

Redis cluster可以为每个主节点设置若干个从节点,单主节点故障时,集群会自动将其中某个从节点提升为主节点。如果某个主节点没有从节点,那么当他发生故障时,集群将完全处于不可用状态,不过Redis提供了一个参数cluster-require-full-coverage可以允许部分节点故障,其他节点还可以继续提供对外访问。

网络抖动

网络经常会抖动,导致redis节点间通信失败,redis cluster提供了一种选型 cluster-node-timeout,表示当某个节点持续timeout时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有这个选项,网络抖动导致主从频繁切换(数据的重新复制)

还有一个选型cluster-slave-validity-factor 作为倍乘系数来放大这个超时时间来宽松容错的紧急程度。如果这个系数为0,那么主从切换是不会抗拒网络抖动的。如果这个系数大于1,他就成了主从切换的松弛系数。

可能下线(PFAIL-Possibly Fail)与确定下线(Fail)

因为Redis Cluster是去中心化的,一个节点认为某个节点失联了并不代表所有的节点都认为他失联了。所以集群还得经过一次协商的过程,只有当大多数节点都认定某个节点失联了,集群才认为该节点需要进行主从切换来容错。

Redis集群节点采用gossip协议来广播自己的状态机自己对整个集群认知的改变。比如,一个接地那发现某个节点失联了(PFail),他会将这条消息向整个集群广播,其他节点也就可以收到这个失联信息。如果一个节点收到了某个节点失联的数量(PFail Count)已经达到了集群的大多数,就可以标记该节点为确定下线的状态(Fail),然后向整个集群广播,,强迫其他节点也接收该节点已经下线的事实,并立即对该是连接点进行主从切换。

槽位迁移感知

当Cluster中某个槽位正在迁移或者已经迁移完了,client如何能感知到槽位的变化呢?客户端保持了槽位和节点的映射关系表,他需要即时得到更新,才可以正常滴将某条指令发到正确的节点中。

我们前面提到Cluster有两个特殊的error指令,MOVED和ASKING
第一个moved是用来纠正槽位的,如果我们将指令发送到错误的节点,该节点发现对应的指令槽位不归自己管,就会将目标节点的地址随同moved指令回复给客户端,通知客户端去目标节点访问,这时客户端就会刷新自己的槽位关系表,然后重试指令,后续所偶遇打在该槽位的指令都会转到目标节点。

第二个asking指令和moved指令不一定,他是用来临时纠正槽位的。如果当前槽位正处于迁移中,指令会先被发送到槽位所在的旧节点,如果旧节点存在数据,那就直接返回结果,如果不存在,那么他可能真的不存在也可能在目标节点上,所以旧节点会通知客户端去新节点尝试取数据,这时候会给客户端返回一个asking error并携带目标节点的地址。客户端收到这个asking error后,就回去目标节点尝试。客户端不会刷新槽位映射关系表,因为他只是临时纠正该指令的槽位信息,不影响后续指令。

重试2次

moved和asking指令都是重试指令,客户端会因为这两个指令多重试一次。客户端有可能重试两次吗?这种可能是存在的,比如一条指令被发送到错误的节点,这个节点会先给你一个moved错误,告知你去另外一个节点重试,所以客户端就去另外的一个节点重试了,结果刚好这个时候这个槽位正在迁移,于是给客户端回复了一个asking指令,告知客户端去目标节点去重试指令,这时就重试了2次。

重试多次

在某些特殊情况下,客户端甚至会重试多次。客户端源码里在执行指令时,会有一个循环,然后设置最大重试次数,当重试次数超过这个值时,客户端会直接想业务层抛出异常。

集群变更感知

当服务器节点变更时,客户端应该立即得到通知,以实时刷新自己的节点关系表。那客户端是如何得到通知的呢?这里分2种情况:
1)目标节点挂了,客户端会抛出一个ConnectionError,接着会随机挑选一个节点来重试,这时被重试的节点会通过moved error告知目标槽位被分配到了新的节点地址
2)运维手动修改了集群信息:将master切换到了其他节点,并将旧master移除集群。这时打在旧节点上的指令,会收到一个ClusterDown的错误,告知当前节点所在集群不可用(当前节点已被孤立了,他不在属于之前的集群)。这时客户端就会关闭所有的链接,清空槽位映射关系表,然后向上层抛错。待下一条指令过来时,就会重新尝试初始化节点信息 – 这里我们公司是通过管理后台定时给客户端推送集群中的节点信息来解决的。

五、redis拓展

拓展1、Stream

redis5.0的新特性:多了一个数据结构stream,是一个新的广播消息的支持持久化的消息队列,原理借鉴了kafka的设计
在这里插入图片描述
redis stream结构上,是一个消息链表,将所有加入的消息串依赖,每个消息都有一个唯一的ID和对应的内容。消息是持久化的,redis重启后,内容还在。

细节略:这样场景不如直接用kafka或rocketmq

拓展2、Info指令

在使用redis时,常常会遇到很多问题需要诊断,在诊断之前,就要了解redis的运行状态,通过强大的info指令,可以清晰地知道redis内部一系列运行参数。

Info指令显示的信息分9大块,每个块都有非常多的参数。这9大块分别是:
1、Server服务器运行的环境参数
2、Clients 客户端相关信息
3、Memory服务器运行内存统计数据
4、Persistence持久化信息
5、Stats通用统计数据
6、Replication主从复制相关信息
7、Cluster集群信息
8、KeySpace键值对统计数量信息

# 获取所有信息
> info
# 获取内存相关信息
> info memory
# 获取复制相关信息
> info replication

通过info指令,可以查很多常见的数据,如:
1)redis每秒执行了多少次指令

info stats

2)redis连接了多少客户端?

info clients

3)redis内存占用多大?

info memory

4)复制积压缓冲区多大

info replication

拓展3、过期策略

redis的所有数据结构,偶读可以设置过期时间,时间一到,就会自动删除。

因为Redis是单线程的,删除的时间也会占用线程的处理时间,如果删除太频繁,会不会导致线上的读写指令出现卡顿?

redis会将每个设置了过期时间的key放到一个独立的字典中,以后会定时遍历这个字典里删除过期的key。除了定时遍历外,还会使用惰性策略来删除过期的key,所谓惰性策略,就是客户端访问这个key的时候,redis对key的过期时间进行检查,如果过期了就自动删除。

所以,定时删除是集中处理,惰性删除是零散处理。

拓展4、LRU

当Redis内存超出物理内存的限制时,内存的数据就会开始和磁盘产生频繁的交换(swap)。交换会让redis的性能急剧下降,对于访问量比较频繁的redis来说,这样慢的存取效率基本上不可用。

为了限制最大使用的内存,redis提供了配置参数maxmemory来限制内存超出了期望大小。

在实际内存超出了maxmemory时,redis提供了几种可选策略让用户自己决定该如何腾出新的空间,以继续提供读写服务:
1)noeviction:不会继续提供写请求服务(del 请求可继续提供服务),读请求可以继续。这时默认的淘汰策略。
2)volatile-lru:尝试淘汰设置了过期时间的key, 最少使用的key优先被淘汰。没有设置过期时间的key不会被淘汰。
3)volatile-ttl:跟上面一样,只是淘汰策略不是LRU, 而是key的剩余寿命ttl的值,ttl越小越优先被淘汰
4)rolatile-random:跟上面一样,不过淘汰的key是过期key集合中随机的key
5)allkeys-lru:区别于volatile-lru, 这个策略要瑶台的key对象是全体的key的集合,而不只是设置了过期的key的集合。
6)allkeys-random, 跟上面一样,不过淘汰的是随机的key
7)volatile-xxx: 只会针对待过期时间的key进行淘汰

LRU算法的实现

实现LRU算法,除了需要key/value字典外,还需要附加一个链表,链表中的元素按照一定的顺序进行排列。当空间慢的时候,会踢掉链表尾部的元素。当字典的某个元素被访问时,他在链表中的位置会被移动到表头。所以链表元素的排列顺序就是元素最近被访问的时间顺序。
位于链表尾部的元素就是不常重用的元素,所以会被踢掉。

近似LRU算法

redis使用的是一种近似LRU的算法,之所以不适用LRU算法,是因为需要消耗大量的额外的内存,需要对现有的数据结构进行较大的改造。

近似LRU算法在;正在现有数据结构的基础上使用随机采样法来淘汰元素,能达到和LRU算法非常近似的效果。Redis为实现近似LRU算法,他给每个key增加了一个额外的小字段,这个字段的长度是24个bit,也就是最后一次被访问的时间戳。

上面提到过处理key的过期方式分为集中处理和惰性处理,LRU淘汰不同,他的处理方式只有惰性处理。当redis执行写操作时,发现内存超出了maxmemory,就会执行一次LRU淘汰算法,这个算法就是随机采样出5个(可配置)key, 然后淘汰掉最旧的key,如果淘汰后内存还是超出maxmemory,那就继续随机采样淘汰。

拓展5、惰性删除

redis是单线程的,但redis内部实际不是只有一个主线程,他还有几个异步线程专门用来处理一些耗时操作。

1、redis为什么要惰性删除

删除指令del会直接释放对象的内存,过程非常快,但如果删除的key是bigkey, 那么会导致单线程卡顿。

Redis为了解决这个问题,在4.0版本引入了unlink指令,他能对删除操作进行惰性处理,丢给后台线程来异步回收内存。

在执行unlink后,这部分数据虽然没有被删除,但已经不能被访问了。

2、flush

redis提供了flushdb和flushall指令,用来清空数据库,这也是极缓慢的操作。redis4.0 同样给这两个指令也提供了异步化,在指令后面增加一个async就可实现

127.0.0.1:6379> flushall async
OK

3、AOF Sync也很慢

redis需要每秒把AOF数据刷到磁盘一次,确保消息尽量不丢,需要调用sync函数,但这个操作也比较耗时,会导致主线程的效率下降,所以redis把这个操作也异步化。执行AOF sync操作的线程是一个独立的异步线程,和前面的惰性删除线程不是同一个线程,同样他也有一个属于自己的任务队列,队列里只用来存放AOF Sync任务。

4、更多异步删除点

Redis回收内存除了del和flush之外,还会存在于key的过期,LRU淘汰,rename指令,及从库全量同步时接受完RDB文件后会立即进行的flush操作。

Redis4.0 为这些删除点也带来了异步删除机制,打开这些配置需要额外的配置选项:
1)slave-lazy-flush 从库接受完 rdb 文件后的 flush 操作
2)lazyfree-lazy-eviction 内存达到 maxmemory 时进行淘汰
3)lazyfree-lazy-expire key 过期删除
4)lazyfree-lazy-server-delrename 指令删除 destKey

拓展4、Redis安全

1、指令安全

redis有一些非常危险的指令,这些指令会对redis的稳定及数据安全造成严重的影响。如keys指令会导致Redis卡顿,flushdb和flushall会让redis的所有数据全部清空。

redis在配置文件中提供了rename-command指令,用于将某些危险的指令修改成特别的名称,用来避免人为错误操作,比如在配置文件的security块增加下面的内容:

rename-command keys abckeysabc

如果还想执行keys,就不能用keys了,需要键入abckeysabc。如果要完全封杀某条指令,可以将指令rename成空串:

rename-command flushall ""

2、端口安全

Redis默认会监听 *:6379, 如果当前服务器主机有外网地址,redis服务将会直接暴露在公网上,任何一个黑客使用适当的工具对IP地址进行端口扫描,就可以探测出来。

redis的服务地址一旦可以被外网直接访问,内部的数据就彻底丧失了安全性。恶意的对手甚至可以清空你的redis数据

所以,运维人员务必在redis的配置文件中指定监听的IP地址,避免这样的惨剧发生。更进一步,还可以增加redis的密码访问限制,客户端必须使用auth指令传入正确的密码才可以访问redis,这样即使地址暴露出去了,普通黑客也无法对redis进行任何指令操作:

requirepass yoursecurepasswordhereplease

密码控制也会影响到从库复制,从库必须在配置文件里使用masterauth指令配置相应的密码才可以进行复制操作:

masterauth yoursecurepasswordhereplease

3、Lua脚本安全

开发者必须禁止Lua脚本由用户输入的内容(UGC)生成,这可能会被黑客利用以植入恶意代码来得到redis的主机权限。
同时,我们应该让redis以普通用户的身份启动,这样即使存在恶意代码,黑客也无法拿到root权限。

4、SSL代理

Redis并不支持SSL连接,意味着客户端和服务端交互的数据,不影响直接暴露在工位,否则会有被窃听的风想,如果必须要用在公网上,可以考虑使用SSL代理。

猜你喜欢

转载自blog.csdn.net/shijinghan1126/article/details/114310841