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

底层数据结构主要是intSDS(简单动态字符串),是可以修改的字符串,内部的结构类似 JavaArrayList , 采用预分配冗余空间的方式减少内存的频繁分配。

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)来保存这个字符串,并将对象的编码设置为embstrembstr编码是专门用于保存短字符串的一种优化编码方式。

  • 如果字符串对象保存的是一个字符串,并且这个字符串的长度大于 32 字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为raw

可以看到embstrraw编码都会使用SDS来保存值,但不同之处在于embstr会通过一次内存分配函数来分配一块连续的内存空间来保存redisObjectSDS,而raw编码会通过调用两次内存分配函数来分别分配两块空间来保存redisObjectSDS

优点:

  • embstr编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次;

  • 释放 embstr编码的字符串对象同样只需要调用一次内存释放函数;

  • embstr编码的字符串对象的所有数据都保存在一块连续的内存里面可以更好的利用 CPU 缓存提升性能。

缺点:

  • 如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,所以embstr编码的字符串对象实际上是只读的,redis没有为embstr编码的字符串对象编写任何相应的修改程序。当我们对embstr编码的字符串对象执行任何修改命令(例如append)时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。

常用命令

# 设置键值对
SET key value

# 获取键值对
GET key

# 一次性获取多个键值对
MGET key1 key2 ...

# 一次设置多个键值对
MSET key1 value1 key2 value2 ..

# 判断键是否存在
EXISTS key

# 删除键值对
DEL key1 key2 ...

# 匹配对应的key,查找foo开头的key
KEYS foo*

# 修改key的名称,若新名称存在,则报错
REMANE key1 key2

# 返回key的总数
DBSIZE

# 设置key的过期时间,单位秒
EXPIRE key seconds

# 设置过期时间,等价于 set+expire
SETEX key time value

# 如果 key 不存在则执行 set 创建,返回 1。否则不创建返回 0
SETNX key value

# 前两个是递增 1 和递减 1 【INCR key】,后两个是指定递增和递减的数字【INCRBY KEY 倍数】。前提是一定要是数字才能进行加减;
INCR/DECR/INCRBY/DECRBY

应用场景

(1)缓存对象

(2)常规计数

(3)分布式锁

// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

三、List(列表)

List 列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向 List 列表添加元素。类似 JavaLinkedList

列表的最大长度为 2^32 - 1,也即每个列表支持超过 40 亿个元素

底层实现双向链表或压缩列表实现的,如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;

但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表

常用命令:

# 将一个或多个值value插入到key列表的表头(最左边),最后的值在最前面
LPUSH key value [value ...] 

# 将一个或多个值value插入到key列表的表尾(最右边)
RPUSH key value [value ...]

# 移除并返回key列表的头元素
LPOP key     

# 移除并返回key列表的尾元素
RPOP key 

# 返回列表key中指定区间内的元素,区间以偏移量start和stop指定,从0开始
LRANGE key start stop

# 从key列表表头弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞
BLPOP key [key ...] timeout

# 从key列表表尾弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞
BRPOP key [key ...] timeout

应用场景

  • 消息队列

消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息可靠性

