数据结构与编码方式
redis中的数据结构有以下五种:string(字符串)、list(列表)、hash(哈希)、set(集合)、zset(有序集合)。
如下:

在redis中所说的这些数据结构只是一种表现形式,或者说是redis承诺给用户的,但它底层不一定就是用这个结构来实现。它的底层实现也就是它的编码方式,不同的数据结构分别有以下几种:

string
raw:最基本的字符串,底层就是一个char数组。int:整数形式,和c语言int同性质,通常可以用来实现一些计数功能,当value就是一个整数时,redis会直接使用int保存。embstr:针对短字符串进行特殊化(压缩存储)。
redis会自动适应,程序员在使用时感知不到。
hash
hashtable:这里的哈希表实现思想和我们学过的常见哈希表一样。ziplist:压缩列表,在哈希表里面元素比较少的时候,优化成ziplist能节省空间。
为什么要压缩?当redis中有很多key对应的是hash,但每个key的hash并不大,这样会白白占用很大的空间,可以通过压缩来减少内存占用。
list
linkedlist:和通常意义下的链表一样。ziplist:压缩链表。quicklist:从redis3.2开始就不再使用以上两种编码方式,而是使用quicklist,quicklist同时兼顾它们的优点,即quicklist就是一个链表,每个元素又是一个ziplist。
注:quicklist类似c++中的std::deque
set/zset
intset:集合中都是整数时,会被优化成intset。skiplist:跳表,有多个指针域,使得在跳表上查询元素复杂度达到O(logN)O(logN)O(logN)
注:object encoding <key>:查询内部实际编码方式
redis只用一个线程处理所有命令请求,但这不是说redis服务进程内部只有一个线程,其实它也有其他线程,比如用来处理网络IO等工作。
并发地向redis发请求,会存在线程安全问题吗?redis是单线程处理任务的,命令是串行执行的,所以线程安全。弊端:一个操作占用时间太长会阻塞其他命令的执行,比如keys *。
一、string
1.1 操作命令
string类型不仅仅可以存储文本数据,它还能存储整数、普通字符串、json、xml、二进制数据(图片、视频、音频...),但redis对string类型限制了大小,最大是512M;如果希望操作更高效,不建议用它存储音视频。
redis中的字符串直接按照二进制数据方式存储,不会做任何编码转换,遇到乱码的概率更小。
键值对操作
set
功能:添加字符串键值对。
语法:
set <key> <value> [ex seconds | px milliseconds] [nx | xx]
ex:设置过期时间,单位是秒。set key value+expire key 10等价于set key value ex 10,优势在于后者只发起了一次请求,网络开销更小、效率更高。px:设置过期时间,单位是毫秒,其他同上。nx:当这个key不存在时才设置。xx:当这个key存在时才设置(相当于更新)。


组合指令:setex、psetex、setnx
语法:
setex <key> <seconds> <value>
setnx <key> <value>

flushall:删除redis所有数据(慎用)
get
功能:通过key获取键值对信息。
语法:
get <key>
注意:get只支持字符串的value,其他类型get获取就会失败。
mset和mget
mset/mget和set/get功能相同,不过mset/mget一次可以操作多个key。
语法:
mset <key> <value> [key value key value ......]
mget <key> [key key ......]

整型/浮点型操作
针对整型有以下操作:
incr:value+1,语法incr <key>
incrby:value+n,语法incrby <key> <n>
decr:value-1,语法decr <key>
decrby:value-n,语法decrby <key> <n>
incrbyfloat:value+/-小数,语法incrbyfloat <key> <n>
它们的返回值都是进行计算后的value值。

string的int编码方式中整数能存储的范围是64位,类似c++中的long long,超过之后会以字符串的方式存储。
如下:

在以上这些操作中即使key不存在也能成功,会把它当做0使用。
字符串操作
append
功能:追加字符串。如果key存在而且是string,返回值是追加完成后字符串的长度;如果没有这个key,效果就和直接使用set一样。
语法:
append <key> <str>
返回值:追加后字符串的长度。

如果存的是一个汉字呢?返回值是多少?

如上,在xshell中默认编码是utf8,而一个汉字在utf8中通常是3字节,也就是append返回值是以字节为单位的。
再get一下,发现展示出来的不是"你好"这两个字,这是因为它按utf8编码展示出来了,正如上文所说,它不会做任何转码。
那么如何证明这就是"你好"这两个字呢?去utf8编码表看一看。
链接:https://mytoolster.com/zh/utf8

