日本综合一区二区|亚洲中文天堂综合|日韩欧美自拍一区|男女精品天堂一区|欧美自拍第6页亚洲成人精品一区|亚洲黄色天堂一区二区成人|超碰91偷拍第一页|日韩av夜夜嗨中文字幕|久久蜜综合视频官网|精美人妻一区二区三区

RELATEED CONSULTING
相關(guān)咨詢
選擇下列產(chǎn)品馬上在線溝通
服務(wù)時(shí)間:8:30-17:00
你可能遇到了下面的問(wèn)題
關(guān)閉右側(cè)工具欄

新聞中心

這里有您想知道的互聯(lián)網(wǎng)營(yíng)銷解決方案
直接上手!Redis在海量數(shù)據(jù)和高并發(fā)下的優(yōu)化實(shí)踐

Redis 對(duì)于從事互聯(lián)網(wǎng)技術(shù)工程師來(lái)說(shuō)并不陌生,幾乎所有的大中型企業(yè)都在使用 Redis 作為緩存數(shù)據(jù)庫(kù)。

創(chuàng)新互聯(lián)公司是一家成都網(wǎng)站設(shè)計(jì)、成都網(wǎng)站制作,提供網(wǎng)頁(yè)設(shè)計(jì),網(wǎng)站設(shè)計(jì),網(wǎng)站制作,建網(wǎng)站,按需規(guī)劃網(wǎng)站,網(wǎng)站開(kāi)發(fā)公司,從2013年開(kāi)始是互聯(lián)行業(yè)建設(shè)者,服務(wù)者。以提升客戶品牌價(jià)值為核心業(yè)務(wù),全程參與項(xiàng)目的網(wǎng)站策劃設(shè)計(jì)制作,前端開(kāi)發(fā),后臺(tái)程序制作以及后期項(xiàng)目運(yùn)營(yíng)并提出專業(yè)建議和思路。

但是對(duì)于絕大多數(shù)企業(yè)來(lái)說(shuō)只會(huì)用到它的最基礎(chǔ)的 KV 緩存功能,還有很多 Redis 的高級(jí)功能可能都未曾認(rèn)真實(shí)踐過(guò)。

來(lái)自掌閱的工程師錢文品將為大家?guī)?lái):《Redis 在海量數(shù)據(jù)和高并發(fā)下的優(yōu)化實(shí)踐》的主題分享。

他將圍繞 Redis 分享在平時(shí)的日常業(yè)務(wù)開(kāi)發(fā)中遇到的 9 個(gè)經(jīng)典案例,希望通過(guò)此次分享可以幫助大家更好的將 Redis 的高級(jí)特性應(yīng)用到日常的業(yè)務(wù)開(kāi)發(fā)中來(lái)。

掌閱電子書(shū)閱讀軟件 ireader 的總用戶量大概是 5 億左右,月活 5000 萬(wàn),日活近 2000 萬(wàn) 。服務(wù)端有 1000 多個(gè) Redis 實(shí)例,100+ 集群,每個(gè)實(shí)例的內(nèi)存控制在 20G 以下。

KV 緩存

第一個(gè)是最基礎(chǔ),也是最常用的就是 KV 功能,我們可以用 Redis 來(lái)緩存用戶信息、會(huì)話信息、商品信息等等。

下面這段代碼就是通用的緩存讀取邏輯:

 
 
 
 
  1. def get_user(user_id):
  2.     user = redis.get(user_id)
  3.     if not user:
  4.         user = db.get(user_id)
  5.         redis.setex(user_id, ttl, user)  // 設(shè)置緩存過(guò)期時(shí)間
  6.     return user
  7. def save_user(user):
  8.     redis.setex(user.id, ttl, user)  // 設(shè)置緩存過(guò)期時(shí)間
  9.     db.save_async(user)  // 異步寫(xiě)數(shù)據(jù)庫(kù)

這個(gè)過(guò)期時(shí)間非常重要,它通常會(huì)和用戶的單次會(huì)話長(zhǎng)度成正比,保證用戶在單次會(huì)話內(nèi)盡量一直可以使用緩存里面的數(shù)據(jù)。

當(dāng)然如果貴公司財(cái)力雄厚,又極其注重性能體驗(yàn),可以將時(shí)間設(shè)置的長(zhǎng)點(diǎn)甚至干脆就不設(shè)置過(guò)期時(shí)間。當(dāng)數(shù)據(jù)量不斷增長(zhǎng)時(shí),就使用 Codis 或者 Redis-Cluster 集群來(lái)擴(kuò)容。

