仿微博社交平臺系統(tǒng)設(shè)計[四]--使用springevent事件驅(qū)動模型(觀察者模式)結(jié)合redis bitmap 運(yùn)用 實(shí)現(xiàn)每日數(shù)據(jù)統(tǒng)計

觀察者模式

當(dāng)對象間存在一對多關(guān)系時,則使用觀察者模式(Observer Pattern)。比如,當(dāng)一個對象被修改時,則會自動通知它的依賴對象。觀察者模式屬于行為型模式。

主要解決:一個對象狀態(tài)改變給其他對象通知的問題,而且要考慮到易用和低耦合,保證高度的協(xié)作。

何時使用:一個對象(目標(biāo)對象)的狀態(tài)發(fā)生改變,所有的依賴對象(觀察者對象)都將得到通知,進(jìn)行廣播通知。

如何解決:使用面向?qū)ο蠹夹g(shù),可以將這種依賴關(guān)系弱化。

優(yōu)點(diǎn): 1、觀察者和被觀察者是抽象耦合的。 2、建立一套觸發(fā)機(jī)制。

缺點(diǎn): 1、如果一個被觀察者對象有很多的直接和間接的觀察者的話,將所有的觀察者都通知到會花費(fèi)很多時間。 2、如果在觀察者和觀察目標(biāo)之間有循環(huán)依賴的話,觀察目標(biāo)會觸發(fā)它們之間進(jìn)行循環(huán)調(diào)用,可能導(dǎo)致系統(tǒng)崩潰。 3、觀察者模式?jīng)]有相應(yīng)的機(jī)制讓觀察者知道所觀察的目標(biāo)對象是怎么發(fā)生變化的,而僅僅只是知道觀察目標(biāo)發(fā)生了變化。

Spring Boot 之事件(Event)

Spring的事件通知機(jī)制是一項(xiàng)很有用的功能,使用事件機(jī)制我們可以將相互耦合的代碼解耦,從而方便功能的修改與添加。本文我來學(xué)習(xí)并分析一下Spring中事件的原理。

舉個例子,假設(shè)有一個添加評論的方法,在評論添加成功之后需要進(jìn)行修改redis緩存、給用戶添加積分等等操作。當(dāng)然可以在添加評論的代碼后面假設(shè)這些操作,但是這樣的代碼違反了設(shè)計模式的多項(xiàng)原則:單一職責(zé)原則、迪米特法則、開閉原則。一句話說就是耦合性太大了,比如將來評論添加成功之后還需要有另外一個操作,這時候我們就需要去修改我們的添加評論代碼了。

在以前的代碼中,我使用觀察者模式來解決這個問題。不過Spring中已經(jīng)存在了一個升級版觀察者模式的機(jī)制,這就是監(jiān)聽者模式。通過該機(jī)制我們就可以發(fā)送接收任意的事件并處理。

Spring 官方文檔翻譯如下 :

ApplicationContext 通過 ApplicationEvent 類和 ApplicationListener 接口進(jìn)行事件處理。 如果將實(shí)現(xiàn) ApplicationListener 接口的 bean 注入到上下文中,則每次使用 ApplicationContext 發(fā)布 ApplicationEvent 時,都會通知該 bean。 本質(zhì)上,這是標(biāo)準(zhǔn)的觀察者設(shè)計模式。

Spring的事件(Application Event)其實(shí)就是一個觀察者設(shè)計模式,一個 Bean 處理完成任務(wù)后希望通知其它 Bean 或者說 一個Bean 想觀察監(jiān)聽另一個Bean的行為。

Spring 事件只需要幾步:

自定義事件,繼承 ApplicationEvent
定義監(jiān)聽器,實(shí)現(xiàn) ApplicationListener 或者通過 @EventListener 注解到方法上
定義發(fā)布者,通過 ApplicationEventPublisher

實(shí)際代碼:

創(chuàng)建event文件夾

并創(chuàng)建event object類和handle類,一個handle類可以對應(yīng)多個object類。


image.png
@Data
@AllArgsConstructor
@NoArgsConstructor
public class EverydayStatisticEventObject {
 
  private Integer id;
 
  private String os;
 
