Spring Cloud 升級之路 - 2020.0.x - 1. 背景知識、需求描述與公共依賴

乾貨滿滿張雜湊發表於2021-04-04

1. 背景知識、需求描述與公共依賴

1.1. 背景知識 & 需求描述

Spring Cloud 官方文件說了,它是一個完整的微服務體系,使用者可以通過使用 Spring Cloud 快速搭建一個自己的微服務系統。那麼 Spring Cloud 究竟是如何使用的呢?他到底有哪些元件?

spring-cloud-commons元件裡面,就有 Spring Cloud 預設提供的所有元件功能的抽象介面,有的還有預設實現。目前的 2020.0.x (按照之前的命名規則應該是 iiford),也就是spring-cloud-commons-3.0.x包括:

  • 服務發現DiscoveryClient,從註冊中心發現微服務。
  • 服務註冊ServiceRegistry,註冊微服務到註冊中心。
  • 負載均衡LoadBalancerClient,客戶端呼叫負載均衡。其中,重試策略spring-cloud-commons-2.2.6加入了負載均衡的抽象中。
  • 斷路器CircuitBreaker,負責什麼情況下將服務斷路並降級
  • 呼叫 http 客戶端:內部 RPC 呼叫都是 http 呼叫

然後,一般一個完整的微服務系統還包括:

  1. 統一閘道器
  2. 配置中心
  3. 全鏈路監控與監控中心

在之前的系列中,我們將 Spring cloud 升級到了 Hoxton 版本,元件體系是:

  1. 註冊中心:Eureka
  2. 客戶端封裝:OpenFeign
  3. 客戶端負載均衡:Spring Cloud LoadBalancer
  4. 斷路器與隔離: Resilience4J

並且實現瞭如下的功能:

註冊中心相關

  1. 所有叢集公用同一個公共 Eureka 叢集
  2. 實現例項的快速上下線。

微服務例項相關

  1. 不同叢集之間不互相呼叫,通過例項的metamap中的zone配置,來區分不同叢集的例項。只有例項的metamap中的zone配置一樣的例項才能互相呼叫。
  2. 微服務之間呼叫依然基於利用 open-feign 的方式,有重試,僅對GET請求並且狀態碼為4xx和5xx進行重試(對4xx重試是因為滾動升級的時候,老的例項沒有新的 api,重試可以將請求發到新的例項上)
  3. 某個微服務呼叫其他的微服務 A 和微服務 B, 呼叫 A 和呼叫 B 的執行緒池不一樣。並且呼叫不同例項的執行緒池也不一樣。也就是例項級別的執行緒隔離
  4. 實現例項 + 方法級別的熔斷,預設的例項級別的熔斷太過於粗暴。例項上某些介面有問題,但不代表所有介面都有問題。
  5. 負載均衡的輪詢演算法,需要請求與請求之間隔離,不能共用同一個 position 導致某個請求失敗之後的重試還是原來失敗的例項。
  6. 對於 WebFlux 這種非 Servlet 的非同步呼叫也實現相同的功能。

閘道器相關

  1. 通過metamap中的zone配置鑑別所處叢集,僅把請求轉發到相同叢集的微服務例項
  2. 轉發請求,有重試,僅對GET請求並且狀態碼為4xx和5xx進行重試
  3. 不同微服務的不同例項執行緒隔離
  4. 實現例項級別的熔斷。
  5. 負載均衡的輪詢演算法,需要請求與請求之間隔離,不能共用同一個 position 導致某個請求失敗之後的重試還是原來失敗的例項
  6. 實現請求 body 修改(可能請求需要加解密,請求 body 需要列印日誌,所以會涉及請求 body 的修改)

