中间件基础之 Redis
本文主要介绍Redis的一些的数据结构,以及Redis常用命令、应用场景等等。
基础数据结构
一、Redis
的数据结构
常见的五种:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
后续新增:BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)
Redis操作命令参考:http://redisdoc.com/index.html
二、String
(字符串)
String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value其实不仅是字符串, 也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M
底层数据结构主要是int 和SDS(简单动态字符串),是可以修改的字符串,内部的结构类似 Java
的 ArrayList
, 采用预分配冗余空间的方式减少内存的频繁分配。
SDS相对c原生字符串的优势:
SDS不仅仅可以保存文本数据,还可以保存二进制数据:因为
SDS
使用len
属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在buf[]
数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。**SDS 获取字符串长度的时间复杂度是 O(1)**:因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用
len
属性记录了字符串长度,所以复杂度为O(1)
。Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出:因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。
字符串对象的内部编码(encoding)有 3 种 :int、raw和 embstr。
如果一个字符串对象保存的是整数值,并且这个整数值可以用
long
类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr
属性里面(将void*
转换成 long),并将字符串对象的编码设置为int
。如果字符串对象保存的是一个字符串,并且这个字符申的长度小于等于 32 字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为
embstr
,embstr
编码是专门用于保存短字符串的一种优化编码方式。如果字符串对象保存的是一个字符串,并且这个字符串的长度大于 32 字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为
raw
可以看到embstr
和raw
编码都会使用SDS
来保存值,但不同之处在于embstr
会通过一次内存分配函数来分配一块连续的内存空间来保存redisObject
和SDS
,而raw
编码会通过调用两次内存分配函数来分别分配两块空间来保存redisObject
和SDS
优点:
embstr
编码将创建字符串对象所需的内存分配次数从raw
编码的两次降低为一次;释放
embstr
编码的字符串对象同样只需要调用一次内存释放函数;embstr
编码的字符串对象的所有数据都保存在一块连续的内存里面可以更好的利用 CPU 缓存提升性能。
缺点:
- 如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,所以embstr编码的字符串对象实际上是只读的,redis没有为embstr编码的字符串对象编写任何相应的修改程序。当我们对embstr编码的字符串对象执行任何修改命令(例如append)时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。
常用命令
1 | 设置键值对 |
应用场景
(1)缓存对象
(2)常规计数
(3)分布式锁
1 | // 释放锁时,先比较 unique_value 是否相等,避免锁的误释放 |
三、List
(列表)
List 列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向 List
列表添加元素。类似 Java
的 LinkedList
。
列表的最大长度为 2^32 - 1
,也即每个列表支持超过 40 亿
个元素
底层实现双向链表或压缩列表实现的,如果列表的元素个数小于 512
个(默认值,可由 list-max-ziplist-entries
配置),列表每个元素的值都小于 64
字节(默认值,可由 list-max-ziplist-value
配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;
但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。
常用命令:
1 | 将一个或多个值value插入到key列表的表头(最左边),最后的值在最前面 |
应用场景
- 消息队列
消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息可靠性。
(1) 如何满足消息保序需求?
List
本身就是按先进先出的顺序对数据进行存取的,所以,如果使用 List
作为消息队列保存消息的话,就已经能满足消息保序的需求了。
List
可以使用 LPUSH
+ RPOP
(或者反过来,RPUSH
+ LPOP
)命令实现消息队列。
生产者使用 LPUSH key value[value...]
将消息插入到队列的头部,如果 key 不存在则会创建一个空的队列再插入消息。
消费者使用 RPOP key
依次读取队列的消息,先进先出。
风险点:
在生产者往 List 中写入数据时,List 并不会主动地通知消费者有新消息写入,如果消费者想要及时处理消息,就需要在程序中不停地调用 RPOP
命令(比如使用一个while(1)循环)。如果有新消息写入,RPOP命令就会返回结果,否则,RPOP命令返回空值,再继续循环。
所以,即使没有新消息写入List,消费者也要不停地调用 RPOP 命令,这就会导致消费者程序的 CPU 一直消耗在执行 RPOP 命令上,带来不必要的性能损失。
为了解决这个问题,Redis提供了 BRPOP 命令。BRPOP命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。和消费者程序自己不停地调用RPOP命令相比,这种方式能节省CPU开销。
(2) 如何处理重复的消息?
消费者要实现重复消息的判断,需要 2 个方面的要求:
- 每个消息都有一个全局的 ID。
- 消费者要记录已经处理过的消息的 ID。当收到一条消息后,消费者程序就可以对比收到的消息 ID 和记录的已处理过的消息 ID,来判断当前收到的消息有没有经过处理。如果已经处理过,那么,消费者程序就不再进行处理了。
但是 List 并不会为每个消息生成 ID 号,所以我们需要自行为每个消息生成一个全局唯一ID,生成之后,我们在用 LPUSH 命令把消息插入 List 时,需要在消息中包含这个全局唯一 ID。
(3) 如何保证消息可靠性?
当消费者程序从 List
中读取一条消息后,List
就不会再留存这条消息了。所以,如果消费者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么,消费者程序再次启动后,就没法再次从 List
中读取消息了。
为了留存消息,List
类型提供了 BRPOPLPUSH
命令,这个命令的作用是让消费者程序从一个 List
中读取消息,同时,Redis
会把这个消息再插入到另一个 List
(可以叫作备份 List
)留存。
这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List
中重新读取消息并进行处理了。
好了,到这里可以知道基于 List
类型的消息队列,满足消息队列的三大需求(消息保序、处理重复的消息和保证消息可靠性)。
- 消息保序:使用
LPUSH
+RPOP
; - 阻塞读取:使用
BRPOP
; - 重复消息处理:生产者自行实现全局唯一
ID
; - 消息的可靠性:使用
BRPOPLPUSH
但是,在用 List 做消息队列时,如果生产者消息发送很快,而消费者处理消息的速度比较慢,这就导致 List 中的消息越积越多,给 Redis 的内存带来很大压力。
要解决这个问题,就要启动多个消费者程序组成一个消费组,一起分担处理 List 中的消息。但是,List 类型并不支持消费组的实现。
这就要说起 Redis 从 5.0 版本开始提供的 Stream 数据类型了,Stream 同样能够满足消息队列的三大需求,而且它还支持「消费组」形式的消息读取。
三、Hash
Hash 是一个键值对(key - value)集合,其中 value 的形式入:value=[{field1,value1},...{fieldN,valueN}]
。Hash 特别适合用于存储对象。
Hash 类型的底层数据结构是由压缩列表或哈希表实现的,如果哈希类型元素个数小于 512
个(默认值,可由 hash-max-ziplist-entries
配置),所有值小于 64
字节(默认值,可由 hash-max-ziplist-value
配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的 底层数据结构。
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
常用命令
1 | 存储一个哈希表key的键值 |
应用场景
(1)缓存对象
(2)购物车
涉及的命令如下:
- 添加商品:
HSET cart:{用户id} {商品id} 1
- 添加数量:
HINCRBY cart:{用户id} {商品id} 1
- 商品总数:
HLEN cart:{用户id}
- 删除商品:
HDEL cart:{用户id} {商品id}
- 获取购物车所有商品:
HGETALL cart:{用户id}
四、Set
(集合)
Set 类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。
一个集合最多可以存储 2^32-1
个元素。概念和数学中个的集合基本类似,可以交集,并集,差集等等,所以 Set 类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。
Set 类型和 List 类型的区别如下:
List
可以存储重复元素,Set
只能存储非重复元素;List
是按照元素的先后顺序存储元素的,而Set
则是无序方式存储元素的。
Set
类型的底层数据结构是由哈希表或整数集合实现的:
- 如果集合中的元素都是整数且元素个数小于
512
(默认值,set-maxintset-entries
配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构; - 如果集合中的元素不满足上面条件,则
Redis
使用哈希表作为Set
类型的底层数据结构。
常用命令
1 | 往集合key中存入元素,元素存在则忽略,若key不存在则新建 |
应用场景
集合的主要几个特性,无序、不可重复、支持并交差等操作。
因此 Set
类型比较适合用来数据去重和保障数据的唯一性,还可以用来统计多个集合的交集、错集和并集等,当我们存储的数据是无序并且需要去重的情况下,比较适合使用集合类型进行存储。
但是要提醒你一下,这里有一个潜在的风险。**Set
的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis
实例阻塞**。
在主从集群中,为了避免主库因为 Set
做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计,或者把数据返回给客户端,由客户端来完成聚合统计。
(1)点赞
(2)共同关注
(3)抽奖活动
五、ZSet
(有序集合)
Zset
类型(有序集合类型)相比于 Set
类型多了一个排序属性 score
(分值),对于有序集合 ZSet
来说,每个存储元素相当于有两个值组成的,一个是有序结合的元素值,一个是排序值。
有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。
Zset
类型的底层数据结构是由压缩列表或跳表实现的:
- 如果有序集合的元素个数小于
128
个,并且每个元素的值小于64
字节时,Redis 会使用压缩列表作为Zset
类型的底层数据结构; - 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为
Zset
类型的底层数据结构;
在 Redis 7.0
中,压缩列表数据结构已经废弃了,交由 listpack
数据结构来实现了。
常用命令
1 | 往有序集合key中加入带分值元素 |
应用场景
Zset
类型(Sorted Set
,有序集合) 可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入 Sorted Set
的时间确定权重值,先插入的元素权重小,后插入的元素权重大。
在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,可以优先考虑使用 Sorted Set
。
(1)排行榜
六、Bitmap
(位图)
Bitmap
,即位图,是一串连续的二进制数组(0
和1
),可以通过偏移量(offset
)定位元素。BitMap
通过最小的单位bit
来进行0|1
的设置,表示某个元素的值或者状态,时间复杂度为O(1)
。
由于 bit
是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景。
Bitmap
本身是用 String
类型作为底层数据结构实现的一种统计二值状态的数据类型。
String
类型是会保存为二进制的字节数组,所以,Redis
就把字节数组的每个 bit
位利用起来,用来表示一个元素的二值状态,你可以把 Bitmap
看作是一个 bit
数组。
常用命令
1 | 设置值,其中value只能是 0 和 1 |
应用场景
Bitmap
类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有 0
和 1
两种,在记录海量数据时,Bitmap
能够有效地节省内存空间。
(1)签到统计
(2)判断用户登录
Bitmap
提供了 GETBIT、SETBIT
操作,通过一个偏移值 offset
对 bit
数组的 offset
位置的 bit
位进行读写操作,需要注意的是 offset
从 0
开始。
只需要一个 key = login_status
表示存储用户登陆状态集合数据, 将用户 ID
作为 offset
,在线就设置为 1
,下线设置 0
。通过 GETBIT
判断对应的用户是否在线。50000
万 用户只需要 6 MB
的空间。
假如我们要判断 ID = 10086
的用户的登陆情况:
第一步,执行以下指令,表示用户已登录。
1 | SETBIT login_status 10086 1 |
第二步,检查该用户是否登陆,返回值 1 表示已登录。
1 | GETBIT login_status 10086 |
第三步,登出,将 offset 对应的 value 设置成 0。
1 | SETBIT login_status 10086 0 |
(3)连续签到用户总数
如何统计出这连续 7
天连续打卡用户总数呢?
我们把每天的日期作为 Bitmap
的 key
,userId
作为 offset
,若是打卡则将 offset
位置的 bit
设置成 1
。
key
对应的集合的每个 bit
位的数据则是一个用户在该日期的打卡记录。
一共有 7 个这样的 Bitmap,如果我们能对这 7 个 Bitmap 的对应的 bit 位做 『与』运算。同样的 UserID offset 都是一样的,当一个 userID 在 7 个 Bitmap 对应对应的 offset 位置的 bit = 1 就说明该用户 7 天连续打卡。
结果保存到一个新 Bitmap 中,我们再通过 BITCOUNT
统计 bit = 1 的个数便得到了连续打卡 3 天的用户总数了。
Redis 提供了 BITOP operation destkey key [key ...]
这个指令用于对一个或者多个 key 的 Bitmap 进行位元操作。
opration
可以是and
、OR
、NOT
、XOR
。当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作0
。空的key
也被看作是包含0
的字符串序列。
举个例子,比如将三个 bitmap 进行 AND 操作,并将结果保存到 destmap 中,接着对 destmap 执行 BITCOUNT 统计。
1 | 与操作 |
即使一天产生一个亿的数据,Bitmap
占用的内存也不大,大约占 12 MB
的内存(10^8/8/1024/1024)
,7
天的 Bitmap 的内存开销约为 84 MB。同时我们最好给 Bitmap 设置过期时间,让 Redis 删除过期的打卡数据,节省内存。
(4)统计用户的活跃天数
七、HyperLogLog
Redis HyperLogLog
是 Redis 2.8.9
版本新增的数据类型,是一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog
是统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%
。
所以,简单来说 HyperLogLog
提供不精确的去重计数。
HyperLogLog
的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的。
在 Redis
里面,每个 HyperLogLog
键只需要花费 12 KB
内存,就可以计算接近 2^64
个不同元素的基数,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。
这什么概念?举个例子给大家对比一下。
用 Java 语言来说,一般 long 类型占用 8 字节,而 1 字节有 8 位,即:1 byte = 8 bit,即 long 数据类型最大可以表示的数是:2^63-1
。对应上面的2^64
个数,假设此时有2^63-1
这么多个数,从 0 ~ 2^63-1
,按照long
以及1k = 1024 字节
的规则来计算内存总数,就是:((2^63-1) * 8/1024)K
,这是很庞大的一个数,存储空间远远超过12K
,而 HyperLogLog
却可以用 12K
就能统计完。
常用命令
1 | 添加指定元素到 HyperLogLog 中 |
应用场景
(1)百万级网页UV计数
1 | 添加用户 |
八、GEO
Redis GEO 是 Redis 3.2 版本新增的数据类型,主要用于存储地理位置信息,并对存储的信息进行操作。
在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在 LBS 服务的场景中。
GEO 本身并没有设计新的底层数据结构,而是直接使用了 Sorted Set 集合类型。
GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。
这样一来,我们就可以把经纬度保存到 Sorted Set 中,利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。
常用命令
1 | 存储指定的地理空间位置,可以将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中。 |
应用场景
(1)滴滴叫车
这里以滴滴叫车的场景为例,介绍下具体如何使用 GEO 命令:GEOADD 和 GEORADIUS 这两个命令。
假设车辆 ID 是 33,经纬度位置是(116.034579,39.030452),我们可以用一个 GEO 集合保存所有车辆的经纬度,集合 key 是 cars:locations。
执行下面的这个命令,就可以把 ID 号为 33 的车辆的当前经纬度位置存入 GEO 集合中:
1 | GEOADD cars:locations 116.034579 39.030452 33 |
当用户想要寻找自己附近的网约车时,LBS 应用就可以使用 GEORADIUS 命令。
例如,LBS 应用执行下面的命令时,Redis 会根据输入的用户的经纬度信息(116.054579,39.030452 ),查找以这个经纬度为中心的 5 公里内的车辆信息,并返回给 LBS 应用。
1 | GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10 |
九、Stream
Redis Stream 是 Redis 5.0 版本新增加的数据类型,Redis 专门为消息队列设计的数据类型。
在前面介绍 List 类型实现的消息队列,有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据。
基于 Stream 类型的消息队列就解决上面的问题,它不仅支持自动生成全局唯一 ID,而且支持以消费组形式消费数据。
Stream 消息队列操作命令:
XADD:插入消息,保证有序,可以自动生成全局唯一 ID;
XREAD:用于读取消息,可以按 ID 读取数据;
XREADGROUP:按消费组形式读取消息;
XPENDING 和 XACK:
XPENDING 命令可以用来查询每个消费组内所有消费者已读取但尚未确认的消息,而 XACK 命令用于向消息队列确认消息处理已完成。
(1)Redis Stream 消息会丢失吗?
使用一个消息队列,其实就分为三大块:生产者、队列中间件、消费者,所以要保证消息就是保证三个环节都不能丢失数据。
Redis Stream 消息队列能不能保证三个环节都不丢失数据?
Redis 生产者会不会丢消息?生产者会不会丢消息,取决于生产者对于异常情况的处理是否合理。从消息被生产出来,然后提交给 MQ 的过程中,只要能正常收到 ( MQ 中间件) 的 ack 确认响应,就表示发送成功,所以只要处理好返回值和异常,如果返回异常则进行消息重发,那么这个阶段是不会出现消息丢失的。
Redis 消费者会不会丢消息?不会,因为 Stream ( MQ 中间件)会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,但是未被确认的消息。消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。等到消费者执行完业务逻辑后,再发送消费确认 XACK 命令,也能保证消息的不丢失。
Redis 队列中间件会不会丢消息?会,Redis 在以下 2 个场景下,都会导致数据丢失:
AOF 持久化配置为每秒写盘,但这个写盘过程是异步的,Redis 宕机时会存在数据丢失的可能
主从复制也是异步的,主从切换时,也存在丢失数据的可能。
可以看到,Redis 在队列中间件环节无法保证消息不丢。像 RabbitMQ 或 Kafka 这类专业的队列中间件,在使用时是部署一个集群,生产者在发布消息时,队列中间件通常会写「多个节点」,也就是有多个副本,这样一来,即便其中一个节点挂了,也能保证集群的数据不丢失。
(2)Redis Stream 消息可堆积吗?
Redis 的数据都存储在内存中,这就意味着一旦发生消息积压,则会导致 Redis 的内存持续增长,如果超过机器内存上限,就会面临被 OOM 的风险。所以 Redis 的 Stream 提供了可以指定队列最大长度的功能,就是为了避免这种情况发生。
但 Kafka、RabbitMQ 专业的消息队列它们的数据都是存储在磁盘上,当消息积压时,无非就是多占用一些磁盘空间。
因此,把 Redis 当作队列来使用时,会面临的 2 个问题:
Redis 本身可能会丢数据;
面对消息挤压,内存资源会紧张;
所以,能不能将 Redis 作为消息队列来使用,关键看你的业务场景:
如果你的业务场景足够简单,对于数据丢失不敏感,而且消息积压概率比较小的情况下,把 Redis 当作队列是完全可以的。
如果你的业务有海量消息,消息积压的概率比较大,并且不能接受数据丢失,那么还是用专业的消息队列中间件吧。
线程模型
Redis
基于 Reactor
模式开发了网络事件处理器,这个处理器被称为文本事件处理器。因为文件事件处理器队列消费是单线程的,所以 Redis
才叫单线程模型
文件事件处理器的结构:
多个
socket
IO 多路复用程序
文件事件分派器
事件处理器(连接应答处理器、命令请求处理器、命令回复器)
一、消息处理流程
文件事件处理器使用
IO
多路复用(multiplexing
)程序同时监听多个套接字,并根据套接字目前执行的任务类型为套接字关联不同的事件处理器当被监听的套接字准备好执行应答(
accpect
)、读取(read
)、写入(write
)、关闭(close
)等操作时,与之相对应的文件事件就会产生,这时文件事件处理器会调用套接字之前关联的好的事件处理器处理。
虽然多个文件事件处理器并发出现, 但是 IO
多路复用程序总是将所有产生事件的套接字推入同一个队列,通过该队列 有序(sequentially
)、同步(synchronously
)、每次一个套接字的方式想文件事件分派器传送套接字。当上一个套接字产生的事件被处理完毕后(该套接字为事件所关联的事件处理器处理完毕),IO
多路复用程序才向文件事件分派器传输下一个套接字。
二、IO多路复用的程序的实现
Redis
的 IO
多路复用程序的所有功能是通过包装 select
、evport
、epoll
、kqueue
这些多路复用的函数库实现的。每个 IO
多路复用的函数在 Redis
源码中都对应了一个单独的文件。如 ae_select.c
、ae_epoll.c
、ae_kqueue.c
等。
Redis
在 IO
多路复用程序的实现源码中,用 #include
宏定义了相关的规则,程序编译时自动选择系统中性能最好的 IO
多路复用函数库来作为 Redis
的IO
多路复用程序的底层实现。
1 | /* Include the best multiplexing layer supported by this system. |
三、文件事件的类型
IO
多路复用程序可以监听多个套接字的 ae.h/AE_READABLE
事件和 ae.h/AE_WRITABLE
事件,这两类事件和套接字操作之间的关系如下:
当套接字变得可读时(客户端对套接字执行
write
操作,或者执行close
操作),或者有新的可应答(acceptable
)套接字出现时(客户端对服务器的监听套接字执行connect
操作),套接字产生AE_READABLE
事件。当套接字变得可写时(客户端对套接字执行
read
操作),套接字产生AE_WRITABLE
事件。I/O
多路复用程序允许服务器同时监听套接字的AE_READABLE
事件和AE_WRITABLE
事件,如果一个套接字同时产生了这两种事件,那么文件事件分派器会优先处理AE_READABLE
事件,等到AE_READABLE
事件处理完之后,才处理AE_WRITABLE
事件。这也就是说,如果一个套接字又可读又可写的话,那么服务器将先读套接字,后写套接字。
四、文件事件处理器
Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通讯需求,常用的处理器如下:
为了对连接服务器的各个客户端进行应答, 服务器要为监听套接字关联连接应答处理器。
为了接收客户端传来的命令请求, 服务器要为客户端套接字关联命令请求处理器。
为了向客户端返回命令的执行结果, 服务器要为客户端套接字关联命令回复处理器。
连接应答处理器
networking.c
中 acceptTcpHandler
函数是 Redis
的连接应答处理器,这个处理器用于对连接服务器监听套接字的客户端进行应答,具体实现为 sys/socket.h/accept
函数的包装。当 Redis
服务器进行初始化的时候,程序会将这个连接应答处理器和服务器监听套接字的 AE_READABLE
事件关联起来,当有客户端用sys/socket.h/connect
函数连接服务器监听套接字的时候, 套接字就会产生AE_READABLE
事件, 引发连接应答处理器执行, 并执行相应的套接字应答操作
命令请求处理器
networking.c
中 readQueryFromClient
函数是 Redis
的命令请求处理器,这个处理器负责从套接字中读入客户端发送的命令请求内容, 具体实现为 unistd.h/read
函数的包装。当一个客户端通过连接应答处理器成功连接到服务器之后, 服务器会将客户端套接字的 AE_READABLE
事件和命令请求处理器关联起来,当客户端向服务器发送命令请求的时候,套接字就会产生 AE_READABLE
事件,引发命令请求处理器执行,并执行相应的套接字读入操作,在客户端连接服务器的整个过程中,服务器都会一直为客户端套接字的 AE_READABLE
事件关联命令请求处理器。
命令回复处理器
networking.c
中 sendReplyToClient
函数是 Redis
的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端,具体实现为 unistd.h/write
函数的包装。当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的 AE_WRITABLE
事件和命令回复处理器关联起来,当客户端准备好接收服务器传回的命令回复时,就会产生 AE_WRITABLE
事件,引发命令回复处理器执行,并执行相应的套接字写入操作,当命令回复发送完毕之后, 服务器就会解除命令回复处理器与客户端套接字的 AE_WRITABLE
事件之间的关联。
五、一次客户端和 redis
的通信流程
建立连接
redis
服务进程初始化时,将server socket
的AE_READABLE
事件与连接应答处理器关联客户端
socket01
向redis
进程sever socket
请求建立连接,此时sever socket
会产生一个AE_READABLE
事件,IO
多路复用程序监听到server socket
产生的事件后,将该socket01
压入队列中文件事件分派器从队列中获取
socket01
,交给连接应答处理器连接应答处理器会创建一个能与客户端通信的
socket01
,并将该socket
的AE_READABLE
的事件与请求应答处理器关联
执行请求
客户端发送
set key value
的请求,此时redis
的socket
产生AE_READABLE
,IO
多路复用程序将socket
压入队列事件分派处理器获取到
socket01
产生的AE_READABLE
事件,而在建立连接的过程中,已经将该socket
的AE_READABLE
事件,关联了请求应答处理器,由此交给请求应答处理器处理请求应答处理器读取
socket01
的key value
, 并在自己的内存中完成key value
的设置。操作完成后,将
socket01
的AE_WRITABLE
与 请求应答处理器关联客户端准备好接受结果,
redis
的socket01
会产生一个AE_WRITABLE
事件,并压入队列事件分派器找到相关联的命令回复处理器,由命令回复处理器对
socket01
输入本次操作的一个结果,比如ok
,之后解除socket01
的AE_WRITABLE
事件与命令回复处理器的关联。
性能优化
一、 Redis
为啥性能这么高?
纯内存操作
核心是基于
IO
多路复用机制C语言实现,更接近操作系统,性能较好
单线程避免了多线程的频繁上下文切换,预发了多线程可能产生的竞争问题
二、Linux
系统优化
ulimit
与 TCP backlog
1、修改 ulimit
通过 ulimit
修改 open files
参数,redis
建议把 open files
至少设置成 10032
maxclients
是10000
(客户端数据是以文本的形式进行保存的),redis
内部最多使用32
个文件描述符。
1 | ulimit -n 10032 #但重启后就无效了,也可以通过配置文件limits.conf 的形式持久修改 |
2、修改 TCP backlog
redis
默认的 tcp-backlog
为 511
,可以通过调整 tcp-backlog
进行调整,若 linux
中的 tcp-backlog
小于 redis
的 tcp-backlog
, 则日志会出现 warning
此参数确定了TCP连接中已完成队列(完成三次握手)的长度,此值必须小于或者等于 Linux 系统中定义的
/proc/sys/net/core/somaxconn
, 而默认为128
,当系统并发量大并且客户端缓慢时,可考虑这两个参数一起设定。
1 | 建议修改为 2048 修改somaxconn |
vm.overcommit_mermory
参数
vm.overcommit_mermory
表示内核在分配内存时做检查的方式。Redis
建议将 vm.overcommit_memory
取值设置为 1
,防止极端情况下 fork
出错
取值说明:
0
:内核将检查是否有足够的内存,如果足够则通过,否则内存申请失败把错误返回给应用进程。1
:表示内核允许超量使用内存直到用完为止。2
:绝不过量使用内存,即整个内存空间不能超过swap
+50%
的RAM
,50%
是overcommit_ratio
的默认值,支持修改。
1 | echo "vm.overcommit_memory=1" > /etc/sysctl.conf |
Linux 对大多数申请内存的回复均为 YES,以运行更多程序,因为申请后并不是立马使用,该技术叫
vm.overcommit
。
swappiness
参数
该参数决定了操作系统使用 swap
的倾向程度,取值从 0-100
, 值越大,说明操作系统使用 swap
的可能性越高,越低说明更倾向使用物理内存。
若系统内存不足, 可能会将
redis
对应的某些页从内存swap
到磁盘上,可以通过/proc
文件夹中的smaps
文件查看数据页是否有被swap
,若发现大量的页出现swap
, 可以通过vmstat
和iostat
进一步查看原因、
(1)Linux 3.5
设置为1
,其余设置为0
1 | echo "vm.swappiness=1" > /etc/sysctl.conf |
Transparent Huge Pages
支持大内存分页(2MB)分配,默认为开启,Redis 建议关闭此功能
1 | sudo chkconfig --add disable-transparent-hugepages |
OOM killer
内存不足时选择性杀掉用户进程,OOM Killer
会为每一个用户进程设置一个权重,权重越大,被 kill
的可能性越大。每个进程的权重放在 /proc/{progress_id}/oom_adj
, 可以将所有 Redis
的 oom_adj
设置为最低值或者较低值,降低被 kill
的值。
三、Redis
参数优化
maxclients
客户端最大连接数,如果连接数不够或者请求返回慢导致连接数不够,会报 max number of clients reached
调整 maxclients
最大连接数或者优化 redis
命令的处理性能。需要注意的是该参数受操作系统最大文件句柄的限制。
repl-ping-slave-period/repl-timeout
slave
会每隔 repl-ping-slave-period
(默认10s
) ping
一次 master
,若过了 repl-timeout
(默认 60s
)都没有收到响应,就会认为 master
挂了。
若 master
阻塞也会报这个错,可以适当调整 repl-timeout
时间
client-output-buffer-limit
客户端输出缓冲区大小,当使用主从复制时,性能压测,数据量会急剧升高,导致从节点复制的数据量很大,耗时增长,slave
没挂但是被阻塞了,master
的指令不能直接发给 slave
, 就会放到 out-put buffer
中。
1 | client-output-buffer-limit slave 256mb 64mb 60 |
上述配置说明:负责发送给 slave
的 client
,如果 buffer
超过 256m
或者连续 60
秒超过 64m
,就会被立刻强行关闭。所以此时应该相应调大数值,否则就会出现很悲剧的循环:Master
传输一个很大的 RDB
给 slave
,slave
努力地装载,但是还没装载完,Master
对 client
的缓存存满了,关闭后再来一次。
四、慢查询语句
Redis
提供了记录慢查询语句的功能,当语句执行(不包括排队时间)耗时超过了配置的阈值,则被认为是慢查询。
配置项:
1 | 语句耗时的阈值,单位是微秒,1秒=1000毫秒=1000 000微妙,默认值:10000,当值为0时,记录所有请求,小于0不记录任何请求 |
查询命令:
1 | 查询慢查询信息 |
主从复制
一、概述
主从复制是指将一台 Redis
服务的数据,复制到 Redis
服务器,前者称为主节点,后者称为为 slave
。数据复制是单向的,只能从主节点到从节点。默认情况下,每台 Redis
服务器都是主节点,且一个主节点可以有多个从节点或者(没有从节点),但是一个从节点只能有一个主节点。架构图如下:
二、作用
数据冗余:主从复制实现了数据的热备份,是数据持久化的又一种数据冗余方式
故障恢复:当主节点出现问题时,可以让从节点提供服务,实现快速的故障恢复,是一种服务冗余。
负载均衡:在主从复制的基础上,配合读写分离,可以让主节点承担写的请求,从节点承担读请求
高可用基石:主从复制是哨兵和集群模式的基础
三、原理
2.8 版权前只有全量复制,2.8 以及以上支持增量复制。
步骤
- 设置主节点的地址和端口
在从服务器中设置主节点的服务器信息,包括 ip
和端口,主从复制完全是在从节点发起的,不需要在主节点做任何事。从节点开启主从复制,有如下三种方式:
- 修改从节点的配置文件
1 | slaveof ${masterip} ${masterport} |
- 从节点启动命令
在从节点的启动命令加入如下配置
1 | --slaveof ${masterip} ${masterport} |
- 客户端命令
Redis
服务器启动后,直接客户端执行命令。
1 | slaveof ${masterip} ${masterport} |
完成上述配置后,从节点会将主节点的服务器地址和端口保存在属性中,可以通过 info Replication
命令查看对应的主从信息。
- 建立套接字连接
执行完 slaveof
,从服务会向主服务建立 socket
连接
- 发送
PING
命令
从节点成为主节点的客户端后,发送 ping
进行首次请求,目的:检查 socket
连接是否可用。以及主节点目前是否能处理命令。
发送 ping
后会出现如下 3
种情况:
返回
pong
: 说明socket
连接正常,且主节点当前可以处理请求,复制过程继续。返回
pong
以外的结果:主节点返回其他的结果,如正在处理运行超时的脚本,主节点无法处理命令,则主节点断开。超时:一定时间后从节点没有收到主节点的回复,说明
socket
不可用,从节点端口,重连。
- 权限验证
若从节点设置了 masterauth
属性,说明从节点需要向主节点验证身份,没有设置该选项,则不需要验证,从节点进行身份验证是通过向主节点发送 auth
命令实现的,命令的参数为配置文件中的 masterauth
的值。
若主节点设置密码的状态,与从节点的 masterauth
一致,即密码相同或者都不存在,则复制继续,否则断开重连。
- 同步
将从节点的数据状态同步为主节点当前的数据库状态。
从节点向主节点发送 psync
(2.8以前是 sync
)命令,基于主节点的不同状态分为全量复制和部分复制。
- 命令传播
同步完成后,主从数据库的状态已经一致了,但是只是临时不一致,也许主节点立刻接收到了新的命令,执行完成后,主从又不一致了。
所有在主从同步完成后,主从节点进入命令传播阶段,在这个阶段,主节点将自己执行的写命令发给从节点,从节点接收命令并执行,保证和主节点的一致性。
在命令传播阶段,我们需要关注如下两个问题
- 延迟与不一致
命令传播是异步的,主节点发送写命令后并不会等从节点的回复,因此主从节点很难保证实时的一致性,延迟在所难免。数据不一致的程度,与主从节点的网络状况、主节点写命令的执行频率以及主节点的 repl-disable-tcp-nodelay
配置相关。
repl-disable-tcp-nodelay
配置如下:
yes
:redis
会合并小的TCP包从而节省带宽,但会增加同步延迟(40ms
),造成master
与slave
数据不一致no
:redis master
会立即发送数据,没有延迟。
总得来说,前者关注性能,后者关注一致性。一般都是 no
, 除非当前应用对一致性的要求不高才会是 yes
。
- 心跳机制
用于确保主从节点的连接一直存活以及命令是否丢失的情况。其主要作用如下:
- 检查主从节点的网络连接情况
主节点信息可以看到所属的从节点的连接信息,state
表示从节点状态,offset
表示从节点复制的偏移量,lag
表示从节点复制的延迟。
- 辅助实现
min-slaves
选项
Redis
配置文件中有如下配置:
1 | 未达到下面两个条件时,写操作就不会被执行 |
如果将两个命令的注释取消,那么如果从节点的数量小于 3
或者从节点的延迟大于 10
,则主节点不会执行写命令。
- 检测命令丢失
在从节点的连接信息中可以看到复制的偏移量,如果此时主服务器的偏移量和从服务器的复制偏移量不一致时,主服务器会补发数据。
cluster集群
Redis cluster
是一种服务器 sharding
技术。主从复制和哨兵模式保证了服务器的高可用,cluster 集群则保证了集群的高拓展。
设计目标
集群目标
高性能线性拓展最多1000个节点。集群没有代理,集群节点之间异步通信,没有归并操作。
集群协议中客户端和服务端的角色
节点负责维护数据和获取集群状态,包括将 keys
映射到正确的节点,集群节点同样可以自动发现其他节点、检测不工作节点以及在发现故障发生时晋升 slave
节点为 master
。
所有集群节点通过 TCP
和二进制协议组成称为 Redis cluster bus
的方式