除此之外 Redis 還提供了緩存模式,Set 指令不必設(shè)置過(guò)期時(shí)間,它也可以將這些鍵值對(duì)按照一定的策略進(jìn)行淘汰。

打開(kāi)緩存模式的指令是:config set maxmemory 20gb ,這樣當(dāng)內(nèi)存達(dá)到 20GB 時(shí),Redis 就會(huì)開(kāi)始執(zhí)行淘汰策略,給新來(lái)的鍵值對(duì)騰出空間。

這個(gè)策略 Redis 也是提供了很多種,總結(jié)起來(lái)這個(gè)策略分為兩塊:劃定淘汰范圍,選擇淘汰算法。

比如我們線上使用的策略是 Allkeys-lru。這個(gè) Allkeys 表示對(duì) Redis 內(nèi)部所有的 Key 都有可能被淘汰,不管它有沒(méi)有帶過(guò)期時(shí)間,而 Volatile 只淘汰帶過(guò)期時(shí)間的。

Redis 的淘汰功能就好比企業(yè)遇到經(jīng)濟(jì)寒冬時(shí)需要勒緊褲腰帶過(guò)冬進(jìn)行一輪殘酷的人才優(yōu)化。

它會(huì)選擇只優(yōu)化臨時(shí)工呢,還是所有人一律平等都可能被優(yōu)化。當(dāng)這個(gè)范圍圈定之后,會(huì)從中選出若干個(gè)名額,怎么選擇呢,這個(gè)就是淘汰算法。

最常用的就是 LRU 算法,它有一個(gè)弱點(diǎn),那就是表面功夫做得好的人可以逃過(guò)優(yōu)化。

比如你乘機(jī)趕緊在老板面前好好表現(xiàn)一下,然后你就安全了。所以到了 Redis 4.0 里面引入了 LFU 算法,要對(duì)平時(shí)的成績(jī)也進(jìn)行考核,只做表面功夫就已經(jīng)不夠用了,還要看你平時(shí)勤不勤快。

最后還有一種極不常用的算法:隨機(jī)搖號(hào)算法。這個(gè)算法有可能會(huì)把 CEO 也給淘汰了,所以一般不會(huì)使用它。

分布式鎖

下面我們看第二個(gè)功能:分布式鎖,這個(gè)是除了 KV 緩存之外最為常用的另一個(gè)特色功能。

比如一個(gè)很能干的資深工程師,開(kāi)發(fā)效率很快,代碼質(zhì)量也很高,是團(tuán)隊(duì)里的明星。所以諸多產(chǎn)品經(jīng)理都要來(lái)煩他,讓他給自己做需求。

如果同一時(shí)間來(lái)了一堆產(chǎn)品經(jīng)理都找他,他的思路呢就會(huì)陷入混亂,再優(yōu)秀的程序員,大腦的并發(fā)能力也好不到哪里去。

所以他就在自己的辦公室的門把上掛了一個(gè)請(qǐng)勿打擾的牌子,當(dāng)一個(gè)產(chǎn)品經(jīng)理來(lái)的時(shí)候先看看門把上有沒(méi)有這個(gè)牌子,如果沒(méi)有呢就可以進(jìn)來(lái)找工程師談需求,談之前要把牌子掛起來(lái),談完了再把牌子摘了。

這樣其他產(chǎn)品經(jīng)理也要來(lái)煩他的時(shí)候,如果看見(jiàn)這個(gè)牌子掛在那里,就可以選擇睡覺(jué)等待或者是先去忙別的事。如是這位明星工程師從此獲得了安寧。

這個(gè)分布式鎖的使用方式非常簡(jiǎn)單,就是使用 Set 指令的擴(kuò)展參數(shù)如下:

 
 
 
 
  1. # 加鎖
  2. set lock:$user_id owner_id nx ex=5
  3. # 釋放鎖
  4. if redis.call("get", KEYS[1]) == ARGV[1] then
  5.     return redis.call("del", KEYS[1])
  6. else
  7.     return 0
  8. end
  9. # 等價(jià)于
  10. del_if_equals lock:$user_id owner_id

一定要設(shè)置這個(gè)過(guò)期時(shí)間,因?yàn)橛龅教厥馇闆r,比如地震(進(jìn)程被 Kill -9,或者機(jī)器宕機(jī)),產(chǎn)品經(jīng)理可能會(huì)選擇從窗戶上跳下去,沒(méi)機(jī)會(huì)摘牌,導(dǎo)致了死鎖饑餓,讓這位優(yōu)秀的工程師成了一位大閑人,造成嚴(yán)重的資源浪費(fèi)。