  private String proxy;
 
  private StatisticEventType statisticEventType;
 
}

創(chuàng)建枚舉類 處理不同的事件類型,運(yùn)用觀察者模式

public enum StatisticEventType {
   
  //注冊數(shù)統(tǒng)計
  REGISTER_COUNTER,
  //活躍數(shù)統(tǒng)計
  ACTIVE_COUNTER,
  //裂變數(shù)統(tǒng)計
  FISSION_COUNTER,
  //播放數(shù)統(tǒng)計
  PLAYED_COUNTER,
  //廣告點(diǎn)擊數(shù)統(tǒng)計
  ADCLICK_COUNTER;
 
  private StatisticEventType() {
  }
}

在事務(wù)service類中注入

@Autowired
  private ApplicationEventPublisher publisher;

處理完相應(yīng)的業(yè)務(wù)邏輯后,調(diào)取publish操作,將事務(wù)發(fā)布出去

其一

public LoginLog increaseLoginLog(String ip, int uid, String username) {
    User user = mixinsService.getUser(uid);
    LoginLog loginLog = new LoginLog();
    loginLog.setLoginIp(ip);
    loginLog.setLoginTime(new Date());
    loginLog.setUid(uid);
    loginLog.setUsername(username);
    loginLog.setProxy(user.getProxy());
    loginLog.setChannel(user.getChannel());
    loginLog.setUserType(user.getUserType());
    loginLog.setOs(user.getOs());
    LoginLog log = loginLogRepository.save(loginLog);
    
    //發(fā)布事件
    publisher.publishEvent(new EverydayStatisticEventObject(log.getUid(), log.getOs(), log.getProxy(),StatisticEventType.ACTIVE_COUNTER));
    ChannelDailyDataManager.fireEvent(new UserActiveEvent(user.getChannel()));
    return log;
  }

Google Guava Cache緩存

Google Guava Cache是一種非常優(yōu)秀本地緩存解決方案,提供了基于容量,時間和引用的緩存回收方式?;谌萘康姆绞絻?nèi)部實(shí)現(xiàn)采用LRU算法,基于引用回收很好的利用了Java虛擬機(jī)的垃圾回收機(jī)制。其中的緩存構(gòu)造器CacheBuilder采用構(gòu)建者模式提供了設(shè)置好各種參數(shù)的緩存對象,緩存核心類LocalCache里面的內(nèi)部類Segment與jdk1.7及以前的ConcurrentHashMap非常相似,都繼承于ReetrantLock,還有六個隊(duì)列,以實(shí)現(xiàn)豐富的本地緩存方案。

Guava Cache與ConcurrentMap的區(qū)別

Guava Cache與ConcurrentMap很相似,但也不完全一樣。最基本的區(qū)別是ConcurrentMap會一直保存所有添加的元素,直到顯式地移除。相對地,Guava Cache為了限制內(nèi)存占用,通常都設(shè)定為自動回收元素。在某些場景下,盡管LoadingCache 不回收元素,它也是很有用的,因?yàn)樗鼤詣蛹虞d緩存。

//bitmap的偏移量offset生產(chǎn),offset越大,占用內(nèi)存越多,所以以每日第一個id作為minid,作為被減數(shù)
//使用guava cache緩存機(jī)制獲取最小id,設(shè)置過期時間為每一天,每天清空一次
private LoadingCache<String, Integer> minId = CacheBuilder.newBuilder().expireAfterWrite(1L, TimeUnit.DAYS).build(new CacheLoader<String, Integer>() {
    @Override
    public Integer load(String s) throws Exception {
      Date date = LocalDate.parse(StringUtils.substringAfter(s, "@")).toDate();
      if (ACTIVE_COUNTER.startsWith(s)) {
        LoginLog loginLog = loginLogRepository.getTopByLoginTimeBeforeOrderByIdDesc(date);
        if (loginLog != null) {
          return loginLog.getId();
        }
      } else if (PLAYED_COUNTER.startsWith(s)) {
        ViewHistory viewHistory = viewHistoryRepository.getTopByViewtimeBeforeOrderByIdDesc(date);
        if (viewHistory != null) {
          return viewHistory.getId();
        }
      } else if (ADCLICK_COUNTER.startsWith(s)) {
        AdvClickHistory advClickHistory = advClickHistoryRepository.getTopByCreateTimeBeforeOrderByIdDesc(date);
        if (advClickHistory != null) {
          return advClickHistory.getId();
        }
      }
      return 0;
    }
  });

