服務假死問題解決過程實記(三)——緩存問題優化

接上篇 《服務假死問題解決過程實記(二)—— 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 文件進行編譯指令,如 ajc -d . Hello.java TxAspect.aj,即可編譯生成一個類,該類會比原先的類多一些內容,通過這種方式實現切面。

原始類:

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 語句 ajc -d . Hello.java TxAspect.aj 編譯後生成的類:

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 的實現原理 (注:事務的實現原理也是切面):

Spring AOP 調用流程

所以,對於筆者這種自身調用切面的情況,可以**改變方法的調用方式:改變調用自身方法的方式,使用調用代理**方法的形式。筆者在 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)**進行防護。

布隆,來幹活了!(弗雷爾卓德之心——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 數據庫連接池配置引發的血案》
《服務假死問題解決過程實記(三)——緩存問題優化》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章