同時(shí)還需要注意這個(gè) owner_id,它代表鎖是誰(shuí)加的,產(chǎn)品經(jīng)理的工號(hào)。以免你的鎖不小心被別人摘掉了。

釋放鎖時(shí)要匹配這個(gè) owner_id,匹配成功了才能釋放鎖。這個(gè) owner_id 通常是一個(gè)隨機(jī)數(shù),存放在 ThreadLocal 變量里(棧變量)。

官方其實(shí)并不推薦這種方式,因?yàn)樗诩耗J较聲?huì)產(chǎn)生鎖丟失的問(wèn)題,在主從發(fā)生切換的時(shí)候。

官方推薦的分布式鎖叫 RedLock,作者認(rèn)為這個(gè)算法較為安全,推薦我們使用。

不過(guò)掌閱這邊一直還使用上面最簡(jiǎn)單的分布式鎖,為什么我們不去使用 RedLock 呢?因?yàn)樗倪\(yùn)維成本會(huì)高一些,需要 3 臺(tái)以上獨(dú)立的 Redis 實(shí)例,用起來(lái)要繁瑣一些。

另外 Redis 集群發(fā)生主從切換的概率也并不高,即使發(fā)生了主從切換出現(xiàn)鎖丟失的概率也很低,因?yàn)橹鲝那袚Q往往都有一個(gè)過(guò)程,這個(gè)過(guò)程的時(shí)間通常會(huì)超過(guò)鎖的過(guò)期時(shí)間,也就不會(huì)發(fā)生鎖的異常丟失。

還有就是分布式鎖遇到鎖沖突的機(jī)會(huì)也不多,這正如一個(gè)公司里明星程序員也比較有限一樣,總是遇到鎖排隊(duì)那說(shuō)明結(jié)構(gòu)上需要優(yōu)化。

延時(shí)隊(duì)列

下面我們繼續(xù)看第三個(gè)功能,延時(shí)隊(duì)列。前面我們提到產(chǎn)品經(jīng)理在遇到「請(qǐng)勿打擾」的牌子時(shí)可以選擇多種策略:

  • 干等待
  • 睡覺(jué)
  • 放棄不干了
  • 歇一會(huì)再干

干等待:就是 Spinlock,這會(huì)燒 CPU,飆高 Redis 的 QPS。

睡覺(jué):就是先 Sleep 一會(huì)再試,這會(huì)浪費(fèi)線程資源,還會(huì)增加響應(yīng)時(shí)長(zhǎng)。

放棄不干了:就是告知前端用戶待會(huì)再試,現(xiàn)在系統(tǒng)壓力大有點(diǎn)忙,影響用戶體驗(yàn)。

最后一種就是現(xiàn)在要講的策略待會(huì)再來(lái):這是在現(xiàn)實(shí)世界里最普遍的策略。

這種策略一般用在消息隊(duì)列的消費(fèi)中,這個(gè)時(shí)候遇到鎖沖突該怎么辦?不能拋棄不處理,也不適合立即重試(Spinlock),這時(shí)就可以將消息扔進(jìn)延時(shí)隊(duì)列,過(guò)一會(huì)再處理。

有很多專業(yè)的消息中間件支持延時(shí)消息功能,比如 RabbitMQ 和 NSQ。Redis 也可以,我們可以使用 Zset 來(lái)實(shí)現(xiàn)這個(gè)延時(shí)隊(duì)列。

Zset 里面存儲(chǔ)的是 Value/Score 鍵值對(duì),我們將 Value 存儲(chǔ)為序列化的任務(wù)消息,Score 存儲(chǔ)為下一次任務(wù)消息運(yùn)行的時(shí)間(Deadline),然后輪詢 Zset 中 Score 值大于 Now 的任務(wù)消息進(jìn)行處理。

 
 
 
 
  1. # 生產(chǎn)延時(shí)消息
  2. zadd(queue-key, now_ts+5, task_json)
  3. # 消費(fèi)延時(shí)消息
  4. while True:
  5.   task_json = zrevrangebyscore(queue-key, now_ts, 0, 0, 1)
  6.   if task_json:
  7.     grabbed_ok = zrem(queue-key, task_json)
  8.     if grabbed_ok:
  9.       process_task(task_json)
  10.   else:
  11.     sleep(1000)  // 歇 1s

當(dāng)消費(fèi)者是多線程或者多進(jìn)程的時(shí)候,這里會(huì)存在競(jìng)爭(zhēng)浪費(fèi)問(wèn)題。當(dāng)前線程明明將 task_json 從 Zset 中輪詢出來(lái)了,但是通過(guò) Zrem 來(lái)爭(zhēng)搶時(shí)卻搶不到手。

