Redis中篇--應(yīng)用
Redis中篇--應(yīng)用
上篇看這里:Redis上篇
csdn地址:https://blog.csdn.net/okok__TXF/article/details/148538138
微信公眾號(hào):https://mp.weixin.qq.com/s/aQUfCQ6OEEdF27V285A2LA
本文示例代碼見(jiàn)GITEE倉(cāng)庫(kù)中的【redis-analysis】
地址:https://gitee.com/quercus-sp204/new-technology/tree/master/redis-analysis
1. 緩存問(wèn)題
Redis作為緩存的時(shí)候,會(huì)發(fā)生一些經(jīng)典問(wèn)題。下面就來(lái)分析一下:
1.1 緩存擊穿
緩存擊穿是指一個(gè)訪問(wèn)量非常大(熱點(diǎn))的緩存 Key,在它過(guò)期失效(Expire)的瞬間,大量并發(fā)的請(qǐng)求同時(shí)發(fā)現(xiàn)緩存失效(Cache Miss),這些請(qǐng)求會(huì)擊穿緩存層,直接去查詢底層數(shù)據(jù)庫(kù)(如 MySQL)。這會(huì)導(dǎo)致數(shù)據(jù)庫(kù)在極短時(shí)間內(nèi)承受巨大的、遠(yuǎn)超其處理能力的請(qǐng)求壓力,可能導(dǎo)致數(shù)據(jù)庫(kù)響應(yīng)變慢、連接耗盡甚至崩潰,進(jìn)而引發(fā)整個(gè)系統(tǒng)的連鎖故障。
舉個(gè)例子,就比如倉(cāng)庫(kù)門口堆放的一件特別搶手的熱門商品剛好賣完(緩存失效),一大群等著買它的人瞬間沖進(jìn)倉(cāng)庫(kù)搶購(gòu),把倉(cāng)庫(kù)管理員(DB)累垮了。

