编辑
2024-06-04
💌中间键
00
请注意,本文编写于 380 天前,最后修改于 225 天前,其中某些信息可能已经过时。

目录

2.1 预备
2.1.1 全局命令
2.1.2数据结构和内部编码
2.1.3 单线程架构
2.2 字符串
2.2.1 命令
设置值
获取值
批量设置值
批量获取值
计数
不常用命令
2.2.2 内部编码
2.2.3 典型使用场景
缓存
计数
共享session
限速
2.3 哈希
2.3.1 命令
设置值
获取值
删除field
计算field个数
批量设置或获取field-value
判断field是否存在
获取所有field
获取所有value
获取所有的field-value
计算value的字符串长度(需要Redis3.2以上)
2.3.2 内部编码
2.3.3 使用场景
2.4 列表
2.4.1 命令
1.添加操作
2.查找
3.删除
4.修改
5.阻塞操作
2.4.2 内部编码
2.4.3 使用场景
2.5 集合
2.5.1 命令
1.集合内操作
2.集合间操作
2.5.2 内部编码

2.1 预备

2.1.1 全局命令

redis
# 查看所有键 keys * # 键总数 dbsize # 键是否存在 exists ke # 删除key del key # 键过期 expire key seconds ttl hello # 有3种返回值:大于等于0的数:键剩余的过期时间;-1:键没设置过期时间;-2:键不存在 # 键的数据结构类型 type key

2.1.2数据结构和内部编码

Redis的5种数据结构

image.png

可以看到每种数据结构都有两种以上的内部编码实现,

例如list数据结构包含了linkedlist和ziplist两种内部编码。

同时有些内部编码,例如ziplist,可以作为多种外部数据结构的内部实现,

可以通过object encoding命令查询内部编码:

redis
>object encoding hello "embstr" > object encoding mylist "ziplist"

image.png

Redis这样设计有两个好处:

  1. 可以改进内部编码,而对外的数据结构和命令没有影响,这样一旦开发出更优秀的内部编码,无需改动外部数据结构和命令,例如Redis3.2提供了quicklist,结合了ziplist和linkedlist两者的优势,为列表类型提供了一种更为优秀的内部编码实现,而对外部用户来说基本感知不到。
  2. 多种内部编码实现可以在不同场景下发挥各自的优势,例如ziplist比较节省内存,但是在列表元素比较多的情况下,性能会有所下降,这时候Redis会根据配置选项将列表类型的内部实现转换为linkedlist

2.1.3 单线程架构

Redis使用了单线程架构和I/O多路复用模型来实现高性能的内存数据库服务,

本节首先通过多个客户端命令调用的例子说明Redis单线程命令处理机制,

接着分析Redis单线程模型为什么性能如此之高,最终给出为什么理解单线程模型是使用和运维Redis的关键。

  1. 引出单线程模型

现在开启了三个redis-cli客户端同时执行命令。 客户端1设置一个字符串键值对:

redis
127.0.0.1:6379> set hello world

客户端2对counter做自增操作:

redis
127.0.0.1:6379> incr counter

客户端3对counter做自增操作:

redis
127.0.0.1:6379> incr counter

Redis客户端与服务端的模型可以简化成图2-3,每次客户端调用都经历了发送命令、执行命令、返回结果三个过程

image.png

其中第2步是重点要讨论的,因为Redis是单线程来处理命令的,所以一条命令从客户端达到服务端不会立刻被执行,

所有命令都会进入一个队列中,然后逐个被执行。

所以上面3个客户端命令的执行顺序是不确定的(如图2-4所示),但是可以确定不会有两条命令被同时执行(如图2-5所示),所以两条incr命令无论怎么执行最终结果都是2,

不会产生并发问题,这就是Redis单线程的基本模型。

但是像发送命令、返回结果、命令排队肯定不像描述的这么简单,Redis使用了I/O多路复用技术来解决I/O的问题,

image.png

image.png

  1. 为什么单线程还能这么快?

通常来讲,单线程处理能力要比多线程差,例如有10000斤货物,每辆车的运载能力是每次200斤,那么要50次才能完成,但是如果有50辆车,只要安排合理,只需要一次就可以完成任务。

那么为什么Redis使用单线程模型会达到每秒万级别的处理能力呢?可以将其归结为三点:

  1. 纯内存访问,Redis将所有数据放在内存中,内存的响应时长大约为100纳秒,这是Redis达到每秒万级别访问的重要基础。
  2. 非阻塞I/O,Redis使用epoll作为I/O多路复用技术的实现,再加上Redis自身的事件处理模型将epoll中的连接、读写、关闭都转换为事件,不在网络I/O上浪费过多的时间,如图2-6所示。

image.png 3. 单线程避免了线程切换和竞态产生的消耗