位圖的基本介紹

概念

什么是位圖?BitMap,大家直譯為位圖. 我的理解是:位圖是內(nèi)存中連續(xù)的二進(jìn)制位(bit),可以用作對大量整形做去重和統(tǒng)計.

引入一個小栗子來幫助理解一下:

假如我們要存儲三個int數(shù)字 (1,3,5),在java中我們用一個int數(shù)組來存儲,那么占用了12個字節(jié).但是我們申請一個bit數(shù)組的話.并且把相應(yīng)下標(biāo)的位置為1,也是可以表示相同的含義的,比如

截屏2021-06-10 下午5.42.57.png

可以看到,對應(yīng)于1,3,5為下標(biāo)的bit上的值為1,我們或者計算機(jī)也是可以get到1,3,5這個信息的.

優(yōu)勢

那么這么做有什么好處呢?感覺更麻煩了鴨,下面這種存儲方式,在申請了bit[8]的場景下才占用了一個字節(jié),占用內(nèi)存是原來的12分之一,當(dāng)數(shù)據(jù)量是海量的時候,比如40億個int,這時候節(jié)省的就是10幾個G的內(nèi)存了.

這就引入了位圖的第一個優(yōu)勢,占用內(nèi)存小.

再想一下,加入我們現(xiàn)在有一個位圖,保存了用戶今天的簽到數(shù)據(jù).下標(biāo)可以是用戶的ID.

A:


截屏2021-06-10 下午5.43.41.png

這代表了用戶(1,3,5)今天簽到了.

當(dāng)然還有昨天的位圖,

B:


截屏2021-06-10 下午5.44.00.png

這代表了用戶(1,2,3,7)昨天簽到了.

我們現(xiàn)在想求:

昨天和今天都簽到的用戶.
昨天或者今天簽到的用戶.
在關(guān)系型數(shù)據(jù)庫中存儲的話,這將是一個比較麻煩的操作,要么要寫一些表意不明的SQL語句,要么進(jìn)行兩次查詢,然后在內(nèi)存中雙重循環(huán)去判斷.

而使用位圖就很簡單了,A & B, A | B 即可.上面的操作明顯是一個集合的與或操作,而二進(jìn)制天然就支持邏輯操作,且眾所周知貓是液體.錯了,眾多周知是計算機(jī)進(jìn)行二進(jìn)制運(yùn)算的效率很高.

這就是位圖的第二個優(yōu)點(diǎn): 支持與或運(yùn)算且效率高.

哇,這么完美,那么哪里可以買到呢?,那么有什么缺點(diǎn)呢?

不足

當(dāng)然有,位圖不能很方便的支持非運(yùn)算,(當(dāng)然,關(guān)系型數(shù)據(jù)庫支持的也不好).這句話可能有點(diǎn)難理解.繼續(xù)舉個例子:

我們想查詢今天沒有簽到的用戶,直接對位圖進(jìn)行取非是不可以的.

對今天簽到的位圖取非得到的結(jié)果如下:


截屏2021-06-10 下午5.44.42.png

這意味著今天(0,2,4,6,7)用戶沒有簽到嗎?不是的,存在沒有7(任意數(shù)字)號用戶的情況,或者他注銷了呢.

這是因?yàn)槲粓D只能表示布爾信息,即true/false.他在這個位圖中,表示的是XX用戶今天有簽到或者沒有簽到,但是不能額外的表達(dá),xx用戶存在/不存在這個狀態(tài)了.

但是我們可以曲線救國,首先搞一個全集用戶的位圖.比如:

全集:


截屏2021-06-10 下午5.45.10.png

然后用全集的位圖和簽到的位圖做異或操作,相同則為0,不相同則為1.

在業(yè)務(wù)的邏輯為: 用戶存在和是否簽到兩個bool值,共四種組合.