那么,我們可以總結(jié)出這個(gè)緩存問(wèn)題的特征:
首先,它是針對(duì)單個(gè) Key 問(wèn)題集中在某一個(gè)特定的熱點(diǎn) Key 上, 這個(gè) Key 對(duì)應(yīng)的數(shù)據(jù)訪問(wèn)頻率非常高;
然后呢,時(shí)機(jī)發(fā)生在在過(guò)期瞬間,在該 Key 設(shè)置的過(guò)期時(shí)間剛好到達(dá)的那一刻,這個(gè)時(shí)刻有高并發(fā)請(qǐng)求,同時(shí)這個(gè)時(shí)刻請(qǐng)求全打到數(shù)據(jù)庫(kù)了;
我們就要防止在緩存失效瞬間,大量請(qǐng)求同時(shí)訪問(wèn)數(shù)據(jù)庫(kù)。那么我們?cè)撛趺唇鉀Q呢?
- 方法一:互斥鎖
該方法的核心思想:既然是大量請(qǐng)求都直接訪問(wèn)數(shù)據(jù)庫(kù)了,那我就在數(shù)據(jù)庫(kù)那一層加鎖,保證同一時(shí)刻只有一個(gè)線程訪問(wèn),這樣就減輕數(shù)據(jù)庫(kù)的壓力。
雙檢鎖
// 緩存擊穿,解決方式1---雙檢鎖
public Goods getGoodsDetailById(String goodsId) {
// 1.先從緩存里面查
Goods goodsObj = (Goods) valueOperations.get(RedisKey.GOODS_KEY + goodsId);
// 2.緩存沒(méi)有再查數(shù)據(jù)庫(kù)
if (goodsObj == null) {
synchronized ( goodsId.intern() ) {
goodsObj = (Goods) valueOperations.get(RedisKey.GOODS_KEY + goodsId);
}
if (goodsObj != null) {
return goodsObj;
}
Goods goods = getDbGoodsById(goodsId);
if (goods != null) {
valueOperations.set(RedisKey.GOODS_KEY + goodsId, goods);
return goods;
}
return null;
}
return goodsObj;
}
加鎖檢查緩存(第一次檢查):當(dāng)用戶請(qǐng)求數(shù)據(jù)時(shí),首先檢查緩存中是否存在該數(shù)據(jù)。
加鎖:如果緩存中沒(méi)有數(shù)據(jù),那么走DB。但是,在嘗試從數(shù)據(jù)庫(kù)查詢數(shù)據(jù)之前,使用本地鎖(或者分布式鎖)來(lái)確保只有一個(gè)請(qǐng)求能夠執(zhí)行數(shù)據(jù)庫(kù)查詢操作。
數(shù)據(jù)庫(kù)查詢:如果成功獲取到鎖,那么第二次檢查緩存,如果確實(shí)緩存中沒(méi)有數(shù)據(jù), 執(zhí)行數(shù)據(jù)庫(kù)查詢操作,獲取最新的數(shù)據(jù)。
更新緩存:將查詢到的數(shù)據(jù)寫入緩存,并設(shè)置一個(gè)合理的過(guò)期時(shí)間。
釋放鎖:完成緩存更新后,釋放分布式鎖,以便其他請(qǐng)求可以繼續(xù)執(zhí)行。
返回?cái)?shù)據(jù):將查詢到的數(shù)據(jù)返回給用戶。
處理其他請(qǐng)求:對(duì)于在等待鎖釋放期間到達(dá)的請(qǐng)求,它們可以直接從緩存中獲取數(shù)據(jù),而不需要再次查詢數(shù)據(jù)庫(kù)。
上面的問(wèn)題也很明顯,那就是假如查數(shù)據(jù)庫(kù),寫入緩存這倆步驟,如果耗時(shí)過(guò)長(zhǎng),前面的請(qǐng)求會(huì)被一直阻塞住的。治標(biāo)不治本哪
- 方法二:key不過(guò)期
【永不過(guò)期】設(shè)置key的時(shí)候,不讓其過(guò)期,由于是永不過(guò)期的,故需要考慮更新這個(gè)key的值;
【邏輯過(guò)期】緩存 Key 不設(shè)置過(guò)期時(shí)間(或設(shè)置一個(gè)很長(zhǎng)的過(guò)期時(shí)間),讓其“永不過(guò)期”。但是我們可以在緩存 Value 中,額外存儲(chǔ)一個(gè)邏輯過(guò)期時(shí)間戳(例如 {value: obj, expireTime: 12345678910}),當(dāng)應(yīng)用讀取緩存時(shí),檢查 Value 中的邏輯過(guò)期時(shí)間戳,如果未過(guò)期,直接返回?cái)?shù)據(jù);如果已過(guò)期,觸發(fā)一個(gè)異步任務(wù)(如放入消息隊(duì)列、啟動(dòng)一個(gè)線程池任務(wù)都可以)去更新緩存。當(dāng)前請(qǐng)求可以直接返回已過(guò)期的舊數(shù)據(jù)(業(yè)務(wù)允許短暫不一致)或嘗試獲取互斥鎖進(jìn)行同步更新(類似方法 1)。
此方法緩存 Key 永不失效,徹底避免“失效瞬間”的問(wèn)題,用戶請(qǐng)求基本不受緩存更新影響,延遲低(直接返回舊數(shù)據(jù)或觸發(fā)異步更新)。
但是問(wèn)題也是非常的明顯:首先就是業(yè)務(wù)要容忍數(shù)據(jù)的不一致性;需要實(shí)現(xiàn)異步更新機(jī)制(消息隊(duì)列、線程池),增加了設(shè)計(jì)的復(fù)雜性;其次呢,如果異步更新失敗或延遲過(guò)大,用戶可能長(zhǎng)時(shí)間讀到舊數(shù)據(jù);
- 方法三:熱點(diǎn)key探測(cè),提前刷新
在緩存熱點(diǎn) Key 即將過(guò)期之前(比如還剩 1 分鐘時(shí)),主動(dòng)觸發(fā)一個(gè)后臺(tái)任務(wù)(定時(shí)任務(wù)、監(jiān)控線程)去查詢數(shù)據(jù)庫(kù)并更新緩存,重置其 TTL。這種方式看起來(lái)算是完美的,但是丟給了我們一個(gè)致命問(wèn)題,那就是哪些key是熱點(diǎn)key?
1.1.1 hotKey問(wèn)題
首先我們要明白一點(diǎn),HotKey是什么,如何定義HotKey?
HotKey指的是 Redis 緩存中被高頻訪問(wèn)的鍵,這類鍵的訪問(wèn)量遠(yuǎn)高于其他普通鍵。這類鍵稱之為“熱鍵”。
比如說(shuō),秒殺模塊中,可能會(huì)將商品信息緩存在Redis中,那么,某些商品的skuId可能作為key,在秒殺這段時(shí)間里面,這類key的訪問(wèn)量可能會(huì)明顯高于其他鍵;此外呢,如果有人惡意訪問(wèn)緩存,發(fā)送大量請(qǐng)求到指定key,也可能導(dǎo)致該key變成熱鍵;還有像新聞網(wǎng)站上的突發(fā)新聞、論壇的熱點(diǎn)文章帖子等等.......
如果這個(gè)時(shí)候這個(gè)熱鍵過(guò)期了,就會(huì)造成緩存擊穿的問(wèn)題,巨量請(qǐng)求直接到達(dá)了MySQL數(shù)據(jù)庫(kù)層,很有可能會(huì)造成服務(wù)的不可用。
如何去監(jiān)測(cè)這些熱key呢,出現(xiàn)了熱key問(wèn)題,該怎么辦?或者說(shuō),我們要如何去預(yù)防這類問(wèn)題的出現(xiàn)?
我認(rèn)為可以由以下幾步結(jié)合起來(lái),給他來(lái)一套組合拳:
- 系統(tǒng)功能設(shè)計(jì)之初,憑借經(jīng)驗(yàn)判斷可能的熱key:
我丟?這種方法是可以說(shuō)的嗎?肯定可以啊,就好比說(shuō)爛了的秒殺系統(tǒng),肯定就可以比較容易判斷出哪些key可能變成熱key了吧,電商系統(tǒng)中的商品詳情頁(yè)、或者是新上的活動(dòng)促銷、社交平臺(tái)上的熱門帖子等數(shù)據(jù)通常容易成為熱點(diǎn)Key。
- Redis客戶端代碼層面增加訪問(wèn)次數(shù)記錄:
在Redis客戶端層面,我們手動(dòng)記錄key的訪問(wèn)次數(shù)、或者是xxx時(shí)間間隔的訪問(wèn)頻率,如果有監(jiān)控系統(tǒng),我們可以將這類數(shù)據(jù)定時(shí)或者實(shí)時(shí)上報(bào)的監(jiān)控系統(tǒng),然后就可以在監(jiān)控系統(tǒng)看到了;
- 在Redis服務(wù)端:
它本身提供了相應(yīng)的功能:執(zhí)行一些相關(guān)命令(如MONITOR、redis-cli --hotkeys等),通過(guò)分析這些命令,可以觀察到哪些Key被頻繁訪問(wèn),識(shí)別出熱點(diǎn)Key。
MONITOR 命令是 Redis 提供的一種實(shí)時(shí)查看 Redis 的所有操作的方式,可以用于臨時(shí)監(jiān)控 Redis 實(shí)例的操作情況,包括讀寫、刪除等操作。由于該命令對(duì) Redis 性能的影響比較大,被逼到墻角的時(shí)候,我們可以暫時(shí)使用這個(gè)命令,得到其輸出之后,關(guān)閉它,然后對(duì)其輸出歸類分析。
同理,--hotkeys也會(huì)增加 Redis 實(shí)例的 CPU 和內(nèi)存消耗(全局掃描),因此需要謹(jǐn)慎使用
- 【無(wú)意之間,我在GITEE上看asyncTool的時(shí)候,在其主頁(yè)翻到了京東零售的熱點(diǎn)數(shù)據(jù)監(jiān)測(cè)】