单线程能带来几个好处:

  • 第一,单线程可以简化数据结构和算法的实现。如果对高级编程语言熟悉的读者应该了解并发数据结构实现不但困难而且开发测试比较麻烦。
  • 第二,单线程避免了线程切换和竞态产生的消耗,对于服务端开发来说,锁和线程切换通常是性能杀手。

单线程会有一个问题:

  • 对于每个命令的执行时间是有要求的。如果某个命令执行过长,会造成其他命令的阻塞,对于Redis这种高性能的服务来说是致命的,所以Redis是面向快速执行场景的数据库。

2.2 字符串

如图2-7所示,字符串类型的值实际可以是字符串(简单的字符串、复杂的字符串(例如JSON、XML))、数字 (整数、浮点数),甚至是二进制(图片、音频、视频),但是值最大不能超过512MB

image.png

2.2.1 命令

设置值

redis
set key value [ex seconds] [px milliseconds] [nx|xx]

下面操作设置键为hello,值为world的键值对,返回结果为OK代表设置 成功:

redis
127.0.0.1:6379> set hello world OK

set命令有几个选项:

  • ex seconds:为键设置秒级过期时间。
  • px milliseconds:为键设置毫秒级过期时间。
  • nx:键必须不存在,才可以设置成功,用于添加。
  • xx:与nx相反,键必须存在,才可以设置成功,用于更新。

除了set选项,Redis还提供了setex和setnx两个命令:

redis
setex key seconds value setnx key value

它们的作用和ex和nx选项是一样的。下面的例子说明了set、setnx、set xx的区别。 当前键hello不存在:

127.0.0.1:6379> exists hello (integer) 0

设置键为hello,值为world的键值对:

127.0.0.1:6379> set hello world OK

因为键hello已存在,所以setnx失败,返回结果为0:

127.0.0.1:6379> setnx hello redis (integer) 0

因为键hello已存在,所以set xx成功,返回结果为OK:

127.0.0.1:6379> set hello jedis xx OK

**setnx和setxx在实际使用中有什么应用场景吗?

以setnx命令为例子,由于Redis的单线程命令处理机制,如果有多个客户端同时执行setnx key value,

根据setnx的特性只有一个客户端能设置成功,setnx可以作为分布式锁的一种实现方案,

Redis官方给出了使用setnx实现分布式锁的方法:http://redis.io/topics/distlock。

获取值

get key

下面操作获取键hello的值:

127.0.0.1:6379> get hello "world"

如果要获取的键不存在,则返回nil(空):

127.0.0.1:6379> get not_exist_key (nil)

批量设置值

mset key value [key value ...]

下面操作通过mset命令一次性设置4个键值对:

127.0.0.1:6379> mset a 1 b 2 c 3 d 4 OK

批量获取值

mget key [key ...] 下面操作批量获取了键a、b、c、d的值:

127.0.0.1:6379> mget a b c d 1) "1" 2) "2" 3) "3" 4) "4"

如果有些键不存在,那么它的值为nil(空),结果是按照传入键的顺序返回:

127.0.0.1:6379> mget a b c f 1) "1" 2) "2" 3) "3" 4) (nil)

image.png

使用mget命令后,要执行n次get命令操作只需要按照图2-9的方式来完 成,具体耗时如下:

n次get时间 = 1次网络时间 + n次命令时间

image.png

Redis可以支撑每秒数万的读写操作,但是这指的是Redis服务端的处理能力,

对于客户端来说,一次命令除了命令时间还是有网络时间,

假设网络时间为1毫秒,命令时间为0.1毫秒(按照每秒处理1万条命令算),

那么执行1000次get命令和1次mget命令的区别如表2-1,

因为Redis的处理能力已经足够高,对于开发人员来说,网络可能会成为性能的瓶颈。

image.png

计数

incr key

incr命令用于对值做自增操作,返回结果分为三种情况:

  • 值不是整数,返回错误。
  • 值是整数,返回自增后的结果。
  • 键不存在,按照值为0自增,返回结果为1。

除了incr命令,Redis提供了decr(自减)、incrby(自增指定数字)、 decrby(自减指定数字)、incrbyfloat(自增浮点数):

decr key incrby key increment decrby key decrement incrbyfloat key increment

很多存储系统和编程语言内部使用CAS机制实现计数功能,会有一定的CPU开销,

但在Redis中完全不存在这个问题,因为Redis是单线程架构,任何命令到了Redis服务端都要顺序执行。

不常用命令

追加值

append key value

append可以向字符串尾部追加值,例如:

127.0.0.1:6379> get key "redis" 127.0.0.1:6379> append key world (integer) 10 127.0.0.1:6379> get key "redisworld"

字符串长度

strlen key

例如,当前值为redisworld,所以返回值为10:

127.0.0.1:6379> get key "redisworld" 127.0.0.1:6379> strlen key (integer) 10

