舒服,又偷學(xué)到一個(gè)高并發(fā)場(chǎng)景面試題的解決方案.
你好呀,我是歪歪。
是這樣的,前幾天我收到掘金的系統(tǒng)通知說我在活動(dòng)中中獎(jiǎng)了。

然后我點(diǎn)進(jìn)去一看,好家伙榮登榜眼:

是我之前寫的這篇文章:《面試官:要不我們聊一下“心跳”的設(shè)計(jì)?》
但是這不重要,重要的是我看一下第一名寫的文章,有點(diǎn)東西,又學(xué)到一個(gè)問題的解決方案,所以我想分享一下。
文章地址在這里:
https://juejin.cn/post/7041076758711369764
高并發(fā) PV 問題
他的文章標(biāo)題是這樣的:

首先他給出了一個(gè)業(yè)務(wù)場(chǎng)景:在一些需要統(tǒng)計(jì) PV(Page View), 即頁(yè)面瀏覽量或點(diǎn)擊量高并發(fā)系統(tǒng)中,如:知乎文章瀏覽量,淘寶商品頁(yè)瀏覽量等,需要統(tǒng)計(jì)相應(yīng)數(shù)據(jù)做分析。
比如我的公眾號(hào)閱讀量大概在 3000 左右,如果要統(tǒng)計(jì)我這種小號(hào)主的 PV 其實(shí)就很簡(jiǎn)單,就用 Redis 的 incr 命令輕輕松松就實(shí)現(xiàn)了。
但是,假設(shè)微信公眾號(hào)每天要統(tǒng)計(jì) 10 萬篇文章,每篇文章的訪問量 10 萬,如果采用 Redis 的 incr命令來實(shí)現(xiàn)計(jì)數(shù)器的話,每天 Redis=100 億次的寫操作,按照每天高峰 12 小時(shí)來算,那么 Redis 大約 QPS=57萬。
如此大的并發(fā)量,CPU 肯定滿負(fù)載運(yùn)行,網(wǎng)絡(luò)資源消耗也巨大,所以直接使用 incr 命令這種技術(shù)方案是行不通的。

假設(shè)這是一個(gè)面試場(chǎng)景題,你會(huì)怎么去回答呢?
其實(shí)你也別想的有多復(fù)雜,剝離開場(chǎng)景,這無外乎就是一個(gè)高并發(fā)的問題。
而高并發(fā)問題的解決方案,基本上逃不過這三板斧:緩存、拆分、加錢。
所以這個(gè)老哥給出的方案就是:緩存。
二級(jí)緩存
Redis 都已經(jīng)是緩存了,那么再加緩存算什么回事呢?
那就算是二級(jí)緩存了。
而且這個(gè)緩存,就在 JVM 內(nèi)存里面,比 Redis 還快。
其核心思想是減少 Redis 的訪問量。這些理論的東西,大家應(yīng)該都知道。
那么通過什么方案去減少 Redis 的訪問量呢,這個(gè)二級(jí)緩存應(yīng)該怎么去設(shè)計(jì)呢?
首先,文章服務(wù)采用了集群部署,在線上可以部署多臺(tái)。
然后每個(gè)文章服務(wù),增加一級(jí) JVM 緩存,即用 Map 存儲(chǔ)在 JVM,key 為當(dāng)前請(qǐng)求所屬的時(shí)間塊。
就是這個(gè)意思:
Map
> = Map<時(shí)間塊,Map<文章id,訪問量>>
但是我覺得巧妙的地方在于這里提到的“時(shí)間塊”的概念。
什么是時(shí)間塊?
就是把時(shí)間切割為一塊塊,例如:一篇文章在1小時(shí),30分鐘、5分鐘的時(shí)間內(nèi)產(chǎn)生了多少閱讀量。
如何切割時(shí)間塊呢?
這里利用到了時(shí)間是不斷增長(zhǎng)的特性。
時(shí)間戳是自 1970 年1月1日(00:00:00 GMT)至當(dāng)前時(shí)間的總數(shù),通過確定時(shí)間塊大小,算出當(dāng)前請(qǐng)求所屬的時(shí)間戳從 1970 年算起位于第幾個(gè)時(shí)間塊,這個(gè)算出來的第幾個(gè)時(shí)間塊就是小時(shí) key ,即 map 的 key。
舉個(gè)例子:
我們把時(shí)間按照“小時(shí)”的維度進(jìn)行劃分。
先把當(dāng)前的時(shí)間轉(zhuǎn)換為為毫秒的時(shí)間戳,然后除以一小時(shí),即當(dāng)前時(shí)間 T/1000*60*60=小時(shí)key,然后用這個(gè)小時(shí)序號(hào)作為key。
比如:
2021-12-26 15:00:00 = 1640502000000毫秒
那么小時(shí)key= 1640502000000/1000*60*60=455695,即是距離 1970 年開始算的第 455695 個(gè)時(shí)間塊。
2021-12-26 15:10:00 = 1640502600000毫秒,那么算出來的 key =455695.1,向下取整,key 還是等于 455695。
意思是這段時(shí)間的時(shí)間塊是一樣的,所以統(tǒng)計(jì)到 JVM 內(nèi)存中的 Map 的時(shí)候,對(duì)應(yīng)的 key 是一樣的。
畫個(gè)圖示意一下:

上圖中,在 2021-12-25?15:00:00 到?2021-12-25?15:59:59 時(shí)間段內(nèi)產(chǎn)生的閱讀量都會(huì)映射到 Map 的 key= 455695 的位置去。
在?2021-12-25?16:00:00?到?2021-12-25?16:59:59 時(shí)間段內(nèi)產(chǎn)生的閱讀量都會(huì)映射到 Map 的 key= 455696 的位置去。
以此類推,每一次PV操作時(shí),先計(jì)算當(dāng)前時(shí)間是那個(gè)時(shí)間塊,然后存儲(chǔ)Map中。
整體方案
當(dāng)我們把數(shù)據(jù)緩存到內(nèi)存中之后,就極大的減少了對(duì)于 Redis 的訪問。
但是我們還是得把數(shù)據(jù)同步到 Redis 里面去,因?yàn)樵L問文章數(shù)據(jù)的時(shí)候還是得從 Redis 中獲取數(shù)據(jù)。
所以,這里就涉及到一個(gè)問題:什么時(shí)候、怎么把數(shù)據(jù)同步到 Redis 呢?
看一下作者給出的方案設(shè)計(jì):

整體流程還是比較清楚,主要說一下里面的兩個(gè)定時(shí)任務(wù)。
其中一級(jí)緩存定時(shí)器的邏輯是這樣的:假設(shè)每 5 分鐘(可以根據(jù)需求調(diào)整)從 JVM 的 Map 里面把時(shí)間塊的閱讀 PV 讀取出來,然后 push 到 Redis 的 list 數(shù)據(jù)結(jié)構(gòu)中。
list 存儲(chǔ)的數(shù)據(jù)為 Map<文章Id,訪問量PV>,即每個(gè)時(shí)間塊的 PV 數(shù)據(jù)。
另外一個(gè)二級(jí)緩存定時(shí)器的邏輯是這樣的:每 6 分鐘(需要比一級(jí)緩存的時(shí)間長(zhǎng)),從 Redis 的 list 數(shù)據(jù)結(jié)構(gòu)中 pop 出數(shù)據(jù),即Map<文章Id,訪問量PV>。
然后把對(duì)應(yīng)的數(shù)據(jù)同步到 DB 和 Redis 中。
代碼實(shí)戰(zhàn)
代碼主要分為四個(gè)步驟,我也把代碼粘過來給大家看看。
步驟1:PV請(qǐng)求處理邏輯
//保存時(shí)間塊和pv數(shù)據(jù)的map
public??static?final??Map>?PV_MAP=new?ConcurrentHashMap();
/**
?* pv請(qǐng)求調(diào)用:
?*?即當(dāng)前時(shí)間T/1000*60*60=小時(shí)key,然后用這個(gè)小時(shí)序號(hào)作為key。
?*?例如:
?*?2021-11-09?15:30:00?=?1636443000000毫秒?
?*?小時(shí)key=1636443000000/1000\*60\*60=454567.5=454567
?*
?*?每一次PV操作時(shí),先計(jì)算當(dāng)前時(shí)間是那個(gè)時(shí)間塊,然后存儲(chǔ)Map中。
?*?@param?id?文章id
?*/
public?void?addPV(Integer?id)?{
????//生成環(huán)境:時(shí)間塊為5分鐘
????//為了方便測(cè)試?改為1分鐘?時(shí)間塊
????int?timer=1;
????long?m1=System.currentTimeMillis()/(1000*60*timer);
????//拿出這個(gè)時(shí)間塊的所有文章數(shù)據(jù)
????Map?mMap=Constants.PV_MAP.get(m1);
????if?(CollectionUtils.isEmpty(mMap)){
????????mMap=new?ConcurrentHashMap();
????????mMap.put(id,new?Integer(1));
????????//<1分鐘的時(shí)間塊,Map<文章Id,訪問量>>
????????Constants.PV_MAP.put(m1,?mMap);
????}else?{
????????//通過文章id?取出瀏覽量
????????Integer?value=mMap.get(id);
????????if?(value==null){
????????????mMap.put(id,new?Integer(1));
????????}else{
????????????mMap.put(id,value+1);
????????}
????}
}
步驟2:一級(jí)緩存定時(shí)器消費(fèi)
定時(shí)(5分鐘)從 JVM 的 ?Map 把時(shí)間塊的閱讀 PV 取出來,然后 push 到 Reids 的 list 數(shù)據(jù)結(jié)構(gòu)中,list 的存儲(chǔ)的數(shù)據(jù)為 Map<文章id,訪問量PV> 即每個(gè)時(shí)間塊的 PV 數(shù)據(jù)
/**
?*?一級(jí)緩存定時(shí)器消費(fèi)調(diào)用方法:
?*?定時(shí)器,定時(shí)(5分鐘)從jvm的map把時(shí)間塊的閱讀pv取出來,
?*?然后push到reids的list數(shù)據(jù)結(jié)構(gòu)中,list的存儲(chǔ)的書為Map<文章id,訪問量PV>即每個(gè)時(shí)間塊的pv數(shù)據(jù)
?*/
public?void?consumePV(){
????//為了方便測(cè)試?改為1分鐘?時(shí)間塊
????long?m1=System.currentTimeMillis()/(1000*60*1);
????Iterator?iterator=?Constants.PV_MAP.keySet().iterator();
????while?(iterator.hasNext()){
????????//取出map的時(shí)間塊
????????Long?key=iterator.next();
????????//小于當(dāng)前的分鐘時(shí)間塊key?,就消費(fèi)
????????if?(key ????????????//先push
????????????Map?map=Constants.PV_MAP.get(key);
????????????//push到reids的list數(shù)據(jù)結(jié)構(gòu)中,list的存儲(chǔ)的書為Map<文章id,訪問量PV>即每個(gè)時(shí)間塊的pv數(shù)據(jù)
????????????this.redisTemplate.opsForList().leftPush(Constants.CACHE_PV_LIST,map);
????????????//后remove
????????????Constants.PV_MAP.remove(key);
????????????log.info("push進(jìn){}",map);
????????}
????}
}
步驟3:二級(jí)緩存定時(shí)器消費(fèi)
定時(shí)(5分鐘),從 Redis 的 list 數(shù)據(jù)結(jié)構(gòu) pop 彈出 Map<文章id,訪問量PV>,彈出來做了2件事:
- 先把 Map<文章id,訪問量PV>,保存到數(shù)據(jù)庫(kù)
- 再把 Map<文章id,訪問量PV>,同步到 Redis 緩存的計(jì)數(shù)器 incr
步驟4:查看瀏覽量
用了一級(jí)緩存,所有的高并發(fā)流量都收集到了本地 JVM,然后 5 分鐘同步給二級(jí)緩存,從而給 Redis 降壓。
@GetMapping(value?=?"/view")
public?String?view(Integer?id)?{
???//文章pv的key
????String?key=?Constants.CACHE_ARTICLE+id;
????//調(diào)用redis的get命令
????String?n=this.stringRedisTemplate.opsForValue().get(key);
????log.info("key={},閱讀量為{}",key,?n);
????return?n;
}
對(duì)應(yīng)視頻
另外,我在 B 站也找到這篇文章對(duì)應(yīng)的視頻:
https://www.bilibili.com/video/BV1PY411p7MG?p=1


