前傳
面試官:什么是大事務?小林哥:就是 很大...的...事務??
二面的面試官來到來我的跟前,開始對我的簡歷進行了一番打量然后就開始了技術提問。
面試官: 看了下你在簡歷上邊有寫到過關於電商系統的設計,那我想深入問下你在電商系統設計的幾個問題哈。
小林: 好的。
面試官: 你們電商系統的每天的日活量大概在多少呢?
小林: 嗯,日活用戶數目在5萬左右,搞促銷活動的時候還會涉及到一些大流量的訪問。
面試官: 嗯嗯,那么接下來我問你幾個系統內部設計的場景吧。
小林: 嗯嗯。(表面風平浪靜,內心還是會有些慌張)
面試官:你剛才提到了促銷活動,那么在搞促銷活動之前,你們應該會有一些特殊的准備吧,能和我講幾個場景的實際案例嗎?
小林: 嗯嗯,我們的商品信息其實是存儲在mysql里面的,當進行促銷活動的時候需要進行一次預熱工作,將一些熱點數據加載到緩存層當中,減少對於實際mysql訪問的壓力。在緩存方面我之前一貫都是使用了redis來存儲數據,但是高峰時期對於redis的查詢依然是需要網絡消耗,有些特殊的業務場景需要在循環里面對redis做查詢(老舊代碼的原因,不推薦在工作中這么使用),因此這部分的模塊我加入了本地緩存作為優化手段。
面試官: 嗯嗯(就這??)
小林停頓了一會,看面試官似乎還覺得說得不夠,然后繼續回答接下來的內容點。
小林: 對於一些熱點數據而言,我們的本地緩存使用的是Guava Cache 技術,它提供了一個LoadingCache接口供開發者進行緩存數據的本地管理。當查詢數據不存在的時候會發生緩存命中失效,這時候可以通過定義內部的一些callable接口來實現對應的策略。
ps: 此時小林想起來自己以前剛學習guava cache技術時接觸的代碼:
//這種類型到好處在於 查詢數據的時候,如果數據不存在,那么就需要寫如何從內存里加載,每次查詢都需要做一個callable的處理 Cache<Object, Object> cache = CacheBuilder.newBuilder().build(); cache.put("k1","v1"); //如果對象數據不存在,則返回一個null值 Object v1=cache.getIfPresent("k1"); Object v2 = cache.get("k2",new Callable<Object>(){ @Override public Object call() throws Exception { System.out.println("該數值不存在,需要到redis去查詢"); return "k2"; } }); System.out.println(v1); System.out.println(v2);
面試官:如果每次查詢不了數據都需要在get的時候去重寫策略,豈不是很麻煩嗎?(其實面試官也用過這款技術,就是故意深入問問求職者是否有更多的了解內部構造)
小林:嗯嗯,其實可以在定義LoadingCache做一個全局性的callable回調操作處理,我腦海中還對這段代碼有印象,主要是通過cacheloader來做實現。
ps:此時一段熟悉的代碼模型從小林腦海中閃過。
LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder() .build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { // 當緩存沒有命中的時候,可以通過這里面的策略去加載數據信息 System.out.println("緩存命中失敗,需要查詢redis"); return "value"+key; } });
面試官: 嗯嗯,那你對於這些緩存算法有過相關研究嗎?可以講講自己的理解嗎?
小林: 嗯呢,常見的緩存隊列可以采用lru算法,所謂的lru其實本質的核心就在於:最近最久未使用的數據,可能在將來也不會再次使用,因此當緩存空間滿了之后就可以將其淘汰掉。簡單的實現思路可以用一條隊列來實現,當數組中的某個元素存在且再次被訪問的時候就會將其挪到鏈表的首位,如果查詢某些新元素發現在lru隊列里面沒有命中,則需要從db中查詢然后插入到隊列的首部。這樣能夠保持隊列里面的元素大多數場景下都是熱點元素,當隊列的體積占滿了之后,訪問最低頻率的元素就會從隊尾被擠出。
面試官: 嗯嗯,可以再深入講解下lru算法所存在的弊端嗎?(內心仿佛在說,就這?)
小林: 嗯嗯,lru算法其實存在這緩存污染的問題,例如說某次批量查詢操作,將一些平時用不到的數據也塞入到了隊列中,將一些真正的熱點數據給擠出了隊列,造成緩存污染現象。因此后邊就衍生出來了lru-k算法,其核心思想是給每個訪問的元素都標識一個訪問次數k值,這類算法需要多維護一條隊列(暫且稱之為訪問隊列),當數據的訪問次數超過了k次之后,才會從原先的訪問隊列中轉移到真正的lru隊列里面。這樣就能避免之前所說的緩存污染問題了,但是采用lru-k算法其實需要涉及到的算法復雜度,空間大小遠高於前邊提到的lru算法,這也是它的一個小”缺陷“吧。
面試官: 嗯嗯,好的,那關於緩存的回收策略你有了解過嗎?
小林: 嗯嗯,我在之前的工程中使用的guava-cache技術就是采用惰性回收策略的,當緩存的數據到達過期時間的時候不會去主動回收內存空間,而是在當程序有需要主動查詢數據的時候才會去做內存回收的檢測等相關操作。之所以不做主動回收的工作,我推測是因為自動回收程序的步驟對於cache自身需要維護的難度較高,所以改成了惰性回收策略,這一點和redis里的惰性回收策略有點類似,這種策略容易造成某些長期不使用的數據一直沒法回收,占用了大量的內存空間。
面試官: 嗯嗯,好的,那么這個面試點先到此告一段落吧,我再問下你其他的業務場景。
小林內心漸漸恢復平靜,一開始的那種焦慮和緊張感漸漸地消失了,又恢復了從前的那種淡定和從容。
小林: 好的。
面試官: 你們的訂單業務系統一般是怎么做分表操作的啊?可以介紹一下嗎?
小林:
嗯嗯,可以的,我們的訂單表每日的增加數目為5萬條數據左右,一個月左右訂單的數據量就會增加到100萬條左右的數據,因此我們通常每個月都會按照月為單位來做分表操作。在用戶的電商app界面上邊有個訂單查詢模塊,關於那塊的業務我也做過相關的開發。
通常我的訂單查詢的數據都是按照時間順序,先查詢最近的數據,再查詢之前的數據信息,結合前端做了分頁涉及的功能,會根據前端傳入的月份時間,來識別應該定位在哪張表進行查詢。通常來說近三個月時間內的訂單數據都是一些熱點數據,所以我們將前三個月的數據存在同一張表里面專門做優化。
關於后續幾個月的數據大多數情況下用戶自身並不會涉及到查詢功能,因此我們會定時將數據同步到es數據庫里面,如果后續需要涉及這塊的數據查詢,則走es數據庫。
關於訂單數據如何定時同步到到es這塊,相關的查詢邏輯圖如下所示:
這里面會有一個job去維護MySQL和ES之間的數據一致性。
面試官: 嗯嗯,那么你們的es和mysql之間是怎么做數據一致性的維護呢?
小林: 我們借助的是阿里的一款開源中間件做數據同步,結合了canal+mysql+rocketmq來進行實現的。
canal會模擬成一台mysql的slave去接收mysql的master節點返回的binlog信息,然后將這些binlog數據解析成一個json字符串,再投遞到mq當中。在rocketmq的接收端會做消息的監聽,一旦有接收到消息就會寫入到es中。
面試官: 嗯嗯,那么你能簡單講解下在這過程中遇到的困難嗎?
小林: 額,其實這一套環境在我入職的時候就已經搭建好來,我也只是大概知道有這么一個東西,具體的很多細節也並不是很熟悉....
(此時小林的內心再一次流下了沒有技術的眼淚.......)
面試官似乎有點失望,看了下項目,於是便切換了另一個問題進行詢問。
ps:
當我們使用canal進行binlog監聽的初始化時候,難免需要遇到一些全量同步和增量同步的問題,那么在這個先全量同步再轉換為增量同步的過渡期間該如何做到程序的自動化銜接呢?
關於這塊的設計方案可以參考下mysql內部是如何重建索引樹的思想。
在mysql進行索引樹重建的時候,會將原先表的所有數據都拷貝存入另外一張表,在拷貝的期間如果有新數據寫入表的話,會建立一份redo log文件將新寫入的數據存放進去,保證整個過程都是online的,因此這也被稱為Online DDL,redo log在這整個過程中就起到了一個類似緩沖池的角色。
同理在使用canal做日志訂閱的時候也可以借助這么一個“緩沖池”角色的幫助。這個緩沖池可以是一些分布式緩存,用於臨時接收數據,當全量同步完成之后,進入一個加鎖的狀態,此時將緩存中的數據也一同刷入到db中,最后釋放鎖。由於將redis中的數據刷入到磁盤中是個非常迅速的瞬間,因此整個過程可以看作為幾乎平滑無感知。
那么你可能也會有所疑惑,mysql表本身已經有初始化數據了,該如何全量將binlog都發送給到canal呢?其實方法有很多種,binlog的產生主要是依靠數據發生變動導致的,假設我們需要同步的表里面包含了update_time字段的話,這里只需要更新下全表的update_time字段為原先值+1 就可以產生出全表的binlog信息了。
整體的設計思路圖如下:
(可惜小林平時在工作中沒有對這塊做過多的梳理)
面試官:好吧,你在平時的工作中有遇到過一些jvm調優相關的內容嗎?
小林:嗯嗯,有的。
面試官:哦,太好了,可以講講你是怎么去做jvm調優的嗎?
小林:我們一般都搞不定,遇到jvm棧溢出的時候重啟並且增加機器就完事了。
面試官:...... 這確實是一種方案,能不能講些有價值點的思路呢?
小林:嗯嗯,我之前有學習了解到過,Java虛擬機將堆內存划分為新生代、老年代和永久代。
圖片
l通常來講,我們代年輕代對象會被存放在eden區域里面,這塊區域當內存占比會相對於survivor區要大一些,大部分當對象在經歷一次Minor GC之后都被銷毀了,剩余當對象會被分入到survivor區里面,然后在survivor區進入新的垃圾回收,當回收當次數超過了閾值之后(默認是15次),對象就會從年輕代中晉升到老年代。當然如果說survivor區中相同年齡的對象體積大小之和大於了survivor區中一半的空間,那么此時對象也會直接晉升到老年代中。哦對了,jdk8
之后還多出來了一個叫做元空間的概念。
小林非常流暢地將自己對於jvm的理解講了出來,感覺自己的這番回答似乎很滿意。可是別小瞧對方面試官,人家畢竟是有過十多年經驗的大佬啊。
面試官:嗯嗯,能深入介紹下嗎你對於垃圾收集器使用方面的一些經驗總結嗎?
小林:額....這塊就不是很熟系了
ps:
對於 JVM 調優來說,主要是 JVM 垃圾收集的優化,一般來說是因為有問題才需要優化,所以對於 JVM GC 來說,如果當我們發現線上的gc回收頻率變得頻繁之后,就是需要進行jvm調優的時候了。而對於jvm的垃圾回收而言,應該是針對不同的垃圾收集器來做優化調整。
例如說cms垃圾收集器,這塊收集器主要是將jvm分為了年輕代,老年代。在年輕代采用了復制整理算法,在老年代使用的是標記清除算法,再其進行標記對象的時候會發生stw的情況。而在jdk9之后,可以看出一定的趨勢,G1回收算法開始在漸漸占領位置,由於以前的分區將jvm的各個模塊eden,survivor區都划分地過大了,因此G1將jvm的區域划分為了多個零散的region,將原先連續固定的eden區和survivor區給拆解開來分割成多個小模塊,這樣一來垃圾回收的停頓時長就會大大降低,減少stw機制造成的影響。
對於 CMS 收集器來說,最重要的是合理地設置年輕代和年老代的大小。年輕代太小的話,會導致頻繁的 Minor GC,並且很有可能存活期短的對象也不能被回收,GC 的效率就不高。
對於 G1 收集器來說,不是太推薦直接設置年輕代的大小,這一點跟 CMS 收集器不一樣,這是因為 G1 收集器會根據算法動態決定年輕代和年老代的大小。因此對於 G1 收集器,我們更加需要關心的是 Java 堆的總大小(-Xmx)。
面試官: 好吧,那今天的面試就先這樣告一段落吧。
小林: 嗯嗯,我還有機會嗎...
面試官: 我覺得你后邊可以進步和提升的空間還有很大(意思是你太菜了),可以再學習學習哈。
聽完此話后,小林留下了沒有技術的淚水....,唉,看來這個工作還是得繼續找啊....
后來,小林問了下以前大學同學,打探到了新的內推崗位....