下面操作返回结果为6,因为每个中文占用3个字节

127.0.0.1:6379> set hello "世界" OK 127.0.0.1:6379> strlen hello (integer) 6

设置并返回原值

getset key value

getset和set一样会设置值,但是不同的是,它同时会返回键原来的值, 例如:

127.0.0.1:6379> getset hello world (nil) 127.0.0.1:6379> getset hello redis "world"

设置指定位置的字符

setrange key offeset value

下面操作将值由pest变为了best:

127.0.0.1:6379> set redis pest OK 127.0.0.1:6379> setrange redis 0 b (integer) 4 127.0.0.1:6379> get redis "best"

获取部分字符串

getrange key start end

start和end分别是开始和结束的偏移量,偏移量从0开始计算,例如下面 操作获取了值best的前两个字符。

127.0.0.1:6379> getrange redis 0 1 "be"

表2-2是字符串类型命令的时间复杂度,开发人员可以参考此表,结合 自身业务需求和数据大小选择适合的命令。

image.png

2.2.2 内部编码

字符串类型的内部编码有3种:

  • int:8个字节的长整型。
  • embstr:小于等于39个字节的字符串。
  • raw:大于39个字节的字符串。

Redis会根据当前值的类型和长度决定使用哪种内部编码实现。

2.2.3 典型使用场景

缓存

图2-10是比较典型的缓存使用场景,其中Redis作为缓存层,MySQL作为存储层,

绝大部分请求的数据都是从Redis中获取。

由于Redis具有支撑高并发的特性,所以缓存通常能起到加速读写和降低后端压力的作用。

下面伪代码模拟了图2-10的访问过程:

image.png

  1. 该函数用于获取用户的基础信息:
UserInfo getUserInfo(long id){ ... }
  1. 首先从Redis获取用户信息:
// 定义键 userRedisKey = "user:info:" + id; // 从Redis获取值 value = redis.get(userRedisKey); if (value != null) { // 将值进行反序列化为UserInfo并返回结果 userInfo = deserialize(value); return userInfo; }

开发提示

与MySQL等关系型数据库不同的是,Redis没有命令空间,而且也没有 对键名有强制要求(除了不能使用一些特殊字符)。

但设计合理的键名,有利于防止键冲突和项目的可维护性,

比较推荐的方式是使用“业务名:对象名:id:[属性]”作为键名(也可以不是分号)。

例如MySQL的数据库名为vs,用户表名为user,

那么对应的键可以用"vs:user:1","vs:user:1:name"来表示,

如果当前Redis只被一个业务使用,甚至可以去掉“vs:”。

如果键名比较长,例如“user:{uid}:friends:messages:{mid}”,

可以在能描述键含义的前提下适当减少键的长度,

例如变为“u:{uid}:fr:m:{mid}”,从而减少由于键过长的内存浪费

  1. 如果没有从Redis获取到用户信息,需要从MySQL中进行获取,并将结果回写到Redis,添加1小时(3600秒)过期时间:
// 从MySQL获取用户信息 userInfo = mysql.get(id); // 将userInfo序列化,并存入Redis redis.setex(userRedisKey, 3600, serialize(userInfo)); // 返回结果 return userInfo

计数

许多应用都会使用Redis作为计数的基础工具,它可以实现快速计数、查询缓存的功能,

同时数据可以异步落地到其他数据源。

例如笔者所在团队的视频播放数系统就是使用Redis作为视频播放数计数的基础组件,

用户每播放一次视频,相应的视频播放数就会自增1:

long incrVideoCounter(long id) { key = "video:playCount:" + id; return redis.incr(key); }

开发提示

实际上一个真实的计数系统要考虑的问题会很多:

防作弊、按照不同维度计数,数据持久化到底层数据源等。

共享session

如图2-11所示,一个分布式Web服务将用户的Session信息(例如用户登 录信息)保存在各自服务器中,这样会造成一个问题,出于负载均衡的考 虑,

分布式服务会将用户的访问均衡到不同服务器上,用户刷新一次访问可 能会发现需要重新登录,这个问题是用户无法容忍的。

image.png

为了解决这个问题,可以使用Redis将用户的Session进行集中管理,

如图2-12所示,在这种模式下只要保证Redis是高可用和扩展性的,

每次用户更新或者查询登录信息都直接从Redis中集中获取。

image.png

限速

很多应用出于安全的考虑,会在每次进行登录时,让用户输入手机验证码,从而确定是否是用户本人。

但是为了短信接口不被频繁访问,会限制用户每分钟获取验证码的频率,例如一分钟不能超过5次

image.png

phoneNum = "138xxxxxxxx"; key = "shortMsg:limit:" + phoneNum; // SET key value EX 60 NX isExists = redis.set(key,1,"EX 60","NX"); if(isExists != null || redis.incr(key) <=5){ // 通过 }else{ // 限速 }

