Redis常用数据结构与应用场景
redis中存储数据是以key-value
键值对的方式去存储的,其中key为string字符类型,value的数据类型可以是string(字符串)、list(列表)、hash(字典)、set(集合) 、 zset(有序集合)。 这5种数据类型在开发中可以应对大部分场景的存储
拓展:key的底层存储方式SDS
这里有一个问题,当我们使用一条redis命令set key value
的时候,redis进行了什么操作?
其实当我们使用命令操作redis的时候,也是会经过redis客户端到redis服务端的过程,这些命令相当于一个请求
- redis客户端通过socket传输这些命令
- redis服务端通过io读取到这些请求命令,把所有的命令解析成一个字符串,并执行对应命令操作,然后再经过socket写回操作结果!
同时redis是使用c语言写的,但它底层在存放redis的key时,并没有用c语言原生的字符串数据结构,而是定义了一个属于redis的数据结构SDS(Simple Dynamic String),
struct sdshdr{
//记录buf数组中已使用字节的数量
//等于 SDS 保存字符串的长度
int len;
//记录 buf 数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
}
为什么要用这样的一个数据结构去存储字符串呢?
- 二进制安全的数据结构。 比如是操作命令是
get aaa\0
:获取aaa\0
的值。如果是c语言的字符数组就会把\0
吞掉,变为get aaa
,而使用SDS
就会完整的操作aaa\0
,SDS
把所有接受到的数据都转成字符串,即使是一些特殊字符! - SDS提供了内存预分配机制,避免频繁的内存分配。 如果是c语言,在修改一个key时,会分配一个新的字符数组,然后进行内存赋值,而SDS则采用预先分配机制,直接把字符串容量扩大两倍,key的长度变化时,直接在已分配的内存中修改即可,如果不够继续扩大2倍
思考:SDS采用的也是一种空间换时间的思路,无论是扩展之后分配多余空间从而降低下次扩展时需要再次内存分配的概率,还是缩容之后并不立即回收空间而是留给下次扩容,这两种操作都会导致空闲空间增大,内存占用提升,而Redis又了很多数据压缩策略来控制内存。 - 杜绝缓冲区溢出。 在C语言原生的字符串中,当需要修改字符串且修改后的长度大于修改前的长度时,在修改之前需要先对原数组申请空间扩容,否则可能导致数组溢出,内容写入到相邻的下一个数组中从而改变下一个字符串的值。
在SDS中,SDS屏蔽了用户对数组空间的分配,SDS在增长之前会根据free属性自动检测是否足够修改之后的字符串所需空间,如果足够则直接修改,并更新修改之后的len和free属性,如果当前剩余空间不够,SDS会根据空间分配策略自动进行扩容,无需用户关心。
思考:类似于Java中的String类,高级容器等,会提供自动扩容,缩容的功能,具体的细节对使用者透明,能减少开发者的编码负担。
string+应用场景
常用操作
命令 | 说明 |
---|---|
SET key value | 存入字符串键值对 |
MSET key value [key value …] | 批量存储字符串键值对 |
SETNX key value | 存入一个不存在的字符串键值对 |
GET key | 获取一个字符串键值 |
MGET key [key …] | 批量获取字符串键值 |
EXPIRE key seconds | 设置一个键的过期时间(秒) |
DEL key [key …] | 删除一个键 |
INCR key | 将key中储存的数字值加1 |
DECR key | 将key中储存的数字值减1 |
INCRBY key increment | 将key所储存的值加上increment |
DECRBY key decrement | 将key所储存的值减去decrement |
- 设置和获取键值对
> SET key value
OK
> GET key
"value"
值得注意的是, 当key存在时,set命令会覆盖掉你上一次设置的值
> SET key newValue
OK
> GET key
"newValue"
- 使用
EXISTS
和DEL
关键字来查询是否存在和删除键值对:
> EXISTS key
(integer) 1
> DEL key
(integer) 1
> GET key
(nil)
- 批量设置键值对
> SET key1 value1
OK
> SET key2 value2
OK
> MGET key1 key2 key3 # 返回一个列表
1) "value1"
2) "value2"
3) (nil)
> MSET key1 value1 key2 value2
> MGET key1 key2
1) "value1"
2) "value2"
- 过期和 SET 命令扩展
可以对 key 设置过期时间,到时间会被自动删除,这个功能常用来控制缓存的失效时间。(过期可以是任意数据结构)
> SET key value1
> GET key
"value1"
> EXPIRE name 5 # 5s 后过期
... # 等待 5s
> GET key
(nil)
- 返回原值的 GETSET 命令
对字符串,还有一个 GETSET 比较让人觉得有意思,它的功能跟它名字一样:为 key 设置一个值并返回原值:
> SET key value
> GETSET key value1
"value"
这可以对于某一些需要隔一段时间就统计的 key 很方便的设置和查看,例如:系统每当由用户进入的时候你就是用 INCR
命令操作一个 key,当需要统计时候你就把这个 key 使用 GETSET
命令重新赋值为 0,这样就达到了统计的目的。
使用场景
- 单值缓存
SET key value
GET key
使用这两条命令可以做用户id存储、商品库存存储等等!
- 对象存储
以缓存user对象为例,有以下两种方式:
(1)SET user:1 value(json格式数据):把对象转json存入redis,也是当下常用的方式,获取数据需要做数据转换
(2)MSET user:1:name zhuge user:1:balance 1888
MGET user:1:name user:1:balance
使用Mset命令,把对象拆开存储,每一个key只保存对象的一个字段信息,适用于经常修改user的某个字段的场景 - 分布式锁
SETNX product:10001 true //操作product:10001
##执行业务操作...
DEL product:10001 //删除product:10001
其中SETNX key value 命令要求如果key已存在,则其他的setnx命令无法对当前key进行操作。
在使用分布式锁时通常还会通过
SET product:10001 true ex 10 nx 命令设置key的超时时间,防止死锁!
- 计数器
NCR 文章id
可以使用INCR命令实现数量自增,可以用于文章阅读量、热度人数统计等,用户每点进去一次执行一次INCR命令
- 分布式系统全局序列号
在分布式系统下,如果需要分库分表, mysql的数据库自增id已经无法满足分库分表下的id自增,这时就需要一个独立于数据库之外的中间件来实现id的分配。
redis的INCR命令可以实现id、序列号的生成,但如果用户量非常大,每生成一个id、序列号都去redis会给redis添加不小的压力,我们可以一次性从redis中自增1000次,把序列号放入本地内存中,这1000个id用完了,再去redis再取1000个,可有效降低redis的压力!
hash+应用场景
Redis 中的字典(hash)相当于 Java 中的 HashMap,内部实现也差不多类似,都是通过 “数组 + 链表” 的链地址法来解决部分 哈希冲突,同时这样的结构也吸收了两种不同数据结构的优点。
常用操作
命令 | 说明 |
---|---|
HSET key field value | 存储一个哈希表key的键值 |
HGET key field | 获取哈希表key对应的field键值 |
HMSET key field value [field value …] | 在一个哈希表key中存储多个键值对 |
HMGET key field [field …] | 批量获取哈希表key中多个field键值 |
HSETNX key field value | 存储一个不存在的哈希表key的键值 |
HDEL key field [field …] | 删除哈希表key中的field键值 |
HLEN key | 返回哈希表key中field的数量 |
HGETALL key | 返回哈希表key中所有的键值 |
HINCRBY key field increment | 为哈希表key中field键的值加上增量increment |
字典相关操作
> HSET books java "think in java" # 命令行的字符串如果包含空格则需要使用引号包裹
(integer) 1
> HSET books python "python cookbook"
(integer) 1
> HGETALL books # key 和 value 间隔出现
1) "java"
2) "think in java"
3) "python"
4) "python cookbook"
> HGET books java
"think in java"
> HSET books java "head first java"
(integer) 0 # 因为是更新操作,所以返回 0
> HMSET books java "effetive java" python "learning python" # 批量操作
OK
使用场景
- 电商购物车
1)以用户id为key === cart:1001
2)以商品id为field===10088
3)商品数量为value === 1
因此,购物车操作可以如下:
1)添加商品:hset cart:1001 10088 1
2)增加数量hincrby cart:1001 10088 1
3)商品总数hlen cart:1001
4)删除商品hdel cart:1001 10088
5)获取购物车所有商品hgetall cart:1001
思考-优点:
1)同类数据归类整合储存,方便数据管理
2)相比string操作消耗内存与cpu更小
因为:string类型通过set key - val 的方式存储数据,通过对key进行hash运算决定当前key是存储在数组哪个位置。如果把hash类型的数据变成string类型来存储,则需要更多的key,同时在存放时也需要更多的hash(key)运算,消耗更多的cpu资源!
3)相比string储存更节省空间
如果把hash类型的数据变成string类型来存储,将需要存储更多key,如果数据量很多的情况下,redis底层那么存储数据的数组将很快会被占满,占满就会进行扩容,加大内存消耗。由此可见,string结构与hash结构只存储一个key相比,需要更多的内存空间!
思考-缺点:
1)过期功能不能使用在field上,只能用在key上
redis的过期时间只能用在key上,而hash的key是一个大的概念,里面的map型结构才是重要数据,但过期时间只能用在外边的大key上,hash结构相比于string不能实现精准过期!
2)hash结构在Redis集群架构下不适合大规模使用
因为如果一个hash的key中的属性很多的话,只能存在一个redis节点上,那么这个节点压力会比其他节点压力大很多,造成redis集群下压力分配不均衡!
list+应用场景
Redis 的列表相当于 Java 语言中的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)。
常用操作
命令 | 说明 |
---|---|
LPUSH key value [value …] | 将一个或多个值value插入到key列表的表头(最左边) |
RPUSH key value [value …] | 将一个或多个值value插入到key列表的表尾(最右边) |
LPOP key | 移除并返回key列表的头元素 |
RPOP key | 移除并返回key列表的尾元素 |
LRANGE key start stop | 返回列表key中指定区间内的元素,区间以偏移量start和stop指定 |
BLPOP key [key …] timeout | 从key列表表头弹出一个元素,若列表中没有元素,阻塞等待 |
timeout:秒,如果timeout=0,一直阻塞等待 | |
BRPOP key [key …] timeout | 从key列表表尾弹出一个元素,若列表中没有元素,阻塞等待 |
timeout:秒,如果timeout=0,一直阻塞等待 |
list相关
LPUSH
和RPUSH
分别可以向list的左边(头部)和右边(尾部)添加一个新元素LRANGE
命令可以从list中取出一定范围的元素LINDEX
命令可以从list中取出指定下标的元素,相当于java量表操作中的get(int index)
操作
> rpush mylist A
(integer) 1
> rpush mylist B
(integer) 2
> lpush mylist first
(integer) 3
> lrange mylist 0 -1 # -1 表示倒数第一个元素, 这里表示从第一个元素到最后一个元素,即所有
1) "first"
2) "A"
3) "B"
- list 实现队列
队列是先进先出的数据结构,常用于消息排队和异步逻辑处理,它会确保元素的访问顺序:
> RPUSH books python java golang
(integer) 3
> LPOP books
"python"
> LPOP books
"java"
> LPOP books
"golang"
> LPOP books
(nil)
- list 实现栈
栈是先进后出的数据结构,跟队列正好相反
> RPUSH books python java golang
> RPOP books
"golang"
> RPOP books
"java"
> RPOP books
"python"
> RPOP books
(nil)
list的使用场景
- 模拟分布式系统数据结构
①:Stack(栈) = LPUSH(左边放) + LPOP(左边取)
②:Queue(队列)= LPUSH(左边放) + RPOP(右边取)
③:Blocking MQ(阻塞队列)= LPUSH(左边放) + BRPOP(右边阻塞取:没有数据就阻塞!)
思考:那么redis实现的数据结构和jdk中提供的数据结构有什么区别呢?
答:jdk提供的数据结构仅在本服务中有用,如果在分布式环境下,则需要借助redis等中间件,模拟数据结构来统一管理数据。 - 微博、朋友圈、公众号等,关注的文章列表展示
假如 小明 关注了 中国青年报、三太子敖丙 等大V的订阅号,当这些大V发布订阅号时,通过推或拉的方式把消息LPUSH放入redis中属于小明的list中。其中key为msg:{小明_ID}。当小明要获取大V们发的消息时,使用LRANGE 命令从队列中获取指定个数的订阅号信息!!
# ①:MacTalk发微博,消息ID为10010
LPUSH msg:{小明_ID} 10010
# ②:备胎说车发微博,消息ID为10086
LPUSH msg:{小明_ID} 10086
# ③:查看最新微博消息(前4条)
LRANGE msg:{小明_ID} 0 4
思考:大V发了消息的是怎么存储在粉丝的redis中呢?一般有两种处理方案!
(1)推送,博主发了消息,通过线程先推送到在线粉丝的队列中,其他不在线的粉丝等后面系统在空闲的时候再慢慢推送过去
(2)拉取,如果粉丝太多,推的方案还是要很长时间去处理,还有一种方案就是拉,每一个粉丝上线后就去关注的博主那里拉取他发送的最新的消息,在使用LRANGE
取出即可。
set+应用场景
Redis 的集合相当于 Java 语言中的 HashSet,它内部的键值对是无序、唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。
set 的常用操作
命令 | 说明 |
---|---|
SADD key member [member …] | 往集合key中存入元素,元素存在则忽略,若key不存在则新建 |
SREM key member [member …] | 从集合key中删除元素 |
SMEMBERS key | 获取集合key中所有元素 |
SCARD key | 获取集合key的元素个数 |
SISMEMBER key member | 判断member元素是否存在于集合key中 |
SRANDMEMBER key [count] | 从集合key中选出count个元素,元素不从key中删除 |
SPOP key [count] | 从集合key中选出count个元素,元素从key中删除 |
set 的运算操作
命令 | 说明 |
---|---|
SINTER key [key …] | 交集运算 |
SINTERSTORE destination key [key …] | 将交集结果存入新集合destination中 |
SUNION key [key …] | 并集运算 |
SUNIONSTORE destination key [key …] | 将并集结果存入新集合destination中 |
SDIFF key [key …] | 差集运算 |
SDIFFSTORE destination key [key …] | SDIFFSTORE destination key [key …] |
集合set的基本使用
> SADD books java
(integer) 1
> SADD books java # 重复
(integer) 0
> SADD books python golang
(integer) 2
> SMEMBERS books # 注意顺序,set 是无序的
1) "java"
2) "python"
3) "golang"
> SISMEMBER books java # 查询某个 value 是否存在,相当于 contains
(integer) 1
> SCARD books # 获取长度
(integer) 3
> SPOP books # 弹出一个
"java"
set的使用场景
- 抽奖活动
1)点击参与抽奖加入集合
SADD key {userlD}
2)查看参与抽奖所有用户
SMEMBERS key
3)随机抽取count名中奖者
SRANDMEMBER key [count] ------元素不从集合中删除
SPOP key [count] ------ 元素从集合中删除 - 朋友圈点赞
当某人在朋友圈发布消息,可用set来点赞展示
1)点赞
SADD like:{消息ID} {用户ID}
2)取消点赞
SREM like:{消息ID} {用户ID}
3)检查用户是否点过赞
SISMEMBER like:{消息ID} {用户ID}
4)获取点赞的用户列表
SMEMBERS like:{消息ID}
5)获取点赞用户数
SCARD like:{消息ID} - 利用set的交、并、差集实现微博、微信关注模型
关注模型如下图:
首先了解一下set的集合操作,假如有三个集合
set1:(a、b、c)
set2:(b、c、d)
set3:(c、d、e)
三个集合的
交集为:SINTER set1 set2 set3 ==> { c }
并集为:SUNION set1 set2 set3 ==> { a,b,c,d,e }
差集为:SDIFF set1 set2 set3 ==> { a }
差集计算方式:set1 - (set2并set3) = {a、b、c} - {b、c、d、e} = {a} 只保留a中单独存在的元素
共同关注A的人:可以用交集来实现
我可能认识的人:可以使用差集来实现,把我的好友求差集,例如a的好友={b,c},b的好友={a,c,d},那么a可能认识的人可以是,b-a={a,b,c,d}-{a,b,c} = {d}。当然这只是一个简单又不大现实的想法,相当于把对方的好友全部推荐给你了,真实情况的还需要考虑到关系网络,好友的权重数等
zset+应用场景
这可能使 Redis 最具特色的一个数据结构了,它类似于 Java 中 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以为每个 value 赋予一个 score 值,用来代表排序的权重。
zset相比于set多一个score 分值,正是根据这个分值进行排序,所以zset才能展示有序的数据
zset 的常用操作
命令 | 说明 |
---|---|
ZADD key score member [[score member]…] | 往有序集合key中加入带分值元素 |
ZREM key member [member …] | 从有序集合key中删除元素 |
ZSCORE key member | 返回有序集合key中元素member的分值 |
ZINCRBY key increment member | 为有序集合key中元素member的分值加上increment |
ZCARD key | 返回有序集合key中元素个数 |
ZRANGE key start stop [WITHSCORES] | 正序获取有序集合key从start下标到stop下标的元素 |
ZREVRANGE key start stop [WITHSCORES] | 倒序获取有序集合key从start下标到stop下标的元素 |
zset的基本使用
> ZADD books 9.0 "think in java"
> ZADD books 8.9 "java concurrency"
> ZADD books 8.6 "java cookbook"
> ZRANGE books 0 -1 # 按 score 排序列出,参数区间为排名范围
1) "java cookbook"
2) "java concurrency"
3) "think in java"
> ZREVRANGE books 0 -1 # 按 score 逆序列出,参数区间为排名范围
1) "think in java"
2) "java concurrency"
3) "java cookbook"
> ZCARD books # 相当于 count()
(integer) 3
> ZSCORE books "java concurrency" # 获取指定 value 的 score
"8.9000000000000004" # 内部 score 使用 double 类型进行存储,所以存在小数点精度问题
> ZRANK books "java concurrency" # 排名
(integer) 1
> ZRANGEBYSCORE books 0 8.91 # 根据分值区间遍历 zset
1) "java cookbook"
2) "java concurrency"
> ZRANGEBYSCORE books -inf 8.91 withscores # 根据分值区间 (-∞, 8.91] 遍历 zset,同时返回分值。inf 代表 infinite,无穷大的意思。
1) "java cookbook"
2) "8.5999999999999996"
3) "java concurrency"
4) "8.9000000000000004"
> ZREM books "java concurrency" # 删除 value
(integer) 1
> ZRANGE books 0 -1
1) "java cookbook"
2) "think in java
zset应用场景
- 实现热搜排行榜
①:点击 “国庆放假” 新闻时,为其分值+1
ZINCRBY hotNews:20190819 1 国庆放假
②:展示当日排行前十
ZREVRANGE hotNews:20190819 0 9 WITHSCORES
③:七日搜索榜单计算
取7天的key求并集放入新的key=hotNews:20190813-20190819中,就得出这7天中的访问量排行榜
ZUNIONSTORE hotNews:20190813-20190819 7
hotNews:20190813 hotNews:20190814… hotNews:20190819
④:展示七日排行前十
根据上边的并集,从新的key=hotNews:20190813-20190819中取出前10名
ZREVRANGE hotNews:20190813-20190819 0 9 WITHSCORES