Redis 的五种基本数据类型和使用场景

数据库 Redis
由于是单线程,所以Redis的命令执行是串行而不是并行的,意味着同一时间内redis只会执行一个命令。

一、redis的特性:单线程

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

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

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

注意,这里的说redis单线程只是指redis执行读写命令的时候是单线程。

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

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

二、redis的五种数据结构

1.字符串类型

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

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

字符串类型的使用场景:

  • 缓存
  • 计数器
  • 分布式锁
  • 等等

常用命令:

get/set/del
incr/decr/incrby/decrby

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

实战场景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不会重复。(单线程无竞争)

实战场景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分布式才可以跨机器获取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,否则要传输的数据过大对网络开销和性能都有负担。 

2. 哈希类型

相关命令如下:

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不好控制

3.列表类型

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

添加元素命令:

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

4.集合类型

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

新增元素命令
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:共同好友

5.有序集合

有序集合的特点是 有序,无重复值,相关命令如下:

zadd key score element
zrem
zscore      # 获取分数
zincrby     # 增加减少分数
zcard       # 元素个数
zrange      # 按下标范围获取元素,加上withscores会按分数排序
zrangebyscore   # 按照分数范围获取元素
zcount      # 按分数范围计算元素个数
zremrangebyrank     # 删除指定下标范围的元素
zremrangebyscore

实战场景1:排行榜

实战场景2:延时队列

最后强调一下,要慎用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
  • Set类型的第二行命令谨慎使用。

Sorted Set类型:

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

其他常用命令:

  • DEL、KEYS
  • KEYS 命令谨慎使用

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

责任编辑:赵宁宁 来源: 程序员阿沛
相关推荐

2019-08-12 15:40:26

Redis数据库数据类型

2023-07-04 08:41:08

Redis数据类型

2020-11-04 07:34:02

Redis数据类型

2023-04-28 15:05:25

React软件项目可维护性

2019-11-11 14:55:25

Redis数据类型命令

2016-08-18 14:13:55

JavaScript基本数据引用数据

2023-11-13 08:31:25

SpringRedis存储

2024-03-13 14:57:37

2017-02-27 08:34:09

JavaScript数据引用

2010-10-08 09:02:03

JavaScript基

2021-06-15 09:20:08

Redis数据类型

2023-03-27 10:04:27

数据类型浮点型布尔型

2020-10-26 13:46:07

Java基础入门篇

2021-04-28 18:16:24

Rust数据类型

2011-05-26 14:27:56

java

2010-04-27 11:03:39

Oracle Java

2024-11-12 13:01:46

2018-05-25 09:50:30

Java数据类型类型转换

2023-08-27 21:22:02

Redis数据类

2024-06-11 08:30:31

点赞
收藏

51CTO技术栈公众号