這時(shí)就可以使用 LUA 腳本來(lái)解決這個(gè)問(wèn)題,將輪詢和爭(zhēng)搶操作原子化,這樣就可以避免競(jìng)爭(zhēng)浪費(fèi)。

 
 
 
 
  1. local res = nil
  2. local tasks = redis.pcall("zrevrangebyscore", KEYS[1], ARGV[1], 0, "LIMIT", 0, 1)
  3. if #tasks > 0 then
  4.   local ok = redis.pcall("zrem", KEYS[1], tasks[1])
  5.   if ok > 0 then
  6.     res = tasks[1] 
  7.   end
  8. end
  9. return res

為什么我要將分布式鎖和延時(shí)隊(duì)列一起講呢,因?yàn)楹茉绲臅r(shí)候線上出了一次故障。

故障發(fā)生時(shí)線上的某個(gè) Redis 隊(duì)列長(zhǎng)度爆表了,導(dǎo)致很多異步任務(wù)得不到執(zhí)行,業(yè)務(wù)數(shù)據(jù)出現(xiàn)了問(wèn)題。

后來(lái)查清楚原因了,就是因?yàn)榉植际芥i沒(méi)有用好導(dǎo)致了死鎖,而且遇到加鎖失敗時(shí)就 Sleep 無(wú)限重試結(jié)果就導(dǎo)致了異步任務(wù)徹底進(jìn)入了睡眠狀態(tài)不能處理任務(wù)。

那這個(gè)分布式鎖當(dāng)時(shí)是怎么用的呢?用的就是 Setnx+Expire,結(jié)果在服務(wù)升級(jí)的時(shí)候停止進(jìn)程直接就導(dǎo)致了個(gè)別請(qǐng)求執(zhí)行了 Setnx,但是 Expire 沒(méi)有得到執(zhí)行,于是就帶來(lái)了個(gè)別用戶的死鎖。

但是后臺(tái)呢又有一個(gè)異步任務(wù)處理,也需要對(duì)用戶加鎖,加鎖失敗就會(huì)無(wú)限 Sleep 重試,那么一旦撞上了前面的死鎖用戶,這個(gè)異步線程就徹底熄火了。

因?yàn)檫@次事故我們才有了今天的正確的分布式鎖形式以及延時(shí)隊(duì)列的發(fā)明,還有就是優(yōu)雅停機(jī),因?yàn)槿绻嬖趦?yōu)雅停機(jī)的邏輯,那么服務(wù)升級(jí)就不會(huì)導(dǎo)致請(qǐng)求只執(zhí)行了一半就被打斷了,除非是進(jìn)程被 Kill -9 或者是宕機(jī)。

定時(shí)任務(wù)

分布式定時(shí)任務(wù)有多種實(shí)現(xiàn)方式,最常見(jiàn)的一種是 Master-Workers 模型。

Master 負(fù)責(zé)管理時(shí)間,到點(diǎn)了就將任務(wù)消息扔到消息中間件里,然后 Worker 們負(fù)責(zé)監(jiān)聽(tīng)這些消息隊(duì)列來(lái)消費(fèi)消息。

著名的 Python 定時(shí)任務(wù)框架 Celery 就是這么干的。但是 Celery 有一個(gè)問(wèn)題,那就是 Master 是單點(diǎn)的,如果這個(gè) Master 掛了,整個(gè)定時(shí)任務(wù)系統(tǒng)就停止工作了。

另一種實(shí)現(xiàn)方式是 Multi-Master 模型。這個(gè)模型什么意思呢,就類似于 Java 里面的 Quartz 框架,采用數(shù)據(jù)庫(kù)鎖來(lái)控制任務(wù)并發(fā)。

會(huì)有多個(gè)進(jìn)程,每個(gè)進(jìn)程都會(huì)管理時(shí)間,時(shí)間到了就使用數(shù)據(jù)庫(kù)鎖來(lái)爭(zhēng)搶任務(wù)執(zhí)行權(quán),搶到的進(jìn)程就獲得了任務(wù)執(zhí)行的機(jī)會(huì),然后就開(kāi)始執(zhí)行任務(wù),這樣就解決了 Master 的單點(diǎn)問(wèn)題。

