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。時間復雜度越高,執行命令消耗的時間越長。