在後續的使用,開發,線上執行過程中,我們還遇到了一些問題:

  1. 業務在某些時刻,例如 6.30 購物狂歡,雙 11 大促,雙 12 剁手節,以及在法定假日的時候的快速增長,是很難預期的。雖然有根據例項 CPU 負載的擴容策略,但是這樣也還是會有滯後性,還是會有流量猛增的時候導致核心業務(例如下單)有一段時間的不可用(可能5~30分鐘)。主要原因是系統壓力大之後導致很多請求排隊,排隊時間過長後等到處理這些請求時已經過了響應超時,導致本來可以正常處理的請求也沒能處理。而且使用者的行為就是,越是下不成單,越要重新整理重試,這樣進一步增加了系統壓力,也就是雪崩。通過例項級別的執行緒隔離,我們限制了每個例項呼叫其他微服務的最大併發度,但是因為等待佇列的存在還是具有排隊。同時,在 API 閘道器由於沒有做限流,由於 API 閘道器 Spring Cloud gateway 是非同步響應式的,導致很多請求積壓,進一步加劇了雪崩。所以這裡,我們要考慮這些情況,重新設計執行緒隔離以及增加 API 閘道器限流。
  2. 微服務發現,未來為了相容雲原生應用,例如 K8s 的一些特性,最好服務發現是多個源
  3. 鏈路監控與指標監控是兩套系統,使用麻煩,並且成本也偏高,是否可以優化成為一套。

接下來,我們要對現有依賴進行升級,並且對現有的功能進行一些擴充和延伸,形成一套完整的 Spring Cloud 微服務體系與監控體系。

1.2. 編寫公共依賴

本次專案程式碼,請參考:https://github.com/HashZhang/spring-cloud-scaffold/tree/master/spring-cloud-iiford

這次我們抽象出更加具體的各種場景的依賴。一般的,我們的整個專案一般會包括:

  1. 公共工具包依賴:一般所有專案都會依賴一些第三方的工具庫,例如 lombok, guava 這樣的。對於這些依賴放入公共工具包依賴。
  2. 傳統 servlet 同步微服務依賴:對於沒有應用響應式程式設計而是用的傳統 web servlet 模式的微服務的依賴管理。
  3. 響應式微服務依賴:對於基於 Project Reactor 響應式程式設計實現的微服務的依賴管理。響應式程式設計是一種大趨勢,Spring 社群也在極力推廣。可以從 Spring 的各個元件,尤其是 Spring Cloud 元件上可以看出來。spring-cloud-commons 更是對於微服務的每個元件抽象都提供了同步介面還有非同步介面。我們的專案中也有一部分使用了響應式程式設計。

為何微服務要抽象分離出響應式的和傳統 servlet 的呢

  1. 首先,Spring 官方其實還是很推崇響應式程式設計的,尤其是在 Hoxton 版本釋出後, spring-cloud-commons 將所有公共介面都抽象了傳統的同步版還有基於 Project Reactor 的非同步版本。並且在實現上,預設的實現同步版的底層也是通過 Project Reactor 轉化為同步實現的。可以看出,非同步化已經是一種趨勢。
  2. 但是, 非同步化學習需要一定門檻,並且傳統專案大多還是同步的,一些新元件或者微服務可以使用響應式實現。
  3. 響應式和同步式的依賴並不完全相容,雖然同一個專案內同步非同步共存,但是這種並不是官方推薦的做法(這種做法其實啟動的 WebServer 還是 Servlet WebServer),並且 Spring Cloud gateway 這種實現的專案就完全不相容,所以最好還是分離開來。
  4. 為什麼響應式程式設計不普及主要因為資料庫 IO,不是 NIO。不論是Java自帶的Future框架,還是 Spring WebFlux,還是 Vert.x,他們都是一種非阻塞的基於Ractor模型的框架(後兩個框架都是利用netty實現)。在阻塞程式設計模式裡,任何一個請求,都需要一個執行緒去處理,如果io阻塞了,那麼這個執行緒也會阻塞在那。但是在非阻塞程式設計裡面,基於響應式的程式設計,執行緒不會被阻塞,還可以處理其他請求。舉一個簡單例子:假設只有一個執行緒池,請求來的時候,執行緒池處理,需要讀取資料庫 IO,這個 IO 是 NIO 非阻塞 IO,那麼就將請求資料寫入資料庫連線,直接返回。之後資料庫返回資料,這個連結的 Selector 會有 Read 事件準備就緒,這時候,再通過這個執行緒池去讀取資料處理(相當於回撥),這時候用的執行緒和之前不一定是同一個執行緒。這樣的話,執行緒就不用等待資料庫返回,而是直接處理其他請求。這樣情況下,即使某個業務 SQL 的執行時間長,也不會影響其他業務的執行。但是,這一切的基礎,是 IO 必須是非阻塞 IO,也就是 NIO(或者 AIO)。官方JDBC沒有 NIO,只有 BIO 實現(因為官方是 Oracle 提供維護,但是 Oracle 認為下面會提到的 Project Loom 是可以解決同步風格程式碼硬體效率低下的問題的,所以一直不出)。這樣無法讓執行緒將請求寫入連結之後直接返回,必須等待響應。但是也就解決方案,就是通過其他執行緒池,專門處理資料庫請求並等待返回進行回撥,也就是業務執行緒池 A 將資料庫 BIO 請求交給執行緒池B處理,讀取完資料之後,再交給 A 執行剩下的業務邏輯。這樣A也不用阻塞,可以處理其他請求。但是,這樣還是有因為某個業務 SQL 的執行時間長,導致B所有執行緒被阻塞住佇列也滿了從而A的請求也被阻塞的情況,這是不完美的實現。真正完美的,需要 JDBC 實現 NIO。
  5. Java 響應式程式設計的未來會怎樣是否會有另一種解決辦法?我個人覺得,如果有興趣可以研究下響應式程式設計 WebFlux,但是不必強求一定要使用響應式程式設計。雖然非同步化程式設計是大趨勢,響應式程式設計越來越被推崇,但是 Java 也有另外的辦法解決同步式編碼帶來的效能瓶頸,也就是 Project LoomProject Loom 可以讓你繼續使用同步風格寫程式碼,在底層用的其實是非阻塞輕量級虛擬執行緒,網路 IO 是不會造成系統執行緒阻塞的,但是目前 sychronized 以及本地檔案 IO 還是會造成阻塞。不過,主要問題是解決了的。所以,本系列還是會以同步風格程式碼和 API 為主。