(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 数据结构来实现了

常用命令

# 存储一个哈希表key的键值
HSET key field value

# 获取哈希表key对应的field键值
HGET key field

# 在一个哈希表key中存储多个键值对
HMSET key field value [field value...]

# 批量获取哈希表key中多个field键值
HMGET key field [field ...]    
   
# 删除哈希表key中的field键值
HDEL key field [field ...]    

# 返回哈希表key中field的数量
HLEN key

# 返回哈希表key中所有的键值
HGETALL key 

# 为哈希表key中field键的值加上增量n
HINCRBY key field n           

应用场景

(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 类型的底层数据结构。

常用命令

# 往集合key中存入元素,元素存在则忽略,若key不存在则新建
SADD key member [member ...]

# 从集合key中删除元素
SREM key member [member ...] 

# 获取集合key中所有元素
SMEMBERS key

# 获取集合key中的元素个数
SCARD key

# 判断member元素是否存在于集合key中
SISMEMBER key member

# 从集合key中随机选出count个元素,元素不从key中删除
SRANDMEMBER key [count]

# 从集合key中随机选出count个元素,元素从key中删除
SPOP key [count]

# 运算操作
# 交集运算
SINTER key [key ...]

# 将交集结果存入新集合destination中
SINTERSTORE destination key [key ...]

# 并集运算
SUNION key [key ...]

# 将并集结果存入新集合destination中
SUNIONSTORE destination key [key ...]

# 差集运算
SDIFF key [key ...]

# 将差集结果存入新集合destination中
SDIFFSTORE destination 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 数据结构来实现了。

常用命令

# 往有序集合key中加入带分值元素
ZADD key score member [[score member]...]   

# 往有序集合key中删除元素
ZREM key member [member...]                 

# 返回有序集合key中元素member的分值
ZSCORE key member

# 返回有序集合key中元素个数
ZCARD key 

# 为有序集合key中元素member的分值加上increment
ZINCRBY key increment member 

# 正序获取有序集合key从start下标到stop下标的元素
ZRANGE key start stop [WITHSCORES]

# 倒序获取有序集合key从start下标到stop下标的元素
ZREVRANGE key start stop [WITHSCORES]

# 返回有序集合中指定分数区间内的成员,分数由低到高排序。
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]

# 返回指定成员区间内的成员,按字典正序排列, 分数必须相同。
ZRANGEBYLEX key min max [LIMIT offset count]

# 返回指定成员区间内的成员,按字典倒序排列, 分数必须相同
ZREVRANGEBYLEX key max min [LIMIT offset count]

#运算操作
# 并集计算(相同元素分值相加),numberkeys一共多少个key,WEIGHTS每个key对应的分值乘积
ZUNIONSTORE destkey numberkeys key [key...] 

# 交集计算(相同元素分值相加),numberkeys一共多少个key,WEIGHTS每个key对应的分值乘积
ZINTERSTORE destkey numberkeys key [key...]

应用场景

Zset 类型(Sorted Set,有序集合) 可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入 Sorted Set 的时间确定权重值,先插入的元素权重小,后插入的元素权重大。

在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,可以优先考虑使用 Sorted Set

(1)排行榜

六、Bitmap(位图)

Bitmap,即位图,是一串连续的二进制数组(01),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,时间复杂度为O(1)

由于 bit 是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景

Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。

String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组。

常用命令

# 设置值,其中value只能是 0 和 1
SETBIT key offset value

# 获取值
GETBIT key offset

# 获取指定范围内值为 1 的个数
# start 和 end 以字节为单位
BITCOUNT key start end

# 运算
# BitMap间的运算
# operations 位移操作符,枚举值
  AND 与运算 &
  OR 或运算 |
  XOR 异或 ^
  NOT 取反 ~
  
# result 计算的结果,会存储在该key中
# key1 … keyn 参与运算的key,可以有多个,空格分割,not运算只能一个key
# 当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0。返回值是保存到 destkey 的字符串的长度(以字节byte为单位),和输入 key 中最长的字符串长度相等。
BITOP [operations] [result] [key1] [keyn…]

# 返回指定key中第一次出现指定value(0/1)的位置
BITPOS [key] [value]

应用场景

Bitmap 类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有 01 两种,在记录海量数据时,Bitmap 能够有效地节省内存空间。

(1)签到统计

(2)判断用户登录

Bitmap 提供了 GETBIT、SETBIT 操作,通过一个偏移值 offsetbit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset0 开始。

只需要一个 key = login_status 表示存储用户登陆状态集合数据, 将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT判断对应的用户是否在线。50000 万 用户只需要 6 MB 的空间。

假如我们要判断 ID = 10086 的用户的登陆情况:

第一步,执行以下指令,表示用户已登录。

SETBIT login_status 10086 1

第二步,检查该用户是否登陆,返回值 1 表示已登录。

GETBIT login_status 10086

第三步,登出,将 offset 对应的 value 设置成 0。

SETBIT login_status 10086 0

(3)连续签到用户总数

如何统计出这连续 7 天连续打卡用户总数呢?

我们把每天的日期作为 BitmapkeyuserId 作为 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 可以是 andORNOTXOR。当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0 。空的 key 也被看作是包含 0 的字符串序列。

举个例子,比如将三个 bitmap 进行 AND 操作,并将结果保存到 destmap 中,接着对 destmap 执行 BITCOUNT 统计。

# 与操作
BITOP AND destmap bitmap:01 bitmap:02 bitmap:03

# 统计 bit 位 =  1 的个数
BITCOUNT destmap

即使一天产生一个亿的数据,Bitmap 占用的内存也不大,大约占 12 MB 的内存(10^8/8/1024/1024)7 天的 Bitmap 的内存开销约为 84 MB。同时我们最好给 Bitmap 设置过期时间,让 Redis 删除过期的打卡数据,节省内存。

(4)统计用户的活跃天数

七、HyperLogLog

Redis HyperLogLogRedis 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 就能统计完。

常用命令

# 添加指定元素到 HyperLogLog 中
PFADD key element [element ...]

# 返回给定 HyperLogLog 的基数估算值。
PFCOUNT key [key ...]

# 将多个 HyperLogLog 合并为一个 HyperLogLog
PFMERGE destkey sourcekey [sourcekey ...]

应用场景

(1)百万级网页UV计数

# 添加用户
PFADD page1:uv user1 user2 user3 user4 user5

# 统计数目
PFCOUNT page1:uv

八、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 服务中频繁使用的“搜索附近”的需求。

常用命令

# 存储指定的地理空间位置,可以将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中。
GEOADD key longitude latitude member [longitude latitude member ...]

# 从给定的 key 里返回所有指定名称(member)的位置(经度和纬度),不存在的返回 nil。
GEOPOS key member [member ...]

# 返回两个给定位置之间的距离。
GEODIST key member1 member2 [m|km|ft|mi]

# 根据用户给定的经纬度坐标来获取指定范围内的地理位置集合。
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]

