網關升級
背景
這是去年做的事情了,去年九月我們將一個系統的網關zuul平滑升級為spring cloud gateway,在此記錄一下升級方案,有相同需求的朋友可以做個參考。
升級原因:
1、之前我們升級了spring boot/cloud版本,網關模塊沒有升級,一直使用舊版本,不統一,公共包的管理和代碼不好維護。
2、低版本的spring cloud 使用zuul 1.x作為網關,zuul 1.x使用的是同步阻塞的serlvet線程模型,處理請求能力薄弱,容易出現線程膨脹問題。
例如我們配置了ribbon.MaxHttpConnectionsPerHost = 600,即每個host會開600個線程處理請求,當請求越多時,就需要開更多的線程支持,而線程是占用資源的,在并發高的時候會造成機器負載高,線程切換頻繁,gc頻繁等問題。
盡管zuul 2.x開始支持異步請求,但spring cloud并沒有集成計劃,而是推出了自家的網關spring cloud gateway。
3、低版本的網關Netflix不再維護,有bug無法解決,同時在一些組件上支持不好,例如不能很好的整合websocket,resilience4j,redis ratelimit等組件。
4、網關作為流量入口,高性能是基本要求,zuul已經不適用,現有使用spring cloud框架,spring cloud gateway是首選。
因此我們決定對網關模塊進行升級。
原理分析
簡介
spring cloud gateway是基于webflux框架構建的,功能豐富,高性能的,響應式網關。
webflux是spring5推出的響應式web服務,與之前的spring mvc對比,傳統的servlet是阻塞的。官網介紹如下:

可見spring并沒有打算用reactive替換傳統的servlet框架,而是兩個分支發展,但毫無疑問,未來的重心發展在reactive stack。
當然,現在看起來,reactive很可能遭受的挑戰是虛擬線程,它比reactive更輕量,性能、可讀性、調試都更優秀。
兩者也公用了一些基礎組件,對于開發者來說,@Controller,@RequestMapping等使用和spring mvc是一樣的。
需要注意的是,響應式web服務并不能降低請求處理時間,例如一個請求本應該就要消耗1s,在webflux框架下,時間不會減少。
響應式服務的重點是:用較少的線程,通常是cpu的核數,處理更多的請求,提升吞吐量。
上面提到的reactive,有一個標準,叫做reactive stream:Reactive Streams is an initiative to provide a standard for asynchronous stream processing with non-blocking back pressure。參考:https://en.wikipedia.org/wiki/Reactive_Streams
這個標準由Netflix, Pivotal and Lightbend發起,其中Pivotal就是開發spring的公司。
project reactor是基于這個標準實現的類庫,webflux是構建在reactor上的web服務。jdk9中對也對這個標準進行實現,提供了Flow接口。
生產者-消費者模型
project reactor是基于生產者-消費者模型,生產者負責生產數據,消費者通過訂閱,可以處理生產者生產的數據,并可以在完成和出錯時做出響應。
頂層Publisher接口:

頂層Subscriber接口:

Mono和Flux是兩個最常用的生產者,我們平時使用的幾乎都是它們,Mono表示生產0或1個元素的生產者,Flux表示生產0至N個元素的生產者,可以簡單理解為Object和List。
如下示例:定義一個包含1,2,3,4的Flux,然后map定義一個方法,將每個元素*2,然后定義一個消費者,打印結果。
Flux.just(1, 2, 3, 4)
.map(s -> s * 2)
.subscribe(s -> System.out.println(s));
看起來和java8里的stream集合操作和相似,它們都是在生產數據,然后定義處理數據的流程(過濾,加工),最后進行消費,同樣是在消費時才會觸發前面的一系列操作。
傳統servlet vs webflux
傳統servlet采用的是一個servlet一個線程的處理方式,這種模式在線程數少的cpu密集型服務下不會有多少問題,但一旦遇到IO,線程就會掛起,等待IO返回,此時線程什么事情都做不了,只能干等待。
遇到這種問題,一般我們的做法就是增加處理線程,但沒有免費的午餐,增加線程會增加資源消耗,每個線程都可以申請占用1M的棧空間,和少量的內核空間,同時更多的線程會帶來線程切換,也會有性能損耗。
而一旦servlet容器的線程被使用完了,請求就不得不排隊,進入隊列,盡管cpu此時是空閑的,但得不到任何利用。

servlet 3.0后開始支持非阻塞,tomcat等常用容器都支持servlet3.0。
與之相比基于響應式的webflux框架是非阻塞的,這樣線程可以立馬返回,處理其它任務,而當IO返回,如讀取數據庫完成時,響應式框架會通知我們,線程接著處理返回的數據。