它的hotKey項(xiàng)目:這個(gè)就交給讀者自行查看了。
上面好像說(shuō)的都是監(jiān)測(cè)key,檢測(cè)到了能怎么辦呢?
在單服務(wù)器承受范圍之內(nèi),我們可以結(jié)合本節(jié)開(kāi)始前的方法三,如果設(shè)置了過(guò)期時(shí)間,可以刷新其過(guò)期時(shí)間,可以避免緩存擊穿的問(wèn)題了哇。
在單個(gè)服務(wù)器承受范圍之外: 這就涉及到Redis主從架構(gòu)等等問(wèn)題了,本文不做探討。
- 如果是用 : redis 主從架構(gòu),可以通過(guò)增加Redis集群中的從節(jié)點(diǎn),增加 多個(gè)讀的副本。通過(guò)對(duì)讀流量進(jìn)行 負(fù)載均衡, 將讀流量 分散到更多的從節(jié)點(diǎn) 上,減輕單個(gè)節(jié)點(diǎn)的壓力。【節(jié)選一下參考1的話】
- 通過(guò)改變Key的結(jié)構(gòu)(如添加隨機(jī)前綴),將同一個(gè)熱點(diǎn)Key拆分成多個(gè)Key,使其分布在不同的Redis節(jié)點(diǎn)上,從而避免所有流量集中在一個(gè)節(jié)點(diǎn)上。【節(jié)選一下參考1的話】
1.2 緩存穿透
緩存穿透是指查詢的數(shù)據(jù)既不在緩存中,也不在數(shù)據(jù)庫(kù)中,但惡意或異常請(qǐng)求持續(xù)訪問(wèn),導(dǎo)致每次請(qǐng)求都直接穿透到數(shù)據(jù)庫(kù)的現(xiàn)象。這會(huì)顯著增加數(shù)據(jù)庫(kù)壓力,甚至引發(fā)系統(tǒng)崩潰。