应用场景

(1)滴滴叫车

这里以滴滴叫车的场景为例,介绍下具体如何使用 GEO 命令:GEOADD 和 GEORADIUS 这两个命令。

假设车辆 ID 是 33,经纬度位置是(116.034579,39.030452),我们可以用一个 GEO 集合保存所有车辆的经纬度,集合 key 是 cars:locations。

执行下面的这个命令,就可以把 ID 号为 33 的车辆的当前经纬度位置存入 GEO 集合中:

GEOADD cars:locations 116.034579 39.030452 33

当用户想要寻找自己附近的网约车时,LBS 应用就可以使用 GEORADIUS 命令。

例如,LBS 应用执行下面的命令时,Redis 会根据输入的用户的经纬度信息(116.054579,39.030452 ),查找以这个经纬度为中心的 5 公里内的车辆信息,并返回给 LBS 应用。

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 多路复用程序

  • 文件事件分派器

  • 事件处理器(连接应答处理器、命令请求处理器、命令回复器)

Redis线程模型

一、消息处理流程

  • 文件事件处理器使用 IO多路复用(multiplexing)程序同时监听多个套接字,并根据套接字目前执行的任务类型为套接字关联不同的事件处理器

  • 当被监听的套接字准备好执行应答(accpect)、读取(read)、写入(write)、关闭(close)等操作时,与之相对应的文件事件就会产生,这时文件事件处理器会调用套接字之前关联的好的事件处理器处理。

虽然多个文件事件处理器并发出现, 但是 IO多路复用程序总是将所有产生事件的套接字推入同一个队列,通过该队列 有序(sequentially)、同步(synchronously)、每次一个套接字的方式想文件事件分派器传送套接字。当上一个套接字产生的事件被处理完毕后(该套接字为事件所关联的事件处理器处理完毕),IO多路复用程序才向文件事件分派器传输下一个套接字。

二、IO多路复用的程序的实现

RedisIO 多路复用程序的所有功能是通过包装 selectevportepollkqueue 这些多路复用的函数库实现的。每个 IO 多路复用的函数在 Redis 源码中都对应了一个单独的文件。如 ae_select.cae_epoll.cae_kqueue.c等。

Redis IO 多路复用

RedisIO 多路复用程序的实现源码中,用 #include 宏定义了相关的规则,程序编译时自动选择系统中性能最好的 IO多路复用函数库来作为 RedisIO多路复用程序的底层实现。

/* Include the best multiplexing layer supported by this system.
 * The following should be ordered by performances, descending. */
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c"
        #else
        #include "ae_select.c"
        #endif
    #endif
#endif

三、文件事件的类型

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.cacceptTcpHandler 函数是 Redis 的连接应答处理器,这个处理器用于对连接服务器监听套接字的客户端进行应答,具体实现为 sys/socket.h/accept 函数的包装。当 Redis 服务器进行初始化的时候,程序会将这个连接应答处理器和服务器监听套接字的 AE_READABLE 事件关联起来,当有客户端用sys/socket.h/connect 函数连接服务器监听套接字的时候, 套接字就会产生AE_READABLE 事件, 引发连接应答处理器执行, 并执行相应的套接字应答操作

命令请求处理器