注意:\x是转义字符,表示后面是16进制数值,它本身并不属于编码内容。
那么有没有办法让它把原始内容显示出来,不用去网站转义呢?在启动时加上一个 --raw,它会自动尝试翻译二进制数据。如下:

getrange
功能:获取字符串的子串,类似c++中的substr,java中的substring。
语法:
getrange <key> <start> <end>
start:开始字符的下标
end:结束字符的下标
注:redis中指定的区间是左闭右闭的。redis的下标是支持负数的,-1是倒数第一个元素。
切中文呢?使用这个指令切割中文没意义基本上都是乱码。
setrange
功能:更改子串。
语法:
setrange <key> <offset> <value>
offset:从第几个字符(填它的下标)开始替换value:要替换成的内容
返回值:替换之后新的字符串长度
那如果对一个不存在的key进行操作呢?此时redis会先把offset之前空缺的位置用\x00(十六进制表示的0)凭空补齐,再在后面写入新的内容。
strlen
功能:获取字符串的长度,单位是字节。
语法:
strlen <key>
如果key不是string会报错,key不存在则返回0。
1.2 应用场景
作为缓存

整体思路:
应用服务访问数据的时候,先查询redis,存在就直接取;不存在再去读取mysql,把结果返回给应用服务器,同时也写到redis。
为什么还要写到redis?redis这样的缓存就是用来存储热点数据、也就是高频使用的数据的。至于什么算高频数据,不同业务有不同的定义方式,刚才的场景就是把最近的数据作为热点数据。
但按上述策略,随着时间推移,redis中的数据会越来越多,存不下怎么办?既然这些数据只在一段时间内会被用到,那就给它们设置一个过期时间(过期后redis会自动删除)。另外,redis还在内存不足的时候提供了淘汰策略。
下面是模拟业务场景的伪代码:
假设根据用户uid获取用户信息
c
UserInfo getUserInfo(long uid)
{
...
}
从redis获取用户信息,假设用户信息保存在user:info:对应键中:
c
//根据uid获取redis的键
string key = "user:info:"+uid;
//尝试从redis中获取对应的值
string value = redis 执行命令:get key;
//如果缓存命中(hit)
if(value != null)
{
//假设用户信息用json字符串存储
UserInfo userInfo = Json 反序列化(value);
return userInfo;
}
//没有命中redis缓存
else
{
//从数据库中,根据uid获取数据
UserInfo userInfo = MySQL 执行 SQL: select * from user_info where uid = <uid>
//如果表没有uid信息
if(userInfo==null)
{
响应404
return null;
}
//查到信息,把信息序列化成Json格式
string value = Json 序列化(userInfo);
//写入缓存,防止数据腐烂,设置过期时间
redis 执行命令:set key value ex 3600
//返回用户信息
return userInfo;
}
注:redis不像mysql那样有专门的表,所有数据都放在一起。可以在uid上加前缀,表明这是用户信息,以便与其他数据做区分。
key名设置技巧:"业务名:对象名:唯一标识:属性",例如:user:info:1001:email,在此基础上可以做一下优化,用u:if:1001:e代替,因为键名过长会导致redis性能明显下降。
计数功能
比如:视频播放次数、点赞次数、转发次数等等。
mysql也能实现,但效率低。在分布式架构中,使用redis是最适合的。
下面键值对表示:video:视频id 播放次数

通常有一个数据库用来统计数据,企业收集用户数据根据统计进一步明确用户需求,根据需求改进和迭代产品。
直接在redis中统计播放量前100的视频有哪些,非常麻烦,但在mysql中一条语句就能解决。所以要写入数据库,但需要异步处理,这样才高效。
实际中要开发一个成熟、稳定的真实计数系统,要面临的挑战远不止如此简单:防作弊、按照不同维度计数、避免单点问题、数据持久化到底层数据源等。
共享会话
比如session会话通常会被存在redis里。
有什么用呢?
举个例子:一个病人到医院挂号看医生,医生做了一些治疗并开了对应的药,告诉他一周后再来复查。一周过去,病人再来医院时,由于医生轮班,之前那位医生并不在岗,而其他医生没给这个病人看过病,并不了解他的情况,这该怎么办?
医生当然不可能靠记忆来记住每个病人的信息,而是要依靠一套系统:比如每个病人都有自己的就诊卡,用来记录病人的状态,这样多个医生之间就能共享。
病人:客户端
医生:服务器
就诊卡:redis
如果各自用自己的session,架构如下:

每个应用服务器维护自己的会话数据,此时彼此之间不共享,用户请求被分配到不同的服务器上,就可能无法正确处理请求了。
共享会话:

手机验证码
- 生成验证码
比如:用户输入手机号点击获取验证码,会限制1分钟之内最多获取5次验证码,或每次获取验证码必须间隔30秒。
为什么要限制?担心用户频繁获取验证码,给服务器造成过大压力。
- 检查验证码
把短信收到的验证码这一串数字提交到系统,进行验证。
如何实现呢?
c
string 发送验证码(phoneNumber)
{
key = "shortMsg:limit:"+phoneNumber;
//设置过期时间1分钟
//使用nx,只在不存在key时才设置成功
bool r = redis执行命令:set key 1 ex 60 nx
if(r == false)
{
//说明之前设置过该手机的验证码了
long c = redis 执行命令:incr key
if(c > 5)
{
//说明超过了一分钟 5 次的限制了
//限制发送
return null;
}
}
}
这里把value设为1,表示用户第一次获取验证码;配合nx,只有key不存在时才能设置成功,设置失败就说明之前已经获取过验证码(而且还没有过期)。这时用incr让计数加一,既记录了这一分钟内已经获取的次数,也为下一次判断做好了准备,一旦超过5次就直接拒绝发送。
如果还要求每次获取验证码必须间隔30秒,同理再设置一个过期时间为30秒的key即可。通过限制检查之后,就可以生成验证码并返回了:
c
string validationCode = 生成随机的6位数字的验证码();
validationkey = "validation:"+phoneNumber;
//验证码5分钟(300秒)内有效
redis执行命令:set validationkey validationCode ex 300;
//返回验证码,随后通过手机短信发送给用户
return validationCode;
给用户发送短信一般会使用第三方短信平台提供的sdk,不过这类服务通常是收费的。
验证验证码是否正确:
c
bool 验证验证码(phoneNumber, validationCode)
{
validationkey = "validation:" + phoneNumber;
string value = redis 执行命令:get validationkey;
if(value == null)
{
//说明没有这个手机号的验证码
return false;
}
if(value == validationCode)
{
return true;
}
else
{
return false;
}
}
二、hash
hash是所有数据结构中最重要的一种,在日常开发中出场频率最高。redis自身的键值对本质上就是通过哈希结构组织的,而当value本身的类型也是hash时,就相当于在这个大哈希表里面又嵌套了一层小的哈希表,形成"套娃"结构。

为了和最外层的key value区分开,hash内部的键值对通常称为field value。
2.1 操作命令
hset
功能:设置hash中一个或多个field value。
语法:
hset <key> <field> <value> [field value ......]
返回值:本次操作新增(设置)成功的键值对个数。
注意:field对应的value只能是字符串类型。
hget
功能:获取hash中某个field对应的value。
语法:
hget <key> <field>

hexists
功能:判断field是否存在。
语法:
hexists <key> <field>
返回值:存在返回1,不存在返回0。
hdel
功能:删除hash中的一个或多个field。
语法:
hdel <key> <field> [field ......]
返回值:实际删除的field个数。
注意:如果要删除整个hash,应该使用del key,而不是把里面的field逐个删掉。

hkeys
功能:获取hash中所有的field。
语法:
hkeys <key>
原理是先根据key找到对应的hash,然后把里面所有field遍历一遍返回,时间复杂度是O(n)。
hvals
功能:和hkeys对应,获取hash中所有的value。
语法:
hvals <key>
同样是O(n)的时间复杂度,数据量大的时候要留意。
hgetall
功能:相当于把hkeys和hvals结合起来,一次性获取所有的field value。
语法:
hgetall <key>
这个指令同样可能阻塞服务器,如果hash里面元素非常多,一次性把所有内容都取出来,会让redis这个单线程在这段时间内无法处理其他请求。实际开发中大多数场景并不需要一次查询全部数据,应尽量避免滥用。