從圖可以看到,通過事件的方式將同步變成異步,請求只需要將阻塞操作提交給Event Loop就可以返回處理其它請求,當操作返回時,EventLoop會通知線程繼續處理,這樣一個線程就可以處理很多個請求。
熟悉Linux IO多路復用模型的同學對這種方式肯定很熟悉,思想上是一樣的。
webflux 需要使用非阻塞的容器,如:netty,tomcat等都可以,默認使用的是netty,服務啟動后可以看到:o.s.b.web.embedded.netty.NettyWebServer : Netty started on port 18001
netty是一個高性能、易擴展、社區活躍的網絡開發框架,已經過大量的生產驗證,ElasticSearch、Dubbo、Rocketmq、HBase、spring webflux,gRPC都使用了netty作為底層網絡開發框架。
注意,既然使用了響應式框架,意味著只有少量處理請求的線程,請求從頭到尾就不能有阻塞操作,否則請求線程很快會消耗完。
正例:web 請求 → 查接口(非阻塞)→ 處理返回數據 → 查數據庫(非阻塞)→ 處理數據
反例:web 請求 → 查接口(非阻塞)→ 處理返回數據 → 查數據庫(阻塞)→ 處理數據
幸運的是現在基本所有的阻塞IO操作都有相應的reactive實現,如Feign → ReactiveFeign,Redis → ReactiveRedis,jdbc → r2dbc。
springcloud gateway處理請求流程