2.3 哈希

几乎所有的编程语言都提供了哈希(hash)类型,

它们的叫法可能是哈希、字典、关联数组。在Redis中,哈希类型是指键值本身又是一个键值对 结构,

形如value={{field1,value1},...{fieldN,valueN}},

Redis键值对和哈希类型二者的关系可以用图2-14来表示。

image.png

2.3.1 命令

设置值

hset key field value

下面为user:1添加一对field-value:

127.0.0.1:6379> hset user:1 name tom (integer) 1

如果设置成功会返回1,反之会返回0。此外Redis提供了hsetnx命令,它 们的关系就像set和setnx命令一样,只不过作用域由键变为field。

获取值

hget key field

例如,下面操作获取user:1的name域(属性)对应的值:

127.0.0.1:6379> hget user:1 name "tom"

如果键或field不存在,会返回nil:

127.0.0.1:6379> hget user:2 name (nil) 127.0.0.1:6379> hget user:1 age (nil)

删除field

hdel key field [field ...]

hdel会删除一个或多个field,返回结果为成功删除field的个数,例如:

127.0.0.1:6379> hdel user:1 name (integer) 1 127.0.0.1:6379> hdel user:1 age (integer) 0

计算field个数

hlen key

例如user:1有3个field:

127.0.0.1:6379> hset user:1 name tom (integer) 1 127.0.0.1:6379> hset user:1 age 23 (integer) 1 127.0.0.1:6379> hset user:1 city tianjin (integer) 1 127.0.0.1:6379> hlen user:1 (integer) 3

批量设置或获取field-value

hmget key field [field ...] hmset key field value [field value ...]

hmset和hmget分别是批量设置和获取field-value,hmset需要的参数是key 和多对field-value,hmget需要的参数是key和多个field。例如:

127.0.0.1:6379> hmset user:1 name mike age 12 city tianjin OK 127.0.0.1:6379> hmget user:1 name city 1) "mike" 2) "tianjin"

判断field是否存在

hexists key field

例如,user:1包含name域,所以返回结果为1,不包含时返回0:

127.0.0.1:6379> hexists user:1 name (integer) 1

获取所有field

hkeys key

hkeys命令应该叫hfields更为恰当,它返回指定哈希键所有的field,例 如:

127.0.0.1:6379> hkeys user:1 1) "name" 2) "age" 3) "city"

获取所有value

hvals key

下面操作获取user:1全部value:

127.0.0.1:6379> hvals user:1 1) "mike" 2) "12" 3) "tianjin"

获取所有的field-value

hgetall key

下面操作获取user:1所有的field-value:

127.0.0.1:6379> hgetall user:1 1) "name" 2) "mike" 3) "age" 4) "12" 5) "city" 6) "tianjin"

开发提示

在使用hgetall时,如果哈希元素个数比较多,会存在阻塞Redis的可能。

如果开发人员只需要获取部分field,可以使用hmget,

如果一定要获取全部field-value,可以使用hscan命令,该命令会渐进式遍历哈希类型,

hincrby hincrbyfloat hincrby key field hincrbyfloat key field

hincrby和hincrbyfloat,就像incrby和incrbyfloat命令一样,但是它们的作 用域是filed。

计算value的字符串长度(需要Redis3.2以上)

hstrlen key field

例如hget user:1name的value是tom,那么hstrlen的返回结果是3: 98

127.0.0.1:6379> hstrlen user:1 name (integer) 3

表2-3是哈希类型命令的时间复杂度,开发人员可以参考此表选择适合 的命令

image.png

2.3.2 内部编码

哈希类型的内部编码有两种:

  • ziplist(压缩列表):当哈希类型元素个数小于hash-max-ziplist-entries配置(默认512个)、同时所有值都小于hash-max-ziplist-value配置(默认64字节)时,Redis会使用ziplist作为哈希的内部实现,ziplist使用更加紧凑的结构实现多个元素的连续存储,所以在节省内存方面比hashtable更加优秀。
  • hashtable(哈希表):当哈希类型无法满足ziplist的条件时,Redis会使用hashtable作为哈希的内部实现,因为此时ziplist的读写效率会下降,而hashtable的读写时间复杂度为O(1)。

下面的示例演示了哈希类型的内部编码,以及相应的变化。

  1. 当field个数比较少且没有大的value时,内部编码为ziplist:
127.0.0.1:6379> hmset hashkey f1 v1 f2 v2 OK 127.0.0.1:6379> object encoding hashkey "ziplist"
  1. 当有value大于64字节,内部编码会由ziplist变为hashtable:
127.0.0.1:6379> hset hashkey f3 "one string is bigger than 64 byte...忽略..." OK 127.0.0.1:6379> object encoding hashkey "hashtable"
  1. 当field个数超过512,内部编码也会由ziplist变为hashtable:
127.0.0.1:6379> hmset hashkey f1 v1 f2 v2 f3 v3 ...忽略... f513 v513 OK 127.0.0.1:6379> object encoding hashkey "hashtable"

2.3.3 使用场景

图2-15为关系型数据表记录的两条用户信息,用户的属性作为表的列,每条用户信息作为行。

image.png

到目前为止,我们已经能够用三种方法缓存用户信息,下面给出三种方案的实现方法和优缺点分析:

  1. 原生字符串类型:每个属性一个键。
set user:1:name tom set user:1:age 23 set user:1:city beijing
  • 优点:简单直观,每个属性都支持更新操作。
  • 缺点:占用过多的键,内存占用量较大,同时用户信息内聚性比较差,

所以此种方案一般不会在生产环境使用。

  1. 序列化字符串类型:将用户信息序列化后用一个键保存。
set user:1 serialize(userInfo)
  • 优点:简化编程,如果合理的使用序列化可以提高内存的使用效率。
  • 缺点:序列化和反序列化有一定的开销,同时每次更新属性都需要把全

部数据取出进行反序列化,更新后再序列化到Redis中

  1. 哈希类型:每个用户属性使用一对field-value,但是只用一个键保存。
hmset user:1 name tomage 23 city beijing
  • 优点:简单直观,如果使用合理可以减少内存空间的使用。
  • 缺点:要控制哈希在ziplist和hashtable两种内部编码的转换,hashtable会消耗更多内存

2.4 列表

列表中的每个字符串称为元素(element),一个列表最多可以存储2^32-1个元素。在Redis中,

可以对列表两端插入(push)和弹出(pop),还可以获取指定范围的元素列表、获取指定索引下标的元素等(如图2-18和图2-19所示)

image.png

列表类型有两个特点:

  • 第一、列表中的元素是有序的,这就意味着可以通过索引下标获取某个元素或者某个范围内的元素列表,例如要获取图2-19的第5个元素,可以执行lindex user:1:message4(索引从0算起)就可以得到元素e。
  • 第二、列表中的元素可以是重复的,例如图2-20所示列表中包含了两个字符串a。

image.png

2.4.1 命令

image.png

1.添加操作

从右边插入元素

rpush key value [value ...]

下面代码从右向左插入元素c、b、a:

127.0.0. 1:6379> rpush listkey c b a (integer) 3

lrange0-1命令可以从左到右获取列表的所有元素:

127.0.0.1:6379> lrange listkey 0 -1 1) "c" 2) "b" 3) "a"

从左边插入元素

lpush key value [value ...]

使用方法和rpush相同,只不过从左侧插入,这里不再赘述。

向某个元素前或者后插入元素

linsert key before|after pivot value

linsert命令会从列表中找到等于pivot的元素,在其前(before)或者后 (after)插入一个新的元素value,例如下面操作会在列表的元素b前插入java:

127.0.0.1:6379> linsert listkey before b java (integer) 4

返回结果为4,代表当前命令的长度,当前列表变为:

127.0.0.1:6379> lrange listkey 0 -1 1) "c" 2) "java" 3) "b" 4) "a"

2.查找

获取指定范围内的元素列表

lrange key start end

lrange操作会获取列表指定索引范围所有的元素。索引下标有两个特点:

  • 第一,索引下标从左到右分别是0到N-1,但是从右到左分别是-1到-N。
  • 第二,lrange中的end选项包含了自身,这个和很多编程语言不包含end不太相同,例如想获取列表的第2到第4个元素,可以执行如下操作:
127.0.0.1:6379> lrange listkey 1 3 1) "java" 2) "b" 3) "a"

获取列表指定索引下标的元素

lindex key index

例如当前列表最后一个元素为a:

127.0.0.1:6379> lindex listkey -1 "a"

获取列表长度

llen key

例如,下面示例当前列表长度为4:

127.0.0.1:6379> llen listkey (integer) 4

3.删除

从列表左侧弹出元素

lpop key 110

如下操作将列表最左侧的元素c会被弹出,弹出后列表变为java、b、a:

127.0.0.1:6379>t lpop listkey "c" 127.0.0.1:6379> lrange listkey 0 -1 1) "java" 2) "b" 3) "a"

从列表右侧弹出

rpop key

它的使用方法和lpop是一样的,只不过从列表右侧弹出,这里不再赘述。

删除指定元素

lrem key count value

lrem命令会从列表中找到等于value的元素进行删除,根据count的不同分为三种情况:

  • count>0,从左到右,删除最多count个元素。
  • count<0,从右到左,删除最多count绝对值个元素。
  • count=0,删除所有。

例如向列表从左向右插入5个a,那么当前列表变为“a a a a a java b a”,