networking.creadQueryFromClient 函数是 Redis 的命令请求处理器,这个处理器负责从套接字中读入客户端发送的命令请求内容, 具体实现为 unistd.h/read 函数的包装。当一个客户端通过连接应答处理器成功连接到服务器之后, 服务器会将客户端套接字的 AE_READABLE 事件和命令请求处理器关联起来,当客户端向服务器发送命令请求的时候,套接字就会产生 AE_READABLE 事件,引发命令请求处理器执行,并执行相应的套接字读入操作,在客户端连接服务器的整个过程中,服务器都会一直为客户端套接字的 AE_READABLE 事件关联命令请求处理器。

命令回复处理器

networking.csendReplyToClient 函数是 Redis 的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端,具体实现为 unistd.h/write 函数的包装。当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的 AE_WRITABLE 事件和命令回复处理器关联起来,当客户端准备好接收服务器传回的命令回复时,就会产生 AE_WRITABLE 事件,引发命令回复处理器执行,并执行相应的套接字写入操作,当命令回复发送完毕之后, 服务器就会解除命令回复处理器与客户端套接字的 AE_WRITABLE 事件之间的关联。

五、一次客户端和 redis 的通信流程

建立连接

  1. redis 服务进程初始化时,将 server socketAE_READABLE 事件与连接应答处理器关联

  2. 客户端 socket01redis 进程 sever socket 请求建立连接,此时 sever socket 会产生一个 AE_READABLE 事件,IO 多路复用程序监听到 server socket 产生的事件后,将该 socket01 压入队列中

  3. 文件事件分派器从队列中获取 socket01,交给连接应答处理器

  4. 连接应答处理器会创建一个能与客户端通信的 socket01 ,并将该 socketAE_READABLE 的事件与请求应答处理器关联

执行请求

  1. 客户端发送 set key value 的请求,此时 redissocket 产生 AE_READABLEIO 多路复用程序将 socket 压入队列

  2. 事件分派处理器获取到 socket01 产生的 AE_READABLE 事件,而在建立连接的过程中,已经将该 socketAE_READABLE 事件,关联了请求应答处理器,由此交给请求应答处理器处理

  3. 请求应答处理器读取 socket01key value, 并在自己的内存中完成 key value 的设置。

  4. 操作完成后,将 socket01AE_WRITABLE 与 请求应答处理器关联

  5. 客户端准备好接受结果, redissocket01 会产生一个 AE_WRITABLE 事件,并压入队列

  6. 事件分派器找到相关联的命令回复处理器,由命令回复处理器对 socket01 输入本次操作的一个结果,比如 ok,之后解除 socket01AE_WRITABLE 事件与命令回复处理器的关联。

Redis请求流程

性能优化

一、 Redis 为啥性能这么高?

  • 纯内存操作

  • 核心是基于 IO 多路复用机制

  • C语言实现,更接近操作系统,性能较好

  • 单线程避免了多线程的频繁上下文切换,预发了多线程可能产生的竞争问题

二、Linux 系统优化

ulimitTCP backlog

1、修改 ulimit

通过 ulimit 修改 open files 参数,redis 建议把 open files 至少设置成 10032

maxclients10000(客户端数据是以文本的形式进行保存的),redis 内部最多使用 32 个文件描述符。

ulimit -n 10032  #但重启后就无效了,也可以通过配置文件limits.conf 的形式持久修改

2、修改 TCP backlog

redis 默认的 tcp-backlog511,可以通过调整 tcp-backlog 进行调整,若 linux 中的 tcp-backlog 小于 redistcp-backlog, 则日志会出现 warning

此参数确定了TCP连接中已完成队列(完成三次握手)的长度,此值必须小于或者等于 Linux 系统中定义的 /proc/sys/net/core/somaxconn, 而默认为 128,当系统并发量大并且客户端缓慢时,可考虑这两个参数一起设定。

#建议修改为 2048 修改somaxconn
#该内核参数默认值一般是128,对于负载很大的服务程序来说大大的不够。一般会将它修改为2048或者更大。
echo 2048 > /proc/sys/net/core/somaxconn # 但是这样系统重启后保存不了

#持久化设置: 在 /etc/sysctl.conf 中添加如下:
net.core.somaxconn = 2048

#然后在终端中执行:
sysctl -p

vm.overcommit_mermory 参数

vm.overcommit_mermory 表示内核在分配内存时做检查的方式。Redis 建议将 vm.overcommit_memory 取值设置为 1,防止极端情况下 fork 出错