如果大家有沒有看明白的地方,可以去 B 站看一下對(duì)應(yīng)的視頻,講的還是很清楚的。
整體方案是沒有問題的,時(shí)間塊的設(shè)計(jì)也非常的巧妙。
當(dāng)然了如果你非要找方案的瑕疵的話,那就是數(shù)據(jù)時(shí)效性和數(shù)據(jù)一致性的問題了。
其實(shí)我了解到這個(gè)方案之后,我還是覺得萬變不離其宗,這個(gè)方案就是一種合并提交的理念。
比如我之前寫過的這篇文章,就聊到了請(qǐng)求合并的這個(gè)概念,有興趣的可以去看看:
《面試官問我:什么是高并發(fā)下的請(qǐng)求合并?》
荒腔走板
前幾天趁著圣誕節(jié)這個(gè)機(jī)會(huì),順便求了個(gè)婚:

為什么是順便呢?
因?yàn)?Merry Christmas,里面有 Marry me,所以可以假借圣誕節(jié)之名,行求婚之實(shí)。本來我的計(jì)劃是不經(jīng)意間把 Merry Christmas 變化成 Marry me 的。
但是她一回家就發(fā)現(xiàn)了,然后對(duì)我說:你知道嗎,其實(shí)你可以把 Merry Christmas 變成 Marry me,這樣就變成求婚了。
她邊說就開始邊操作。
雖然這不在我的計(jì)劃內(nèi),但是誰(shuí)擺都是擺,所以等她擺字母的時(shí)候,我已經(jīng)單膝跪地,她一轉(zhuǎn)頭才發(fā)現(xiàn)原來這就是一場(chǎng)求婚。
求婚只需要一分鐘,但是這一分鐘會(huì)是寶貴的回憶。
拉個(gè)票
時(shí)間荏苒,白駒過隙,轉(zhuǎn)眼間一年就過去了,又到一年拉票的時(shí)間了。
回想上次拉票,仿佛還是在上次。
有掘金 APP 的小伙伴可以幫我投上幾票:
每人每天可以投的票數(shù)是遞增的,網(wǎng)頁(yè)端投完票了,APP 端還可以再投篇,一直到 29 號(hào)。
掘金是我體驗(yàn)過的一個(gè)非常不錯(cuò)的技術(shù)分享平臺(tái),如果你之前沒有接觸過的話,可以去了解一下,順便給我投上一票。
我常常說寫文章是非常需要正反饋的,這就是體現(xiàn)正反饋?zhàn)詈玫臅r(shí)候了,我的目標(biāo)已經(jīng)從最開始的沖擊前 30 調(diào)整為保住前 100 了。
能不能實(shí)現(xiàn)就看你投不投票了。不管你投不投我吧,至少謝謝你點(diǎn)進(jìn)了這篇文章,抱拳了,老鐵。

推薦???:扒一扒這個(gè)注解,我發(fā)現(xiàn)還有點(diǎn)意思。
推薦???:當(dāng)面試官問你這個(gè)問題的時(shí)候,想聽到什么?
推薦???:當(dāng)我看源碼的時(shí)候,我在想什么?
··································你好呀,我是歪歪。一個(gè)主要敲代碼,經(jīng)常懟文章,偶爾拍視頻的成都人。
我沒進(jìn)過一線大廠,沒創(chuàng)過業(yè),也沒寫過書,更不是技術(shù)專家,所以也沒有什么亮眼的title。
當(dāng)年以超過二本線 13 分的優(yōu)異成績(jī)順利進(jìn)入某二本院校計(jì)算機(jī)專業(yè),誤打誤撞,進(jìn)入了程序員的行列,開始了運(yùn)氣爆棚的程序員之路。
說起程序員之路還是有點(diǎn)意思,可以看看。點(diǎn)擊藍(lán)字,查看我的程序員之路