下面操作将从列表左边开始删除4个为a的元素:

127.0.0.1:6379> lrem listkey 4 a (integer) 4 127.0.0.1:6379> lrange listkey 0 -1 1) "a" 2) "java" 3) "b" 4) "a"

按照索引范围修剪列表

ltrim key start end

例如,下面操作会只保留列表listkey第2个到第4个元素:

127.0.0.1:6379> ltrim listkey 1 3 OK 127.0.0.1:6379> lrange listkey 0 -1 1) "java" 2) "b" 3) "a"

4.修改

修改指定索引下标的元素:

lset key index newValue

下面操作会将列表listkey中的第3个元素设置为python:

127.0.0.1:6379> lset listkey 2 python OK 127.0.0.1:6379> lrange listkey 0 -1 1) "java" 2) "b" 3) "python"

5.阻塞操作

阻塞式弹出如下:

blpop key [key ...] timeout brpop key [key ...] timeout

blpop和brpop是lpop和rpop的阻塞版本,它们除了弹出方向不同,使用 方法基本相同,所以下面以brpop命令进行说明,brpop命令包含两个参数:

  • key[key...]:多个列表的键。
  • timeout:阻塞时间(单位:秒)。

列表为空:如果timeout=3,那么客户端要等到3秒后返回,如果timeout=0,那么客户端一直阻塞等下去:

127.0.0.1:6379> brpop list:test 3 (nil) (3.10s) 127.0.0.1:6379> brpop list:test 0 ...阻塞...

如果此期间添加了数据element1,客户端立即返回:

127.0.0.1:6379> brpop list:test 3 1) "list:test" 2) "element1" (2.06s)

列表不为空:客户端会立即返回。

127.0.0.1:6379> brpop list:test 0 1) "list:test" 2) "element1"

在使用brpop时,有两点需要注意。

  • 第一点,如果是多个键,那么brpop会从左至右遍历键,一旦有一个键能弹出元素,客户端立即返回:
    127.0.0.1:6379> brpop list:1 list:2 list:3 0 ..阻塞..
    此时另一个客户端分别向list:2和list:3插入元素:
    client-lpush> lpush list:2 element2 (integer) 1 client-lpush> lpush list:3 element3 (integer) 1
    客户端会立即返回list:2中的element2,因为list:2最先有可以弹出的元素:
    127.0.0.1:6379> brpop list:1 list:2 list:3 0 1) "list:2" 2) "element2_1"
  • 第二点,如果多个客户端对同一个键执行brpop,那么最先执行brpop命令的客户端可以获取到弹出的值。
    客户端1: client-1> brpop list:test 0 ...阻塞... 客户端2: client-2> brpop list:test 0 ...阻塞... 客户端3: client-3> brpop list:test 0 ...阻塞...
    此时另一个客户端lpush一个元素到list:test列表中:
    client-lpush> lpush list:test element (integer) 1
    那么客户端1最会获取到元素,因为客户端1最先执行brpop,而客户端2 和客户端3继续阻塞:
    client> brpop list:test 0 1) "list:test" 2) "element"

有关列表的基础命令已经介绍完了,表2-5是这些命令的时间复杂度,开发人员可以参考此表选择适合的命令

image.png

2.4.2 内部编码

列表类型的内部编码有两种。

  • ziplist(压缩列表):当列表的元素个数小于list-max-ziplist-entries配置(默认512个),同时列表中每个元素的值都小于list-max-ziplist-value配置时(默认64字节),Redis会选用ziplist来作为列表的内部实现来减少内存的使用。
  • linkedlist(链表):当列表类型无法满足ziplist的条件时,Redis会使用linkedlist作为列表的内部实现。

下面的示例演示了列表类型的内部编码,以及相应的变化。

当元素个数较少且没有大元素时,内部编码为ziplist:

127.0.0.1:6379> rpush listkey e1 e2 e3 (integer) 3 127.0.0.1:6379> object encoding listkey "ziplist"

当元素个数超过512个,内部编码变为linkedlist:

127.0.0.1:6379> rpush listkey e4 e5 ...忽略... e512 e513 (integer) 513 127.0.0.1:6379> object encoding listkey "linkedlist"

或者当某个元素超过64字节,内部编码也会变为linkedlist:

127.0.0.1:6379> rpush listkey "one string is bigger than 64 byte............... ................." (integer) 4 127.0.0.1:6379> object encoding listkey 116 "linkedlist"

开发提示

Redis3.2版本提供了quicklist内部编码,简单地说它是以一个ziplist为节点的linkedlist,

它结合了ziplist和linkedlist两者的优势,为列表类型提供了一种更为优秀的内部编码实现,

它的设计原理可以参考Redis的另一个作者Matt Stancliff的博客https://matt.sh/redis-quicklist。

