昨天我們談了一下如何設(shè)計(jì)點(diǎn)贊模塊,最后給出了優(yōu)化這個(gè)模塊的方案.
我們提到,可以通過合并收到的數(shù)據(jù),來進(jìn)行優(yōu)化.而如何合并數(shù)據(jù),就成了一個(gè)尤為重要的問題.
針對(duì)這個(gè)問題,我們也給出了幾種解決方案,一種是重寫Tomcat,通過Tomcat進(jìn)行攔截并定時(shí)處理,但是這種方案,實(shí)現(xiàn)起來難度有些大,第二種是通過服務(wù)器在處理請(qǐng)求時(shí),是為其新建一個(gè)線程來處理的原理,來實(shí)現(xiàn),這種方案相對(duì)較簡單一些,第三種是通過消息隊(duì)列來實(shí)現(xiàn),這種方案挺復(fù)雜,但是難度不高.
今天我們就采用第二種方案來實(shí)現(xiàn).實(shí)現(xiàn)起來也是一波三折.且聽我慢慢道來.
我們打算這樣實(shí)現(xiàn):
- 創(chuàng)建一個(gè)緩沖區(qū),讓所有線程共享,然后將一分鐘內(nèi)收到的數(shù)據(jù),全部保存到這個(gè)變量中,并給前臺(tái)返回一個(gè)成功的標(biāo)記.
- 過了一分鐘后,當(dāng)收到請(qǐng)求時(shí),先將緩沖區(qū)中的數(shù)據(jù),全部保存到數(shù)據(jù)庫中,然后將這個(gè)緩沖區(qū)清空,并再次寫入下個(gè)一分鐘之內(nèi)收到的數(shù)據(jù).
過程就是這么簡單.但是我們需要注意一些問題:
如果在我們將數(shù)據(jù)保存到數(shù)據(jù)庫之后,清空緩沖區(qū)之前的這個(gè)時(shí)間段中,還有線程在向這個(gè)緩沖區(qū)寫入數(shù)據(jù),那么這些新寫入的數(shù)據(jù)沒有被保存就會(huì)被清除.所以我們需要先等待將之前收到的請(qǐng)求寫入到緩沖區(qū)結(jié)束后,阻塞后來收到的請(qǐng)求,再將數(shù)據(jù)保存到數(shù)據(jù)庫中,并清空緩沖區(qū).
如果有多個(gè)線程執(zhí)行將數(shù)據(jù)保存到數(shù)據(jù)庫,并清空緩沖區(qū)的操作怎么辦?
我們先來看第一版的源代碼:
在第一版中,我們使用ReentrantLock來實(shí)現(xiàn)線程之間的同步.
我們先判斷此請(qǐng)求到來的時(shí)間與上次保存數(shù)據(jù)到數(shù)據(jù)庫的時(shí)間差,是否大于一分鐘.也就是判斷是否過去了一分鐘.如果是這樣,就獲取到ReentrantLock,并在有鎖的期間,將數(shù)據(jù)保存到數(shù)據(jù)庫中,然后重置保存數(shù)據(jù)到數(shù)據(jù)庫的時(shí)間點(diǎn),并清空保存一分鐘之內(nèi)收到的全部數(shù)據(jù)的緩沖區(qū).lastAggegate這個(gè)變量是AtomicLong類型的,這是一個(gè)線程安全的Long類型.likeData這個(gè)緩沖區(qū),是ConcurrentHashMap類型的,這是一個(gè)線程安全的HashMap.
我們?cè)谧鐾晟厦娴墓ぷ髦?釋放掉ReentrantLock.
下面的那塊代碼中,我們判斷是否已經(jīng)有線程占有ReentrantLock了,如果是,我們就一直循環(huán),等待它釋放.實(shí)際上就是起到了阻塞一分鐘之后收到的請(qǐng)求寫數(shù)據(jù)的作用.然后執(zhí)行后面的步驟.在后面,我們將收到的數(shù)據(jù),保存或者更新到likeData這個(gè)緩沖區(qū)中.
這里我們拿兩個(gè)同時(shí)到來的請(qǐng)求A和B,來分析一下上面的代碼:
第一種情況,先假設(shè)A和B都是在一分鐘之內(nèi)到來的,則會(huì)直接執(zhí)行下面的代碼塊.因?yàn)榇藭r(shí)ReentrantLock并沒有被任意一個(gè)線程占有,所以這兩個(gè)線程A和B,會(huì)并發(fā)更新likeData這個(gè)緩存區(qū).這個(gè)沒有什么問題.
第二種情況,假設(shè)A和B都是一分鐘之后到來的,先假設(shè)A先判斷是否過去一分鐘,并判斷為true,然后在下面獲得鎖,重置時(shí)間點(diǎn),這時(shí),B在判斷是否過去了一分鐘,判斷為false,然后B執(zhí)行下面的代碼塊,而因?yàn)?strong>ReentrantLock已經(jīng)被A獲取,所以它只能在while循環(huán)中一直等待.當(dāng)A清空likeData這個(gè)緩沖區(qū)并釋放ReentrantLock之后,B才得以和A同時(shí)并行的將數(shù)據(jù)寫入到likeData這個(gè)緩沖區(qū)中.如果單看這兩個(gè)線程,這個(gè)過程也沒有什么問題.結(jié)果也是正確的.可是,在A清空likeData這個(gè)緩沖區(qū)的時(shí)候,在高并發(fā)的情況下,很可能有一分鐘之內(nèi)到來的請(qǐng)求C,正在向緩沖區(qū)中寫入數(shù)據(jù)!!這樣,請(qǐng)求C的數(shù)據(jù),就被悄無聲息的刪除了.
第三種情況,假設(shè)A和B都是一分鐘之后到來的,它倆又同時(shí)判斷是否過去了一分鐘,并同時(shí)判斷為true,然后它們同時(shí)請(qǐng)求鎖,A或B其中一個(gè)獲得了ReentrantLock,然后執(zhí)行后面的流程.這個(gè)沒有什么好解釋的.同樣,它也有第二種情況中我們說的那種特點(diǎn),數(shù)據(jù)很可能被悄無聲息的刪除了.同時(shí),它還有一種特點(diǎn),就是在高并發(fā)情況下,假設(shè)有一百個(gè)線程是串行著請(qǐng)求鎖,即第一個(gè)線程釋放了鎖,第二個(gè)線程才請(qǐng)求鎖,等第二個(gè)線程釋放了鎖之后,第三個(gè)線程才請(qǐng)求鎖,依次類推.我們可以看到,這樣不僅會(huì)使意外清除數(shù)據(jù)的情況更加嚴(yán)重,還會(huì)有性能問題.我們需要執(zhí)行獲得一百次鎖,執(zhí)行一百次鎖內(nèi)需要執(zhí)行的操作.
第四種情況,假設(shè)A是一分鐘之內(nèi)到來的,B是一分鐘之后到來的,就可能出現(xiàn)我們上面第二種情況中,所說的那種意外.
這幾種情況里面,意外清空數(shù)據(jù)的情況最難處理,因?yàn)槲覀儫o法做到讓鎖內(nèi)需要執(zhí)行的一系列操作,讓其等到一分鐘之內(nèi)到來的請(qǐng)求都把數(shù)據(jù)寫入到likeData這個(gè)緩沖區(qū)之內(nèi),再執(zhí)行.
而多個(gè)線程同時(shí)判斷是否過去一分鐘,并判斷為true這種情況,我們可以通過將判斷及執(zhí)行所內(nèi)的操作放到**synchronized **塊中,來解決.
其實(shí)意外清空數(shù)據(jù)的這種情況,我們可以通過ReadWriteLock或者StampedLock來解決.我們之前介紹過這兩種鎖.
這兩種鎖為何能夠解決意外清空數(shù)據(jù)的情況呢?
各位應(yīng)該都知道,ReadWriteLock的規(guī)則,即:
如果沒有線程持有寫鎖,那么可以有任意多個(gè)線程同時(shí)持有讀鎖,來讀數(shù)據(jù),因?yàn)樽x操作肯定是線程安全的.
寫鎖最多只能被一個(gè)線程同時(shí)占有.
不能同時(shí)占有讀鎖和寫鎖.如果線程A占有讀鎖,而線程B請(qǐng)求寫鎖,那么B必須等待A先釋放讀鎖,才能占有寫鎖.同樣,如果線程A占有寫鎖,而線程B請(qǐng)求讀鎖,那么B必須等待A先釋放寫鎖,才能占有讀鎖.
這個(gè)規(guī)則是否跟我們這里的需求很相似呢?
于是,我們寫出了第二版的代碼:
這里我們將判斷是否已經(jīng)過去了一分鐘,以及需要執(zhí)行的對(duì)應(yīng)的操作,都放到了synchronized同步代碼塊中,這樣,當(dāng)執(zhí)行這個(gè)代碼塊的時(shí)候,因?yàn)槭谴胁僮?所以同一時(shí)間只能有一個(gè)線程能夠獲得寫鎖,并執(zhí)行相應(yīng)的操作.
而可以并行執(zhí)行的將數(shù)據(jù)寫入緩沖區(qū)的操作,我們給其加一個(gè)讀鎖,讓其并行執(zhí)行.
這里我們可以看到,當(dāng)一個(gè)線程A判斷已經(jīng)過去一分鐘,并要將數(shù)據(jù)寫入到數(shù)據(jù)庫時(shí),需要先獲取寫鎖,而要占有寫鎖,必須沒有線程持有讀鎖.即之前的所有請(qǐng)求已經(jīng)將數(shù)據(jù)都寫入了likeData這個(gè)緩沖區(qū)之后,讀鎖都釋放了之后,線程A才能占有寫鎖并執(zhí)行相應(yīng)的操作.這就解決了數(shù)據(jù)被意外清除的問題.
同樣,因?yàn)橐@得讀鎖來將數(shù)據(jù)寫入到緩沖區(qū)時(shí),必須先等待寫鎖的釋放,也就相當(dāng)于阻塞了之后到來的請(qǐng)求的寫數(shù)據(jù)操作,防止在獲得寫鎖并執(zhí)行操作的這段時(shí)間中,到來的請(qǐng)求意外的向緩沖區(qū)中寫入數(shù)據(jù)并最終被清空.
用一百個(gè)線程,各發(fā)送了九次請(qǐng)求,沒有發(fā)現(xiàn)問題.請(qǐng)求中的全部數(shù)據(jù),可以被正確的保存到數(shù)據(jù)庫中.
在上面我們使用循環(huán)來獲取讀鎖和寫鎖,其實(shí)還有更好的寫法,就是使用上面提到過的StampedLock.因?yàn)?strong>ReadWriteLock的tryLock()方法是立即返回的,所以我們需要通過while循環(huán),不斷地測試是否能夠獲得鎖.即使可以為其設(shè)置超時(shí)時(shí)間,也是極不方便的.如果設(shè)置的過小,我們無法保證在這個(gè)超時(shí)時(shí)間之內(nèi),會(huì)獲得鎖,如果設(shè)置的過大,又浪費(fèi)時(shí)間,降低了效率.而StampedLock中的獲得鎖的方法,是會(huì)阻塞當(dāng)前線程的.也就是說,如果獲取不到鎖,就會(huì)阻塞當(dāng)前線程,一直到獲取到.這種方式其實(shí)更好一些,減少了上面因?yàn)?strong>while循環(huán)中CPU空轉(zhuǎn)造成的資源浪費(fèi).
還有一種更簡單的方案,就是將全部的操作,都放在synchronized代碼塊中.這個(gè)應(yīng)該也很好理解.這里不再詳細(xì)敘述這種方案.
但是,使用這種方案,有一個(gè)致命缺陷,就是性能的問題.我們向緩沖區(qū)寫數(shù)據(jù)的操作是可以并行的,如果全都放在synchronized里面,就只能是串行的,那全部的請(qǐng)求都得一個(gè)個(gè)的串行處理,對(duì)性能是極大的消耗.
而我們使用讀寫鎖的方案,只是將判斷以及寫數(shù)據(jù)庫的操作放入到synchronized塊中,雖然是串行,但是相當(dāng)輕量級(jí).大多數(shù)情況下,實(shí)際上只有判斷這條語句是需要并行執(zhí)行的,匯編指令也就是三條.
上面的讀寫鎖的代碼中,synchronized的expression中,我們用的是一個(gè)用final修飾的,Integer類型的變量.它是不可變的.synchronized會(huì)取得** expression中的對(duì)象的monitor,將其當(dāng)做互斥條件,一個(gè)對(duì)象只有一個(gè)monitor與之對(duì)應(yīng),如果我們拿一個(gè)不是final的可變的對(duì)象來做expression,那么很可能并沒有被正確的同步,得到的結(jié)果也是不正確的.當(dāng)我用likeData這個(gè)ConcurrentHashMap對(duì)象時(shí),以及lastAggegate**這個(gè)變量時(shí),會(huì)出現(xiàn)錯(cuò)誤的結(jié)果.
上面我們的緩沖區(qū)用的是ConcurrentHashMap這個(gè)容器,這個(gè)容器在讀多寫少時(shí),性能很好,而我們現(xiàn)在是寫多讀少,跟它相反,雖然還沒有遇到什么性能問題,但是這里應(yīng)該選擇一個(gè)合適的適合寫多讀少的線程安全的Map.不知道有沒有.