這種模型有一個(gè)缺點(diǎn),那就是會(huì)造成競(jìng)爭(zhēng)浪費(fèi)問(wèn)題,不過(guò)通常大多數(shù)業(yè)務(wù)系統(tǒng)的定時(shí)任務(wù)并沒(méi)有那么多,所以這種競(jìng)爭(zhēng)浪費(fèi)并不嚴(yán)重。

還有一個(gè)問(wèn)題它依賴于分布式機(jī)器時(shí)間的一致性,如果多個(gè)機(jī)器上時(shí)間不一致就會(huì)造成任務(wù)被多次執(zhí)行,這可以通過(guò)增加數(shù)據(jù)庫(kù)鎖的時(shí)間來(lái)緩解。

現(xiàn)在有了 Redis 分布式鎖,那么我們就可以在 Redis 之上實(shí)現(xiàn)一個(gè)簡(jiǎn)單的定時(shí)任務(wù)框架:

 
 
 
 
  1. # 注冊(cè)定時(shí)任務(wù)
  2. hset tasks name trigger_rule
  3. # 獲取定時(shí)任務(wù)列表
  4. hgetall tasks
  5. # 爭(zhēng)搶任務(wù)
  6. set lock:${name} true nx ex=5
  7. # 任務(wù)列表變更(滾動(dòng)升級(jí))
  8. # 輪詢版本號(hào),有變化就重加載任務(wù)列表,重新調(diào)度時(shí)間有變化的任務(wù)
  9. set tasks_version $new_version
  10. get tasks_version

如果你覺(jué)得 Quartz 內(nèi)部的代碼復(fù)雜的讓人看不懂,分布式文檔又幾乎沒(méi)有,很難折騰,可以試試 Redis,使用它會(huì)讓你少掉點(diǎn)頭發(fā)。

 
 
 
 
  1. Life is Short,I use Redis
  2. https://github.com/pyloque/taskino

頻率控制

如果你做過(guò)社區(qū)就知道,不可避免總是會(huì)遇到垃圾內(nèi)容。一覺(jué)醒來(lái)你會(huì)發(fā)現(xiàn)首頁(yè)突然會(huì)被某些莫名其妙的廣告帖刷屏了。如果不采取適當(dāng)?shù)臋C(jī)制來(lái)控制就會(huì)導(dǎo)致用戶體驗(yàn)受到嚴(yán)重影響。

控制廣告垃圾貼的策略非常多,高級(jí)一點(diǎn)的通過(guò) AI,最簡(jiǎn)單的方式是通過(guò)關(guān)鍵詞掃描。

還有比較常用的一種方式就是頻率控制,限制單個(gè)用戶內(nèi)容生產(chǎn)速度,不同等級(jí)的用戶會(huì)有不同的頻率控制參數(shù)。

頻率控制就可以使用 Redis 來(lái)實(shí)現(xiàn),我們將用戶的行為理解為一個(gè)時(shí)間序列,我們要保證在一定的時(shí)間內(nèi)限制單個(gè)用戶的時(shí)間序列的長(zhǎng)度,超過(guò)了這個(gè)長(zhǎng)度就禁止用戶的行為。

它可以是用 Redis 的 Zset 來(lái)實(shí)現(xiàn):

圖中綠色的部分就是我們要保留的一個(gè)時(shí)間段的時(shí)間序列信息,灰色的段會(huì)被砍掉。統(tǒng)計(jì)綠色段中時(shí)間序列記錄的個(gè)數(shù)就知道是否超過(guò)了頻率的閾值。

 
 
 
 
  1. # 下面的代碼控制用戶的 ugc 行為為每小時(shí)最多 N 次
  2. hist_key = "ugc:${user_id}"
  3. with redis.pipeline() as pipe:
  4.   # 記錄當(dāng)前的行為
  5.   pipe.zadd(hist_key, ts, uuid)
  6.   # 保留1小時(shí)內(nèi)的行為序列
  7.   pipe.zremrangebyscore(hist_key, 0, now_ts - 3600)
  8.   # 獲取這1小時(shí)內(nèi)的行為數(shù)量
  9.   pipe.zcard(hist_key)
  10.   # 設(shè)置過(guò)期時(shí)間,節(jié)約內(nèi)存
  11.   pipe.expire(hist_key, 3600)
  12.   # 批量執(zhí)行
  13.   _, _, count, _ = pipe.exec()
  14.   return count > N

服務(wù)發(fā)現(xiàn)

技術(shù)成熟度稍微高一點(diǎn)的企業(yè)都會(huì)有服務(wù)發(fā)現(xiàn)的基礎(chǔ)設(shè)施。通常我們都會(huì)選用 Zookeeper、Etcd、Consul 等分布式配置數(shù)據(jù)庫(kù)來(lái)作為服務(wù)列表的存儲(chǔ)。

