redis常用数据类型与操作方法

数据结构与编码方式

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存在时才设置(相当于更新)。

组合指令:setexpsetexsetnx

语法:

复制代码
setex <key> <seconds> <value>
setnx <key> <value>

flushall:删除redis所有数据(慎用)

get

功能:通过key获取键值对信息。

语法:

复制代码
get <key>

注意:get只支持字符串的value,其他类型get获取就会失败。

msetmget

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. 生成验证码

  比如:用户输入手机号点击获取验证码,会限制1分钟之内最多获取5次验证码,或每次获取验证码必须间隔30秒。

为什么要限制?担心用户频繁获取验证码,给服务器造成过大压力。

  1. 检查验证码

  把短信收到的验证码这一串数字提交到系统,进行验证。

如何实现呢?

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

  hkeyshvalshgetall都有阻塞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:nameuser: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 操作命令

lpushrpush

功能:向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)遇到非法下标会直接抛异常,需要多一步做下标合法性校验,优点是问题会被更及时地暴露出来,容错能力(鲁棒性)也更强,两种设计各有取舍。

lpushxrpushx

功能:和lpush/rpush基本一样,唯一区别是只有key已经存在时才会插入成功。

lpoprpop

功能:从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个element
  • count < 0:从右往左查找,最多删除-count个element
  • count = 0:删除list中所有的element

ltrim

功能:按下标范围截取list,只保留指定区间内的元素,其余部分直接丢弃。

语法:

复制代码
ltrim <key> <start> <stop>

注意:这里的start、stop是下标(闭区间),和lrange不修改原list不同,ltrim会真的把区间外的元素从list里删掉。

lset

功能:按下标修改元素的值。

语法:

复制代码
lset <key> <index> <element>

时间复杂度同样是O(n)。

阻塞版本命令:blpopbrpop

  如果list中本来就有元素,blpopbrpop的表现和lpoprpop完全一样;如果list是空的或者key不存在,则当前客户端会阻塞等待,直到有新元素被插入为止。

它可以显式指定一个阻塞超时时间,并不是无休止地等下去;阻塞期间redis仍然可以正常处理其他客户端发来的命令。

  blpopbrpop还支持同时监听多个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/rpoprpush/lpop的组合,还可以分别模拟出栈和队列这两种经典的数据结构。

四、set集合

  set集合和前面的数据结构相比,有两个非常鲜明的特性:

  1. 集合中的元素是无序的,这一点和list正好相反。
  2. 集合中的元素不能重复。

  和其他类型一样,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 bsdiff 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。

zpopminbzpopmin

功能:和zpopmaxbzpopmax相对应,取出并删除的是分数最小的元素。

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/zunionstoreweights选项,就能很方便地按加权方式把这些维度合并成最终的热度分数。

六、渐进式遍历

  前面提到的keys *是个比较危险的命令,一次可能返回海量的key,从而阻塞redis服务器。渐进式遍历就是为了解决这个矛盾:既能拿到所有的key,又不会一次性卡死服务器。

  它的思路不是用一条命令把所有key全拿到,而是每执行一次命令只获取其中一小部分,保证单次操作不会太卡;想拿到全部key,就需要多次执行渐进式遍历命令。

  渐进式遍历其实是一组命令(针对不同数据结构还有hscansscanzscan等),用法都是一样的,这里以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

非常感谢您能耐心读完这篇文章。倘若您从中有所收获,还望多多支持呀!🎉

相关推荐
灯澜忆梦1 小时前
【MySQL10】进阶篇 | 索引_#1
数据库·sql·mysql
OceanWaves19932 小时前
mysql 5.7.29 主从同步配置
数据库·mysql·adb
molaoye2 小时前
win10下切换MySQL版本
数据库·mysql
牢姐与蒯2 小时前
c++高级数据结构之图(基本概念,存储结构,BFS,DFS,最小生成树)
数据结构·c++·
翼龙云_cloud2 小时前
阿里云国际代理商:Redis缓存实例配置和性能优化 从参数调优到持久化
运维·redis·阿里云·缓存·云计算
丁引2 小时前
《数据清洗的艺术:如何用20行核心逻辑优雅地删除无标签图片》
前端·数据库·python
雨辰AI2 小时前
K8s 数据库 Secret 加密实战|密码明文漏洞彻底修复,等保密评双合规(金仓 / 达梦双库适配)
数据库·容器·kubernetes
Tongzhi20262 小时前
考勤数据本地化存储:通芝科技无感考勤一体机的数据安全逻辑
大数据·数据结构·科技·均值算法·数据库开发·推荐算法
XR1234567882 小时前
学校图书馆网络改造,方案谁更优?
网络·数据库·php