Spring Cloud 系列之 Netflix Hystrix 服務容錯(一)

什麼是 Hystrix

Hystrix 源自 Netflix 團隊於 2011 年開始研發。2012年 Hystrix 不斷髮展和成熟,Netflix 內部的許多團隊都採用了它。如今,每天在 Netflix 上通過 Hystrix 執行數百億個線程隔離和數千億個信號量隔離的調用。極大地提高了系統的穩定性。

在分佈式環境中,不可避免地會有許多服務依賴項中的某些服務失敗而導致雪崩效應。Hystrix 是一個庫,可通過添加等待時間容限和容錯邏輯來幫助您控制這些分佈式服務之間的交互。Hystrix 通過隔離服務之間的訪問點,停止服務之間的級聯故障並提供後備選項來實現此目的,所有這些都可以提高系統的整體穩定性。

雪崩效應

在微服務架構中,一個請求需要調用多個服務是非常常見的。如客戶端訪問 A 服務,而 A 服務需要調用 B 服務,B 服務需要調用 C 服務,由於網絡原因或者自身的原因,如果 B 服務或者 C 服務不能及時響應,A 服務將處於阻塞狀態,直到 B 服務 C 服務響應。此時若有大量的請求涌入,容器的線程資源會被消耗完畢,導致服務癱瘓。服務與服務之間的依賴性,故障會傳播,造成連鎖反應,會對整個微服務系統造成災難性的嚴重後果,這就是服務故障的“雪崩”效應。以下圖示完美解釋了什麼是雪崩效應。

當一切服務正常時,請求看起來是這樣的:

當其中一個服務有延遲時,它可能阻塞整個用戶請求:

在高併發的情況下,一個服務的延遲可能導致所有服務器上的所有資源在數秒內飽和。比起服務故障,更糟糕的是這些應用程序還可能導致服務之間的延遲增加,從而備份隊列,線程和其他系統資源,從而導致整個系統出現更多級聯故障。

總結

造成雪崩的原因可以歸結爲以下三點:

  • 服務提供者不可用(硬件故障,程序 BUG,緩存擊穿,用戶大量請求等)
  • 重試加大流量(用戶重試,代碼邏輯重試)
  • 服務消費者不可用(同步等待造成的資源耗盡)

最終的結果就是:一個服務不可用,導致一系列服務的不可用。

解決方案

雪崩是系統中的蝴蝶效應導致,其發生的原因多種多樣,從源頭我們無法完全杜絕雪崩的發生,但是雪崩的根本原因來源於服務之間的強依賴,所以我們可以提前評估做好服務容錯。解決方案大概可以分爲以下幾種:

  • 請求緩存:支持將一個請求與返回結果做緩存處理;
  • 請求合併:將相同的請求進行合併然後調用批處理接口;
  • 服務隔離:限制調用分佈式服務的資源,某一個調用的服務出現問題不會影響其他服務調用;
  • 服務熔斷:犧牲局部服務,保全整體系統穩定性的措施;
  • 服務降級:服務熔斷以後,客戶端調用自己本地方法返回缺省值。

環境準備

hystrix-demo 聚合工程。SpringBoot 2.2.4.RELEASESpring Cloud Hoxton.SR1

  • eureka-server:註冊中心
  • eureka-server02:註冊中心
  • product-service:商品服務,提供了 /product/{id} 接口,/product/list 接口,/product/listByIds 接口
  • order-service-rest:訂單服務,基於 Ribbon 通過 RestTemplate 調用商品服務
  • order-server-feign:訂單服務,基於 Feign 通過聲明式服務調用商品服務

模擬高併發場景

服務提供者接口添加 Thread.sleep(2000),模擬服務處理時長。

package com.example.controller;

import com.example.pojo.Product;
import com.example.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/product")
public class ProductController {

    @Autowired
    private ProductService productService;

    /**
     * 查詢商品列表
     *
     * @return
     */
    @GetMapping("/list")
    public List<Product> selectProductList() {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return productService.selectProductList();
    }

    /**
     * 根據多個主鍵查詢商品
     *
     * @param ids
     * @return
     */
    @GetMapping("/listByIds")
    public List<Product> selectProductListByIds(@RequestParam("id") List<Integer> ids) {
        return productService.selectProductListByIds(ids);
    }