hmget
功能:类似mget,一次查询多个field,按查询顺序返回结果。
语法:
hmget <key> <field> [field ......]
注:有没有hmset呢?有,但没有必要使用,因为hset本身已经支持一次设置多个field value了。
hscan
hkeys、hvals、hgetall都有阻塞redis的风险,hscan可以解决这个问题,它是渐进式遍历,每次只返回一部分数据,不会一次性把整个hash锁住。
hlen
功能:获取hash中键值对的个数。
语法:
hlen <key>
redis内部对这个数量有记录,不需要遍历整个hash,是O(1)操作。
hsetnx
功能:类似setnx,只有field不存在的时候才能设置成功,存在则设置失败。
语法:
hsetnx <key> <field> <value>
hincrby
功能:hash里的value也可以当数字来处理,做自增操作。
语法:
hincrby <key> <field> <increment>
2.2 应用场景
作为缓存
string也能用来做缓存,但如果要存的是结构化数据,用hash会更合适。

string存结构化数据的话,通常需要转成json字符串整体存入,相比hash效率要低一些。
这样做的问题是:只想读或改其中一个字段的时候,也必须把整个json取出来,先反序列化,改完之后再序列化整体写回去,比较浪费。
而用hash存储的话,一个对象的每个属性对应一个field,读写单个字段时可以直接hget/hset,不需要动其他字段,代价是hash会比string多占用一些内存空间。
那能不能把一个对象的每个属性都单独存成一个string类型的key呢?例如user:1001:name、user:1001:age这样拆分?
这种做法非常不推荐,因为它把本该属于同一个对象的数据分散成了很多个毫不相关的key,也就是内聚性太低,管理和维护的成本都会明显上升。
购物车
电商场景中的购物车非常适合用hash来表示:以用户id作为外层的key,购物车里的每一件商品作为一个field,商品的数量作为value。
redis中的哈希类型是稀疏的,而像mysql这样的关系型数据库完全是结构化的:redis的哈希每个键都可以有不同的field,而关系型数据库一旦添加新的列,所有行都要为它设置值,即使填的是null。
关系型数据库可以做复杂的关联查询,而redis很难做到,成本很高。

加入购物车、修改数量都可以直接用hset,需要增加或减少某件商品的数量时,用hincrby即可原子地完成,不用先读出来再写回去。
删除某件商品用hdel,清空购物车则直接del掉这个key,查看购物车里所有商品用hgetall。
三、list列表
list相当于我们熟悉的数组或顺序表,不过它的元素是有序的------这里的"有序"不一定是指升序或降序,更多时候是指元素之间的先后顺序很重要,谁先插入谁在前面是确定的。它的结构更接近于c++中的"双端队列"(deque)。
头插头删:lpush,lpop(l可以理解为left)
尾插尾删:rpush,rpop(r可以理解为right)

3.1 操作命令
lpush、rpush
功能:向list头部(左侧)或尾部(右侧)插入一个或多个元素。
语法:
lpush <key> <element> [element ......]
rpush <key> <element> [element ......]
返回值:插入之后list的长度。
注意:lpush一次插入多个元素时,是逐个头插的,所以最后一个element反而会排在最前面。
lrange
功能:查看list中的一段元素。
语法:
lrange <key> <start> <stop>
这个start, stop是闭区间,lrange key 0 -1就能获取到list的全部元素。
注意:这里的start、stop是结果集里的序号,并不是元素在数组里的下标概念,下标不合法时不会报错,只会得到一个空结果。
这种"未定义行为直接返回空"的处理方式,好处是效率最高,程序员也能第一时间发现自己传参有问题;相比之下有些语言(比如java)遇到非法下标会直接抛异常,需要多一步做下标合法性校验,优点是问题会被更及时地暴露出来,容错能力(鲁棒性)也更强,两种设计各有取舍。
lpushx、rpushx
功能:和lpush/rpush基本一样,唯一区别是只有key已经存在时才会插入成功。
lpop、rpop
功能:从list的头部或尾部弹出元素。
语法:
lpop <key> [count]
rpop <key> [count]
注意:count参数用来指定一次弹出多少个元素,这是redis从6.2版本才开始支持的,老版本只能一次弹一个。
lindex
功能:按下标查找元素。
语法:
lindex <key> <index>
时间复杂度是O(n),下标非法时返回nil,支持负数下标,-1表示倒数第一个元素。
linsert
功能:在指定元素的前面或后面插入一个新元素。
语法:
linsert <key> before|after <pivot> <element>
返回值:插入之后list的新长度。
注意:这里定位的方式不是通过下标,而是通过元素的值去查找,如果list中有多个值相同的元素,会从左往右找到第一个匹配的作为插入的基准位置,时间复杂度是O(n)。
llen
功能:获取list的长度。
语法:
llen <key>
lrem
功能:按值删除list中的元素。
语法:
lrem <key> <count> <element>
count > 0:从左往右查找,最多删除count个elementcount < 0:从右往左查找,最多删除-count个elementcount = 0:删除list中所有的element