它們有非常及時(shí)的通知機(jī)制來(lái)通知服務(wù)消費(fèi)者服務(wù)列表發(fā)生了變更。那我們?cè)撊绾问褂?Redis 來(lái)做服務(wù)發(fā)現(xiàn)呢?

這里我們要再次使用 Zset 數(shù)據(jù)結(jié)構(gòu),我們使用 Zset 來(lái)保存單個(gè)服務(wù)列表。多個(gè)服務(wù)列表就使用多個(gè) Zset 來(lái)存儲(chǔ)。

Zset 的 Value 和 Score 分別存儲(chǔ)服務(wù)的地址和心跳的時(shí)間。服務(wù)提供者需要使用心跳來(lái)匯報(bào)自己的存活,每隔幾秒調(diào)用一次 Zadd。

服務(wù)提供者停止服務(wù)時(shí),使用 Zrem 來(lái)移除自己。

 
 
 
 
  1. zadd service_key heartbeat_ts addr
  2. zrem service_key addr

這樣還不夠,因?yàn)榉?wù)有可能是異常終止,根本沒(méi)機(jī)會(huì)執(zhí)行鉤子,所以需要使用一個(gè)額外的線程來(lái)清理服務(wù)列表中的過(guò)期項(xiàng):

 
 
 
 
  1. zremrangebyscore service_key 0 now_ts - 30  # 30s 都沒(méi)來(lái)心跳

接下來(lái)還有一個(gè)重要的問(wèn)題是如何通知消費(fèi)者服務(wù)列表發(fā)生了變更,這里我們同樣使用版本號(hào)輪詢機(jī)制。

當(dāng)服務(wù)列表變更時(shí),遞增版本號(hào)。消費(fèi)者通過(guò)輪詢版本號(hào)的變化來(lái)重加載服務(wù)列表。

 
 
 
 
  1. if zadd() > 0 || zrem() > 0 || zremrangebyscore() > 0:
  2.   incr service_version_key

但是還有一個(gè)問(wèn)題,如果消費(fèi)者依賴了很多的服務(wù)列表,那么它就需要輪詢很多的版本號(hào),這樣的 IO 效率會(huì)比較低下。

這時(shí)我們可以再增加一個(gè)全局版本號(hào),當(dāng)任意的服務(wù)列表版本號(hào)發(fā)生變更時(shí),遞增全局版本號(hào)。

這樣在正常情況下消費(fèi)者只需要輪詢?nèi)职姹咎?hào)就可以了。當(dāng)全局版本號(hào)發(fā)生變更時(shí)再挨個(gè)比對(duì)依賴的服務(wù)列表的子版本號(hào),然后加載有變更的服務(wù)列表:https://github.com/pyloque/captain。

位圖

掌閱的簽到系統(tǒng)做的比較早,當(dāng)時(shí)用戶量還沒(méi)有上來(lái),設(shè)計(jì)上比較簡(jiǎn)單,就是將用戶的簽到狀態(tài)用 Redis 的 Hash 結(jié)構(gòu)來(lái)存儲(chǔ),簽到一次就在 Hash 結(jié)構(gòu)里記錄一條。

簽到有三種狀態(tài),未簽到、已簽到和補(bǔ)簽,分別是 0、1、2 三個(gè)整數(shù)值:

 
 
 
 
  1. hset sign:${user_id} 2019-01-01 1
  2. hset sign:${user_id} 2019-01-02 1
  3. hset sign:${user_id} 2019-01-03 2
  4. ...

這非常浪費(fèi)用戶空間,到后來(lái)簽到日活過(guò)千萬(wàn)的時(shí)候,Redis 存儲(chǔ)問(wèn)題開(kāi)始凸顯,直接將內(nèi)存飚到了 30G+,我們線上實(shí)例通常過(guò)了 20G 就開(kāi)始報(bào)警,30G 已經(jīng)屬于嚴(yán)重超標(biāo)了。

這時(shí)候我們就開(kāi)始著手解決這個(gè)問(wèn)題,去優(yōu)化存儲(chǔ)。我們選擇了使用位圖來(lái)記錄簽到信息,一個(gè)簽到狀態(tài)需要兩個(gè)位來(lái)記錄,一個(gè)月的存儲(chǔ)空間只需要 8 個(gè)字節(jié)。

這樣就可以使用一個(gè)很短的字符串來(lái)存儲(chǔ)用戶一個(gè)月的簽到記錄。優(yōu)化后的效果非常明顯,內(nèi)存直接降到了 10 個(gè) G。