這種緩存問(wèn)題和緩存擊穿有點(diǎn)像,但是它的特征是請(qǐng)求的Key在緩存和數(shù)據(jù)庫(kù)中均不存在(如惡意構(gòu)造的非法ID:id=-1或id=99999999)。
緩存穿透:數(shù)據(jù)根本不存在(緩存和數(shù)據(jù)庫(kù)均無(wú))。
緩存擊穿:數(shù)據(jù)存在但緩存失效(數(shù)據(jù)庫(kù)中有數(shù)據(jù))?!緹狳c(diǎn)key問(wèn)題】
那么,緩存穿透我們?cè)撛趺唇鉀Q呢?
- 方法一:緩存空值
這個(gè)很容易就想到了,但是為了避免緩存被長(zhǎng)時(shí)間占用,需要給這個(gè)空值加一個(gè)比較短的過(guò)期時(shí)間,例如幾分鐘。
假如有大量無(wú)效請(qǐng)求穿透過(guò)來(lái)時(shí),緩存內(nèi)就會(huì)有 大量的空值緩存。
所以,我們針對(duì)這種接口,我認(rèn)為可以在查詢緩存之前,判斷那種必定無(wú)效參數(shù),直接將無(wú)效參數(shù)的請(qǐng)求給過(guò)濾掉,比如說(shuō):數(shù)據(jù)庫(kù)中id都是正數(shù),但是傳過(guò)來(lái)的id是個(gè)負(fù)數(shù),我們就可以將這種請(qǐng)求直接過(guò)濾掉了,等等。但是,這個(gè)措施也僅僅能起到一丟丟作用罷了?!菊?qǐng)求層攔截過(guò)濾一下】
那么有什么辦法可以避免緩存大量的空value呢?
- 方法二:布隆過(guò)濾器
布隆過(guò)濾器的介紹見(jiàn)這篇文章:【分分鐘搞懂布隆過(guò)濾器】
地址:https://mp.weixin.qq.com/s/PmJ8nMFbxJ1qI3hGt8s9lw
我們可以在查詢緩存前,先走一遍布隆過(guò)濾器,如果布隆過(guò)濾器說(shuō)不存在該key,則一定不存在;反之,則可能存在。
但是,我們需要預(yù)先將數(shù)據(jù)庫(kù)中的key加載到布隆過(guò)濾器中去。這就引出了另一個(gè)問(wèn)題:那就是數(shù)據(jù)庫(kù)中key和布隆過(guò)濾器中的一致性問(wèn)題。
下面給出布隆過(guò)濾器的簡(jiǎn)單例子:
// 首先使用Redisson創(chuàng)建一個(gè)商品goods的布隆過(guò)濾器
@Bean("goodsBloomFilter")
public RBloomFilter<Object> bloomFilter() {
RBloomFilter<Object> goodsBloomFilter = redissonClient.getBloomFilter("goodsBloomFilter");
goodsBloomFilter.tryInit(expectedInsertions, fpp);
// 初始化布隆過(guò)濾器
List<Goods> goods = Goods.goods;
for (Goods good : goods) {
goodsBloomFilter.add(good.getGoodsId());
}
return goodsBloomFilter;
}
// 2.緩存穿透,解決方式 -- 布隆過(guò)濾器【本方法只用于解決緩存穿透的問(wèn)題】
// 實(shí)際肯定也要考慮緩存擊穿的。
public Goods getGoodsDetailById_cacheThrough(String goodsId) {
boolean contains = goodsBloomFilter.contains(goodsId);
if (contains) {
// 先到緩存里面找
Goods goodsObj = (Goods) valueOperations.get(RedisKey.GOODS_KEY + goodsId);
if (goodsObj != null) return goodsObj;
// 如果找不到
Goods goods = getDbGoodsById(goodsId);
if (goods != null) {
valueOperations.set(RedisKey.GOODS_KEY + goodsId, goods);
return goods;
}
return null;
} else {
return null;
}
}
看過(guò)上面給的鏈接里面的文章,想必大家也知道問(wèn)題所在:那就是其存在誤判率(可通過(guò)增加哈希函數(shù)降低),同時(shí)呢需要我們定期更新合法Key集;
現(xiàn)在,我們來(lái)對(duì)比一下:
| 方案 | 適用場(chǎng)景 | 注意事項(xiàng) |
|---|---|---|
| 緩存空值 | 動(dòng)態(tài)生成的Key或臨時(shí)數(shù)據(jù) | 設(shè)置短TTL,避免內(nèi)存浪費(fèi) |
| 請(qǐng)求層攔截 | 所有場(chǎng)景的初級(jí)防御 | 結(jié)合參數(shù)校驗(yàn)與限流機(jī)制 |
| 布隆過(guò)濾器 | 海量合法Key已知的場(chǎng)景(如商品ID) | 需定期更新Key集合,控制誤判率 |
綜上呢,我們可以組合一下這些方案,形成一個(gè)比較有效的解決方案

如果誤判了,怎么辦呢?
1.3 緩存雪崩
緩存雪崩是指因大量緩存數(shù)據(jù)在同一時(shí)間集中失效或緩存服務(wù)崩潰,導(dǎo)致海量請(qǐng)求直接涌向數(shù)據(jù)庫(kù),引發(fā)數(shù)據(jù)庫(kù)瞬時(shí)壓力激增、系統(tǒng)性能驟降甚至崩潰的現(xiàn)象。這些 Key 的大量并發(fā)請(qǐng)求,瞬間全部穿透緩存層,直接涌向底層數(shù)據(jù)庫(kù)(如 MySQL)。
與前兩個(gè)問(wèn)題比對(duì)一下,可以發(fā)現(xiàn)其特征:涉及大量緩存 Key(可能是幾十、幾百、甚至成千上萬(wàn)),而非單個(gè) Key;這些 Key 的失效時(shí)間點(diǎn)非常集中(例如,在同一秒、同一分鐘內(nèi));在失效發(fā)生的時(shí)刻,恰好有對(duì)這些 Key 的正常業(yè)務(wù)請(qǐng)求(通常量很大)。主要還是同一時(shí)間過(guò)期key的數(shù)量太多了。