1.2.1. 公共 parent

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.4</version>
    </parent>

    <modelVersion>4.0.0</modelVersion>

    <groupId>com.github.hashjang</groupId>
    <artifactId>spring-cloud-iiford</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.version>1.0-SNAPSHOT</project.version>
    </properties>

    <dependencies>
        <!--junit單元測試-->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <scope>test</scope>
        </dependency>
        <!--spring-boot單元測試-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!--mockito擴充套件,主要是需要mock final類-->
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-inline</artifactId>
            <version>3.6.28</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>2020.0.2</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.6.1</version>
                <configuration>
                    <!--最好用JDK 12版本及以上編譯,11.0.7對於spring-cloud-gateway有時候編譯會有bug-->
                    <!--雖然官網說已解決,但是11.0.7還是偶爾會出現-->
                    <source>11</source>
                    <target>11</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

1.2.2. 公共基礎依賴包

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>spring-cloud-iiford</artifactId>
        <groupId>com.github.hashjang</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>spring-cloud-iiford-common</artifactId>

    <properties>
        <guava.version>30.1.1-jre</guava.version>
        <fastjson.version>1.2.75</fastjson.version>
        <disruptor.version>3.4.2</disruptor.version>
        <jaxb.version>2.3.1</jaxb.version>
        <activation.version>1.1.1</activation.version>
    </properties>

    <dependencies>
        <!--內部快取框架統一採用caffeine-->
        <!--這樣Spring cloud loadbalancer用的本地例項快取也是基於Caffeine-->
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
        </dependency>
        <!-- guava 工具包 -->
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>${guava.version}</version>
        </dependency>
        <!--內部序列化統一採用fastjson-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>${fastjson.version}</version>
        </dependency>
        <!--日誌需要用log4j2-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-logging</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-log4j2</artifactId>
        </dependency>
        <!--lombok簡化程式碼-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!--log4j2非同步日誌需要的依賴,所有專案都必須用log4j2和非同步日誌配置-->
        <dependency>
            <groupId>com.lmax</groupId>
            <artifactId>disruptor</artifactId>
            <version>${disruptor.version}</version>
        </dependency>
        <!--JDK 9之後的模組化特性導致javax.xml不自動載入,所以需要如下模組-->
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>${jaxb.version}</version>
        </dependency>
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-impl</artifactId>
            <version>${jaxb.version}</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jaxb</groupId>
            <artifactId>jaxb-runtime</artifactId>
            <version>${jaxb.version}</version>
        </dependency>
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-xjc</artifactId>
            <version>${jaxb.version}</version>
        </dependency>
        <dependency>
            <groupId>javax.activation</groupId>
            <artifactId>activation</artifactId>
            <version>${activation.version}</version>
        </dependency>
    </dependencies>