因?yàn)椴樵冋麄€(gè)月的簽到狀態(tài) API 調(diào)用的很頻繁,所以接口的通信量也跟著小了很多。

但是位圖也有一個(gè)缺點(diǎn),它的底層是字符串,字符串是連續(xù)存儲(chǔ)空間,位圖會(huì)自動(dòng)擴(kuò)展,比如一個(gè)很大的位圖 8M 個(gè)位,只有最后一個(gè)位是 1,其他位都是零,這也會(huì)占用 1M 的存儲(chǔ)空間,這樣的浪費(fèi)非常嚴(yán)重。

所以呢就有了咆哮位圖這個(gè)數(shù)據(jù)結(jié)構(gòu),它對(duì)大位圖進(jìn)行了分段存儲(chǔ),全位零的段可以不用存。

另外還對(duì)每個(gè)段設(shè)計(jì)了稀疏存儲(chǔ)結(jié)構(gòu),如果這個(gè)段上置 1 的位不多,可以只存儲(chǔ)它們的偏移量整數(shù)。這樣位圖的存儲(chǔ)空間就得到了非常顯著的壓縮。

這個(gè)咆哮位圖在大數(shù)據(jù)精準(zhǔn)計(jì)數(shù)領(lǐng)域非常有價(jià)值,感興趣的同學(xué)可以了解一下:https://juejin.im/post/5cf5c817e51d454fbf5409b0

模糊計(jì)數(shù)

前面提到這個(gè)簽到系統(tǒng),如果產(chǎn)品經(jīng)理需要知道這個(gè)簽到的日活月活怎么辦呢?通常我們會(huì)直接甩鍋,請(qǐng)找數(shù)據(jù)部門。

但是數(shù)據(jù)部門的數(shù)據(jù)往往不是很實(shí)時(shí),經(jīng)常前一天的數(shù)據(jù)需要第二天才能跑出來(lái),離線計(jì)算通常是定時(shí)的一天一次。那如何實(shí)現(xiàn)一個(gè)實(shí)時(shí)的活躍計(jì)數(shù)?

最簡(jiǎn)單的方案就是在 Redis 里面維護(hù)一個(gè) Set 集合,來(lái)一個(gè)用戶,就 Sadd 一下,最終集合的大小就是我們需要的 UV 數(shù)字。

但是這個(gè)空間浪費(fèi)很嚴(yán)重,僅僅為了一個(gè)數(shù)字要存儲(chǔ)這樣一個(gè)龐大的集合似乎非常不值當(dāng)。那該怎么辦?

這時(shí)你就可以使用 Redis 提供的 HyperLogLog 模糊計(jì)數(shù)功能,它是一種概率計(jì)數(shù),有一定的誤差,誤差大約是 0.81%。

但是空間占用很小,其底層是一個(gè)位圖,它最多只會(huì)占用 12K 的存儲(chǔ)空間。而且在計(jì)數(shù)值比較小的時(shí)候,位圖使用稀疏存儲(chǔ),空間占用就更小了。

 
 
 
 
  1. # 記錄用戶
  2. pfadd sign_uv_${day} user_id
  3. # 獲取記錄數(shù)量
  4. pfcount sign_uv_${day}

微信公眾號(hào)文章的閱讀數(shù)可以使用它,網(wǎng)頁(yè)的 UV 統(tǒng)計(jì)它都可以完成。但是如果產(chǎn)品經(jīng)理非常在乎數(shù)字的準(zhǔn)確性,比如某個(gè)統(tǒng)計(jì)需求和金錢直接掛鉤,那么你可以考慮一下前面提到的咆哮位圖。

它使用起來(lái)會(huì)復(fù)雜一些,需要提前將用戶 ID 進(jìn)行整數(shù)序列化。Redis 沒(méi)有原生提供咆哮位圖的功能,但是有一個(gè)開(kāi)源的 Redis Module 可以拿來(lái)即用:https://github.com/aviggiano/redis-roaring。

布隆過(guò)濾器

最后我們要講一下布隆過(guò)濾器,如果一個(gè)系統(tǒng)即將會(huì)有大量的新用戶涌入時(shí),它就會(huì)非常有價(jià)值,可以顯著降低緩存的穿透率,降低數(shù)據(jù)庫(kù)的壓力。

