做者:老錢前端
來源公衆號:碼洞git
---------------------------------------程序員
此次分享主要是圍繞 Redis,分享在平時的平常業務開發中遇到的 9 個經典案例,但願經過這次分享能夠幫助你們更好的將 Redis 的高級特性應用到平常的業務開發中來。 github
首先介紹一下業務背景:總用戶量大概是 5億左右,月活 5kw,日活近 2kw 。服務端有 1000 多個 Redis 實例,100+ 集羣,每一個實例的內存控制在 20g 如下。redis
第一個是最基礎也是最經常使用的就是KV功能,咱們能夠用 Redis 來緩存用戶信息、會話信息、商品信息等等。算法
下面這段代碼就是通用的緩存讀取邏輯。數據庫
def get_user(user_id):
user = redis.get(user_id)
if not user:
user = db.get(user_id)
redis.setex(user_id, ttl, user) // 設置緩存過時時間
return user
def save_user(user):
redis.setex(user.id, ttl, user) // 設置緩存過時時間
db.save_async(user) // 異步寫數據庫
複製代碼
這個過時時間很是重要,它一般會和用戶的單次會話長度成正比,保證用戶在單次會話內儘可能一直可使用緩存裏面的數據。json
固然若是貴公司財力雄厚,又極致注重性能體驗,能夠將時間設置的長點甚至乾脆就不設置過時時間。緩存
當數據量不斷增加時,就使用 Codis 或者 Redis-Cluster 集羣來擴容。安全
除此以外 Redis 還提供了緩存模式,Set 指令沒必要設置過時時間,它也能夠將這些鍵值對按照必定的策略進行淘汰。
打開緩存模式的指令是:config set maxmemory 20gb ,這樣當內存達到 20gb 時,Redis 就會開始執行淘汰策略,給新來的鍵值對騰出空間。
這個策略 Redis 也是提供了不少種,總結起來這個策略分爲兩塊:劃定淘汰範圍,選擇淘汰算法。
好比咱們線上使用的策略是 allkeys-lru。這個 allkeys 表示對 Redis 內部全部的 key 都有可能被淘汰,無論它有沒有帶過時時間,而volatile只淘汰帶過時時間的。
Redis 的淘汰功能就比如企業遇到經濟寒冬時須要勒緊褲腰帶過冬須要進行一輪殘酷的人才優化。它會選擇只優化臨時工呢,仍是全部人一概平等均可能被優化。
當這個範圍圈定以後,會從中選出若干個名額,怎麼選擇呢,這個就是淘汰算法。
最經常使用的就是 LRU 算法,它有一個弱點,那就是表面功夫作得好的人能夠逃過優化。
好比你伺機趕忙在老闆面前好好表現一下,而後你就安全了。因此到了 Redis 4.0 裏面引入了 LFU 算法,要對平時的成績也進行考覈,只作表面功夫就已經不夠用了,還要看你平時勤不勤快。
最後還一種極不經常使用的算法 —— 隨機搖號算法,這個算法有可能會把 CEO 也給淘汰了,因此通常不會使用它。
下面咱們看第二個功能 —— 分佈式鎖,這個是除了 KV 緩存以外最爲經常使用的另外一個特點功能。
好比一個很能幹的資深工程師,開發效率很快,代碼質量也很高,是團隊裏的明星。因此呢諸多產品經理都要來煩他,讓他給本身作需求。
若是同一時間來了一堆產品經理都找他,它的思路呢就會陷入混亂,再優秀的程序員,大腦的併發能力也好不到哪裏去。
因此呢他就在本身的辦公室的門把上掛了一個請勿打擾的牌子,當一個產品經理來的時候先看看門把上有沒有這個牌子,若是沒有呢就能夠進來找工程師談需求,談以前要把牌子掛起來,談完了再把牌子摘了。
這樣其它產品經理也要來煩他的時候,若是看見這個牌子掛在那裏,就能夠選擇睡覺等待或者是先去忙別的事。如是這位明星工程師今後得到了安寧。
這個分佈式鎖的使用方式很是簡單,就是使用 Set 指令的擴展參數以下
# 加鎖
set lock:$user_id owner_id nx ex=5
# 釋放鎖
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
# 等價於
del_if_equals lock:$user_id owner_id
複製代碼
必定要設置這個過時時間,由於遇到特殊狀況 —— 好比地震(進程被 kill -9,或者機器宕機),產品經理可能會選擇從窗戶上跳下去,沒機會摘牌,致使了死鎖飢餓,讓這位優秀的工程師成了一位大閒人,形成嚴重的資源浪費。
同時還須要注意這個 owner_id,它表明鎖是誰加的 —— 產品經理的工號。以避免你的鎖不當心被別人摘掉了。
釋放鎖時要匹配這個 owner_id,匹配成功了才能釋放鎖。這個 owner_id 一般是一個隨機數,存放在 ThreadLocal 變量裏(棧變量)。
官方其實並不推薦這種方式,由於它在集羣模式下會產生鎖丟失的問題 —— 在主從發生切換的時候。官方推薦的分佈式鎖叫 RedLock,做者認爲這個算法較爲安全,推薦咱們使用。
不過咱們一直還使用上面最簡單的分佈式鎖。爲何咱們不去使用 RedLock 呢,由於它的運維成本會高一些,須要 3 臺以上獨立的 Redis 實例,用起來要繁瑣一些。
另外,Redis 集羣發生主從切換的機率也並不高,即便發生了主從切換出現鎖丟失的機率也很低,由於主從切換每每都有一個過程,這個過程的時間一般會超過鎖的過時時間,也就不會發生鎖的異常丟失。
還有呢就是分佈式鎖遇到鎖衝突的機會也很少,這正如一個公司裏明星程序員也比較有限同樣,老是遇到鎖排隊那說明結構上須要優化。
下面咱們繼續看第三個功能 —— 延時隊列。
前面咱們提到產品經理在遇到「請勿打擾」的牌子時能夠選擇多種策略,
乾等待就是 spinlock,這種方式會燒 CPU,飆高 Redis 的QPS。睡覺就是先 sleep 一會再試,這會浪費線程資源,還會增長響應時長。
放棄不幹呢就是告知前端用戶待會再試,如今系統壓力大有點忙,影響用戶體驗。
最後一種呢就是如今要講的策略 —— 待會再來,這是在現實世界裏最廣泛的策略。
這種策略通常用在消息隊列的消費中,這個時候遇到鎖衝突該怎麼辦?不能拋棄不處理,也不適合當即重試(spinlock),這時就能夠將消息扔進延時隊列,過一會再處理。
有不少專業的消息中間件支持延時消息功能,好比 RabbitMQ 和 NSQ。Redis 也能夠,咱們可使用 zset 來實現這個延時隊列。
zset 裏面存儲的是 value/score 鍵值對,咱們將 value 存儲爲序列化的任務消息,score 存儲爲下一次任務消息運行的時間(deadline),而後輪詢 zset 中 score 值大於 now 的任務消息進行處理。
# 生產延時消息
zadd(queue-key, now_ts+5, task_json)
# 消費延時消息
while True:
task_json = zrevrangebyscore(queue-key, now_ts, 0, 0, 1)
if task_json:
grabbed_ok = zrem(queue-key, task_json)
if grabbed_ok:
process_task(task_json)
else:
sleep(1000) // 歇 1s
複製代碼
當消費者是多線程或者多進程的時候,這裏會存在競爭浪費問題。當前線程明明將 task_json 從 zset 中輪詢出來了,可是經過 zrem 來爭搶時卻搶不到手。
這時就可使用 LUA 腳原本解決這個問題,將輪詢和爭搶操做原子化,這樣就能夠避免競爭浪費。
local res = nil
local tasks = redis.pcall("zrevrangebyscore", KEYS[1], ARGV[1], 0, "LIMIT", 0, 1)
if #tasks > 0 then
local ok = redis.pcall("zrem", KEYS[1], tasks[1])
if ok > 0 then
res = tasks[1]
end
end
return res
複製代碼
爲何我要將分佈式鎖和延時隊列一塊兒講呢,由於很早的時候線上出了一次故障。
故障發生時線上的某個 Redis 隊列長度爆表了,致使不少異步任務得不到執行,業務數據出現了問題。
後來查清楚緣由了,就是由於分佈式鎖沒有用好致使了死鎖,並且遇到加鎖失敗時就 sleep 無限重試結果就致使了異步任務完全進入了睡眠狀態不能處理任務。
那這個分佈式鎖當時是怎麼用的呢?用的就是 setnx + expire,結果在服務升級的時候中止進程直接就致使了個別請求執行了 setnx,可是 expire 沒有獲得執行,因而就帶來了個別用戶的死鎖。
可是後臺呢又有一個異步任務處理,也須要對用戶加鎖,加鎖失敗就會無限 sleep 重試,那麼一旦撞上了前面的死鎖用戶,這個異步線程就完全熄火了。
由於此次事故咱們纔有了今天的正確的分佈式鎖形式以及延時隊列的發明,還有就是優雅停機,由於若是存在優雅停機的邏輯,那麼服務升級就不會致使請求只執行了一半就被打斷了,除非是進程被 kill -9 或者是宕機。
分佈式定時任務有多種實現方式,最多見的一種是 master-workers 模型。
master 負責管理時間,到點了就將任務消息仍到消息中間件裏,而後worker們負責監聽這些消息隊列來消費消息。
著名的 Python 定時任務框架 Celery 就是這麼幹的。可是 Celery 有一個問題,那就是 master 是單點的,若是這個 master 掛了,整個定時任務系統就中止工做了。
另外一種實現方式是 multi-master 模型。這個模型什麼意思呢,就相似於 Java 裏面的 Quartz 框架,採用數據庫鎖來控制任務併發。
會有多個進程,每一個進程都會管理時間,時間到了就使用數據庫鎖來爭搶任務執行權,搶到的進程就得到了任務執行的機會,而後就開始執行任務,這樣就解決了 master 的單點問題。
這種模型有一個缺點,那就是會形成競爭浪費問題,不過一般大多數業務系統的定時任務並無那麼多,因此這種競爭浪費並不嚴重。
還有一個問題它依賴於分佈式機器時間的一致性,若是多個機器上時間不一致就會形成任務被屢次執行,這能夠經過增長數據庫鎖的時間來緩解。
如今有了 Redis 分佈式鎖,那麼咱們就能夠在 Redis 之上實現一個簡單的定時任務框架。
# 註冊定時任務
hset tasks name trigger_rule
# 獲取定時任務列表
hgetall tasks
# 爭搶任務
set lock:${name} true nx ex=5
# 任務列表變動(滾動升級)
# 輪詢版本號,有變化就重加載任務列表,從新調度時間有變化的任務
set tasks_version $new_version
get tasks_version
複製代碼
若是你以爲 Quartz 內部的代碼複雜的讓人看不懂,分佈式文檔又幾乎沒有,很難折騰,能夠試試 Redis,使用它會讓你少掉點頭髮。
Life is Short,I use Redis
https://github.com/pyloque/taskino
複製代碼
若是你作過社區就知道,不可避免老是會遇到垃圾內容。一覺醒來你會發現首頁忽然會某些莫名其妙的廣告帖刷屏了。若是不採起適當的機制來控制就會致使用戶體驗收到嚴重影響。
控制廣告垃圾貼的策略很是多,高級一點的經過 AI,最簡單的方式是經過關鍵詞掃描。還有比較經常使用的一種方式就是頻率控制,限制單個用戶內容生產速度,不一樣等級的用戶會有不一樣的頻率控制參數。
頻率控制就可使用 Redis 來實現,咱們將用戶的行爲理解爲一個時間序列,咱們要保證在必定的時間內限制單個用戶的時間序列的長度,超過了這個長度就禁止用戶的行爲。它能夠是用 Redis 的 zset 來實現。
圖中綠色的部門就是咱們要保留的一個時間段的時間序列信息,灰色的段會被砍掉。統計綠色段中時間序列記錄的個數就知道是否超過了頻率的閾值。
# 下面的代碼控制用戶的 ugc 行爲爲每小時最多 N 次
hist_key = "ugc:${user_id}"
with redis.pipeline() as pipe:
# 記錄當前的行爲
pipe.zadd(hist_key, ts, uuid)
# 保留1小時內的行爲序列
pipe.zremrangebyscore(hist_key, 0, now_ts - 3600)
# 獲取這1小時內的行爲數量
pipe.zcard(hist_key)
# 設置過時時間,節約內存
pipe.expire(hist_key, 3600)
# 批量執行
_, _, count, _ = pipe.exec()
return count > N
複製代碼
技術成熟度稍微高一點的企業都會有服務發現的基礎設施。一般咱們都會選用 zookeeper、etcd、consul 等分佈式配置數據庫來做爲服務列表的存儲。
它們有很是及時的通知機制來通知服務消費者服務列表發生了變動。那咱們該如何使用 Redis 來作服務發現呢?
這裏咱們要再次使用 zset 數據結構,咱們使用 zset 來保存單個服務列表。多個服務列表就使用多個 zset 來存儲。
zset 的 value 和 score 分別存儲服務的地址和心跳的時間。服務提供者須要使用心跳來彙報本身的存活,每隔幾秒調用一次 zadd。服務提供者中止服務時,使用 zrem 來移除本身。
zadd service_key heartbeat_ts addr
zrem service_key addr
複製代碼
這樣還不夠,由於服務有多是異常終止,根本沒機會執行鉤子,因此須要使用一個額外的線程來清理服務列表中的過時項
zremrangebyscore service_key 0 now_ts - 30 # 30s 都沒來心跳
複製代碼
接下來還有一個重要的問題是如何通知消費者服務列表發生了變動,這裏咱們一樣使用版本號輪詢機制。當服務列表變動時,遞增版本號。消費者經過輪詢版本號的變化來重加載服務列表。
if zadd() > 0 || zrem() > 0 || zremrangebyscore() > 0:
incr service_version_key
複製代碼
可是還有一個問題,若是消費者依賴了不少的服務列表,那麼它就須要輪詢不少的版本號,這樣的 IO 效率會比較低下。
這時咱們能夠再增長一個全局版本號,當任意的服務列表版本號發生變動時,遞增全局版本號。
這樣在正常狀況下消費者只須要輪詢全局版本號就能夠了。當全局版本號發生變動時再挨個比對依賴的服務列表的子版本號,而後加載有變動的服務列表。
咱們的簽到系統作的比較早,當時用戶量尚未上來,設計上比較簡單,就是將用戶的簽到狀態用 Redis的 hash 結構來存儲,簽到一次就在 hash 結構裏記錄一條,簽到有三種狀態,未簽到、已簽到和補籤,分別是 0、一、2 三個整數值。
hset sign:${user_id} 2019-01-01 1
hset sign:${user_id} 2019-01-02 1
hset sign:${user_id} 2019-01-03 2
...
複製代碼
這很是浪費用戶空間,到後來簽到日活過千萬的時候,Redis 存儲問題開始凸顯,直接將內存飈到了 30G+,咱們線上實例一般過了 20G 就開始報警,30G 已經屬於嚴重超標了。
這時候咱們就開始着手解決這個問題,去優化存儲。咱們選擇了使用位圖來記錄簽到信息,一個簽到狀態須要兩個位來記錄,一個月的存儲空間只須要 8 個字節。這樣就可使用一個很短的字符串來存儲用戶一個月的簽到記錄。
優化後的效果很是明顯,內存直接降到了 10 個G。由於查詢整個月的簽到狀態 API 調用的很頻繁,因此接口的通訊量也跟着小了不少。
可是位圖也有一個缺點,它的底層是字符串,字符串是連續存儲空間,位圖會自動擴展,好比一個很大的位圖 8m 個位,只有最後一個位是 1,其它位都是零,這也會佔用1m 的存儲空間,這樣的浪費很是嚴重。
因此呢就有了咆哮位圖這個數據結構,它對大位圖進行了分段存儲,全位零的段能夠不用存。
另外還對每一個段設計了稀疏存儲結構,若是這個段上置 1 的位很少,能夠只存儲它們的偏移量整數。這樣位圖的存儲空間就獲得了很是顯著的壓縮。
這個咆哮位圖在大數據精準計數領域很是有價值,感興趣的同窗能夠了解一下。
https://juejin.im/post/5cf5c817e51d454fbf5409b0
前面提到這個簽到系統,若是產品經理須要知道這個簽到的日活月活怎麼辦呢?一般咱們會直接甩鍋——請找數據部門。
可是數據部門的數據每每不是很實時,常常前一天的數據須要次日才能跑出來,離線計算是一般是定時的一天一次。那如何實現一個實時的活躍計數?
最簡單的方案就是在 Redis 裏面維護一個 set 集合,來一個用戶,就 sadd 一下,最終集合的大小就是咱們須要的 UV 數字。
可是這個空間浪費很嚴重,僅僅爲了一個數字要存儲這樣一個龐大的集合彷佛很是不值當。那該怎麼辦?
這時你就可使用 Redis 提供的 HyperLogLog 模糊計數功能,它是一種機率計數,有必定的偏差,偏差大約是 0.81%。
可是空間佔用很小,其底層是一個位圖,它最多隻會佔用 12k 的存儲空間。並且在計數值比較小的時候,位圖使用稀疏存儲,空間佔用就更小了。
# 記錄用戶
pfadd sign_uv_${day} user_id
# 獲取記錄數量
pfcount sign_uv_${day}
複製代碼
微信公衆號文章的閱讀數可使用它,網頁的 UV 統計它均可以完成。可是若是產品經理很是在意數字的準確性,好比某個統計需求和金錢直接掛鉤,那麼你能夠考慮一下前面提到的咆哮位圖。
它使用起來會複雜一些,須要提早將用戶 ID 進行整數序列化。Redis 沒有原生提供咆哮位圖的功能,可是有一個開源的 Redis Module 能夠拿來即用。
https://github.com/aviggiano/redis-roaring
最後咱們要講一下布隆過濾器,若是一個系統即將會有大量的新用戶涌入時,它就會很是有價值,能夠顯著下降緩存的穿透率,下降數據庫的壓力。
這個新用戶的涌入不必定是業務系統的大規模鋪開,也多是由於來自外部的緩存穿透攻擊。
def get_user_state0(user_id):
state = cache.get(user_id)
if not state:
state = db.get(user_id) or {}
cache.set(user_id, state)
return state
def save_user_state0(user_id, state):
cache.set(user_id, state)
db.set_async(user_id, state)
複製代碼
好比上面就是這個業務系統的用戶狀態查詢接口代碼,如今一個新用戶過來了,它會先去緩存裏查詢有沒有這個用戶的狀態數據
由於是新用戶,因此確定緩存裏沒有。而後它就要去查數據庫,結果數據庫也沒有。若是這樣的新用戶大批量瞬間涌入,那麼能夠預見數據庫的壓力會比較大,會存在大量的空查詢。
咱們很是但願 Redis 裏面有這樣的一個 set,它存放了全部用戶的 id,這樣經過查詢這個 set 集合就知道是否是新用戶來了。
當用戶量很是龐大的時候,維護這樣的一個集合須要的存儲空間是很大的。
這時候就可使用布隆過濾器,它至關於一個 set,可是呢又不一樣於 set,它須要的存儲空間要小的多。
好比你存儲一個用戶 id 須要 64 個字節,而布隆過濾器存儲一個用戶 id 只須要 1個字節多點。可是呢它存的不是用戶 id,而是用戶 id 的指紋,因此會存在必定的小几率誤判,它是一個具有模糊過濾能力的容器。
當它說用戶 id 不在容器中時,那麼就確定不在。當它說用戶 id 在容器裏時,99% 的機率下它是正確的,還有 1% 的機率它產生了誤判。
不過在這個案例中,這個誤判並不會產生問題,誤判的代價只是緩存穿透而已。
至關於有 1% 的新用戶沒有獲得布隆過濾器的保護直接穿透到數據庫查詢,而剩下的 99% 的新用戶均可以被布隆過濾器有效的擋住,避免了緩存穿透。
def get_user_state(user_id):
exists = bloomfilter.is_user_exists(user_id)
if not exists:
return {}
return get_user_state0(user_id)
def save_user_state(user_id, state):
bloomfilter.set_user_exists(user_id)
save_user_state0(user_id, state)
複製代碼
布隆過濾器的原理有一個很好的比喻,那就是在冬天一片白雪覆蓋的地面上,若是你從上面走過,就會留下你的腳印。
若是地面上有你的腳印,那麼就能夠大機率判定你來過這個地方,可是也不必定,也許別人的鞋正好和你穿的如出一轍。但是若是地面上沒有你的腳印,那麼就能夠 100% 判定你沒來過這個地方
END
推薦一個專欄:
《從零開始帶你成爲JVM實戰高手》
做者是我多年好友,之前團隊的左膀右臂
一塊兒經歷過各類大型複雜系統上線的血雨腥風
現任阿里資深技術專家,對JVM有豐富的生產實踐經驗
專欄目錄參見文末,能夠掃下方海報進行試讀
經過上面海報購買,再返你24元
領取方式:加微信號:Giotto1245,暗號:返現