它的成因:
【設(shè)置相同的過(guò)期時(shí)間(TTL)】: 這是最常見(jiàn)的原因。在初始化緩存數(shù)據(jù)或在批量更新緩存時(shí),為大量 Key 設(shè)置了完全相同的 TTL(例如,都設(shè)為 24 小時(shí))。當(dāng) 24 小時(shí)后,這些 Key 同時(shí)失效。又或者是在每天凌晨執(zhí)行批量緩存刷新任務(wù),刷新后新 Key 的 TTL 相同。
【緩存服務(wù)災(zāi)難性故障:】Redis 集群宕機(jī): 主節(jié)點(diǎn)宕機(jī)且未成功切換從節(jié)點(diǎn),或哨兵/集群模式本身出現(xiàn)故障。網(wǎng)絡(luò)分區(qū): 導(dǎo)致應(yīng)用服務(wù)器無(wú)法連接 Redis 集群。大范圍重啟/升級(jí): 運(yùn)維操作導(dǎo)致整個(gè) Redis 集群或大部分節(jié)點(diǎn)不可用。
問(wèn)題不能完全解決,我們只能盡可能的去預(yù)防這些問(wèn)題的產(chǎn)生:
首先在key層面,采用過(guò)期時(shí)間隨機(jī)化:這個(gè)例子就不舉了吧,設(shè)置緩存的時(shí)候,將TTL給它Random,如果想要更隨機(jī),可以網(wǎng)上找一些均勻的隨機(jī)算法,然后自己diy一下就好了。對(duì)相當(dāng)核心、容易預(yù)測(cè)的key數(shù)據(jù)可采用 永不過(guò)期+異步刷新。
其次呢,可以在Redis層面,搭建高可用的集群,來(lái)預(yù)防基礎(chǔ)設(shè)施故障;
在緩存層面,我們可以構(gòu)建多級(jí)緩存來(lái)提升韌性: 對(duì)于性能要求極高、存在極端熱點(diǎn)的系統(tǒng),考慮引入本地緩存(caffeine)構(gòu)建多級(jí)緩存,能有效抵御 Redis 層雪崩對(duì)數(shù)據(jù)庫(kù)的沖擊。但是需解決一致性問(wèn)題。
最后可以來(lái)一個(gè)兜底措施:比如說(shuō)熔斷降級(jí) ,來(lái)防止數(shù)據(jù)庫(kù)崩潰和雪崩擴(kuò)散;服務(wù)限流 來(lái)控制流量洪峰、從而也可以保護(hù)數(shù)據(jù)庫(kù)。
綜合比較一下上面所說(shuō)的/緩存三個(gè)主要的問(wèn)題:
- 緩存擊穿: 單個(gè)熱點(diǎn) Key 失效瞬間遭遇 高并發(fā)。關(guān)鍵:?jiǎn)蝹€(gè)熱點(diǎn) Key 失效 + 高并發(fā)。倉(cāng)庫(kù)門口堆放的一件特別搶手的熱門商品剛好賣完(緩存失效),一大群等著買它的人瞬間沖進(jìn)倉(cāng)庫(kù)搶購(gòu),把倉(cāng)庫(kù)管理員(DB)累垮了。其他商品還能正常在門口買。
- 緩存穿透: 查詢 數(shù)據(jù)庫(kù)中根本不存在 的數(shù)據(jù)。關(guān)鍵:數(shù)據(jù)不存在。 請(qǐng)求必定穿透緩存訪問(wèn) DB。你要找的東西壓根不存在于倉(cāng)庫(kù)(DB)里,每次都得翻遍倉(cāng)庫(kù)確認(rèn)沒(méi)有。倉(cāng)庫(kù)管理員(DB)每次都得白忙活一趟。不管門口有沒(méi)有貨架(緩存),都得進(jìn)倉(cāng)庫(kù)。
- 緩存雪崩: 大量 Key 同時(shí)失效 或 Redis 集群整體不可用。關(guān)鍵:大規(guī)模失效 + 高并發(fā)。倉(cāng)庫(kù)門口堆放的很多常用物品(緩存)在同一時(shí)間被清空了(同時(shí)失效),所有人(請(qǐng)求)涌進(jìn)倉(cāng)庫(kù)(數(shù)據(jù)庫(kù))找東西,把倉(cāng)庫(kù)擠爆了,并且因?yàn)閭}(cāng)庫(kù)癱瘓,導(dǎo)致整個(gè)商場(chǎng)(系統(tǒng))停擺。
雪崩就像一場(chǎng)雪,擊穿就像這場(chǎng)雪里面的一片雪花。
2. 其他實(shí)戰(zhàn)
2.1 排行榜
Redis 的 ZSET 天然支持按分值(Score)排序,完美契合排行榜需求,下面我們來(lái)模擬一下文章閱讀量前十排行榜,結(jié)合Redis實(shí)現(xiàn)的:
Key 設(shè)計(jì):article_ranking(存儲(chǔ)文章閱讀量排行)
成員(Member):文章標(biāo)題(如 article:鋼鐵是怎樣煉成的!),也可以放文章ID,查出前十文章ID之后,然后通過(guò)id查詢文章。
分值(Score):文章閱讀量(整數(shù)類型)
見(jiàn)如下代碼:
@RequestMapping("/other")
@RestController
public class OtherController {
@Resource
private ZSetOperations<String, Object> zSetOperations;
// 1.訪問(wèn)文章
@GetMapping("/accessArticle")
public R accessArticle() {
// 假設(shè)有30篇文章
List<Integer> list = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30);
// 模擬100個(gè)用戶訪問(wèn)文章
Random random = new Random();
for ( int i = 0; i < 100; ++i ) {
// 生成1-30隨機(jī)數(shù)
int index = random.nextInt(list.size());
// 文章訪問(wèn)數(shù)加一
zSetOperations.incrementScore(RedisKey.ARTICLE_RANK_KEY, list.get(index), 1);
}
return R.success("訪問(wèn)ok!");
}
}
// 1.排行榜
@GetMapping("/rankingList")
public R rankingList() {
// 獲取前十
Set<Object> range = zSetOperations.range(RedisKey.ARTICLE_RANK_KEY, 0, 9);
if (range == null) {
return R.fail("沒(méi)有數(shù)據(jù)!");
}
String str;
for (Object o : range) {
str = o.toString();
String[] split = str.split(":");
System.out.println("文章ID或者標(biāo)題:" + split[1]); // 這里可以根據(jù)id去數(shù)據(jù)庫(kù)查具體文章
}
return R.success().setData("rankingList", range);
}
優(yōu)化點(diǎn):如果文章過(guò)多的話,比如說(shuō)member達(dá)到了數(shù)以萬(wàn)計(jì)乃至十萬(wàn)計(jì),我們可以選擇僅保留 Top N 文章:定期清理非熱門文章(如只保留前 1000 名)
ZREMRANGEBYRANK article_ranking 1000 -1 # 刪除1000名后的成員
2.2 附近商家
Redis實(shí)現(xiàn)附近的商家功能。這個(gè)問(wèn)題在LBS(基于位置的服務(wù))應(yīng)用中非常常見(jiàn),比如外賣、打車、社交等場(chǎng)景都需要查找附近的服務(wù)點(diǎn)。Redis的GEO模塊是解決這類問(wèn)題的核心工具,它在3.2版本后被引入。我們可以將商家的經(jīng)緯度實(shí)現(xiàn)導(dǎo)入Redis,然后就可以實(shí)現(xiàn)這個(gè)功能了。
如下代碼:讀者們可以去這里轉(zhuǎn)換經(jīng)緯度:http://jingweidu.757dy.com/
鄂州市經(jīng)緯度: 114.895003, 30.390893
石家莊:114.515000,38.042401
public class Shop {
private String shopId;
private String shopName;
private Double longitude; // 經(jīng)度
private Double latitude; // 緯度
public static final List<Shop> shops = new ArrayList<>();
static {
// 模擬導(dǎo)入11個(gè)城市的經(jīng)緯度
shops.add(new Shop("1", "上海鄉(xiāng)村基", 121.48, 31.22));
shops.add(new Shop("2", "北京鄉(xiāng)村基", 116.46, 39.92));
shops.add(new Shop("3", "廣州鄉(xiāng)村基", 113.27, 23.13));
shops.add(new Shop("4", "深圳鄉(xiāng)村基", 114.07, 22.62));
shops.add(new Shop("5", "杭州鄉(xiāng)村基", 120.19, 30.26));
shops.add(new Shop("6", "南京鄉(xiāng)村基", 118.78, 32.04));
shops.add(new Shop("7", "西安鄉(xiāng)村基", 108.95, 34.27));
shops.add(new Shop("8", "武漢鄉(xiāng)村基", 114.31, 30.52));
shops.add(new Shop("9", "長(zhǎng)沙鄉(xiāng)村基", 113.01, 28.21));
shops.add(new Shop("10", "蘇州店肯德基", 120.62, 31.32));
shops.add(new Shop("11", "天津店肯德基", 117.2, 39.13));
}
}
@GetMapping("/importLAL")
public R importLAL() {
List<Shop> shops = Shop.shops;
for (Shop shop : shops) {
redisTemplate.opsForGeo().add(RedisKey.SHOP_KEY, new Point(shop.getLongitude(), shop.getLatitude()), shop.getShopName());
}
return R.success("導(dǎo)入成功!");
}
// 查詢附近
@GetMapping("/nearbyShop")
public R nearbyShop(@RequestParam("longitude") Double longitude, @RequestParam("latitude") Double latitude) {
// =========1.獲取指定成員的地理位置
//List<Point> position = redisTemplate.opsForGeo().position(RedisKey.SHOP_KEY, "上海鄉(xiāng)村基");
// =========2.distance:計(jì)算兩個(gè)成員之間的距離(默認(rèn)以米為單位)
//Distance distance = redisTemplate.opsForGeo().distance(RedisKey.SHOP_KEY, "上海鄉(xiāng)村基", "北京鄉(xiāng)村基");
//double distanceInKm = distance.getValue() / 1000;
// =========3.radiusByMember:根據(jù)給定的成員,返回與該成員距離在指定范圍內(nèi)的其他成員(按距離由近到遠(yuǎn)排序)
// =========4.radius:根據(jù)給定的中心點(diǎn),返回與中心點(diǎn)距離在指定范圍內(nèi)的成員(按距離由近到遠(yuǎn)排序)
// 構(gòu)建中心點(diǎn)
Point center = new Point(longitude, latitude);
// 構(gòu)建圓形搜索區(qū)域
Circle circle = new Circle(center, new Distance(1000, Metrics.KILOMETERS));
// 構(gòu)建搜索參數(shù)
RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs
.newGeoRadiusArgs()
.includeDistance() // 返回距離
.includeCoordinates() // 返回坐標(biāo)
.sortAscending() // 按距離升序
.limit(15); // 限制數(shù)量
// 執(zhí)行搜索
GeoResults<RedisGeoCommands.GeoLocation<Object>> results = redisTemplate.opsForGeo().radius(RedisKey.SHOP_KEY, circle, args);
if (results == null) return R.fail("沒(méi)有!");
List<NearbyPlace> places = results.getContent().stream()
.map(result -> {
RedisGeoCommands.GeoLocation<Object> location = result.getContent();
Distance dist = result.getDistance();
Point pos = location.getPoint();
return new NearbyPlace(
location.getName().toString(),
pos.getX(), pos.getY(),
dist.getValue(),
dist.getUnit()
);
}).toList();
// 可以在這里對(duì)結(jié)果處理一下,比如說(shuō)從數(shù)據(jù)庫(kù)查一下該店鋪的評(píng)分、評(píng)價(jià)之類的,等等
return R.success().setData("places", places);
}
優(yōu)化點(diǎn):同理哦,如果商家巨多,都在同一個(gè)key里面可能會(huì)出現(xiàn)大key問(wèn)題,我們可以考慮給商家按照區(qū)域劃分到不同的key,這算是一種優(yōu)化方案。
2.3 簽到
Redis實(shí)現(xiàn)簽到功能的核心是使用位圖(BitMap)數(shù)據(jù)結(jié)構(gòu)。用戶簽到記錄可以按用戶ID+月份作為Key,日期作為偏移量,簽到狀態(tài)用0/1表示,這種設(shè)計(jì)非常節(jié)省空間,100萬(wàn)用戶一年的簽到數(shù)據(jù)才43MB左右。
具體實(shí)現(xiàn)上,簽到操作使用SETBIT命令,查詢是否簽到用GETBIT,統(tǒng)計(jì)當(dāng)月簽到次數(shù)用BITCOUNT,獲取連續(xù)簽到天數(shù)則通過(guò)BITFIELD獲取位圖數(shù)據(jù)后做位運(yùn)算。
具體代碼如下:
// 模擬用戶簽到
@PostMapping("/mockSign")
public R signIn() {
// 這里以六月舉例子,模擬6月1日-6月30日
// 有十個(gè)用戶
for (int i = 1; i <= 10; ++i ) {
String key = RedisKey.SIGN_IN_KEY + ":" + i + ":202506";
// 30天中隨機(jī)20天簽到
for (int j = 0; j < 20; ++j) {
int day = (int) (Math.random() * 30) + 1;
redisTemplate.opsForValue().setBit(key, day, true);
}
}
return R.success();
}
// 獲取用戶的簽到日歷
@GetMapping("/signInCalendar")
public R signInCalendar() {
// 獲取當(dāng)前日期
LocalDate now = LocalDate.now();
// 當(dāng)前月有多少天
int daysInMonth = now.lengthOfMonth();
// 獲取用戶的簽到日歷
List<SignCalendar> calendars = new ArrayList<>();
for (int i = 1; i <= 10; ++i) { // 遍歷這十位用戶
String key = RedisKey.SIGN_IN_KEY + ":" + i + ":202506"; // key
SignCalendar calendar = new SignCalendar(i, new ArrayList<>());
for (int j = 1; j <= daysInMonth; ++j) { // 判斷每一天
SignCalendarDay day = new SignCalendarDay(2025, now.getMonthValue(), j, false);
int offset = j - 1;
Boolean sign = redisTemplate.opsForValue().getBit(key, offset);
day.setSign(sign);
calendar.addDaySign(day);
}
calendars.add(calendar);
}
System.out.println(calendars);
return R.success().setData("calendars", calendars);
}
具體詳情請(qǐng)看倉(cāng)庫(kù)項(xiàng)目。
2.4 網(wǎng)站uv統(tǒng)計(jì)
獨(dú)立用戶訪問(wèn)量Unique Visitor。
Redis HyperLogLog 是用來(lái)做基數(shù)統(tǒng)計(jì)的算法,HyperLogLog 的優(yōu)點(diǎn)是,在輸入元素的數(shù)量或者體積非常非常大時(shí),計(jì)算基數(shù)所需的空間總是固定 的、并且是很小的。在 Redis 里面,每個(gè) HyperLogLog 鍵只需要花費(fèi) 12 KB 內(nèi)存,就可以計(jì)算接近 2^64 個(gè)不同元素的基 數(shù)。這和計(jì)算基數(shù)時(shí),元素越多耗費(fèi)內(nèi)存就越多的集合形成鮮明對(duì)比。
示例請(qǐng)看如下代碼:
@GetMapping("/mockAccess")
public R webUv() {
String[] keys = buildKey();
// 記錄訪問(wèn)-- 模擬1000個(gè)用戶訪問(wèn)
List<Integer> uids = new ArrayList<>();
for (int i = 0; i < 1000000; ++i) uids.add( i);
// uids.forEach(uid -> {
// redisTemplate.opsForHyperLogLog().add(keys[0], uid);
// redisTemplate.opsForHyperLogLog().add(keys[1], uid);
// redisTemplate.opsForHyperLogLog().add(keys[2], uid);
// });
redisTemplate.opsForHyperLogLog().add(keys[0], uids.toArray());
redisTemplate.opsForHyperLogLog().add(keys[1], uids.toArray());
redisTemplate.opsForHyperLogLog().add(keys[2], uids.toArray());
return R.success();
}
@GetMapping("/webUv")
public R webUv() {
String[] keys = buildKey();
Long day = redisTemplate.opsForHyperLogLog().size(keys[0]);
Long month = redisTemplate.opsForHyperLogLog().size(keys[1]);
Long year = redisTemplate.opsForHyperLogLog().size(keys[2]);
return R.success().setData("day", day)
.setData("month", month)
.setData("year", year);
}
我這里訪問(wèn)量測(cè)試了
{
"code": 200,
"data": {
"msg": "響應(yīng)成功!",
"month": 1009972,
"year": 1009972,
"day": 1009972
}
}
占用內(nèi)存連0.5MB都不到. 雖然訪問(wèn)量可能不是精準(zhǔn)的,但是大差不差了。實(shí)際情況地話,這些key肯定是要定期清零的,比如說(shuō),月訪問(wèn)量,一個(gè)月過(guò)去了,肯定是需要重新統(tǒng)計(jì)的。
2.5 社交關(guān)系
利用Redis的集合(Set)數(shù)據(jù)結(jié)構(gòu)來(lái)存儲(chǔ)關(guān)注關(guān)系,每個(gè)用戶用兩個(gè)集合分別存儲(chǔ)關(guān)注列表(following)和粉絲列表(followers),當(dāng)用戶A關(guān)注用戶B時(shí),系統(tǒng)會(huì)執(zhí)行兩條原子操作——把A加入B的粉絲集合,同時(shí)把B加入A的關(guān)注集合。
比如要查A和B的共同關(guān)注,只需對(duì)A的關(guān)注集合和B的關(guān)注集合取交集(SINTER命令),Redis集合運(yùn)算的時(shí)間復(fù)雜度是O(N),百萬(wàn)級(jí)關(guān)系數(shù)據(jù)也能毫秒級(jí)響應(yīng),這種性能對(duì)社交場(chǎng)景至關(guān)重要。
但社交關(guān)系不只是關(guān)注功能,搜索結(jié)果還展示了更豐富的可能性:比如用有序集合(Sorted Set)存儲(chǔ)關(guān)注時(shí)間,可以實(shí)現(xiàn)"最近關(guān)注"排序;用發(fā)布訂閱(Pub/Sub)實(shí)現(xiàn)實(shí)時(shí)動(dòng)態(tài)推送——當(dāng)用戶發(fā)帖時(shí),系統(tǒng)會(huì)向所有粉絲的推送頻道發(fā)布消息等等。
同時(shí)需要注意的是,如果網(wǎng)站中有大V,比如說(shuō)有十萬(wàn)百萬(wàn)粉絲,同理又會(huì)出現(xiàn)大key問(wèn)題,這個(gè)時(shí)候就要注意了,可以用粉絲ID的哈希值分桶,比如把1億粉絲分散到100個(gè)分片集合中。
本文僅給出簡(jiǎn)單例子:
// 舉例子
# 用戶123 關(guān)注用戶456
SADD following:123 456 # 123的關(guān)注列表加入456
SADD followers:456 123 # 456的粉絲列表加入123
具體情況見(jiàn)代碼。
2.6 限流計(jì)數(shù)
見(jiàn)這篇文章:限流算法
地址:https://blog.csdn.net/okok__TXF/article/details/148048960
end. 參考
- https://mp.weixin.qq.com/s/d_JJuTGiN6Lspn0oyYxKFA 【阿里面試:緩存擊穿、緩存穿透、緩存雪崩 3大問(wèn)題,如何徹底解決?】
- https://mp.weixin.qq.com/s/Bt8yVVZ-F4H7KzJnZlwJMQ 【hotKey】
- https://mp.weixin.qq.com/s/PmJ8nMFbxJ1qI3hGt8s9lw 【布隆過(guò)濾器】
- https://mp.weixin.qq.com/s/HSQVnPW4aRdLP2jnTOFr4A 【分布式鎖】
- https://www.runoob.com/redis/redis-hyperloglog.html 【菜鳥教程】

浙公網(wǎng)安備 33010602011771號(hào)