觀察者模式
當(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)點: 1、觀察者和被觀察者是抽象耦合的嘶摊。 2延蟹、建立一套觸發(fā)機(jī)制。
缺點: 1叶堆、如果一個被觀察者對象有很多的直接和間接的觀察者的話阱飘,將所有的觀察者都通知到會花費很多時間。 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ī)制是一項很有用的功能散址,使用事件機(jī)制我們可以將相互耦合的代碼解耦,從而方便功能的修改與添加宣赔。本文我來學(xué)習(xí)并分析一下Spring中事件的原理预麸。
舉個例子,假設(shè)有一個添加評論的方法儒将,在評論添加成功之后需要進(jìn)行修改redis緩存吏祸、給用戶添加積分等等操作。當(dāng)然可以在添加評論的代碼后面假設(shè)這些操作钩蚊,但是這樣的代碼違反了設(shè)計模式的多項原則:單一職責(zé)原則贡翘、迪米特法則、開閉原則砰逻。一句話說就是耦合性太大了鸣驱,比如將來評論添加成功之后還需要有另外一個操作,這時候我們就需要去修改我們的添加評論代碼了诱渤。
在以前的代碼中丐巫,我使用觀察者模式來解決這個問題。不過Spring中已經(jīng)存在了一個升級版觀察者模式的機(jī)制勺美,這就是監(jiān)聽者模式。通過該機(jī)制我們就可以發(fā)送接收任意的事件并處理碑韵。
Spring 官方文檔翻譯如下 :
ApplicationContext 通過 ApplicationEvent 類和 ApplicationListener 接口進(jìn)行事件處理赡茸。 如果將實現(xiàn) ApplicationListener 接口的 bean 注入到上下文中,則每次使用 ApplicationContext 發(fā)布 ApplicationEvent 時祝闻,都會通知該 bean占卧。 本質(zhì)上遗菠,這是標(biāo)準(zhǔn)的觀察者設(shè)計模式。
Spring的事件(Application Event)其實就是一個觀察者設(shè)計模式华蜒,一個 Bean 處理完成任務(wù)后希望通知其它 Bean 或者說 一個Bean 想觀察監(jiān)聽另一個Bean的行為辙纬。
Spring 事件只需要幾步:
自定義事件,繼承 ApplicationEvent
定義監(jiān)聽器叭喜,實現(xiàn) ApplicationListener 或者通過 @EventListener 注解到方法上
定義發(fā)布者贺拣,通過 ApplicationEventPublisher
實際代碼:
創(chuàng)建event文件夾
并創(chuàng)建event object類和handle類,一個handle類可以對應(yīng)多個object類捂蕴。
@Data
@AllArgsConstructor
@NoArgsConstructor
public class EverydayStatisticEventObject {
private Integer id;
private String os;
private String proxy;
private StatisticEventType statisticEventType;
}
創(chuàng)建枚舉類 處理不同的事件類型譬涡,運用觀察者模式
public enum StatisticEventType {
//注冊數(shù)統(tǒng)計
REGISTER_COUNTER,
//活躍數(shù)統(tǒng)計
ACTIVE_COUNTER,
//裂變數(shù)統(tǒng)計
FISSION_COUNTER,
//播放數(shù)統(tǒng)計
PLAYED_COUNTER,
//廣告點擊數(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)部實現(xiàn)采用LRU算法,基于引用回收很好的利用了Java虛擬機(jī)的垃圾回收機(jī)制级乍。其中的緩存構(gòu)造器CacheBuilder采用構(gòu)建者模式提供了設(shè)置好各種參數(shù)的緩存對象拾酝,緩存核心類LocalCache里面的內(nèi)部類Segment與jdk1.7及以前的ConcurrentHashMap非常相似,都繼承于ReetrantLock卡者,還有六個隊列蒿囤,以實現(xiàn)豐富的本地緩存方案。
Guava Cache與ConcurrentMap的區(qū)別
Guava Cache與ConcurrentMap很相似崇决,但也不完全一樣材诽。最基本的區(qū)別是ConcurrentMap會一直保存所有添加的元素,直到顯式地移除恒傻。相對地脸侥,Guava Cache為了限制內(nèi)存占用,通常都設(shè)定為自動回收元素盈厘。在某些場景下睁枕,盡管LoadingCache 不回收元素,它也是很有用的沸手,因為它會自動加載緩存外遇。
//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,也是可以表示相同的含義的,比如
可以看到,對應(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:
這代表了用戶(1,3,5)今天簽到了.
當(dāng)然還有昨天的位圖,
B:
這代表了用戶(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ōu)點: 支持與或運算且效率高.
哇,這么完美,那么哪里可以買到呢?,那么有什么缺點呢?
不足
當(dāng)然有,位圖不能很方便的支持非運算,(當(dāng)然,關(guān)系型數(shù)據(jù)庫支持的也不好).這句話可能有點難理解.繼續(xù)舉個例子:
我們想查詢今天沒有簽到的用戶,直接對位圖進(jìn)行取非是不可以的.
對今天簽到的位圖取非得到的結(jié)果如下:
這意味著今天(0,2,4,6,7)用戶沒有簽到嗎?不是的,存在沒有7(任意數(shù)字)號用戶的情況,或者他注銷了呢.
這是因為位圖只能表示布爾信息,即true/false.他在這個位圖中,表示的是XX用戶今天有簽到或者沒有簽到,但是不能額外的表達(dá),xx用戶存在/不存在這個狀態(tài)了.
但是我們可以曲線救國,首先搞一個全集用戶的位圖.比如:
全集:
然后用全集的位圖和簽到的位圖做異或操作,相同則為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 ^ 全集:
此外,位圖對于稀疏數(shù)據(jù)的表現(xiàn)不是很好,(當(dāng)然聰明的大佬們已經(jīng)基本解決掉了這個問題).原生的位圖來講,如果我們只有兩個用戶,1號和100000000號用戶,那么直接存儲int需要8個字節(jié)也就是32個bit,而用位圖存儲需要1億個bit.當(dāng)數(shù)據(jù)量少,且跨度極大也就是稀疏的時候,原生的位圖不太適合.
點擊這里跳轉(zhuǎn)到稀疏數(shù)據(jù)的解決方案
總結(jié)
那么我們來做一下總結(jié):
位圖是用二進(jìn)制位來存儲整形數(shù)據(jù)的一種數(shù)據(jù)結(jié)構(gòu),在很多方面都有應(yīng)用,尤其是在大數(shù)據(jù)量的場景下,節(jié)省內(nèi)存及提高運算效率十分實用.
他的優(yōu)點有:
節(jié)省內(nèi)存.
-> 因此在大數(shù)據(jù)量的時候更加顯著.
與或運算效率高.
->可以快速求交集和并集.
缺點有:
不能直接進(jìn)行非運算.
-> 根本原因是位圖只能存儲一個布爾信息,信息多了就需要借助全量集合等數(shù)據(jù)輔助.
數(shù)據(jù)稀疏時浪費空間.
-> 這個不用很擔(dān)心,后面會講到大佬們的解法,基本可以解決掉.
只能存儲布爾類型.
-> 有限制,但是業(yè)務(wù)中很多數(shù)據(jù)都可以轉(zhuǎn)換為布爾類型.比如上面的例子中, 業(yè)務(wù)原意:用戶每天的簽到記錄,以用戶為維度. 我們可以轉(zhuǎn)換為: 每天的每個用戶是否簽到,就變?yōu)榱瞬紶栴愋偷臄?shù)據(jù).
應(yīng)用場景
應(yīng)用場景其實是很考驗人的,不能學(xué)以致用,在程序員行業(yè)里基本上就相當(dāng)于沒有學(xué)了吧…
經(jīng)過自己的摸索以及在網(wǎng)上的瀏覽,大致見到了一些應(yīng)用場景,粗略的寫出來,方便大家理解并且以后遇到類似的場景可以想到位圖并應(yīng)用他!
用戶簽到/搶購等唯一限制
用戶簽到每天只能一次,搶購活動中只能購買一件,這些需求導(dǎo)致的有一種查詢請求,給定的id做沒做過某事.而且一般這種需求都無法接受你去查庫的延遲.當(dāng)然你查一次庫之后在redis中寫入:key = 2345 , value = 簽到過了.也是可以實現(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ù)庫的,可以用來做實時的接口查詢等.
用戶標(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)簽存儲為一個位圖(邏輯上,實際上你還可以按照尾號分開等等操作),在需要的時間進(jìn)行快速的統(tǒng)計及計算. 如:
可以清晰的統(tǒng)計出,0,3,6用戶喜歡旅游.
用戶0,1,6是00后.
那么對兩個位圖取與即可得到愛旅游的00后用戶為0,6.
大家都知道的是一個字節(jié)用的是8個二進(jìn)制位來存儲的捐晶,也就是8個0或者1菲语,即一個字節(jié)可以存儲十進(jìn)制0~127的數(shù)字妄辩,也即包含了所有的數(shù)字、英文大小寫字母以及標(biāo)點符號山上。
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)”可以實現(xiàn)對位的操作澈吨。Bitmaps本身不是一種數(shù)據(jù)結(jié)構(gòu),實際上就是字符串寄摆,但是它可以對字符串的位進(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.各種實時分析(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ù)!
用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ù)量杖玲。
優(yōu)點: 占用內(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;
});
}