更多优质内容
请关注公众号

深入Redis之  Redis的五种基本数据类型和使用场景 (一)-张柏沛IT博客

正文内容

深入Redis之 Redis的五种基本数据类型和使用场景 (一)

栏目:其他内容 系列:深入Redis系列 发布时间:2020-02-22 16:46 浏览量:3844

1.redis的特性:单线程
由于是单线程,所以redis的命令执行是串行而不是并行的,意味着同一时间内redis只会执行一个命令。

由于一次只能执行一条命令,所以要拒绝长命令(就是运行时间长的命令),因为会引起后面的命令阻塞。长命令如:keys,flushall,flushdb,mutil/exec等。

单线程为什么这么快:
因为redis是纯内纯操作。


其实redis不全是单线程,在执行普通读写命令时是单线程,在进行aof持久化时会单独开一个线程进行。

redis在接收和处理读写请求的时候虽然使用的是单线程,但redis采用了多路复用技术来处理网络IO(即用户的读写请求),通过其内置的eventloop事件循环机制监听多个读写事件,从而使得读写请求在单线程下也能并发执行。

而后来为了能够处理更高QPS的请求,redis6.0版本之后开始使用多线程接收和处理用户的网络IO请求,每个线程再使用多路复用技术,能够极高的提升网络IO的效率(对于redis而言,CPU和内存IO不是其瓶颈,网络IO才是)。不过在执行读写操作依旧是单线程处理。


2.redis的数据结构

A.字符串类型

字符串的key是字符串,value可以是字符串,数字,二进制,json,但本质上value也还是字符串。

单个value大小不能超过512M,但实际应用中一般不会存超过100K的内容。


字符串类型的使用场景:
缓存
计数器
分布式锁
等等

常用命令:
get/set/del
incr/decr/incrby/decrby

实战场景1:
记录每一个用户的访问次数,或者记录每一个商品的浏览次数。
方案:键名: userid:pageview 或者 pageview:userid 如pageview:5
      使用命令:incr
使用理由:每一个用户访问次数或者商品浏览次数的修改是很频繁的,如果使用mysql这种文件系统频繁修改会造成mysql压力,效率也低。
而使用redis的好处有二:使用内存,很快;单线程,所以无竞争,数据不会被改乱

实战场景2:
缓存频繁读取,但是不常修改的信息,如用户信息,视频信息
方案:
    业务逻辑上:先从redis读取,有就从redis读取;没有则从mysql读取,并写一份到redis中作为缓存,注意要设置过期时间。
    键值设计上:一种是直接将用户一条mysql记录做序列化(serialize或json_encode)作为值,userInfo:userid 作为键名如:userInfo:1
    另一种是以 表名:主键名:字段名:id值 作为键,字段值作为值。如 user:id:name:1 = "zbp"
    
实战场景3:
分布式id生成器
incr id
例如,mysql做了分布式,数据分摊到每一个mysql服务器,在插入数据时,每一个mysql服务器的id要自增但却不能相同。此时可以使用redis的incr来完成。原因是,redis是单线程,意味并发请求生成id时,生成的id不会重复。(单线程无竞争)


set setnx setxx
set 不管key是否存在都设置
setnx key不存在才设置,相当于新增
set key value xx key存在才设置,相当于修改


mget/mset 批量操作

n次get命令花费的时间 = n次网络时间+n次命令时间
一次mget命令获取n个key的时间 = 1次网络时间+n次命令时间 
尤其是客户端(php/Python)和redis服务端不在同一主机上,网络时间就会比较长。

所以尽量用mget,但是mget不要获取太多key,否则要传输的数据过大对网络开销和性能都有负担。

 

实战场景4:
限定某个ip特定时间内的访问次数
使用 incr + setex

//限定某ip在10秒内访问api的次数不能超过1000次

<?php

$r=new Redis();
$r->connect($RedisHost,$RedisPort);
$redis_key = "arts_api|".$_SERVER["REMOTE_ADDR"];

if(!$r->exists($redis_key)){
    $r->setex($redis_key,10,"1");
}else{
    $r->incr($redis_key);
    
    //判断是否超过规定次数
    if($r->get($redis_key)>1000){
        die("访问过快");
    }
    
}