2.4.3 使用场景

1.消息队列

如图2-21所示,Redis的lpush+brpop命令组合即可实现阻塞队列,生产者客户端使用lrpush从列表左侧插入元素,多个消费者客户端使用brpop命令阻塞式的“抢”列表尾部的元素,多个客户端保证了消费的负载均衡和高可用性。

2.文章列表

每个用户有属于自己的文章列表,现需要分页展示文章列表。此时可以考虑使用列表,因为列表不但是有序的,同时支持按照索引范围获取元素

image.png

  1. 每篇文章使用哈希结构存储,例如每篇文章有3个属性title、timestamp、content:
hmset acticle:1 title xx timestamp 1476536196 content xxxx ... 118 hmset acticle:k title yy timestamp 1476512536 content yyyy ...
  1. 向用户文章列表添加文章,user:{id}:articles作为用户文章列表的键:
lpush user:1:acticles article:1 article3 ... lpush user:k:acticles article:5 ...
  1. 分页获取用户文章列表,例如下面伪代码获取用户id=1的前10篇文章:
articles = lrange user:1:articles 0 9 for article in {articles} hgetall {article}

使用列表类型保存和获取文章列表会存在两个问题。

  • 第一,如果每次分页获取的文章个数较多,需要执行多次hgetall操作,此时可以考虑使用Pipeline(第3章会介绍)批量获取,或者考虑将文章数据序列化为字符串类型,使用mget批量获取。
  • 第二,分页获取文章列表时,lrange命令在列表两端性能较好,但是如果列表较大,获取列表中间范围的元素性能会变差,此时可以考虑将列表做二级拆分,或者使用Redis3.2的quicklist内部编码实现,它结合ziplist和linkedlist的特点,获取列表中间范围的元素时也可以高效完成。

开发提示

实际上列表的使用场景很多,在选择时可以参考以下口诀:

  • lpush+lpop=Stack(栈)
  • lpush+rpop=Queue(队列)
  • lpsh+ltrim=Capped Collection(有限集合)
  • lpush+brpop=Message Queue(消息队列)

2.5 集合

集合(set)类型也是用来保存多个的字符串元素,但和列表类型不一样的是,集合中不允许有重复元素,并且集合中的元素是无序的,不能通过索引下标获取元素

一个集合最多可以存储2^32-1个元素。

Redis除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集,合理地使用好集合类型,能在实际开发中解决很多实际问题

image.png

2.5.1 命令

1.集合内操作

添加元素

sadd key element [element ...]

返回结果为添加成功的元素个数,例如:

127.0.0.1:6379> exists myset (integer) 0 127.0.0.1:6379> sadd myset a b c (integer) 3 127.0.0.1:6379> sadd myset a b (integer) 0

删除元素

srem key element [element ...]

返回结果为成功删除元素个数,例如:

127.0.0.1:6379> srem myset a b (integer) 2 127.0.0.1:6379> srem myset hello (integer) 0

计算元素个数

scard key

scard的时间复杂度为O(1),它不会遍历集合所有元素,而是直接用Redis内部的变量,例如:

127.0.0.1:6379> scard myset (integer) 1

判断元素是否在集合中

sismember key element

如果给定元素element在集合内返回1,反之返回0,例如:

127.0.0.1:6379> sismember myset c (integer) 1

随机从集合返回指定个数元素

srandmember key [count]

[count]是可选参数,如果不写默认为1,例如:

127.0.0.1:6379> srandmember myset 2 1) "a" 2) "c" 127.0.0.1:6379> srandmember myset "d"

从集合随机弹出元素

spop key

spop操作可以从集合中随机弹出一个元素,例如下面代码是一次spop后,集合元素变为"d b a":

127.0.0.1:6379> spop myset "c" 127.0.0.1:6379> smembers myset 1) "d" 2) "b" 3) "a"

需要注意的是Redis从3.2版本开始,spop也支持[count]参数。

srandmember和spop都是随机从集合选出元素,两者不同的是spop命令执行后,元素会从集合中删除,而srandmember不会。

获取所有元素

smembers key

下面代码获取集合myset所有元素,并且返回结果是无序的:

127.0.0.1:6379> smembers myset 1) "d" 2) "b" 3) "a"

smembers和lrange、hgetall都属于比较重的命令,如果元素过多存在阻塞Redis的可能性,这时候可以使用sscan来完成,有关sscan命令2.7节会介绍。

2.集合间操作

现在有两个集合,它们分别是user:1:follow和user:2:follow:

127.0.0.1:6379> sadd user:1:follow it music his sports (integer) 4 127.0.0.1:6379> sadd user:2:follow it news ent sports (integer) 4

求多个集合的交集

sinter key [key ...]

例如下面代码是求user:1:follow和user:2:follow两个集合的交集, 返回结果是sports、it:

127.0.0.1:6379> sinter user:1:follow user:2:follow 1) "sports" 2) "it"

求多个集合的并集

suinon key [key ...]

例如下面代码是求user:1:follow和user:2:follow两个集合的并集,返回结果是sports、it、his、news、music、ent:

127.0.0.1:6379> sunion user:1:follow user:2:follow 1) "sports" 2) "it" 3) "his" 4) "news" 5) "music" 6) "ent"

求多个集合的差集

sdiff key [key ...]

例如下面代码是求user:1:follow和user:2:follow两个集合的差集,返回结果是music和his:

127.0.0.1:6379> sdiff user:1:follow user:2:follow 1) "music" 2) "his"

前面三个命令如图2-23所示。

image.png

将交集、并集、差集的结果保存

sinterstore destination key [key ...] suionstore destination key [key ...] sdiffstore destination key [key ...]

集合间的运算在元素较多的情况下会比较耗时,

所以Redis提供了上面三个命令(原命令+store)将集合间交集、并集、差集的结果保存在 destination key中,

例如下面操作将user:1:follow和user:2:follow两个集合的交集结果保存在user:1_2:inter中,user:1_2:inter本身也是集合类型:

127.0.0.1:6379> sinterstore user:1_2:inter user:1:follow user:2:follow (integer) 2 127.0.0.1:6379> type user:1_2:inter set 127.0.0.1:6379> smembers user:1_2:inter 1) "it" 2) "sports"

至此有关集合的命令基本已经介绍完了,表2-6给出集合常用命令的时 间复杂度,开发人员可以根据自身需求进行选择。

image.png

2.5.2 内部编码

集合类型的内部编码有两种:

  • intset(整数集合):当集合中的元素都是整数且元素个数小于set-maxintset-entries配置(默认512个)时,Redis会选用intset来作为集合的内部实现,从而减少内存的使用。
  • hashtable(哈希表):当集合类型无法满足intset的条件时,Redis会使用hashtable作为集合的内部实现。

下面用示例来说明:

  1. 当元素个数较少且都为整数时,内部编码为intset:
    127.0.0.1:6379> sadd setkey 1 2 3 4 (integer) 4 127.0.0.1:6379> object encoding setkey "intset"
  2. 当元素个数超过512个,内部编码变为hashtable:
    127.0.0.1:6379> sadd setkey 1 2 3 4 5 6 ... 512 513 (integer) 509 127.0.0.1:6379> scard setkey (integer) 513 127.0.0.1:6379> object encoding listkey "hashtable"
  3. 当某个元素不为整数时,内部编码也会变为hashtable:
    127.0.0.1:6379> sadd setkey a (integer) 1 127.0.0.1:6379> object encoding setkey "hashtable"

2.5.3 使用场景

集合类型比较典型的使用场景是标签(tag)。

例如一个用户可能对娱乐、体育比较感兴趣,另一个用户可能对历史、新闻比较感兴趣,这些兴趣点就是标签。

有了这些数据就可以得到喜欢同一个标签的人,以及用户的共同喜好的标签,这些数据对于用户体验以及增强用户黏度比较重要。

例如一个电子商务的网站会对不同标签的用户做不同类型的推荐,比如对数码产品比较感兴趣的人,在各个页面或者通过邮件的形式给他们推荐最新的数码产品,通常会为网站带来更多的利益。下面使用集合类型实现标签功能的若干功能。

  1. 给用户添加标签
    sadd user:1:tags tag1 tag2 tag5 sadd user:2:tags tag2 tag3 tag5 ... sadd user:k:tags tag1 tag2 tag4 ...
  2. 给标签添加用户
    sadd tag1:users user:1 user:3 sadd tag2:users user:1 user:2 user:3 ... sadd tagk:users user:1 user:2 ...

开发提示

用户和标签的关系维护应该在一个事务内执行,防止部分命令失败造成的数据不一致,

有关如何将两个命令放在一个事务,第3章会介绍事务以及Lua的使用方法。

  1. 删除用户下的标签
    srem user:1:tags tag1 tag5 ...
  2. 删除标签下的用户
srem tag1:users user:1 srem tag5:users user:1 ...

(3)和(4)也是尽量放在一个事务执行。

  1. 计算用户共同感兴趣的标签

可以使用sinter命令,来计算用户共同感兴趣的标签,如下代码所示:

sinter user:1:tags user:2:tags

开发提示

前面只是给出了使用Redis集合类型实现标签的基本思路,

实际上一个标签系统远比这个要复杂得多,不过集合类型的应用场景通常为以下几种:

  • sadd=Tagging(标签)
  • spop/srandmember=Random item(生成随机数,比如抽奖)
  • sadd+sinter=Social Graph(社交需求)

本文作者:Eric

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!