</project>

1. 快取框架 caffeine
很高效的本地快取框架,介面設計與 Guava-Cache 完全一致,可以很容易地升級。效能上,caffeine 原始碼裡面就有和 Guava-Cache, ConcurrentHashMap,ElasticSearchMap,Collision 和 Ehcache 等等實現的對比測試,並且測試給予了 yahoo 測試庫,模擬了近似於真實使用者場景,並且,caffeine 參考了很多論文實現不同場景適用的快取,例如:

  1. Adaptive Replacement Cache:http://www.cs.cmu.edu/~15-440/READINGS/megiddo-computer2004.pdf
    2.Quadruply-segmented LRU:http://www.cs.cornell.edu/~qhuang/papers/sosp_fbanalysis.pdf
  2. 2 Queue:http://www.tedunangst.com/flak/post/2Q-buffer-cache-algorithm
  3. Segmented LRU:http://www.is.kyusan-u.ac.jp/~chengk/pub/papers/compsac00_A07-07.pdf
  4. Filtering-based Buffer Cache:http://storageconference.us/2017/Papers/FilteringBasedBufferCacheAlgorithm.pdf

所以,我們選擇 caffeine 作為我們的本地快取框架

參考:https://github.com/ben-manes/caffeine

2. guava

guava 是 google 的 Java 庫,雖然本地快取我們不使用 guava,但是 guava 還有很多其他的元素我們經常用到。

參考:https://guava.dev/releases/snapshot-jre/api/docs/

3. 內部序列化從 fastjson 改為 jackson

json 庫一般都需要預熱一下,後面會提到怎麼做。
我們專案中有一些內部序列化是 fastjson 序列化,但是看 fastjson 已經很久沒有更新,有很多 issue 了,為了避免以後出現問題(或者漏洞,或者效能問題)增加線上可能的問題點,我們這一版本做了相容。在下一版本會把 fastjson 去掉。後面會詳細說明如何去做。

4. 日誌採用 log4j2

主要是看中其非同步日誌的特性,讓列印大量業務日誌不成為效能瓶頸。但是,還是不建議線上上環境輸出程式碼行等位置資訊,具體原因以及解決辦法後面會提到。由於 log4j2 非同步日誌特性依賴 disruptor,還需要加入 disruptor 的依賴。

參考:

5. 相容 JDK 9+ 需要新增的一些依賴

JDK 9之後的模組化特性導致 javax.xml 不自動載入,而專案中的很多依賴都需要這個模組,所以手動新增了這些依賴。

1.2.3. Servlet 微服務公共依賴

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>spring-cloud-iiford</artifactId>
        <groupId>com.github.hashjang</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>spring-cloud-iiford-service-common</artifactId>

    <dependencies>
        <dependency>
            <groupId>com.github.hashjang</groupId>
            <artifactId>spring-cloud-iiford-common</artifactId>
            <version>${project.version}</version>
        </dependency>
        <!--註冊到eureka-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <!--不用Ribbon,用Spring Cloud LoadBalancer-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-loadbalancer</artifactId>
        </dependency>
        <!--微服務間呼叫主要靠 openfeign 封裝 API-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <!--resilience4j 作為重試,斷路,限併發,限流的元件基礎-->
        <dependency>
            <groupId>io.github.resilience4j</groupId>
            <artifactId>resilience4j-spring-cloud2</artifactId>
        </dependency>
        <!-- https://mvnrepository.com/artifact/io.github.resilience4j/resilience4j-feign -->
        <dependency>
            <groupId>io.github.resilience4j</groupId>
            <artifactId>resilience4j-feign</artifactId>
        </dependency>
        <!--actuator介面-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <!--呼叫路徑記錄-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-sleuth</artifactId>
        </dependency>
        <!--暴露actuator相關埠-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <!--暴露http介面, servlet框架採用nio的undertow,注意直接記憶體使用,減少GC-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-tomcat</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-undertow</artifactId>
        </dependency>
    </dependencies>
</project>

這裡面相關的依賴,我們後面會用到。

1.2.4. Webflux 微服務相關依賴

對於 Webflux 響應式風格的微服務,其實就是將 spring-boot-starter-web 替換成 spring-boot-starter-webflux 即可

參考:pom.xml

相關文章