- global filter,實現GlobalFilter接口,攔截所有請求
- gateway filter,實現GatewayFilter接口,攔截指定的路由請求
功能調整
3.1 配置調整
3.1.1
server:
port: 8001
tomcat:
max-threads: 5000
->
server:
port: 8002
說明:使用8002端口,與8001會有一段時間并行運行,等驗證切換正常,下掉8001服務,詳細見下面上線方案。
3.1.2
spring:
application:
name: gateway
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
enabled: true
->
spring:
application:
name: gateway
說明:servlet配置對webflux不再適用,gateway下,網關不再需要配置請求大小,由ng和后端服務決定。
3.1.3
management:
endpoints:
web:
exposure:
include: health, prometheus
說明:不需要調整,端點測試正常。
3.1.4
ribbon:
MaxAutoRetries: 0
MaxAutoRetriesNextServer: 1
OkToRetryOnAllOperations: true
retryableStatusCodes: 500,503
ReadTimeout: 30000
ConnectTimeout: 1000
MaxHttpConnectionsPerHost: 200
MaxTotalHttpConnections: 5000
ServerListRefreshInterval: 12000
restclient:
enabled: true
->
spring:
cloud:
loadbalancer:
retry:
retryableStatusCodes: 500,503
retryOnAllOperations: true
cache:
enabled: false
gateway:
httpclient:
connect-timeout: 1000
response-timeout: 30000
說明:gateway下ribbon已經廢棄,使用loadbalancer。
cache.enabled: false 禁止loadbalancer緩存,避免雙緩存,使用eureka client緩存即可。
3.1.5
hystrix:
command:
default:
fallback:
isolation:
semaphore:
maxConcurrentRequests: 500
->
說明:刪掉,gateway下hystrix已經廢棄,改用resilience4j。
3.1.6
zuul:
semaphore:
max-semaphores: 5000
retryable: true
routes:
data:
stripPrefix: false
path: /data/service/**
serviceId: data-server
->
routes:
- id: data-server
uri: lb://data-server
predicates:
- Path=/data/service/**
filters:
- name: CircuitBreaker
3.1.7
eureka:
client:
registry-fetch-interval-seconds: 13
->
eureka:
client:
registry-fetch-interval-seconds: 25
3.2 阻塞代碼改寫
3.2.1 feign
openfeign并沒有提供reactive的實現,而是推薦使用第三方的:https://github.com/PlaytikaOSS/feign-reactive, 這是一家游戲公司開源的。
相關issues:https://github.com/spring-cloud/spring-cloud-openfeign/issues/668#issuecomment-1607854972。
使用方式如下,ReactiveFeignClient標記Feign接口,接口方法返回值必須是Mono或者Flux。
<dependency>
<groupId>com.playtika.reactivefeign</groupId>
<artifactId>feign-reactor-spring-cloud-starter</artifactId>
<version>3.2.11</version>
</dependency>
@EnableReactiveFeignClients
public class SpringCloudGatewayApplication{}
//定義feigin
@ReactiveFeignClient(name = "data-server")
public interface DataClient {
@GetMapping("/user")
Mono<List<String>> user(@RequestParam("uid") Long uid);
}
3.2.2 redis
使用非阻塞的ReactiveRedisTemplate。
@Autowired
ReactiveRedisTemplate reactiveRedisTemplate;
3.3 其它
3.3.1 session問題
webflux使用的是WebSession,redis session使用的是EnableRedisWebSession。
sessionId問題需要重寫一下解析sessionId的方法,保證傳到下游服務的sessionId一致,參考:https://juejin.cn/post/7181636384979943481。
3.3.2 國際化問題
現有i18n工具類,獲取國際化信息,LocaleContextHolder內部使用了ThreadLocal,ThreadLocal在webflux中不適用,需要改寫。
可以通過exchange.getRequest().getHeaders().getAcceptLanguageAsLocales()拿到當前語言的Locales對象。
public static String get(String key, String defaultMessage) {
return messageSource.getMessage(key, new Object[]{defaultMessage}, defaultMessage, LocaleContextHolder.getLocale());
}
3.3.3 全局異常處理
原有的GlobalFallbackProvider和ErrorFilter已經不適用,使用ErrorWebExceptionHandler。
@Slf4j
@Component
@Order(-1)
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
//handle exception
return null;
}
}
3.3.4 日志
必須使用AsyncAppender異步寫入方式,在響應式的世界里,所有涉及到io的都必須非阻塞。
3.3.5 不支持
spring redis SessionCreateEvent等事件在WebSession不被支持,無法使用。
四、壓測
部署環境:4C32G
服務部署:單機
jvm參數:-Xms1g -Xmx1g
壓測時間:3min,壓測線程30s內啟動完成
壓測超時時間:2s
壓測接口:接口20%的時間為100ms,80%的時間為50ms
4.1 zuul
線程數:200
執行情況:失敗率:0,P99:388,吞吐量:1403

gc情況:2秒左右一次young gc,無full gc(超過3分鐘沒觀測到)

線程情況:大量線程

cpu情況:cpu負載14,使用率60%

線程數:600
執行情況:失敗率:0.01,P99:810,吞吐量:1952

gc情況:每秒一次young gc,每分鐘一次full gc

線程情況:大量線程

cpu情況:cpu負載77,使用率80%

4.2 springcloud gateway
線程數:200
執行情況:失敗率:0,P99:403,吞吐量:1388

gc情況:3秒左右一次young gc,無full gc

線程情況:線程數穩定

cpu情況:cpu負載14,使用率50%

線程數:600
執行情況:失敗率:0.02%,P99:787,吞吐量:2202

gc情況:2秒左右一次young gc,無full gc

線程情況:線程數穩定

cpu情況:cpu負載28,使用率70%

官網的benchmark:https://github.com/spencergibb/spring-cloud-gateway-bench

總結
使用springcloud gateway在并發增加時,線程數始終穩定,與cpu核數一致,圖中的http-reactor線程,而zuul會創建大量線程。
在并發高時,springcloud gateway對cpu的使用顯要優于zuul,吞吐量也更好。springcloud gateway gc表現稍好。
springcloud gateway請求時間并沒有比zuul好,這也符合前面的原理分析,webflux接口相應時間并沒有減少。
上線方案
網關是所有流量的入口,為了避免新網關出現問題影響業務,需要平滑過度,新網關驗證正常后,再將流量完全切換,下掉舊網關服務。
發版時不能直接使用滾動發布,可以使用灰度發布或藍綠發布,本次采用灰度發布的方式,切換過程要運維配合,需提前通知。
方案一:灰度發布
逐個節點切換為新代碼,部署時先部署1個節點,放少量流量,驗證沒問題,再部署其它節點。
優點:不需要部署新服務,切換過程簡單,不需要下線舊服務。
缺點:切換驗證過程,老網關節點壓力會有較大壓力。
整體過程如下:

方案二:藍綠發布
部署一套新網關服務,放少量流量到新網關服務,驗證沒問題,直接下線老網關服務。
優點:對老網關完全沒有影響,不會增加節點壓力。
缺點:需要部署一套新服務,切換過程比較復雜,需要下線舊服務。
整體過程如下:

回滾方案
驗證過程發現有問題,通過ng切量回老網關。
老網關代碼master checkout一個分支保留,有問題可以隨時回退到老代碼。
其它問題
1.熔斷,https://cloud.spring.io/spring-cloud-gateway/reference/html/#spring-cloud-circuitbreaker-filter-factory
熔斷后默認拋出的異常不友好,無法看出是被熔斷了,可以重寫其邏輯。
2.reactive feign超時設置,沒有application直接配置方式,代碼配置。https://github.com/PlaytikaOSS/feign-reactive/tree/develop/feign-reactor-spring-configuration
3.注意Mono/Flux寫法,避免嵌套太深。1.抽取方法 2.流式寫法。
4.url編碼問題,解密接口前端進行了兩次編碼,在zuul沒有問題,zuul會decode一次,進入后端服務,spring mvc會decode一次,gateway則沒有decode,導致到后端服務只decod一次,報錯。
5.參數調優
目前的機器配置,connect-timeout設置為2s時,在請求量大時會出現connection timeout exception,原因是處理連接的線程能力不足,將其設置到5s。這會導致量大時請求時間增加,可以通過增加機器解決,不過只有一瞬間出現,暫時忽略。或可以嘗試設置netty IO_SELECT_COUNT為比較大的值,這個會增加線程成本。
上線后出現一些連接提前關閉的錯誤,例如connection reset by peer, Connection prematurely closed BEFORE response,原因是gateway對連接緩存,但下游服務在一定時間后會關閉連接,導致用了一個已關閉的鏈接。設置spring.cloud.gateway.http-client.pool.max-idle-time為15s,連接空閑15s后關閉。
更多分享,歡迎關注我的github:https://github.com/jmilktea/jtea

浙公網安備 33010602011771號