?>


实战场景5:分布式session
我们知道,session是以文件的形式保存在服务器中的; 如果你的应用做了负载均衡,将网站的项目放在多个服务器上,当用户在服务器A上进行登陆,session文件会写在A服务器;当用户跳转页面时,请求被分配到B服务器上的时候,就找不到这个session文件,用户就要重新登陆

如果想要多个服务器共享一个session,可以将session存放在redis中,redis可以独立与所有负载均衡服务器,也可以放在其中一台负载均衡服务器上;但是所有应用所在的服务器连接的都是同一个redis服务器。

实现如下:
以PHP为例

设置php.ini 文件中的session.save_handle 和session.save_path
session.save_handler = Redis
session.save_path =  "tcp://47.94.203.119:6379"     # 大部分情况下,使用的都是远程redis,因为redis要为多个应用服务

如果为redis已经添加了auth权限(requirpass),session.save_path项则应该这样写
session.save_path =  "tcp://47.94.203.119:6379?persistent=1&database=10&auth=myredisG506"


使用redis存储session信息
/**
 * 将session存储在redis中
 */
session_start();
echo session_id();
echo "<br>";
$_SESSION['age'] = 26;
$_SESSION['name'] = 'xiaobudiu';
$_SESSION['sex'] = 'man';
var_dump($_SESSION);

# 此时session_id依旧存在cookie中。

redis中的key为 PHPREDIS_SESSION:session_id。

当用户跳转页面的时候,php内部会先根据session_id()获取cookie的session_id,再根据session_id获取到redis中的key
再根据key获取value

所以redis的session是通过cookie中的session_id得知 调用$_SESSION['name']是要获取张三的用户名而不是李四的用户名

如果关闭浏览器,cookie会失效,再打开浏览器的时候,session_id就不见了; 这个时候,虽然redis还保存这张三的session。
但是php已经无法获取到这个session。

所以张三再登陆的时候,会重新生成一个session。此时张三的session会有两个,一个是正在使用的,一个是已经失效的。失效的session不会一直放在redis中占用内存,php自动给这个redis的可以设置了过期时间。你也可以给session手动设置过期时间,通过ini_set('session.gc_maxlifetime',$lifetime)。(如果是文件的形式存储的session,php会定时清理失效的session文件,失效的session就是在浏览器cookie中找不到session_id的session)

 

我们可以封装一个session类,这个session类在原基础上多了可以对session中的某个属性设置过期时间
封装session类

class Session
{
 
    function __construct($lifetime = 3600)
    {
        //初始化设置session会话存活时间,如果redis中的key存在超过3600秒,会自动执行session_destory(),具体表现为key被删除
        ini_set('session.gc_maxlifetime',$lifetime);
    }
 
    /**
     * 设置当前会话session的key-value
     * @param String $name   session name
     * @param Mixed  $data   session data
     * @param Int    $expire 有效时间(秒)
     */
    function set($name, $data, $expire = 600)   # session中的单独的某个键也可以设置过期时间,很灵活
    {
        $session_data = array();
        $session_data['data'] = $data;
        $session_data['expire'] = time()+$expire;
        $_SESSION[$name] = $session_data;
    }
 
    /**
     * 读取当前会话session中的key-value
     * @param  String $name  session name
     * @return Mixed
     */
    function get($name)
    {
        if(isset($_SESSION[$name])) {
            if($_SESSION[$name]['expire'] > time()) {
                return $_SESSION[$name]['data'];
            }else{
                self::clear($name);
            }
        }
        return false;
    }
 
    /**
     * 清除当前session会话中的某一key-value
     * @param  String  $name  session name
     */
    function clear($name)
    {
        unset($_SESSION[$name]);
    }
 
    /**
     * 删除当前session_id对应的session文件(清空当前session会话存储,在redis中的表现为删掉一个session的key,在文件形式session中表现为删除一个session文件)
     */
    function destroy()
    {
        session_destroy();
    }
 
}

在一个会话生命周期中,一个redis的key存着这个会话的$_SESSION所有信息包括 $_SESSION['name'],["age"]等

redis存session比文件存session的优势在: 前者可以做分布式session,后者不行;前者是纯内存操作,更快,后者是文件IO操作