取值说明:

  • 0 :内核将检查是否有足够的内存,如果足够则通过,否则内存申请失败把错误返回给应用进程。

  • 1 :表示内核允许超量使用内存直到用完为止。

  • 2 :绝不过量使用内存,即整个内存空间不能超过 swap + 50%RAM50%overcommit_ratio 的默认值,支持修改。

echo "vm.overcommit_memory=1" > /etc/sysctl.conf 

Linux 对大多数申请内存的回复均为 YES,以运行更多程序,因为申请后并不是立马使用,该技术叫 vm.overcommit

swappiness 参数

该参数决定了操作系统使用 swap 的倾向程度,取值从 0-100, 值越大,说明操作系统使用 swap 的可能性越高,越低说明更倾向使用物理内存。

若系统内存不足, 可能会将 redis 对应的某些页从内存 swap 到磁盘上,可以通过 /proc 文件夹中的 smaps 文件查看数据页是否有被 swap,若发现大量的页出现 swap, 可以通过 vmstatiostat 进一步查看原因、

(1)Linux 3.5 设置为1,其余设置为0

echo "vm.swappiness=1" > /etc/sysctl.conf 

Transparent Huge Pages

支持大内存分页(2MB)分配,默认为开启,Redis 建议关闭此功能

sudo chkconfig --add disable-transparent-hugepages

OOM killer

内存不足时选择性杀掉用户进程,OOM Killer 会为每一个用户进程设置一个权重,权重越大,被 kill 的可能性越大。每个进程的权重放在 /proc/{progress_id}/oom_adj , 可以将所有 Redisoom_adj 设置为最低值或者较低值,降低被 kill 的值。

三、Redis 参数优化

maxclients

客户端最大连接数,如果连接数不够或者请求返回慢导致连接数不够,会报 max number of clients reached

调整 maxclients 最大连接数或者优化 redis 命令的处理性能。需要注意的是该参数受操作系统最大文件句柄的限制。

repl-ping-slave-period/repl-timeout