用戶存在,且簽到了. 兩個集合的對應(yīng)位都為1,那么結(jié)果就為0.
用戶存在,但是沒簽到. 全集對應(yīng)位為1,簽到為0,所以結(jié)果是1.
用戶不存在,那么必然沒可能簽到, 兩個集合的對應(yīng)位都是0,結(jié)果為0.
所以結(jié)果中,為1的只有一種可能:用戶存在且沒有簽到,正好是我們所求的結(jié)果.

A ^ 全集:


截屏2021-06-10 下午5.45.36.png

此外,位圖對于稀疏數(shù)據(jù)的表現(xiàn)不是很好,(當(dāng)然聰明的大佬們已經(jīng)基本解決掉了這個問題).原生的位圖來講,如果我們只有兩個用戶,1號和100000000號用戶,那么直接存儲int需要8個字節(jié)也就是32個bit,而用位圖存儲需要1億個bit.當(dāng)數(shù)據(jù)量少,且跨度極大也就是稀疏的時候,原生的位圖不太適合.

點(diǎn)擊這里跳轉(zhuǎn)到稀疏數(shù)據(jù)的解決方案

總結(jié)
那么我們來做一下總結(jié):

位圖是用二進(jìn)制位來存儲整形數(shù)據(jù)的一種數(shù)據(jù)結(jié)構(gòu),在很多方面都有應(yīng)用,尤其是在大數(shù)據(jù)量的場景下,節(jié)省內(nèi)存及提高運(yùn)算效率十分實(shí)用.

他的優(yōu)點(diǎn)有:

節(jié)省內(nèi)存.
-> 因此在大數(shù)據(jù)量的時候更加顯著.

與或運(yùn)算效率高.
->可以快速求交集和并集.

缺點(diǎn)有:

不能直接進(jìn)行非運(yùn)算.
-> 根本原因是位圖只能存儲一個布爾信息,信息多了就需要借助全量集合等數(shù)據(jù)輔助.

數(shù)據(jù)稀疏時浪費(fèi)空間.
-> 這個不用很擔(dān)心,后面會講到大佬們的解法,基本可以解決掉.

只能存儲布爾類型.
-> 有限制,但是業(yè)務(wù)中很多數(shù)據(jù)都可以轉(zhuǎn)換為布爾類型.比如上面的例子中, 業(yè)務(wù)原意:用戶每天的簽到記錄,以用戶為維度. 我們可以轉(zhuǎn)換為: 每天的每個用戶是否簽到,就變?yōu)榱瞬紶栴愋偷臄?shù)據(jù).

應(yīng)用場景

應(yīng)用場景其實(shí)是很考驗(yàn)人的,不能學(xué)以致用,在程序員行業(yè)里基本上就相當(dāng)于沒有學(xué)了吧…
經(jīng)過自己的摸索以及在網(wǎng)上的瀏覽,大致見到了一些應(yīng)用場景,粗略的寫出來,方便大家理解并且以后遇到類似的場景可以想到位圖并應(yīng)用他!

用戶簽到/搶購等唯一限制

用戶簽到每天只能一次,搶購活動中只能購買一件,這些需求導(dǎo)致的有一種查詢請求,給定的id做沒做過某事.而且一般這種需求都無法接受你去查庫的延遲.當(dāng)然你查一次庫之后在redis中寫入:key = 2345 , value = 簽到過了.也是可以實(shí)現(xiàn)的,但是內(nèi)存占用太大.

而使用位圖之后,當(dāng)2345用戶簽到過/搶購過之后,在redis中調(diào)用setbit 2019-07-01-簽到 2345 1即可,之后用戶的每次簽到/搶購請求進(jìn)來,只需要執(zhí)行相應(yīng)的getbit即可拿到是否放行的bool值.

這樣記錄,不僅可以節(jié)省空間,以及加快訪問速度之外,還可以提供一些額外的統(tǒng)計功能,比如調(diào)用bitcount來統(tǒng)計今天簽到總?cè)藬?shù)等等.統(tǒng)計速度一般是優(yōu)于關(guān)系型數(shù)據(jù)庫的,可以用來做實(shí)時的接口查詢等.