我们可以看一下一个key里面的内容
get PHPREDIS_SESSION:6mmndoqm87st2s75ntlsvbp25q

得到
"name|a:2:{s:4:\"data\";s:3:\"zbp\";s:6:\"expire\";i:1584351986;}age|a:2:{s:4:\"data\";i:18;s:6:\"expire\";i:1584351986;}job|a:2:{s:4:\"data\";s:10:\"programmer\";s:6:\"expire\";i:1584351986;}"

是一堆序列化的内容

所以这种方式相比于使用hash结构来存的效率更低
因为这种方式取其中一个字段name就要将整个key获取出来,而且序列化和反序列化也要消耗性能

 

使用的时候记得要session_start()

 

题外话:在网站分布多台机器的时候,要做session分布式才可以跨机器获取session; 如果我们不用session,改用纯cookie代替session,将用户信息都存到cookie中,这样无论用户访问到哪台机器都无所谓,反正都可以在浏览器中获取用户信息。

但是这真的是一种很好的解决分布式session的方式吗?

本人有时候也会做做爬虫,知道有些页面必须登陆后才能访问,如果将用户信息存在cookie,爬虫完全可以伪造一份用户的cookie来访问用户的隐私页面。所以使用cookie会带来这样的安全问题。
或者你的cookie是在浏览器可视的,而使用session,只有session_id在浏览器是可视的,用户具体信息在服务端中你是看不到的。


mget/mset 批量操作

n次get命令花费的时间 = n次网络时间+n次命令时间
一次mget命令获取n个key的时间 = 1次网络时间+n次命令时间 
尤其是客户端(php/Python)和redis服务端不在同一主机上,网络时间就会比较长。

所以尽量用mget,但是mget不要获取太多key,否则要传输的数据过大对网络开销和性能都有负担。
 


B.哈希类型
一个哈希相当于一条mysql记录(类似于map结构)

hget/hset/hdel/hgetall

hexists/hlen

hmget/hmset

实战场景1:
记录每一个用户的访问次数
方案: 
键名: user:1:info   字段名:pageview
使用命令:hincrby

和单纯使用字符串类型进行记录不同,这里可以将用户访问次数也放到用户信息中作为一个整体,user:1:info中还存储着name,email,age之类的信息


hgetall/hvals/hkeys

PS:慎用hgetall,因为hgetall会获取一个hash key中的所有字段,这是一个长命令,而redis是单线程,会阻塞住后面的命令的执行。


字符串和哈希类型对比:
将一个用户的信息存为redis字符串和哈希
字符串存储方式:
方案1: 键名 user:1:info  值 序列化后的用户对象
方案2: 键名 user:1:字段名   值 字段值

哈希存储方式:
方案3: 键名 user:1:info  值 用户数据

方案1的优点是设计简单,可节省内存(相对于方案2),缺点一是如果要修改用户对象中的某个属性要将整个用户对象从redis中取出来,二是要对数据进行序列化和反序列化也会产生一定CPU开销。

方案2的优点是可以单独更新用户的属性,无需将这个用户所有属性取出。
缺点一是单个用户的数据是分散的不利于管理,二是占用内存,方案1一个用户的数据用一个key就可以保存,方案2一个用户的数据要多个key才可以保存。

方案3的优点:直观,节省空间,可以单独更新hash中的某个属性
缺点:ttl不好控制


C.列表类型
列表本质是一个有序的,元素可重复的队列


rpush/lpush

rpush c b a   # cba,插入方向<-,即从右往左
lpush c b a   # abc,插入方向->,从左往右

linsert  # 在一个元素前或后插入元素


lpop/rpop   #弹出
lrem        #删除
ltrim   # 修剪列表返回一个子列表,会影响原列表


lrange  # 按照范围查询列表返回一个子列表
lindex  # 按索引取
llen    # 列表长度


lset    # 修改某索引的值为新值


实战场景1:
微博中的时间轴功能(文章按时间排序,还可以做分页)
方案:做一个列表用于存放某个用户的所有微博id,key为 weiboList:user:1,值为微博id。
做一个哈希,里面放微博的内容

该用户新增一个微博就会忘列表中lpop一个微博id,查询的时候使用lrange即可,分页也可以使用lrange。