    /**
     * 根據主鍵查詢商品
     *
     * @param id
     * @return
     */
    @GetMapping("/{id}")
    public Product selectProductById(@PathVariable("id") Integer id) {
        return productService.selectProductById(id);
    }

}

服務消費者降低 Tomcat 最大線程數方便模擬高併發。

server:
  port: 8080
  tomcat:
    max-threads: 10 # 降低最大線程數方便模擬高併發

JMeter

點擊鏈接觀看:JMeter 視頻(獲取更多請關注公衆號「哈嘍沃德先生」)

Apache JMeter 應用程序是開源軟件,100% 純 Java 應用而設計的負載測試功能行爲和測量性能。它最初是爲測試 Web 應用程序而設計的,但此後已擴展到其他測試功能。

Apache JMeter 可用於測試靜態和動態資源,Web 動態應用程序的性能。它可用於模擬服務器,服務器組,網絡或對象上的繁重負載,以測試其強度或分析不同負載類型下的整體性能。

安裝

官網:https://jmeter.apache.org/ 本文安裝 Windows 版本。

解壓 apache-jmeter-5.2.1.zip,進入 bin 目錄運行 jmeter.bat 即可。不過運行之前我們先來修改一下配置文件,方便大家更友好的使用。

修改配置

進入 bin 目錄編輯 jmeter.properties 文件,修改 37 行和 1085 行兩處代碼(不同的電腦可能行數不一致,不過上下差距不大)。

  • language=zh_CN 界面顯示中文
  • sampleresult.default.encoding=UTF-8 編碼字符集使用 UTF-8
#language=en
language=zh_CN

#sampleresult.default.encoding=ISO-8859-1
sampleresult.default.encoding=UTF-8

運行

運行 bin/jmeter.bat 文件,界面顯示如下。

大家可以通過 選項外觀 選擇自己喜歡的界面風格。

基本使用

添加線程組

添加 HTTP 請求

HTTP 請求配置爲服務消費者的 http://localhost:9090/order/1/product/list

添加結果數

我們可以添加結果數來查看請求響應的結果數據。

下圖是執行請求以後所顯示的效果。

執行請求

如下圖所示,點擊啓動按鈕即可開始執行請求。STOP 按鈕則爲停止請求。

測試請求

瀏覽器請求 http://localhost:9090/order/1/product 統計耗時如下。請求耗時:235ms

通過 JMeter 開啓 50 線程循環 50 次請求服務消費者 http://localhost:9090/order/1/product/list 然後瀏覽器再次請求 http://localhost:9090/order/1/product 統計耗時如下。請求耗時:9.12s

通過以上測試我們可以發現,/product/list 服務如果出現故障或延遲整個系統的資源會被耗盡從而導致影響其他服務的正常使用,這種情況在微服務項目中是非常常見的,所以我們需要對服務做出容錯處理。接下來我們就一個個學習服務容錯的解決方案。

請求緩存

點擊鏈接觀看:請求緩存視頻(獲取更多請關注公衆號「哈嘍沃德先生」)

Hystrix 爲了降低訪問服務的頻率,支持將一個請求與返回結果做緩存處理。如果再次請求的 URL 沒有變化,那麼 Hystrix 不會請求服務,而是直接從緩存中將結果返回。這樣可以大大降低訪問服務的壓力。

安裝 Redis

Hystrix 自帶緩存有兩個缺點:

  • 本地緩存,集羣情況下緩存無法同步。
  • 不支持第三方緩存容器,如:Redis,MemCache。

本文使用 Spring 的緩存集成方案,NoSql 使用 Redis 來實現,Redis 使用的是 5.0.7 版本。

添加依賴

服務消費者 pom.xml 添加 redis 和 commons-pool2 依賴。

<!-- spring boot data redis 依賴 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- commons-pool2 對象池依賴 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

配置文件

服務消費者 application.yml 配置 Redis 緩存。

spring:
  # redis 緩存
  redis:
    timeout: 10000        # 連接超時時間
    host: 192.168.10.101  # Redis服務器地址
    port: 6379            # Redis服務器端口
    password: root        # Redis服務器密碼
    database: 0           # 選擇哪個庫,默認0庫
    lettuce:
      pool:
        max-active: 1024  # 最大連接數,默認 8
        max-wait: 10000   # 最大連接阻塞等待時間,單位毫秒,默認 -1
        max-idle: 200     # 最大空閒連接,默認 8
        min-idle: 5       # 最小空閒連接,默認 0