ltrim
功能:按下标范围截取list,只保留指定区间内的元素,其余部分直接丢弃。
语法:
ltrim <key> <start> <stop>
注意:这里的start、stop是下标(闭区间),和lrange不修改原list不同,ltrim会真的把区间外的元素从list里删掉。
lset
功能:按下标修改元素的值。
语法:
lset <key> <index> <element>
时间复杂度同样是O(n)。
阻塞版本命令:blpop、brpop
如果list中本来就有元素,blpop、brpop的表现和lpop、rpop完全一样;如果list是空的或者key不存在,则当前客户端会阻塞等待,直到有新元素被插入为止。
它可以显式指定一个阻塞超时时间,并不是无休止地等下去;阻塞期间redis仍然可以正常处理其他客户端发来的命令。
blpop、brpop还支持同时监听多个key,哪个key先有元素就优先从哪个key弹出数据;如果多个客户端同时对同一个key执行阻塞弹出,最先发起请求的客户端会拿到刚插入的元素。
正是因为有这种"没有元素就等,来了元素立刻消费"的特性,blpop/brpop常常被用来实现消息队列里的消费者。
注意:lindex能按下标获取元素的值,lrem则是按值删除元素,两者的定位方式不一样,使用时不要混淆。
3.2 应用场景
信息关联
redis本身没有mysql那样强大的关联查询能力,list常被用来做一种简单的关联:把一个对象关联的多个元素id按顺序存进一个list,比如某篇文章的评论id列表,需要的时候通过这个list再去逐个查询具体内容。
消息队列
用rpush生产消息、blpop消费消息,就能简单实现一个消息队列:多个消费者同时对同一个key执行blpop,谁先请求谁先拿到数据,天然形成一种轮询(谁先到谁先得)的效果。

不过用list实现的消息队列还是偏简陋,没有真正的消息中间件(比如ack确认、消费组、消息持久化保证)那么完善。
实际使用中通常会按业务把队列拆分成多个"频道",用不同的key去解耦不同类型的数据。比如短视频类应用可以拆成:一条通道传输视频本体数据,一条通道传输弹幕,另外还有专门的通道处理点赞、转发、收藏这些互动数据,各自互不干扰。

微博timeline
每一条微博用一个hash来存储它的具体内容(作者、正文、发布时间等字段)。
而用户和微博之间"谁发过哪些微博"的关系,则用一个list来维护:以用户id作为key,list里按时间顺序存放这个用户发布过的微博id,查看主页时先从这个list里取出一批微博id,再拿着这些id去上面的hash中逐条查询具体内容。
分页获取用户的timeline,例如获取用户1的前10篇微博:
c
keylist = lrange user:1:mblogs 0 9
for key in keylist
{
hgetall key
}
这种设计存在的问题是:每次分页获取一批微博id之后,还需要对这些id逐条执行hgetall才能拿到完整内容,而每一页里到底有多少条是不确定的,如果这一页数据量偏大,循环调用hgetall的次数就会随之变多,对redis造成额外压力。
另外,lrange如果要访问的是list中间偏后的位置,效率也并不理想,一种优化思路是对list做分片:比如把一个用户的一万条微博id拆成十份、每份一千条,分页查询时先定位到具体是哪一片,再在这一片里做lrange,避免每次都要在一个超长的list中间去查找。
从这些应用也能看出,list不仅能表示单纯的顺序数据,配合lpush/rpop或rpush/lpop的组合,还可以分别模拟出栈和队列这两种经典的数据结构。
四、set集合
set集合和前面的数据结构相比,有两个非常鲜明的特性:
- 集合中的元素是无序的,这一点和list正好相反。
- 集合中的元素不能重复。
和其他类型一样,set里的每个元素也都是string类型。set相关的命令基本都带有s前缀,记忆起来比较方便。
4.1 操作命令
sadd
功能:向集合中添加一个或多个元素。
语法:
sadd key member [member ...]
返回值:成功添加的元素个数,已经存在的元素会被忽略,不计入返回值。
smembers
功能:获取集合中的所有元素。
语法:
smembers key
注意:和hgetall类似,如果集合里元素非常多,一次性全部取出同样有阻塞redis的风险。
sismember
功能:判断某个元素是否在集合中。
语法:
sismember key member
返回值:存在返回1,不存在返回0,时间复杂度O(1)。
scard
功能:获取集合的元素个数。
语法:
scard key
redis内部对元素个数有记录,不需要遍历整个集合,是O(1)操作。
spop
功能:随机删除并返回集合中的一个(或多个)元素。
语法:
spop key [count]
set本身是无序的,所以spop删除的元素是随机的。这里的"随机"是redis官方明确承诺的真随机,而不是伪随机,也不是每次都固定按某种内部顺序去删。
示例:

那它是怎么做到随机的呢?其实是针对spop实现时候采取了"生成随机数"的方式
srandmember
功能:随机获取集合中的元素,但不删除。
语法:
srandmember key [count]
smove
功能:把一个元素从一个集合移动到另一个集合。
语法:
smove source destination member
它会先把member从source中删除,再添加到destination中。
返回值:移动成功返回1;如果member在source中本来就不存在,返回0,表示移动失败。

srem
功能:删除集合中的一个或多个元素。
语法:
srem key member [member ...]
返回值:成功删除的元素个数。
4.2 集合间操作
set相比其他数据结构的一大优势,就是原生支持集合运算:交集(inter)、并集(union)、差集(diff)。
需要注意的是,差集没有交换律,sdiff a b和sdiff b a的结果通常是不一样的。
sinter
功能:求多个集合的交集。
语法:
sinter key [key ...]
每个key各自对应一个集合。时间复杂度为O(N*M),其中N是元素最少的那个集合的元素个数,M是元素最多的那个集合的元素个数。
sinterstore
功能:求交集,并把结果直接保存到destination对应的集合中。
语法:
sinterstore destination key [key ...]
sunion
功能:求多个集合的并集。
语法:
sunion key [key ...]
时间复杂度O(N),N为所有key的元素总个数。
sunionstore
功能:求并集,并把结果保存到destination中。
语法:
sunionstore destination key [key ...]
sdiff
功能:求差集。
语法:
sdiff key [key ...]
时间复杂度O(N),N为所有key的元素总个数。
sdiffstore
功能:求差集,并把结果保存到destination中。
语法:
sdiffstore destination key [key ...]
4.3 应用场景
保存用户标签(用户画像)
所谓用户画像,就是通过分析一个人的种种行为,提炼出他的特征,再投其所好地做推荐。这些搜集到的用户特征通常会被转化成一个个标签,本质上就是一些简短的字符串,而且这些用户数据在一定条件下还可能在公司之间共享。
标签天然不希望重复,正好契合set元素不重复的特性,所以用set来存储用户标签非常合适。不过这也会带来一个广为讨论的副作用------信息茧房,你看到的内容会越来越集中在你本就愿意看到的那部分。
计算共同好友
像QQ的"你可能认识的人"这类好友推荐功能,核心就是求交集。set可以很方便地计算两个用户之间的公共标签或公共好友,基于这些交集,就能进一步衍生出各种用户关系并做出推荐。
统计UV
衡量一个互联网产品的用户量、用户规模时,常用两个指标:
- PV(Page View):用户每打开一次页面、每访问一次服务器就产生一个PV。它不够精准,因为无法反映到底有多少个不同的用户。
- UV(Unique Visitor):按用户去重统计,同一个用户多次访问只算一个UV。
UV的关键就在于"去重",而这恰好是set最擅长的事情,因此可以用set来实现UV统计。
五、zset有序集合
zset的"有序"和list的"有序"不是一回事:list强调的是元素插入的先后顺序,而zset强调的是按大小的升降序排列。
为了能排序,zset给每个member额外绑定了一个属性------分数(score),是浮点类型。member和score一一绑定,zset就按score来进行升降序排列。这里的member和score有点像C++中的std::pair,不要理解成普通的键值对。
注意:zset中的member要求唯一,但score是可以重复的。zset存储和操作的核心仍然是member。
5.1 操作命令
zadd
功能:添加或更新一个或多个member及其分数。
语法:
zadd key [nx | xx] [gt | lt] [ch] [incr] score member [score member ...]
不加任何选项时:member不存在就添加,存在就更新它的分数。各选项含义如下:
nx:只添加新元素,member已存在则不做处理。xx:只更新已存在的元素,member不存在则不做处理。gt:greater than,只有新分数比原分数大时才更新。lt:less than,只有新分数比原分数小时才更新。ch:让返回值变为"被改变(新增+更新)的元素个数"。incr:在原有分数的基础上做增减,效果类似zincrby。
时间复杂度为O(logN),N为有序集合中的元素个数。前面的hash、set、list添加元素都是O(1),而zset由于是有序结构,新增元素需要放到合适的位置上,因此要付出O(logN)的代价,这也正是利用了它内部有序的特点------其底层主要依靠跳表实现。
注意:zset内部默认按分数升序排列,如果分数相同,则按member的字典序排列。
zcard
功能:获取有序集合的基数,即元素个数(member的个数)。
语法:
zcard key
时间复杂度O(1)。
zcount
功能:统计分数在min, max区间内的member个数。
语法:
zcount key min max
默认是闭区间,可以在分数前加(来排除边界,例如(min max表示排除min、(min (max表示两端都排除。zset的分数是浮点数,支持inf表示正无穷、-inf表示负无穷。
zcount的时间复杂度是O(logN)。它之所以这么快,是因为zset内部会记录每个元素当前排在第几位(次序),只要分别定位到min和max对应元素的次序,两者相减就能直接得到区间内的元素个数,而不需要真正去逐个遍历。
zrange
功能:按次序(下标区间)查看有序集合,类似list的lrange。
语法:
zrange key start stop [withscores]
加上withscores会连同分数一起返回。时间复杂度O(logN+M),M为区间内的元素个数。
zrevrange
功能:和zrange类似,但按分数从大到小的逆序返回。
语法:
zrevrange key start stop [withscores]
zrangebyscore
功能:按分数区间来查找元素。
语法:
zrangebyscore key min max [withscores]
时间复杂度O(logN+M)。注意:这个命令在较新版本中可能被弃用,其功能已经合并进zrange。
zpopmax
功能:删除并返回分数最高的count个元素,常用来解决TopK问题。
语法:
zpopmax key [count]
不写count时默认只删一个。返回值是被删除的元素,删除多个就返回多个。如果存在多个并列的最大分数,会优先删除字典序最大的那个。时间复杂度O(logN*M),N为元素个数,M为要删除的个数。
有人可能会问:既然是删最大值,为什么不专门记录尾部元素的位置,让删除做到O(1)呢?事实上redis源码里针对有序集合确实记录了尾部这样的特定位置,但实际删除时并没有用上这个特性,而是走了一个通用的删除函数------先根据member的值查找位置,再删除。这里确实存在优化空间,但优化要用在刀刃上,一般得先找到真正的性能瓶颈再针对性优化,而O(logN)本身已经很快、基本可以看作O(1)了,这么优化意义不大,所以未来会不会真的这么改也不好说。
bzpopmax
功能:zpopmax的阻塞版本,类比list的blpop/brpop(实现类似阻塞队列的效果)。
语法:
bzpopmax key [key ...] timeout
这里可以把"有序集合"看作"优先级队列",bzpopmax则相当于一个带阻塞功能的优先级队列:每个key都是一个有序集合,当有序集合为空时就阻塞等待。可以用timeout指定最多阻塞多久,单位是秒,支持小数,比如0.1就是100ms。
zpopmin、bzpopmin
功能:和zpopmax、bzpopmax相对应,取出并删除的是分数最小的元素。
zrank
功能:获取member的排名,即升序方式下的次序(从0开始)。
语法:
zrank key member
zrevrank
功能:和zrank相对,按降序方式获取member的排名。
语法:
zrevrank key member
zscore
功能:获取指定member的分数。
语法:
zscore key member
时间复杂度O(1)。redis认为查询分数是高频操作,为此做了特殊化处理,付出了额外的空间代价来换取速度。
zrem
功能:删除一个或多个指定的member。
语法:
zrem key member [member ...]
返回值:成功删除的元素个数,时间复杂度O(logN*M)。
zremrangebyrank
功能:按排名(下标)区间删除元素。
语法:
zremrangebyrank key start stop
start、stop是下标,时间复杂度O(logN+M)。
zremrangebyscore
功能:按分数区间删除元素。
语法:
zremrangebyscore key min max
时间复杂度O(logN+M)。
zincrby
功能:给指定member的分数做增减。
语法:
zincrby key increment member
它在改变分数的同时,会自动调整该元素的位置,保持有序集合的有序结构。
zinterstore
功能:求多个有序集合的交集,并把结果保存到destination中。
语法:
zinterstore destination numkeys key [key ...] [weights weight ...] [aggregate <sum | min | max>]
destination:把结果保存到哪个key对应的zset中。numkeys:参与运算的key的个数。因为key有多个、后面又还跟着其他选项,必须先说明key的数量,redis才知道从哪里开始是选项,避免和key混淆(有点像TCP面向字节流时的"粘包"问题,需要明确边界)。weights:权重,最终每个元素的分数为它各自的score乘以对应的weight。aggregate:求交集时只要求member相同即可,那么当同一个member在不同集合中分数不同时,结果分数怎么取?sum(默认)求和、min取最小、max取最大。
示例:


zunionstore
功能:求多个有序集合的并集,并把结果保存到destination中,用法和zinterstore完全一致。
语法:
zunionstore destination numkeys key [key ...] [weights weight ...] [aggregate <sum | min | max>]
这里不再赘述,参考zinterstore即可。
5.2 应用场景
排行榜系统
热度排行、游戏天梯排行、成绩排行等都是典型场景。这类需求的关键点在于:用来排序的分数是实时变化的,而zset恰好天然维护有序结构,只要把玩家(或内容)作为member、把分数作为score放进有序集合,就自动形成了一个会随分数变化实时调整的排行榜。
那分数怎么算?以内容热度为例,可以综合浏览量、点击量、转发量、评论量等多个维度,给每个维度设置权重,再计算出一个综合得分。借助zinterstore/zunionstore的weights选项,就能很方便地按加权方式把这些维度合并成最终的热度分数。
六、渐进式遍历
前面提到的keys *是个比较危险的命令,一次可能返回海量的key,从而阻塞redis服务器。渐进式遍历就是为了解决这个矛盾:既能拿到所有的key,又不会一次性卡死服务器。
它的思路不是用一条命令把所有key全拿到,而是每执行一次命令只获取其中一小部分,保证单次操作不会太卡;想拿到全部key,就需要多次执行渐进式遍历命令。
渐进式遍历其实是一组命令(针对不同数据结构还有hscan、sscan、zscan等),用法都是一样的,这里以scan为例。
scan
语法:
scan cursor [match pattern] [count count] [type type]
cursor:光标,指向本次遍历开始的位置;命令的返回值会给出"下一次遍历应该从哪个光标开始"。注意这里的光标不能理解为下标,它不是连续递增的整数,只有redis服务器才知道背后的规则去定位对应位置。第一次遍历传0,直到返回的光标又变回0,说明遍历结束。match pattern:匹配规则,和keys的正则匹配一样。count:限制这一次遍历大概获取多少个元素,默认为10。它和mysql的limit不同,limit是精确的,而这里的count只是一个建议值。type:只获取指定类型的key。

注意:遍历过程中服务器不会存储任何状态信息,因此这个遍历随时可以终止,不会对服务器产生任何副作用。
注:渐进式遍历scan虽然解决了阻塞问题,但如果在遍历期间键发生了变化(新增、修改、删除),可能导致某些键被重复遍历或者遗漏,这一点在实际开发中务必要考虑到。
七、数据管理
redis里有没有像mysql那样的database(库)呢?有,但不像mysql那样可以随意创建和删除。redis中的database是现成的,默认提供16个库,编号0~15,默认使用0号库。
select
功能:切换数据库。
语法:
select num
dbsize
功能:获取当前数据库中key的个数。
语法:
dbsize
flushdb
功能:删除当前数据库中的所有key。
语法:
flushdb
flushall
功能:删除所有数据库中的所有key(谨慎使用)。
语法:
flushall
非常感谢您能耐心读完这篇文章。倘若您从中有所收获,还望多多支持呀!🎉
