1. 程式人生 > 實用技巧 >高併發和海量資料下的 9 個 Redis 經典案例

高併發和海量資料下的 9 個 Redis 經典案例

業務背景這次分享主要是圍繞 Redis,分享在平時的日常業務開發中遇到的 9 個經典案例,希望通過此次分享可以幫助大家更好的將 Redis 的高階特性應用到日常的業務開發中來。首先介紹一下業務背景:總使用者量大概是 5億左右,月活 5kw,日活近 2kw 。服務端有 1000 多個 Redis 例項,100+ 叢集,每個例項的記憶體控制在 20g 以下。KV 快取第一個是最基礎也是最常用的就是KV功能,我們可以用 Redis 來快取使用者資訊、會話資訊、商品資訊等等。下面這段程式碼就是通用的快取讀取邏輯。

defget_user(user_id):
user=redis.get(user_id)
ifnotuser:
user=db.get(user_id)
redis.setex(user_id,ttl,user)//設定快取過期時間
returnuser

defsave_user(user):
redis.setex(user.id,ttl,user)//設定快取過期時間
db.save_async(user)//非同步寫資料庫


這個過期時間非常重要,它通常會和使用者的單次會話長度成正比,保證使用者在單次會話內儘量一直可以使用快取裡面的資料。當然如果貴公司財力雄厚,又極致注重效能體驗,可以將時間設定的長點甚至乾脆就不設定過期時間。當資料量不斷增長時,就使用 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 指令的擴充套件引數如下

#加鎖
setlock:$user_idowner_idnxex=5
#釋放鎖
ifredis.call("get",KEYS[1])==ARGV[1]then
returnredis.call("del",KEYS[1])
else
return0
end
#等價於
del_if_equalslock:$user_idowner_id


一定要設定這個過期時間,因為遇到特殊情況 —— 比如地震(程序被 kill -9,或者機器宕機),產品經理可能會選擇從窗戶上跳下去,沒機會摘牌,導致了死鎖飢餓,讓這位優秀的工程師成了一位大閒人,造成嚴重的資源浪費。同時還需要注意這個owner_id,它代表鎖是誰加的 —— 產品經理的工號。以免你的鎖不小心被別人摘掉了。釋放鎖時要匹配這個 owner_id,匹配成功了才能釋放鎖。這個 owner_id 通常是一個隨機數,存放在 ThreadLocal 變數裡(棧變數)。官方其實並不推薦這種方式,因為它在叢集模式下會產生鎖丟失的問題 —— 在主從發生切換的時候。官方推薦的分散式鎖叫 RedLock,作者認為這個演算法較為安全,推薦我們使用。不過我們一直還使用上面最簡單的分散式鎖。為什麼我們不去使用 RedLock 呢,因為它的運維成本會高一些,需要 3 臺以上獨立的 Redis 例項,用起來要繁瑣一些。另外,Redis 叢集發生主從切換的概率也並不高,即使發生了主從切換出現鎖丟失的概率也很低,因為主從切換往往都有一個過程,這個過程的時間通常會超過鎖的過期時間,也就不會發生鎖的異常丟失。還有呢就是分散式鎖遇到鎖衝突的機會也不多,這正如一個公司裡明星程式設計師也比較有限一樣,總是遇到鎖排隊那說明結構上需要優化。延時佇列下面我們繼續看第三個功能 —— 延時佇列。前面我們提到產品經理在遇到「請勿打擾」的牌子時可以選擇多種策略,

  1. 乾等待

  2. 睡覺

  3. 放棄不幹了

  4. 歇一會再幹

乾等待就是 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)
#消費延時訊息
whileTrue:
task_json=zrevrangebyscore(queue-key,now_ts,0,0,1)
iftask_json:
grabbed_ok=zrem(queue-key,task_json)
ifgrabbed_ok:
process_task(task_json)
else:
sleep(1000)//歇1s


當消費者是多執行緒或者多程序的時候,這裡會存在競爭浪費問題。當前執行緒明明將 task_json 從 zset 中輪詢出來了,但是通過 zrem 來爭搶時卻搶不到手。這時就可以使用 LUA 指令碼來解決這個問題,將輪詢和爭搶操作原子化,這樣就可以避免競爭浪費。

localres=nil
localtasks=redis.pcall("zrevrangebyscore",KEYS[1],ARGV[1],0,"LIMIT",0,1)
if#tasks>0then
localok=redis.pcall("zrem",KEYS[1],tasks[1])
ifok>0then
res=tasks[1]
end
end

returnres


為什麼我要將分散式鎖和延時佇列一起講呢,因為很早的時候線上出了一次故障。故障發生時線上的某個 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 之上實現一個簡單的定時任務框架。

#註冊定時任務
hsettasksnametrigger_rule
#獲取定時任務列表
hgetalltasks
#爭搶任務
setlock:${name}truenxex=5
#任務列表變更(滾動升級)
#輪詢版本號,有變化就重載入任務列表,重新排程時間有變化的任務
settasks_version$new_version
gettasks_version


如果你覺得 Quartz 內部的程式碼複雜的讓人看不懂,分散式文件又幾乎沒有,很難折騰,可以試試 Redis,使用它會讓你少掉點頭髮。

LifeisShort,IuseRedis
https://github.com/pyloque/taskino