配置類

添加 Redis 配置類重寫序列化規則。

package com.example.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

/**
 * Redis 配置類
 */
@Configuration
public class RedisConfig {

    // 重寫 RedisTemplate 序列化
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        // 爲 String 類型 key 設置序列化器
        template.setKeySerializer(new StringRedisSerializer());
        // 爲 String 類型 value 設置序列化器
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        // 爲 Hash 類型 key 設置序列化器
        template.setHashKeySerializer(new StringRedisSerializer());
        // 爲 Hash 類型 value 設置序列化器
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }

    // 重寫 Cache 序列化
    @Bean
    public RedisCacheManager redisCacheManager(RedisTemplate redisTemplate) {
        RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisTemplate.getConnectionFactory());
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                // 設置默認過期時間 30 min
                .entryTtl(Duration.ofMinutes(30))
                // 設置 key 和 value 的序列化
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisTemplate.getKeySerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisTemplate.getValueSerializer()));
        return new RedisCacheManager(redisCacheWriter, redisCacheConfiguration);
    }

}

啓動類

服務消費者啓動類開啓緩存註解

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;

// 開啓緩存註解
@EnableCaching
@SpringBootApplication
public class OrderServiceRestApplication {

    @Bean
    @LoadBalanced
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }

    public static void main(String[] args) {
        SpringApplication.run(OrderServiceRestApplication.class, args);
    }

}

業務層

服務消費者業務層代碼添加緩存規則。

package com.example.service.impl;

import com.example.pojo.Product;
import com.example.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.List;

@Service
public class ProductServiceImpl implements ProductService {

    @Autowired
    private RestTemplate restTemplate;

    /**
     * 查詢商品列表
     *
     * @return
     */
    @Cacheable(cacheNames = "orderService:product:list")
    @Override
    public List<Product> selectProductList() {
        // ResponseEntity: 封裝了返回數據
        return restTemplate.exchange(
                "http://product-service/product/list",
                HttpMethod.GET,
                null,
                new ParameterizedTypeReference<List<Product>>() {
                }).getBody();
    }

    /**
     * 根據主鍵查詢商品
     *
     * @param id
     * @return
     */
    @Cacheable(cacheNames = "orderService:product:single", key = "#id")
    @Override
    public Product selectProductById(Integer id) {
        return restTemplate.getForObject("http://product-service/product/" + id, Product.class);
    }

}

測試

爲了方便查看效果我們在服務提供者對應接口中添加打印語句。

訪問:http://localhost:9090/order/1/product/list 和 http://localhost:9090/order/1/product 效果如下。

當我們請求相同服務時,服務提供者也不再打印語句說明服務消費者的請求直接獲取了緩存的數據。

JMeter 開啓 50 線程循環 50 次請求 http://localhost:9090/order/1/product/list

瀏覽器請求 http://localhost:9090/order/1/product,結果如下:

從結果可以看出請求緩存已解決之前服務響應速度過慢的問題。

請求合併

在微服務架構中,我們將一個項目拆分成很多個獨立的模塊,這些獨立的模塊通過遠程調用來互相配合工作,但是,在高併發情況下,通信次數的增加會導致總的通信時間增加,同時,線程池的資源也是有限的,高併發環境會導致有大量的線程處於等待狀態,進而導致響應延遲,爲了解決這些問題,我們需要來了解 Hystrix 的請求合併。

請求合併的缺點

設置請求合併之後,本來一個請求可能 5ms 就搞定了,但是現在必須再等 10ms 看看還有沒有其他的請求一起,這樣一個請求的耗時就從 5ms 增加到 15ms 了。

如果我們要發起的命令本身就是一個高延遲的命令,那麼這個時候就可以使用請求合併了,因爲這個時候時間消耗就顯得微不足道了,另外高併發也是請求合併的一個非常重要的場景。

添加依賴

服務消費者 pom.xml 添加 hystrix 依賴。

<!-- spring-cloud netflix hystrix 依賴 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

業務層

服務消費者業務層代碼添加請求合併規則。

package com.example.service.impl;

