接上篇 《服務假死問題解決過程實記(二)—— C3P0 數據庫連接池配置引發的血案》
五、04.17—04.21 緩存邏輯修正
這段時間我一直在優化服務的性能,主要是從分布式緩存和業務邏輯修正兩個角度出發進行的。首先是將我們的緩存邏輯給修正了一下。
關于緩存,我們業務存在兩個重要問題:
- 集群部署的情況下,每個服務都用了很多本地 ConcurrentHashMap 緩存;
- 在業務邏輯計算出結果之后,直接將計算出的結果存在了本地緩存中(即緩存過程與業務邏輯緊密耦合);
對于第一個問題,主要有兩個隱患:首先集群部署,也就意味著為了提高服務的性能,環境中有多臺服務,所以對于相同的數據,每個服務都要自己記錄一份緩存,這樣對內存是很大的浪費。其次多臺服務的緩存也很容易出現不同步的問題,極易出現數據臟讀的現象。
對于第二個問題,將結果存放到緩存中,本身與業務并沒有關系,不管是否置入緩存,都不會對業務結果不會有影響。但如果將緩存的一部分放在業務邏輯中,就相當于緩存被強行的綁在了業務邏輯之中。所以對這個問題進行優化,就是將緩存從業務邏輯中解耦。
筆者是先解決了后一個問題,然后再解決前一個問題。
1. 切面思想的體會
我認為將緩存從業務邏輯中解耦,這種工作交給 AOP 后置增強是最合適的。所以我就開始對業務代碼進行一通分析,提取出來他們的共同點,將置入緩存的邏輯從業務代碼中拆了出來,放到了一個后置切面中。具體思路就是這樣,過程不表。
筆者之前只是會使用 AOP 切面,但在這個過程中,筆者切實的加深了對 AOP 的理解。代碼抽取過程中,同事也問我這樣做有什么好處,對性能有什么優化?我想了一下,回答:這對性能沒有任何優化。同事問我做 AOP 切面的意義,我開了個腦洞,用這個例子給出了一個比較通俗易懂的解釋:
問:把大象放在冰箱里總共分幾步?
答:分三步。第一步把冰箱門打開,第二步把大象給塞進去,第三步把冰箱門關上。
這個經典段子在筆者看來,很有用 AOP 思路分析的價值。首先,我們的目的是把大象放進冰箱里,這就是我們的業務所在。但是要放大象進去,開冰箱門和關冰箱門可以省略嗎?不能。那這兩者和塞大象的業務有關嗎?沒有。
所以與業務無關,但又必須做的工作(或者優化的工作),就是切面的意義所在了。緩存的加入,優化了數據的讀取,但如果去掉了緩存,業務依舊可以正常工作,只是效率低一點而已。所以把緩存從業務代碼中拿出來,就實現了解耦。
2. AOP 的代理思想
參考地址:
《Spring AOP 的實現原理》
[《Spring service 本類中方法調用另一個方法事務不生效問題》](https:// blog.csdn.net/dapinxiaohuo/article/details/52092447)
另外在該過程中,筆者也終于理解了代理的意義。
首先敘述一下問題:筆者有一次在 A 類的 a 方法上加入了后置切面方法后,用 A 類的 b 方法調用了自身的 a 方法,但多次測試發現怎么也不會進后置切面方法。經過好長時間的加班折騰,筆者終于發現了一個問題:自身調用方法,是不會進入切面方法的。
AOP 的基本是使用代理實現的。通常使用的是 AspectJ 或者 Spring AOP 切面。
AspectJ 使用靜態編譯的方式實現 AOP 功能。對于一個寫好的類,對其編寫 aspectj 腳本,然后對該 *.java 文件進行編譯指令,如 <code>ajc -d . Hello.java TxAspect.aj</code>,即可編譯生成一個類,該類會比原先的類多一些內容,通過這種方式實現切面。
原始類:
public class Hello {
public void sayHello() {
System.out.println("hello");
}
public static void main(String[] args) {
Hello h = new Hello();
h.sayHello();
}
}
編寫的 aspectj 語句:
public aspect TxAspect {
void around():call(void Hello.sayHello()){
System.out.println("開始事務 ...");
proceed();
System.out.println("事務結束 ...");
}
}
執行 aspectj 語句 <code>ajc -d . Hello.java TxAspect.aj</code> 編譯后生成的類:
public class Hello {
public Hello() {
}
public void sayHello() {
System.out.println("hello");
}
public static void main(String[] args) {
Hello h = new Hello();
sayHello_aroundBody1$advice(h, TxAspect.aspectOf(), (AroundClosure)null);
}
}
Spring AOP 是通過動態代理的形式實現的,其中又分為通過 JDK 動態代理,以及 CGLIB 動態代理。
- JDK 動態代理:使用反射原理,對實現了接口的類進行代理;
- CGLIB 動態代理:字節碼編輯技術,對沒有實現接口的類進行代理;
主要原因筆者后續也終于分析理解了:由于筆者雖然使用的是 @AspectJ 注解,但實際上使用的依舊是 Spring AOP。
如果使用 Spring AOP,使用過程中可能會出現一個問題:自身調用切面注解方法,切面失效。這是因為 AOP 的實現是通過代理的形式實現的,所以自身調用方法不滿足代理調用的條件,所以不會執行切面。切面的調用流程如下文鏈接所示,文中以事務出發,講解了 AOP 的實現原理 (注:事務的實現原理也是切面):
[圖片上傳失敗...(image-96f767-1556680853759)]
所以,對于筆者這種自身調用切面的情況,可以改變方法的調用方式:改變調用自身方法的方式,使用調用代理方法的形式。筆者在 Spring 的 XML 中對 aop 進行配置:
<!—- 注解風格支持 -->
<aop:aspectj-autoproxy expose-proxy="true"/>
<!—- xml 風格支持 -->
<aop:config expose-proxy="true"/>
然后在方法中通過 Spring 的 AopContext.currentProxy 獲取代理對象,然后通過代理調用方法。例如有自身方法調用如下:
this.b();
變為:
((AService) AopContext.currentProxy()).b();
筆者又開了一次腦洞,用娛樂圈明星和代理人之間的關系來類比理解了一下代理模式。作為一個代理人,目的是協助明星的工作。明星主要工作,就是唱,跳,RAP 之類的,而代理人,就是類似于在演出開始之前找廠商談出場費,演出之后找廠商結賬,買熱搜,或者發個律師函之類的。總之不管好事兒壞事兒,代理干的事兒都賊 TM 操心,又和明星的演出工作沒有直接的關系。
數據庫事務也是一樣的道理。增刪改查,是 SQL 語句關心的核心業務,SQL 語句只要按照語句執行就順利完成了任務。由于事務的原子性,一個事務內的所有執行完畢后,事務一起提交結果。如果執行過程中出現了意外呢?那么事務就把狀態回滾到最開始的狀態。事務依舊做著處理后續工作,還有幫人擦屁股的工作,而且還是和業務本身沒有關系的事兒,這和代理人是一樣的命啊……
這樣,AOP 和代理思想,筆者用一頭大象,還有一個明星經紀人的例子便頓悟了。
3. 分布式緩存問題(緩存雪崩,緩存穿透,緩存擊穿)
好的,把緩存邏輯從業務代碼邏輯揪了出來,后一個問題就解決了,現在解決前一個問題:將集群中所有服務的緩存從本地緩存轉為分布式緩存,降低緩存在服務中占用的資源。
由于業務組只有 Memcache 緩存集群,并沒有搭起來 Redis,所以筆者還是選了 Memcache 作為分布式緩存工具。筆者用了一天時間封裝了我們服務自己用的 MemcacheService,把初始化、常用的 get, set 方法封裝完畢,測試也沒有問題。由于具體過程只是對 Memcache 的 API 進行簡單封裝,故具體過程不表。但是進行到這里,筆者也只是簡單的封裝完畢,仍然有可以優化的空間。
集群服務的緩存,有三大問題:緩存雪崩、緩存穿透、緩存擊穿。在并發量高的時候,這三個緩存問題很容易引起服務與數據庫的宕機。雖然我們的小服務并不存在高并發的場景,但既然要做性能優化,就要盡量做到最好,所以筆者還是在我這小小的服務上事先了這幾個緩存問題并加以解決。
(1) 緩存雪崩
緩存雪崩和緩存擊穿都和分布式緩存的緩存過期時間有關。
緩存雪崩,指的是對于某些熱點緩存,如果都設置了相同的過期時間,在過期時間范圍之內是正常的。但等到經過了這個過期時間之后,大量并發再訪問這些緩存內容,會因為緩存內容已經過期而失效,從而大量并發短時間內涌向數據庫,很容易造成數據庫的崩潰。
這樣的情況發生的主要原因,在于熱點數據設置了相同的過期時間。解決的方案是對這些熱點數據設置隨機的過期時間即可。比如筆者在封裝 Memcache 接口的參數中有過期時間 int expireTime,并設置了默認的過期時間為 30min,這樣的緩存策略確實容易產生緩存雪崩現象。此后筆者在傳入的 expireTime 值的基礎上,由加上了一個 0~300 秒的隨機值。這樣所有緩存的過期時間都有了一定的隨機性,從而避免了緩存雪崩現象。
(2) 緩存擊穿
假設有某個熱點數據,該數據在數據庫中存在該值,但緩存中不存在,那么如果同一時間大量并發查詢該緩存,則會由于緩存中不存在該數據,從而將大量并發釋放,大量并發涌向數據庫,容易引起數據庫的宕機。
看到這里也可以體會到,前面的緩存雪崩與緩存擊穿有很大的相似性。緩存雪崩針對的是對一批在數據庫中存在,但在緩存中不存在的數據;而緩存擊穿針對的是一個數據。
在《緩存穿透,緩存擊穿,緩存雪崩解決方案分析》一文中提到了四種方式,筆者采用了類似于第一種方式的解決方法:使用互斥鎖。由于這里的環境是分布式環境,所以這里的互斥鎖指的其實是分布式鎖。筆者又按照《緩存穿透、緩存擊穿、緩存雪崩區別和解決方案》一文中的思路,以業務組的 Zookeeer 集群為基礎實現了分布式鎖,解決了緩存擊穿的問題。偽代碼如下:
public Object getData(String key) {
// 1. 從緩存中讀取數據
Object result = getDataFromMemcache(key);
// 2. 如果緩存中不存在數據,則從數據庫中 (或者計算) 獲取
if (result == null) {
InterProcessMutex lock = new InterProcessMutex(client, "/service/lock/test1");
// 2.1 嘗試獲取鎖
try {
if (lock.acquire(10, TimeUnit.SECONDS)) {
// ※ 2.1.1 嘗試再次獲取緩存,如果獲取值不為空,則直接返回
result = getDataFromMemcache(key);
if (result != null) {
log.info("獲取鎖后再次嘗試獲取緩存,緩存命中,直接返回");
return result;
}
// 2.1.2 從數據庫中獲取原始數據 (或者計算獲取得到數據)
result = queryData(key);
// 2.1.3 將結果存入緩存
setDataToMemcache(key, result);
}
// 2.2 獲取鎖失敗,暫停短暫時間,嘗試再次重新獲取緩存信息
else {
TimeUnit.MILLISECONDS.sleep(100);
result = getData(key);
}
} catch (Exception e) {
e.printStackTrace();
}
// 2.3 退出方法前釋放分布式鎖
finally {
if (lock != null && lock.isAcquiredInThisProcess()) {
lock.release();
}
}
}
return result;
}
筆者解決緩存擊穿的思路,是集群中服務如果同時處理大量并發,且嘗試獲取同一數據時,所有并發都會嘗試獲取 InterProcessMutex 的分布式鎖。這里的 InterProcessMutex,是 Curator 自帶的一個分布式鎖,它基于 Zookeeper 的 Znode 實現了分布式鎖的功能。在 InterProcessMutex 的傳參中,需要傳入一個 ZNode 路徑,當大量并發都嘗試獲取這個分布式鎖時,只有一個鎖可以獲得該鎖,其他鎖需要等待一定時間 (acquire 方法中傳入的時間)。如果經過這段時間仍然沒有獲得該鎖,則 acquire 方法返回 false。
筆者解決緩存擊穿的邏輯偽代碼如上所示。邏輯比較簡單,但其中值得一提的是,在 2.1.1 中,對于已經獲取了分布式鎖的請求,筆者又重新嘗試獲取一次緩存。這是因為 Memcache 緩存的存入與讀取可能會不同步的情況。假想一種情況:對于嘗試獲取分布式鎖的請求 req1, req2,如果 req1 首先獲取到了鎖,且將計算的結果存入了 Memcache,然后 req2 在等待時間內又重新獲取到了該鎖,如果直接繼續執行,也就會重新從數據庫中獲取一次 req1 已經獲取且存入緩存的數據,這樣就造成了重復數據的讀取。所以需要在獲取了分布式鎖之后重新再獲取一次緩存,判斷在爭搶分布式鎖的過程中,緩存是否已經處理完畢。
(3) 緩存穿透
緩存穿透,指的是當數據庫與緩存中都沒有某數據時,該條數據就會成為漏洞,如果有人蓄意短時間內大量查詢這條數據,大量連接就很容易穿透緩存涌向數據庫,會造成數據庫的宕機。針對這種情況,比較普遍的應對方法是使用布隆過濾器 (Bloom Filter)進行防護。
布隆過濾器和弗雷爾卓德之心有一些相似的地方,它的防御不是完全抵擋的,是不準確的。換句話說,針對某條數據,布隆過濾器只保證在數據庫中一定沒有該數據,不能保證一定有這條數據。
布隆過濾器的最大的好處是,判斷簡單,消耗空間少。通常如果直接使用 Map 訪問結果來判斷是否存在數據是否存在,雖然可以實現,但 Map 通常的內存利用率不會太高,對于幾百萬甚至幾億的大數據集,太浪費空間。而布隆過濾器本身是一個 bitmap 的結構(筆者個人理解基本是一個很大很大的 0-1 數組),初始狀態下全部為 0。當有值存入緩存時,使用多個 Hash 函數分別計算對應 Key 值的結果,結果轉換為 bitmap 指定的位數,對應位上置 1。這樣,越來越多的值存入,bitmap 上也填充了越來越多的 1。
這樣如果有請求查詢某個數據是否存在,則依舊利用相同的 Hash 函數計算結果,并在 bitmap 上查找計算結果的位置上是否全部為 1。只要有一個位置不為 1,緩存中就必然沒有該數據。但是如果所有位置都為 1,那么也不能說明緩存中一定有這條數據。因為隨著越來越多的數據存入緩存,布隆過濾器 bitmap 中的 1 值也越來越多,所以即使計算結果中所有位數的值都為 1,也有可能是其他若干計算結果將這些位置上的 1 給占據了。布隆過濾器雖然有誤判率,但是有文章指出布隆過濾器的誤判率在合適的參數設置之下會變得很低。具體可以見文章《使用BloomFilter布隆過濾器解決緩存擊穿、垃圾郵件識別、集合判重》。
除了不能判斷數據庫中一定存在某條數據之外,布隆過濾器還有一個問題,在于它不能刪除某個值填充在 bitmap 中的結果。
筆者本來想用 guava 包中自帶的 BloomFilter 來實現 Memcache 的緩存穿透防護,本來都已經研究好該怎么加入布隆的大盾牌了,但是后來一想,布隆過濾器應該是在 Memcache 端做的事情,而不是在我集群服務這里該做的。如果每個服務都建一個 BloomFilter,這幾個過濾器的值肯定是不同步的,而且會造成大量的空間浪費,所以最后并沒有付諸實踐。
六、04.17—04.25 業務邏輯修正
與解決技術層面同步進行的,是對于業務邏輯的修正。修正的主要思路是調整消息訂閱后的處理方式,以及方法、緩存的粒度調整(從粗粒度調整到細粒度)。涉及具體的業務邏輯,此處不表。
結語
經過一段長時間的奮戰,我們的并發效率提升了二到三倍。
但筆者并不是感覺我們做的很好,筆者更認為這是項目整個過程中的問題爆發。由于去年項目趕的太緊,三個月下來幾乎天天 9107 的節奏,小伙伴們都累的沒脾氣,自然而然產生了抵觸心理,代碼質量與效率也自然下降。整個過程下來,堆積的坑越攢越多,最終到了某個時間不得不改。
看著這些被修改的代碼,有一部分確實都是自己的手筆,確實算是段悲傷的黑歷史了。但歷史已不再重要了,而是在這段解決問題的過程中積累學習的經驗,是十分寶貴的。希望以后在工作中能夠不再出現類似的問題吧。
本文于 2019.03.06 始,于 2019 五一勞動節終。
系列文章:
《服務假死問題解決過程實記(一)——問題發現篇》
《服務假死問題解決過程實記(二)——C3P0 數據庫連接池配置引發的血案》
《服務假死問題解決過程實記(三)——緩存問題優化》