這個(gè)新用戶的涌入不一定是業(yè)務(wù)系統(tǒng)的大規(guī)模鋪開(kāi),也可能是因?yàn)閬?lái)自外部的緩存穿透攻擊。

 
 
 
 
  1. def get_user_state0(user_id):
  2.   state = cache.get(user_id)
  3.   if not state:
  4.     state = db.get(user_id) or {}
  5.     cache.set(user_id, state)
  6.   return state
  7. def save_user_state0(user_id, state):
  8.   cache.set(user_id, state)
  9.   db.set_async(user_id, state)

比如上面就是這個(gè)業(yè)務(wù)系統(tǒng)的用戶狀態(tài)查詢接口代碼,現(xiàn)在一個(gè)新用戶過(guò)來(lái)了,它會(huì)先去緩存里查詢有沒(méi)有這個(gè)用戶的狀態(tài)數(shù)據(jù),因?yàn)槭切掠脩?,所以肯定緩存里沒(méi)有。

然后它就要去查數(shù)據(jù)庫(kù),結(jié)果數(shù)據(jù)庫(kù)也沒(méi)有。如果這樣的新用戶大批量瞬間涌入,那么可以預(yù)見(jiàn)數(shù)據(jù)庫(kù)的壓力會(huì)比較大,會(huì)存在大量的空查詢。

我們非常希望 Redis 里面有這樣的一個(gè) Set,它存放了所有用戶的 ID,這樣通過(guò)查詢這個(gè) Set 集合就知道是不是新用戶來(lái)了。

當(dāng)用戶量非常龐大的時(shí)候,維護(hù)這樣的一個(gè)集合需要的存儲(chǔ)空間是很大的。

這時(shí)候就可以使用布隆過(guò)濾器,它相當(dāng)于一個(gè) Set,但是呢又不同于 Set,它需要的存儲(chǔ)空間要小的多。

比如你存儲(chǔ)一個(gè)用戶 ID 需要 64 個(gè)字節(jié),而布隆過(guò)濾器存儲(chǔ)一個(gè)用戶 ID 只需要 1 個(gè)字節(jié)多點(diǎn)。

但是呢它存的不是用戶 ID,而是用戶 ID 的指紋,所以會(huì)存在一定的小概率誤判,它是一個(gè)具備模糊過(guò)濾能力的容器。

當(dāng)它說(shuō)用戶 ID 不在容器中時(shí),那么就肯定不在。當(dāng)它說(shuō)用戶 ID 在容器里時(shí),99% 的概率下它是正確的,還有 1% 的概率它產(chǎn)生了誤判。

不過(guò)在這個(gè)案例中,這個(gè)誤判并不會(huì)產(chǎn)生問(wèn)題,誤判的代價(jià)只是緩存穿透而已。

相當(dāng)于有 1% 的新用戶沒(méi)有得到布隆過(guò)濾器的保護(hù)直接穿透到數(shù)據(jù)庫(kù)查詢,而剩下的 99% 的新用戶都可以被布隆過(guò)濾器有效的擋住,避免了緩存穿透。

 
 
 
 
  1. def get_user_state(user_id):
  2.   exists = bloomfilter.is_user_exists(user_id)
  3.   if not exists:
  4.     return {}
  5.   return get_user_state0(user_id)
  6. def save_user_state(user_id, state):
  7.   bloomfilter.set_user_exists(user_id)
  8.   save_user_state0(user_id, state)

布隆過(guò)濾器的原理有一個(gè)很好的比喻,那就是在冬天一片白雪覆蓋的地面上,如果你從上面走過(guò),就會(huì)留下你的腳印。

如果地面上有你的腳印,那么就可以大概率斷定你來(lái)過(guò)這個(gè)地方,但是也不一定,也許別人的鞋正好和你穿的一模一樣。

可是如果地面上沒(méi)有你的腳印,那么就可以 100% 斷定你沒(méi)來(lái)過(guò)這個(gè)地方。

作者:錢文品(老錢)

簡(jiǎn)介:互聯(lián)網(wǎng)分布式高并發(fā)技術(shù)十年老兵,目前任掌閱服務(wù)端技術(shù)專家。熟練使用 Java、Python、Golang 等多種計(jì)算機(jī)語(yǔ)言,開(kāi)發(fā)過(guò)游戲,制作過(guò)網(wǎng)站,寫(xiě)過(guò)消息推送系統(tǒng)和 MySQL 中間件,實(shí)現(xiàn)過(guò)開(kāi)源的 ORM 框架、Web 框架、RPC 框架等。


當(dāng)前標(biāo)題:直接上手!Redis在海量數(shù)據(jù)和高并發(fā)下的優(yōu)化實(shí)踐
網(wǎng)站鏈接:http://www.dlmjj.cn/article/dpdecgo.html