用戶標(biāo)簽等數(shù)據(jù)

大數(shù)據(jù)已經(jīng)很普遍了,用戶畫像大家也都在做,這時候需要根據(jù)標(biāo)簽分類用戶,進(jìn)行存儲.方便后續(xù)的推薦等操作.

而用戶及標(biāo)簽的數(shù)據(jù)結(jié)構(gòu)設(shè)計是一件比較麻煩的事情,且很容易造成查詢性能太低.同時,對多個標(biāo)簽經(jīng)常需要進(jìn)行邏輯操作,比如喜歡電子產(chǎn)品的00后用戶有哪些,女性且愛旅游的用戶有哪些等等,這在關(guān)系型數(shù)據(jù)庫中都會造成處理的困難.

可以使用位圖來進(jìn)行存儲,每一個標(biāo)簽存儲為一個位圖(邏輯上,實(shí)際上你還可以按照尾號分開等等操作),在需要的時間進(jìn)行快速的統(tǒng)計及計算. 如:


截屏2021-06-10 下午5.47.05.png

可以清晰的統(tǒng)計出,0,3,6用戶喜歡旅游.


截屏2021-06-10 下午5.47.19.png

用戶0,1,6是00后.

那么對兩個位圖取與即可得到愛旅游的00后用戶為0,6.

大家都知道的是一個字節(jié)用的是8個二進(jìn)制位來存儲的,也就是8個0或者1,即一個字節(jié)可以存儲十進(jìn)制0~127的數(shù)字,也即包含了所有的數(shù)字、英文大小寫字母以及標(biāo)點(diǎn)符號。

1Byte=8bit

1KB=1024Byte

1MB=1024KB

1GB=1024MB

位數(shù)組在redis存儲世界里,每一個字節(jié)也是8位,初始都是:

0 0 0 0 0 0 0 0

而位操作就是在對應(yīng)的offset偏移量上設(shè)置0或者1,比如將第3位設(shè)置為1,即:

0 0 0 0 1 0 0 0
#對應(yīng)redis操作即:
setbit key 3 1
在此基礎(chǔ)上,如果要在偏移量為13的位置設(shè)置1,即:

setbit key 13 1
#對應(yīng)redis中的存儲為:
0 0 1 0 | 0 0 0 0 | 0 0 0 0 | 1 0 0 0

Bitmaps介紹

Redis提供的Bitmaps這個“數(shù)據(jù)結(jié)構(gòu)”可以實(shí)現(xiàn)對位的操作。Bitmaps本身不是一種數(shù)據(jù)結(jié)構(gòu),實(shí)際上就是字符串,但是它可以對字符串的位進(jìn)行操作。
可以把Bitmaps想象成一個以位為單位數(shù)組,數(shù)組中的每個單元只能存0或者1,數(shù)組的下標(biāo)在bitmaps中叫做偏移量。
單個bitmaps的最大長度是512MB,即2^32個比特位。
bitmaps的最大優(yōu)勢是節(jié)省存儲空間。例如,在一個以自增id代表不同用戶的系統(tǒng)中,我們只需要512MB空間就可以記錄40億用戶的某個單一信息(比如,用戶是否希望接收新聞郵件)。

Bitmaps使用場景

1.各種實(shí)時分析(Real time analytics of all kinds)。
2.存儲與對象ID關(guān)聯(lián)的布爾信息,要求高效且高性能(Storing space efficient but high performance boolean information associated with object IDs.)。

Bitmaps常用命令

1.設(shè)置值

命令:setbit key offset value

setbit命令接收兩個參數(shù),

第一個參數(shù)表示你要操作的是第幾個bit位,第二個參數(shù)表示你要將這個位設(shè)為何值,可選值只有0,1兩個。
如果所操作的bit位超過了當(dāng)前字串的長度,reids會自動增大字串長度。

2 獲取值

命令:getbit key offset

getbit只是返回特定bit位的值。如果試圖獲取的bit位在當(dāng)前字串長度范圍外,該命令返回0。

3 獲取Bitmaps指定范圍值為1的個數(shù)

命令:bitcount key [start] [end]
查看某一天是否有打卡!