slave 会每隔 repl-ping-slave-period(默认10sping 一次 master,若过了 repl-timeout(默认 60s)都没有收到响应,就会认为 master 挂了。

master 阻塞也会报这个错,可以适当调整 repl-timeout 时间

client-output-buffer-limit

客户端输出缓冲区大小,当使用主从复制时,性能压测,数据量会急剧升高,导致从节点复制的数据量很大,耗时增长,slave 没挂但是被阻塞了,master 的指令不能直接发给 slave , 就会放到 out-put buffer 中。

client-output-buffer-limit slave 256mb 64mb 60

上述配置说明:负责发送给 slaveclient ,如果 buffer 超过 256m 或者连续 60 秒超过 64m,就会被立刻强行关闭。所以此时应该相应调大数值,否则就会出现很悲剧的循环:Master 传输一个很大的 RDBslaveslave 努力地装载,但是还没装载完,Masterclient 的缓存存满了,关闭后再来一次。

四、慢查询语句

Redis 提供了记录慢查询语句的功能,当语句执行(不包括排队时间)耗时超过了配置的阈值,则被认为是慢查询。

配置项:

# 语句耗时的阈值,单位是微秒,1秒=1000毫秒=1000 000微妙,默认值:10000,当值为0时,记录所有请求,小于0不记录任何请求
slowlog-log-slower-than 

# 记录慢查询的语句条数
slowlog-max-len

查询命令:

# 查询慢查询信息
slowlog get 

# 显示当前有多少条慢查询 
slowlog len

主从复制

一、概述

主从复制是指将一台 Redis 服务的数据,复制到 Redis 服务器,前者称为主节点,后者称为为 slave。数据复制是单向的,只能从主节点到从节点。默认情况下,每台 Redis 服务器都是主节点,且一个主节点可以有多个从节点或者(没有从节点),但是一个从节点只能有一个主节点。架构图如下:

Redis主从复制

二、作用

  • 数据冗余:主从复制实现了数据的热备份,是数据持久化的又一种数据冗余方式

  • 故障恢复:当主节点出现问题时,可以让从节点提供服务,实现快速的故障恢复,是一种服务冗余。

  • 负载均衡:在主从复制的基础上,配合读写分离,可以让主节点承担写的请求,从节点承担读请求

  • 高可用基石:主从复制是哨兵和集群模式的基础

三、原理

2.8 版权前只有全量复制,2.8 以及以上支持增量复制。

步骤

  1. 设置主节点的地址和端口

在从服务器中设置主节点的服务器信息,包括 ip 和端口,主从复制完全是在从节点发起的,不需要在主节点做任何事。从节点开启主从复制,有如下三种方式:

  • 修改从节点的配置文件
slaveof ${masterip} ${masterport}
  • 从节点启动命令

在从节点的启动命令加入如下配置

--slaveof ${masterip} ${masterport}
  • 客户端命令

Redis 服务器启动后,直接客户端执行命令。

slaveof ${masterip} ${masterport}

完成上述配置后,从节点会将主节点的服务器地址和端口保存在属性中,可以通过 info Replication 命令查看对应的主从信息。

  1. 建立套接字连接

执行完 slaveof,从服务会向主服务建立 socket 连接

  1. 发送 PING 命令

从节点成为主节点的客户端后,发送 ping 进行首次请求,目的:检查 socket 连接是否可用。以及主节点目前是否能处理命令。

发送 ping 后会出现如下 3 种情况:

  • 返回 pong : 说明 socket 连接正常,且主节点当前可以处理请求,复制过程继续。

  • 返回 pong 以外的结果:主节点返回其他的结果,如正在处理运行超时的脚本,主节点无法处理命令,则主节点断开。

  • 超时:一定时间后从节点没有收到主节点的回复,说明 socket 不可用,从节点端口,重连。

  1. 权限验证

若从节点设置了 masterauth 属性,说明从节点需要向主节点验证身份,没有设置该选项,则不需要验证,从节点进行身份验证是通过向主节点发送 auth 命令实现的,命令的参数为配置文件中的 masterauth 的值。

若主节点设置密码的状态,与从节点的 masterauth 一致,即密码相同或者都不存在,则复制继续,否则断开重连。

  1. 同步

将从节点的数据状态同步为主节点当前的数据库状态。

从节点向主节点发送 psync(2.8以前是 sync)命令,基于主节点的不同状态分为全量复制和部分复制。

  1. 命令传播

同步完成后,主从数据库的状态已经一致了,但是只是临时不一致,也许主节点立刻接收到了新的命令,执行完成后,主从又不一致了。

所有在主从同步完成后,主从节点进入命令传播阶段,在这个阶段,主节点将自己执行的写命令发给从节点,从节点接收命令并执行,保证和主节点的一致性。

在命令传播阶段,我们需要关注如下两个问题

  • 延迟与不一致

命令传播是异步的,主节点发送写命令后并不会等从节点的回复,因此主从节点很难保证实时的一致性,延迟在所难免。数据不一致的程度,与主从节点的网络状况、主节点写命令的执行频率以及主节点的 repl-disable-tcp-nodelay 配置相关。

repl-disable-tcp-nodelay 配置如下:

  • yesredis会合并小的TCP包从而节省带宽,但会增加同步延迟(40ms),造成 masterslave 数据不一致

  • noredis master 会立即发送数据,没有延迟。

总得来说,前者关注性能,后者关注一致性。一般都是 no, 除非当前应用对一致性的要求不高才会是 yes

  • 心跳机制

用于确保主从节点的连接一直存活以及命令是否丢失的情况。其主要作用如下:

  • 检查主从节点的网络连接情况

主节点信息可以看到所属的从节点的连接信息,state 表示从节点状态,offset 表示从节点复制的偏移量,lag 表示从节点复制的延迟。

  • 辅助实现 min-slaves 选项

Redis 配置文件中有如下配置:

# 未达到下面两个条件时,写操作就不会被执行
# 最少包含的从服务器
# min-slaves-to-write 3
# 延迟值
# min-slaves-max-lag 10

如果将两个命令的注释取消,那么如果从节点的数量小于 3 或者从节点的延迟大于 10,则主节点不会执行写命令。

  • 检测命令丢失

在从节点的连接信息中可以看到复制的偏移量,如果此时主服务器的偏移量和从服务器的复制偏移量不一致时,主服务器会补发数据。

cluster集群

Redis cluster 是一种服务器 sharding 技术。主从复制和哨兵模式保证了服务器的高可用,cluster 集群则保证了集群的高拓展。

设计目标

集群目标

高性能线性拓展最多1000个节点。集群没有代理,集群节点之间异步通信,没有归并操作。

集群协议中客户端和服务端的角色

节点负责维护数据和获取集群状态,包括将 keys 映射到正确的节点,集群节点同样可以自动发现其他节点、检测不工作节点以及在发现故障发生时晋升 slave 节点为 master

所有集群节点通过 TCP 和二进制协议组成称为 Redis cluster bus 的方式

持久化

事务机制

故障排查

淘汰策略与缓存机制

大key风险和改造