由于一次只能执行一条命令,所以要拒绝长命令(就是运行时间长的命令),因为会引起后面的命令阻塞。长命令如: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次
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_start();
echo session_id();
echo "
";
$_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);
}
function set($name, $data, $expire = 600) # session中的单独的某个键也可以设置过期时间,很灵活
{
$session_data = array();
$session_data['data'] = $data;
$session_data['expire'] = time()+$expire;
$_SESSION[$name] = $session_data;
}
function get($name)
{
if(isset($_SESSION[$name])) {
if($_SESSION[$name]['expire'] > time()) {
return $_SESSION[$name]['data'];
}else{
self::clear($name);
}
}
return false;
}
function clear($name)
{
unset($_SESSION[$name]);
}
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。时间复杂度越高,执行命令消耗的时间越长。