統(tǒng)計操作,統(tǒng)計打卡的天數(shù)!


image.png

用Redis bitmap統(tǒng)計活躍用戶、留存

對于個int型的數(shù)來說,若用來記錄id,則只能記錄一個,而若轉(zhuǎn)換為二進(jìn)制存儲,則可以表示32個,空間的利用率提升了32倍.對于海量數(shù)據(jù)的處理,這樣的存儲方式會節(jié)省很多內(nèi)存空間.對于未登陸的用戶,可以使用Hash算法,把對應(yīng)的用戶標(biāo)識哈希為一個數(shù)字id.對于一億個數(shù)據(jù)來說,我們也只需要1000000000/8/1024/1024大約12M空間左右.

而Redis已經(jīng)為我們提供了SETBIT的方法,使用起來非常的方便,我們在item頁面可以不停地使用SETBIT命令,設(shè)置用戶已經(jīng)訪問了該頁面,也可以使用GETBIT的方法查詢某個用戶是否訪問。最后通過BITCOUNT統(tǒng)計該網(wǎng)頁每天的訪問數(shù)量。


image.png

優(yōu)點(diǎn): 占用內(nèi)存更小,查詢方便,可以指定查詢某個用戶,對于非登陸的用戶,可能不同的key映射到同一個id,否則需要維護(hù)一個非登陸用戶的映射,有額外的開銷。

//使用觀察者模式,根據(jù)不同的type來判斷不同的事務(wù)
public String progressChanged(EverydayStatisticEventObject registerEventObject) {
    String Type = "";
    StatisticEventType eventType = registerEventObject.getStatisticEventType();
    switch (eventType) {
      case REGISTER_COUNTER:
        Type = REGISTER_COUNTER;
        break;
      case ACTIVE_COUNTER:
        Type = ACTIVE_COUNTER;
        break;
      case FISSION_COUNTER:
        Type = FISSION_COUNTER;
        break;
      case PLAYED_COUNTER:
        Type = PLAYED_COUNTER;
        break;
      case ADCLICK_COUNTER:
        Type = ADCLICK_COUNTER;
        break;
      default:
        break;
    }
    return Type;
  }
 
  //事件監(jiān)聽器
  //異步
  @EventListener
  @Async
  public void registerCountEvent(EverydayStatisticEventObject registerEventObject) {
 
 
    String date = LocalDate.now().toString(STATISTIC_DATE_FMT);
    String type = progressChanged(registerEventObject);
    
    //數(shù)據(jù)庫主鍵id 減去當(dāng)天第一個id 這樣每天的偏移量都是從一開始可以有效減少偏移量對內(nèi)存的占用。
    int offset = registerEventObject.getId() + 1 - minId.getUnchecked(StringUtils.join(type, "@", date));
 
    String key = StringUtils.join(STATISTIC_CACHE_KEY_PREFIX, type,
      date, ":", registerEventObject.getOs());
 
 
    setBitmap(offset, key);
 
    String proxyKey = StringUtils.join(STATISTIC_CACHE_KEY_PREFIX, type,
      date, ":", registerEventObject.getProxy(), ":", registerEventObject.getOs());
 
    setBitmap(offset, proxyKey);
 
        
       /* redisTemplate.execute((RedisCallback) connection -> {
            Long count = connection.bitCount(key.getBytes());
            log.info("key={},count = {},offset={}",key,count,offset);
            return true;
        });
        redisTemplate.execute((RedisCallback) connection -> {
            Long count = connection.bitCount(proxyKey.getBytes());
            log.info("proxyKey={},count = {},offset={}",proxyKey,count,offset);
            return true;
        });*/
  }
 
private void setBitmap(int offset, String key) {
 
    byte[] bitKey = key.getBytes();
 
    redisTemplate.execute((RedisCallback) connection -> {
      boolean exists = connection.getBit(bitKey, offset);
      if (!exists) {
        connection.setBit(bitKey, offset, true);
        //設(shè)置過期時間 每天的數(shù)據(jù)統(tǒng)計 只保留2天
        connection.expire(bitKey, 60L * 60 * 24 * 2);  //2 days
        return true;
      }
      return false;
    });
  }
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容