blpop/brpop     # 是lpop和rpop的阻塞版
当列表长度不为空是,lpop和blpop效果一样。
当列表长度为空,lpop会立刻返回nil,而blpop会等待,直到有元素进入列表,blpop就会执行弹出。
它的应用场景就是消息队列。


小结:
用列表实现栈:lpush+lpop = stack
用列表实现队列:lpush+rpop = queue
用列表实现固定集合: lpush+ltrim = capped collection
用列表实现消息队列:lpush+brpop = message queue


D.集合类型
集合的特点是无序性和确定性(不重复)。


sadd

删 
srem

scard #个数
sismember   #是否存在
srandmember # 随机选n个元素
spop    # 随机弹出元素,影响原集合
smembers    # 返回所有元素,要慎用,不要获取内容较大的集合


实战场景1:
抽奖

使用spop即可,利用的是它的无序性和不重复


实战场景2:
赞,踩,收藏功能等。
方案: 每一个用户做一个收藏的集合,每个收藏的集合存放用户收藏过的文章id或者商品id。

键名: set:userCol:用户id
值:   文章id

如果使用mysql实现,需要建立多对多关系,要建中间表。

实战场景3:
给文章添加标签
方案: 
要创建两种集合,以文章id为键名放标签的集合,以标签id为键名放文章的集合。
创建两种集合是因为我们会查询某标签下有什么文章,也会查询某文章下有什么标签

键名: article:1:tags    值:tag的id
键名: tag:1:users        值:user的id

而且这两个集合创建时要放在一个事务中进行。

sdiff/sinter/sunion     # 交集并集差集

实战场景4:
共同好友

E.有序集合
有序集合的特点是 有序,无重复值

zadd key score element

zrem

zscore      # 获取分数

zincrby     # 增加减少分数

zcard       # 元素个数

zrange      # 按下标范围获取元素,加上withscores会按分数排序

zrangebyscore   # 按照分数范围获取元素

zcount      # 按分数范围计算元素个数

zremrangebyrank     # 删除指定下标范围的元素

zremrangebyscore


实战场景:
排行榜


最后强调一下,要慎用hgetall,原因如下:
当一个hash的字段数很多,存储的内容很多时,处理hgetall请求会花费较长时间;而redis是单线程,同一时间只能处理一个操作,所以后面的操作都要等待hgetall处理完毕才能处理,很影响效率和性能。

还有一种情况:列表或者集合中存了很多哈希的键名。
通过 lrange 0 -1 或者 smembers 这样的命令取出列表或者集合中所有键名再通过hgetall取出大量的hash,而每个hash中又有大量的字段。这种情况下性能会急剧下降,而且占用大量内存,甚至会造成宕机。


下面总结时间复杂度为n的命令:
String类型
MSET、MSETNX、MGET

List类型
LPUSH、RPUSH、LRANGE、LINDEX、LSET、LINSERT
LINDEX、LSET、LINSERT 这三个命令谨慎使用

Hash类型
HDEL、HGETALL、HKEYS/HVALS
HGETALL、HKEYS/HVALS 谨慎使用

Set类型
SADD、SREM、SRANDMEMBER、SPOP、
SMEMBERS、SUNION/SUNIONSTORE、SINTER/SINTERSTORE、SDIFF/SDIFFSTORE
第二行命令谨慎使用

Sorted Set类型
ZADD、ZREM、
ZRANGE/ZREVRANGE、ZRANGEBYSCORE/ZREVRANGEBYSCORE、ZREMRANGEBYRANK/ZREMRANGEBYSCORE
第二行时间复杂度 O(log(N)+M),需要谨慎使用

其他常用命令
DEL、KEYS
KEYS 谨慎使用

基本上,设置多个值或者获取多个值的命令其时间复杂度为n
时间复杂度越高,执行命令消耗的时间越长。




更多内容请关注微信公众号
zbpblog微信公众号

如果您需要转载,可以点击下方按钮可以进行复制粘贴;本站博客文章为原创,请转载时注明以下信息

张柏沛IT技术博客 > 深入Redis之 Redis的五种基本数据类型和使用场景 (一)

热门推荐
推荐新闻