import com.example.pojo.Product;
import com.example.service.ProductService;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixCollapser;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.List;
import java.util.concurrent.Future;

@Service
public class ProductServiceImpl implements ProductService {

    @Autowired
    private RestTemplate restTemplate;

    /**
     * 根據多個主鍵查詢商品
     *
     * @param ids
     * @return
     */
    // 聲明需要服務容錯的方法
    @HystrixCommand
    @Override
    public List<Product> selectProductListByIds(List<Integer> ids) {
        System.out.println("-----orderService-----selectProductListByIds-----");
        StringBuffer sb = new StringBuffer();
        ids.forEach(id -> sb.append("id=" + id + "&"));
        return restTemplate.getForObject("http://product-service/product/listByIds?" + sb.toString(), List.class);
    }

    /**
     * 根據主鍵查詢商品
     *
     * @param id
     * @return
     */
    // 處理請求合併的方法一定要支持異步,返回值必須是 Future<T>
    // 合併請求
    @HystrixCollapser(batchMethod = "selectProductListByIds", // 合併請求方法
            scope = com.netflix.hystrix.HystrixCollapser.Scope.GLOBAL, // 請求方式
            collapserProperties = {
                    // 間隔多久的請求會進行合併,默認 10ms
                    @HystrixProperty(name = "timerDelayInMilliseconds", value = "20"),
                    // 批處理之前,批處理中允許的最大請求數
                    @HystrixProperty(name = "maxRequestsInBatch", value = "200")
            })
    @Override
    public Future<Product> selectProductById(Integer id) {
        System.out.println("-----orderService-----selectProductById-----");
        return null;
    }

}

@HystrixCollapser 註解各項參數說明如下:

服務消費者模擬同一時間用戶發起多個請求。

package com.example.service.impl;

import com.example.pojo.Order;
import com.example.pojo.Product;
import com.example.service.OrderService;
import com.example.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Arrays;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;

@Service
public class OrderServiceImpl implements OrderService {

    @Autowired
    private ProductService productService;

    /**
     * 根據主鍵查詢訂單
     *
     * @param id
     * @return
     */
    @Override
    public Order searchOrderById(Integer id) {
        // 模擬同一時間用戶發起多個請求。
        Future<Product> p1 = productService.selectProductById(1);
        Future<Product> p2 = productService.selectProductById(2);
        Future<Product> p3 = productService.selectProductById(3);
        Future<Product> p4 = productService.selectProductById(4);
        Future<Product> p5 = productService.selectProductById(5);
        try {
            System.out.println(p1.get());
            System.out.println(p2.get());
            System.out.println(p3.get());
            System.out.println(p4.get());
            System.out.println(p5.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        return new Order(id, "order-003", "中國", 29000D, null);
    }

}

啓動類

服務消費者啓動類開啓熔斷器註解。

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;

// 開啓熔斷器註解 2 選 1,@EnableHystrix 封裝了 @EnableCircuitBreaker
// @EnableHystrix
@EnableCircuitBreaker
@SpringBootApplication
public class OrderServiceRestApplication {

    @Bean
    @LoadBalanced
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }

    public static void main(String[] args) {
        SpringApplication.run(OrderServiceRestApplication.class, args);
    }

}

測試

訪問:http://localhost:9090/order/1/product 控制檯打印結果如下:

-----orderService-----selectProductListByIds-----
{id=1, productName=電視機1, productNum=1, productPrice=5800.0}
{id=2, productName=電視機2, productNum=1, productPrice=5800.0}
{id=3, productName=電視機3, productNum=1, productPrice=5800.0}
{id=4, productName=電視機4, productNum=1, productPrice=5800.0}
{id=5, productName=電視機5, productNum=1, productPrice=5800.0}

根據結果得知,請求本來調用的是單個商品查詢,請求合併以後只請求了一次批處理查詢。

下一篇我們講解 Hystrix 服務隔離中的線程池隔離與信號量隔離,記得關注噢~

本文采用 知識共享「署名-非商業性使用-禁止演繹 4.0 國際」許可協議

大家可以通過 分類 查看更多關於 Spring Cloud 的文章。


🤗 您的點贊轉發是對我最大的支持。

📢 掃碼關注 哈嘍沃德先生「文檔 + 視頻」每篇文章都配有專門視頻講解,學習更輕鬆噢 ~

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