0. 引言
基于Redis豐富的數(shù)據(jù)結(jié)構(gòu)袍镀,除了充當(dāng)緩存層來提升查詢效率以外,還能應(yīng)用在很多常見的場景冻晤,比如:分布式鎖苇羡,消息隊列,限流等。看到這些場景你可能會有疑問为朋,Redis在這些領(lǐng)域好像并不出名啊碳柱,比如消息隊列姻成,出名的有Rocketmq、rabbitmq等等,很少聽Redis來做這個場景,是不是存在什么問題歼捏?是的,下面的文字就來總結(jié)下Redis在這些場景的常規(guī)用法以及存在的問題笨篷。
1. 分布式鎖
1.1 基本使用
分布式應(yīng)用通常會遇到并發(fā)問題瞳秽,邏輯上我們可以使用setnx
指令占一個“坑”,然后處理自己的業(yè)務(wù)邏輯率翅,最后再調(diào)用del
指令釋放“坑”练俐。
> setnx lock.test true
OK
... do something ...
> del lock.test
(integer) 1
以上是常規(guī)使用,會有個問題冕臭,如果邏輯執(zhí)行過程出現(xiàn)異常痰洒,導(dǎo)致沒有執(zhí)行到del
指令瓢棒,最后會陷入死鎖浴韭。
1.2 過期時間
因此丘喻,更進一步的做法是拿到鎖以后,再給鎖設(shè)置一個過期時間念颈,這樣當(dāng)過程出現(xiàn)異常泉粉,沒有執(zhí)行del
指令,鎖也會在5s后自動釋放榴芳。
> setnx lock.test true
OK
> expire lock.test 5
... do something ...
> del lock.test
(integer) 1
1.3 原子性問題
實現(xiàn)以上邏輯后嗡靡,仍存在問題,比如在執(zhí)行setnx
指令之后窟感,但在expire
指令時服務(wù)器出現(xiàn)異常讨彼,沒有給鎖設(shè)置上過期時間,鎖依然會陷入死鎖的狀態(tài)柿祈,一直不會釋放哈误。
如何解決呢?可能你會想到事務(wù)躏嚎,但在這里不行蜜自,因為expire
是依賴于setnx
的執(zhí)行結(jié)果的,如果沒有搶到鎖卢佣,expire
不應(yīng)該被執(zhí)行重荠。事務(wù)里沒有if-else的邏輯,要么全部執(zhí)行虚茶,要么一個都不執(zhí)行戈鲁。
在Redis 2.8 版本中,作者加入了set指令的擴展參數(shù)嘹叫,使得setnx
和expire
指令可以一起執(zhí)行婆殿。
> set lock.test true ex 5 nx
OK
... do something ...
> del lock.test
上面的指令就是setnx
和expire
組合在一起的原子指令。
1.4 超時問題
Redis分布式鎖并不解決鎖超時的問題待笑,所以不建議在獲取分布式鎖后處理耗時較長的邏輯鸣皂。因為邏輯執(zhí)行得太長,鎖到期自動釋放暮蹂,就會出現(xiàn)問題寞缝。
有一個稍微安全點的方案:在搶鎖時,set
指令的value
參數(shù)設(shè)置為一個隨機數(shù)仰泻,釋放鎖時先匹配value是否一致荆陆,再進行刪除key。這種方式可以確保當(dāng)前連接的操作集侯,不會被其他連接釋放被啼,除非是過期自動釋放帜消。
以上的匹配value和刪除key不是原子性的,所以需要使用lua腳本浓体,來保證連續(xù)多個指令的原子性執(zhí)行泡挺。但是這也不是一個完美的方案,只是相對安全一點命浴。它始終沒能解決鎖超時娄猫,其他線程“乘虛而入”的問題。
2. 消息隊列
2.1 基本使用
基于Redis的list數(shù)據(jù)結(jié)構(gòu)生闲,利用lpush
和rpop
的指令組合媳溺,可以模擬隊列。
使用的代碼就不貼了碍讯,邏輯比較簡單悬蔽。下面討論兩個個問題:
- 隊列空了怎么辦?
- Redis主動斷開空閑連接怎么處理捉兴?
隊列空了怎么辦蝎困?
在rpop
返回空時,sleep(1000)
轴术∧阉ィ可以這么做,但是這導(dǎo)致消費的延遲逗栽,Redis提供了更好的方案:阻塞讀(blpop/brpop
)盖袭,用這個指令替代邏輯里的rpop
即可。
Redis主動斷開空閑連接怎么處理彼宠?
使用了阻塞讀以后鳄虱,線程會一直阻塞在那里,如果一直沒有數(shù)據(jù)凭峡,這個連接就會成了閑置連接拙已,如果時間過久,Redis會主動斷開連接摧冀,從而減少閑置資源占用倍踪。此時blpop/brpop
會拋出異常,所以客戶端需要捕捉該異常索昂,并重試建车。
2.2 延遲隊列
最近有個業(yè)務(wù)需求:當(dāng)某個行為觸發(fā)了,則在10s后執(zhí)行一段邏輯椒惨。
看到「10s后執(zhí)行」這種典型的場景缤至,個人的第一反應(yīng)便是延遲隊列。在Redis中康谆,可以通過(zset
)有序集來實現(xiàn)领斥。將消息序列化為value
嫉到,將執(zhí)行時間作為score
,然后輪詢zset獲取到期的任務(wù)進行處理月洛。
多進程同時消費的場景中何恶,Redis的zrem
方法是關(guān)鍵,通過zrem
來決定唯一的屬主膊存,它的返回值決定了是否有搶到任務(wù)导而。
進一步優(yōu)化
使用lua腳本,將zrangebyscore
和zrem
操作一同發(fā)送到服務(wù)端執(zhí)行隔崎,可以減少爭搶任務(wù)時的浪費。
2.3 消息多播
上面討論的是Redis作為消息隊列的基本使用韵丑,實際情況Redis仍有很多不足爵卒,其中一個就是它不支持多播機制。
消息多播是指生產(chǎn)者生產(chǎn)一次消息撵彻,由中間件將消息復(fù)制到多個消息隊列钓株,每個隊列都有相應(yīng)的消費者進行消費。
2.3.1 PubSub
為了支持多播陌僵,Redis引入了新的模塊去支持:PubSub轴合,即發(fā)布者/訂閱者模式。如何使用這里就不說了碗短,文檔很詳細(xì)受葛。下面總結(jié)下缺點:
- 如果一個消費者都沒有的情況下,消息會直接丟棄偎谁;
- 如果消費者連接斷開了总滩,當(dāng)它重連上以后,斷開期間的消息會丟失巡雨;
- 如果Redis宕機闰渔,PubSub消息不會持久化,消息直接丟棄铐望;
2.3.2 Stream
Redis 5.0 新增了一個數(shù)據(jù) Stream冈涧,它是一個搶到的支持多播的可持久化消息隊列,作者坦言它極大地借鑒了Kafka的設(shè)計正蛙。
Stream的消費模型借鑒了Kafka的消費分組的概念督弓,彌補了PubSub不能持久化消息的缺陷。Stream又不同于Kafka跟畅,Kafka可以分Partition咽筋,而Stream不行。
3. 位圖
給個場景:記錄用戶一年的簽到情況徊件,簽到為1奸攻,沒簽為0蒜危。
如果用key/value
的方式存,一年365天睹耐,當(dāng)用戶量上億辐赞,所需要的存儲空間是驚人的。為了解決這一問題硝训,Redis提供了位圖數(shù)據(jù)結(jié)構(gòu)响委,上面的場景(可以引申存儲bool型數(shù)據(jù)的其他場景),每天的簽到記錄只占1個位窖梁,365個位對應(yīng)46個字節(jié)赘风,大大節(jié)省存儲空間。
3.1 基本使用
位圖不是特殊的數(shù)據(jù)結(jié)構(gòu)纵刘,它的內(nèi)容其實就是普通字符串邀窃,也就是byte數(shù)組。對字符串的指令get/set
假哎,是對整個內(nèi)容的操作瞬捕,而對其中的位操作Redis提供了getbit/setbit
的指令。
字符“he”的ASCII碼與位的對應(yīng)關(guān)系:
通過位操作設(shè)置“he”字符:
3.2 統(tǒng)計與查找
除了設(shè)置和獲取位圖的值以外舵抹,Redis還提供了bitcount
和bitpos
分別用于統(tǒng)計和查找肪虎。比如:
- 通過
bitcount
統(tǒng)計用戶一共簽到了多少天,可指定范圍[start, end]惧蛹; - 通過
bitpos
查找用戶從那一天開始第一次簽到扇救,可指定范圍[start, end];
但遺憾的是赊淑,start和end參數(shù)是字節(jié)索引爵政,也就是指定的位范圍必須是8的倍數(shù),而不能任意指定陶缺。因此钾挟,我們無法直接計算某個月內(nèi)用戶簽到了多少天。
具體操作就不說了饱岸,看文檔就好掺出。
4. 布隆過濾器
通過位圖來節(jié)省空間,談到這種方式苫费,怎么能不談布隆過濾器汤锨。布隆過濾器是什么,以及原理這里就不說了百框,只說跟Redis相關(guān)的闲礼。
Redis官方提供的布隆過濾器到了Redis 4.0 提供了插件功能才正式登出。兩個基本指令,bf.add
和bf.exists
柬泽。如果需要一次添加多個慎菲,就需要使用到bf.madd
,同樣的锨并,一次查詢多個元素是否存在露该,就需要用到bf.mexists
指令。
什么時候用布隆過濾器呢第煮?
判斷某個值存在解幼,會出現(xiàn)誤判;判斷某個值不存在包警,100%準(zhǔn)確撵摆。基于這個特性去思考揽趾,就很容易找到使用場景啦台汇,比如:
- 爬蟲系統(tǒng)對URL去重,爬過的網(wǎng)頁不爬篱瞎;
- 郵箱系統(tǒng)的垃圾郵件過濾功能;
- NoSQL數(shù)據(jù)庫痒芝,常用布隆過濾器過濾掉不存在的row俐筋,減少數(shù)據(jù)庫的IO請求數(shù)量。
如何控制低誤判率严衬?
Redis中提供了bf.reserve
指令澄者,可設(shè)置key,error_rate和initial_size请琳,設(shè)置的error_rate越低粱挡,需要的空間越大。
5. 附近的店/人/車
Redis 3.2 版本以后增加了地理位置Geo模塊俄精,可以實現(xiàn)類似摩拜單車的“附近的車”询筏、美團和餓了么的“附近的餐館”這樣的功能。
位置數(shù)據(jù)通常使用二維的經(jīng)緯度表示竖慧,經(jīng)度范圍[-180, 180]嫌套,緯度范圍[-90, 90]。試想下如果使用關(guān)系型數(shù)據(jù)庫存儲(元素 id, 經(jīng)度 x, 緯度 y)圾旨,該如何計算踱讨?
假設(shè)(x0, y0)是用戶,r是半徑砍的,使用一條SQL就可以圈出來痹筛。
select id from positions where x0-r < x < x0+r and y0-r < y < y0+r;
可以對經(jīng)緯度坐標(biāo)加上索引進行優(yōu)化,但數(shù)據(jù)庫查詢性能畢竟有限,可能不是一個很好的方案帚稠。
業(yè)界比較通用的地理位置距離排序算法是GeoHash算法谣旁,它是將二維的經(jīng)緯度數(shù)據(jù)映射到一維的整數(shù)。映射的算法和用法這里就不具體展開了翁锡。下面是兩個使用注意事項:
一維映射是有損的
在使用Redis的Geo查詢時蔓挖,時刻想著它的內(nèi)部結(jié)構(gòu)實際上是一個zset(skiplist)。通過zset的score排序就可以得到坐標(biāo)附近的其他元素馆衔,通過score還原成坐標(biāo)值就可以得到元素的原始坐標(biāo)瘟判。但需要注意,通過映射再還原回來的值會出現(xiàn)較小的差別角溃,原因是二維坐標(biāo)進行一維映射是有損的拷获。Geo數(shù)據(jù)單獨Redis實例部署更加
Redis的Geo數(shù)據(jù)結(jié)構(gòu),數(shù)據(jù)會全部放到一個zset集合中减细。如果在Redis集群環(huán)境匆瓜,集合可能從一個節(jié)點遷移到另一個節(jié)點,如果單個key的數(shù)據(jù)過大未蝌,會對集群遷移工作造成較大影響驮吱。因此,Geo的數(shù)據(jù)建議使用單獨Redis實例部署萧吠,不適用集群環(huán)境左冬。
6. 限流
6.1 簡單限流
限流的場景非常常見,控制用戶行為纸型,如發(fā)帖拇砰、回復(fù)、點贊等狰腌。簡單的限流策略:限定用戶的某個行為在指定的時間內(nèi)只允許發(fā)生n次除破,這里我們可以使用zset數(shù)據(jù)結(jié)構(gòu)的score值,存儲毫秒時間戳琼腔,就可以很方便的取某個時間窗口內(nèi)用戶的行為次數(shù)瑰枫。
此方案有個缺點,因為它要記錄時間窗口內(nèi)所有的行為記錄展姐,如果這個量很大躁垛,此方案就不合適了,因為會消耗大量的存儲空間圾笨。
6.2 漏斗限流
Redis 4.0 提供了一個限流Redis模塊教馆,叫Redis-Cell。該模塊使用了漏斗算法擂达,并提供了原子的限流指令土铺。指令只有一條:cl.throttle
,對著文檔來使用即可。
7. 總結(jié)
上面的描述沒有深入過多的技術(shù)細(xì)節(jié)悲敷,重點還是以討論場景為主究恤,因為據(jù)個人的了解,其實很大一個部分開發(fā)者對Redis的認(rèn)識還只是作為緩存層后德,但基于Redis的豐富數(shù)據(jù)結(jié)構(gòu)部宿,Redis可以在很多場景中發(fā)揮作用。結(jié)合場景再思考其數(shù)據(jù)結(jié)構(gòu)設(shè)計瓢湃,也能有所感悟理张。后面準(zhǔn)備再整理一篇關(guān)于Redis數(shù)據(jù)結(jié)構(gòu)以及內(nèi)存優(yōu)化的總結(jié)(希望能完成吧哈哈)。
參考書籍:
- 《Redis深度歷險 核心原理與應(yīng)用實踐》