頻率控制
如果你做過社群就知道,不可避免總是會遇到垃圾內容。一覺醒來你會發現首頁突然會某些莫名其妙的廣告帖刷屏了。如果不採取適當的機制來控制就會導致使用者體驗收到嚴重影響。控制廣告垃圾貼的策略非常多,高階一點的通過 AI,最簡單的方式是通過關鍵詞掃描。還有比較常用的一種方式就是頻率控制,限制單個使用者內容生產速度,不同等級的使用者會有不同的頻率控制引數。頻率控制就可以使用 Redis 來實現,我們將使用者的行為理解為一個時間序列,我們要保證在一定的時間內限制單個使用者的時間序列的長度,超過了這個長度就禁止使用者的行為。它可以是用 Redis 的 zset 來實現。圖片
圖中綠色的部門就是我們要保留的一個時間段的時間序列資訊,灰色的段會被砍掉。統計綠色段中時間序列記錄的個數就知道是否超過了頻率的閾值。

#下面的程式碼控制使用者的ugc行為為每小時最多N次

hist_key="ugc:${user_id}"
withredis.pipeline()aspipe:
#記錄當前的行為
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()
returncount>N


服務發現
技術成熟度稍微高一點的企業都會有服務發現的基礎設施。通常我們都會選用 zookeeper、etcd、consul 等分散式配置資料庫來作為服務列表的儲存。它們有非常及時的通知機制來通知服務消費者服務列表發生了變更。那我們該如何使用 Redis 來做服務發現呢?
圖片
這裡我們要再次使用 zset 資料結構,我們使用 zset 來儲存單個服務列表。多個服務列表就使用多個 zset 來儲存。zset 的 value 和 score 分別儲存服務的地址和心跳的時間。服務提供者需要使用心跳來彙報自己的存活,每隔幾秒呼叫一次 zadd。服務提供者停止服務時,使用 zrem 來移除自己。

zaddservice_keyheartbeat_tsaddr
zremservice_keyaddr


這樣還不夠,因為服務有可能是異常終止,根本沒機會執行鉤子,所以需要使用一個額外的執行緒來清理服務列表中的過期項

zremrangebyscoreservice_key0now_ts-30#30s都沒來心跳


接下來還有一個重要的問題是如何通知消費者服務列表發生了變更,這裡我們同樣使用版本號輪詢機制。當服務列表變更時,遞增版本號。消費者通過輪詢版本號的變化來重載入服務列表。

ifzadd()>0||zrem()>0||zremrangebyscore()>0:
incrservice_version_key


但是還有一個問題,如果消費者依賴了很多的服務列表,那麼它就需要輪詢很多的版本號,這樣的 IO 效率會比較低下。這時我們可以再增加一個全域性版本號,當任意的服務列表版本號發生變更時,遞增全域性版本號。這樣在正常情況下消費者只需要輪詢全域性版本號就可以了。當全域性版本號發生變更時再挨個比對依賴的服務列表的子版本號,然後載入有變更的服務列表。
圖片點陣圖
我們的簽到系統做的比較早,當時使用者量還沒有上來,設計上比較簡單,就是將使用者的簽到狀態用 Redis的 hash 結構來儲存,簽到一次就在 hash 結構裡記錄一條,簽到有三種狀態,未簽到、已簽到和補籤,分別是 0、1、2 三個整數值。

hsetsign:${user_id}2019-01-011
hsetsign:${user_id}2019-01-021
hsetsign:${user_id}2019-01-032
...


這非常浪費使用者空間,到後來簽到日活過千萬的時候,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 的儲存空間。而且在計數值比較小的時候,點陣圖使用稀疏儲存,空間佔用就更小了。

#記錄使用者
pfaddsign_uv_${day}user_id
#獲取記錄數量
pfcountsign_uv_${day}


微信公眾號文章的閱讀數可以使用它,網頁的 UV 統計它都可以完成。但是如果產品經理非常在乎數字的準確性,比如某個統計需求和金錢直接掛鉤,那麼你可以考慮一下前面提到的咆哮點陣圖。它使用起來會複雜一些,需要提前將使用者 ID 進行整數序列化。Redis 沒有原生提供咆哮點陣圖的功能,但是有一個開源的 Redis Module 可以拿來即用。https://github.com/aviggiano/redis-roaring布隆過濾器最後我們要講一下布隆過濾器,如果一個系統即將會有大量的新使用者湧入時,它就會非常有價值,可以顯著降低快取的穿透率,降低資料庫的壓力。這個新使用者的湧入不一定是業務系統的大規模鋪開,也可能是因為來自外部的快取穿透攻擊。

defget_user_state0(user_id):
state=cache.get(user_id)
ifnotstate:
state=db.get(user_id)or{}
cache.set(user_id,state)
returnstate

defsave_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% 的新使用者都可以被布隆過濾器有效的擋住,避免了快取穿透。

defget_user_state(user_id):
exists=bloomfilter.is_user_exists(user_id)
ifnotexists:
return{}
returnget_user_state0(user_id)

defsave_user_state(user_id,state):
bloomfilter.set_user_exists(user_id)
save_user_state0(user_id,state)


布隆過濾器的原理有一個很好的比喻,那就是在冬天一片白雪覆蓋的地面上,如果你從上面走過,就會留下你的腳印。如果地面上有你的腳印,那麼就可以大概率斷定你來過這個地方,但是也不一定,也許別人的鞋正好和你穿的一模一樣。可是如果地面上沒有你的腳印,那麼就可以 100% 斷定你沒來過這個地方

https://mp.weixin.qq.com/s/1ycdr7gs-7yqGrhEb6NAdw