Sentinel嵌入式集羣模式搭建(Nacos)

1.環境準備

    1)代碼準備

        筆者目前是基於Sentinel-release-1.7的源碼進行測試的,在原sentinel-demo-cluster的基礎上,適配筆者本地的環境進行修改的。源碼地址如下:https://github.com/alibaba/Sentinel/tree/release-1.7 

 

    2)Nacos環境準備

        由於Sentinel集羣測試是基於Nacos配置中心的,所以需要在本地啓動一個Nacos服務,這裏就不再贅述Nacos的搭建。可參考:https://github.com/alibaba/nacos 

 

    3)Sentinel控制檯準備

        由於我們需要Sentinel控制檯去觀察我們的集羣流控信息,所以需要提前把這個控制檯準備好。

        這個啓動方式官網有介紹,筆者不再贅述

 

2.基礎概念

    * token server:集羣流控服務端,用來處理token client的請求,根據集羣規則來判斷是否允許通過

    * token client:集羣流控客戶端,向token server發送請求,詢問是否允許該請求通過,token server返回client結果,決定是否限流

    * 集羣模式支持的規則:限流、熱點

    * 規則閾值計算方式

        集羣總體模式(限定集羣內某資源的所有qps不超過此閾值);

        單機均攤模式(配置的閾值爲單機能夠承受的限額,集羣總閾值=單機閾值*機器數量)

 

    * token server部署方式

        獨立部署(單獨啓動一個token server服務來處理client的請求)

        嵌入模式(將token server與應用一起部署)

 

3.client-server交互原理及必備項

    原理:類似於單機限流,單機限流統計的qps在每個實例中單獨統計,集羣限流是有一個專門的實例(token server)來進行qps統計。其他實例(token client)在處理真正的業務之前會先向token server發送一個請求,如果server返回一個token,則說明集羣qps未達到閾值,則可以繼續處理業務,否則拋錯。

 

    token server配置項:

        * namespace(爲token server的一個抽象概念,代表一個應用/服務,我們可以主動指定namespace,不指定的話就是${project.name})

        * ServerTransportConfig(指定了server服務的port和idleSeconds)

        * FlowRule/ParamFlowRule(集羣限流規則或參數限流規則)

        * 表明當前服務爲token server(通過ClusterStateManager.registerProperty()方法來確認)

 

    token client配置項:

        * ClusterClientAssignConfig配置(指定token server的host和port)

        * ClusterClientConfig配置(指定requestTimeout時間)

        * 加載client降級處理FlowRule(在連接token server失敗時降級方案,使用本地FlowRule方案)

        * 表明當前服務爲token client

 

4.Nacos準備

    爲何要準備這個?筆者打算使用Nacos當做配置中心,然後上述所有需要的配置項都在Nacos直接加載即可,下面展示下需要提前在Nacos做的配置項:

    1)先根據項目名clusterDemo來創建命名服務,如下即可

    2)在命名空間clusterDemo的配置列表中添加配置

        * 集羣FlowRule規則 DataId:clusterDemo-flow-rules,配置內容如下:

[
    {
        "resource" : "cluster-resource",     
        "grade" : 1,                         
        "count" : 10,                        
        "clusterMode" :  true,               
        "clusterConfig" : {
            "flowId" : 111,                  
            "thresholdType" : 1,            
            "fallbackToLocalWhenFail" : true 
        }
    }
]

        * ClusterClientConfig配置 DataId:clusterDemo-cluster-client-config,內容如下:

{"requestTimeout":3000}

        * 用於指定server和client的host:port信息 DataId:clusterDemo-cluster-map(後續會在代碼中解析這個json,獲獲取server和port,並確認當前應用身份[client/server]),內容如下:

[{
	"clientSet": ["169.254.207.96@8729", "169.254.207.96@8727"],// client的集合
	"ip": "169.254.207.96",// server ip
	"machineId": "169.254.207.96@8720",// server ip:port
	"port": 7717// server所在服務的啓動port
}]

    結果如下:

5.嵌入式集羣模式

    1)主代碼

    根據原來的DemoClusterInitFunc.java 創建的,代碼如下

package com.alibaba.csp.sentinel.demo.cluster.init;

import com.alibaba.csp.sentinel.cluster.ClusterStateManager;
import com.alibaba.csp.sentinel.cluster.client.config.ClusterClientAssignConfig;
import com.alibaba.csp.sentinel.cluster.client.config.ClusterClientConfig;
import com.alibaba.csp.sentinel.cluster.client.config.ClusterClientConfigManager;
import com.alibaba.csp.sentinel.cluster.flow.rule.ClusterFlowRuleManager;
import com.alibaba.csp.sentinel.cluster.flow.rule.ClusterParamFlowRuleManager;
import com.alibaba.csp.sentinel.cluster.server.config.ClusterServerConfigManager;
import com.alibaba.csp.sentinel.cluster.server.config.ServerTransportConfig;
import com.alibaba.csp.sentinel.datasource.ReadableDataSource;
import com.alibaba.csp.sentinel.datasource.nacos.NacosDataSource;
import com.alibaba.csp.sentinel.demo.cluster.DemoConstants;
import com.alibaba.csp.sentinel.demo.cluster.entity.ClusterGroupEntity;
import com.alibaba.csp.sentinel.init.InitFunc;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;
import com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowRuleManager;
import com.alibaba.csp.sentinel.transport.config.TransportConfig;
import com.alibaba.csp.sentinel.util.AppNameUtil;
import com.alibaba.csp.sentinel.util.HostNameUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import com.alibaba.nacos.api.PropertyKeyConst;

import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;

/**
 * 簡單版本
 * @author Eric Zhao
 */
public class DemoClusterInitFuncSimple implements InitFunc {

    private static final String APP_NAME = AppNameUtil.getAppName();

    private final String remoteAddress = "localhost";
    // 這裏需要注意的是,我們使用Nacos指定namespace後,這裏獲取的實際不是namespace的名稱clusterDemo,
    // 而是其對應的一串ID,大家可以從各自的Nacos上找到
    private final String nacosNamespace = "11f4068f-d5e7-4c97-af73-b9b1b037f5bd";// clusterDemo
    private final String groupId = "DEFAULT_GROUP";
    private Properties properties = new Properties();

    private final String flowDataId = APP_NAME + DemoConstants.FLOW_POSTFIX;
    private final String paramDataId = APP_NAME + DemoConstants.PARAM_FLOW_POSTFIX;
    private final String configDataId = APP_NAME + "-cluster-client-config";
    private final String clusterMapDataId = APP_NAME + DemoConstants.CLUSTER_MAP_POSTFIX;

    @Override
    public void init() throws Exception {
        // 使用namespace的方式加載Nacos配置
        properties.put(PropertyKeyConst.SERVER_ADDR, remoteAddress);
        properties.put(PropertyKeyConst.NAMESPACE, nacosNamespace);


        // client:加載FlowRule(降級規則)
        initDynamicRuleProperty();

        // client:加載ClusterClientConfig(requestTimeout)
        initClientConfigProperty();
        // client:加載ClusterClientAssignConfig(serverHost、serverPort)
        initClientServerAssignProperty();

        // server:加載集羣規則,namespace下對應的FlowRule
        registerClusterRuleSupplier();
        // server:從assignMap中獲取ServerTransportConfig(port、idleSeconds)
        initServerTransportConfigProperty();

        // 根據我們的clusterDemo-cluster-map配置,設置當前應用狀態(CLIENT/SERVER/NOT_STARTED)
        initStateProperty();
    }

    // 這個最簡單,本地加載降級規則,沒啥好說的 
    private void initDynamicRuleProperty() {
        ReadableDataSource<String, List<FlowRule>> ruleSource = new NacosDataSource<>(properties, groupId,
            flowDataId, source -> JSON.parseObject(source, new TypeReference<List<FlowRule>>() {}));
        FlowRuleManager.register2Property(ruleSource.getProperty());
    }

    // client端加載requestTimeout配置
    private void initClientConfigProperty() {
        ReadableDataSource<String, ClusterClientConfig> clientConfigDs = new NacosDataSource<>(properties, groupId,
            configDataId, source -> JSON.parseObject(source, new TypeReference<ClusterClientConfig>() {}));
        ClusterClientConfigManager.registerClientConfigProperty(clientConfigDs.getProperty());
    }

    // server端加載port
    private void initServerTransportConfigProperty() {
        ReadableDataSource<String, ServerTransportConfig> serverTransportDs = new NacosDataSource<>(properties, groupId,
            clusterMapDataId, source -> {
            List<ClusterGroupEntity> groupList = JSON.parseObject(source, new TypeReference<List<ClusterGroupEntity>>() {});
            return Optional.ofNullable(groupList)
                // 主要在這裏,通過clusterDemo-cluster-map配置的值中的machineID來比對當前應用IP:port是否符合,符合則代表是server端
                // 獲取配置中的port值
                .flatMap(this::extractServerTransportConfig)
                .orElse(null);
        });
        ClusterServerConfigManager.registerServerTransportProperty(serverTransportDs.getProperty());
    }

    // 這個是最關鍵的,根據namespace來動態從Nacos中獲取FlowRule
    // namespace可以主動加載,通過代碼ClusterServerConfigManager.loadServerNamespaceSet(Collections.singleton(APP_NAME));
    // 也可以不寫,在啓動項中添加project.name=xxx,則namespace默認取該配置項值
    private void registerClusterRuleSupplier() {
        // Register cluster flow rule property supplier which creates data source by namespace.
        // Flow rule dataId format: ${namespace}-flow-rules
        ClusterFlowRuleManager.setPropertySupplier(namespace -> {
            ReadableDataSource<String, List<FlowRule>> ds = new NacosDataSource<>(properties, groupId,
                namespace + DemoConstants.FLOW_POSTFIX, source -> JSON.parseObject(source, new TypeReference<List<FlowRule>>() {}));
            return ds.getProperty();
        });
    }

    // 這裏主要是通過map配置項中的clientSet,比對當前應用的ip:port來確認當前是否client端,如果是,則設置serverIp:serverPort爲配置中的ip:port
    private void initClientServerAssignProperty() {
        // Cluster map format:
        // [{"clientSet":["169.254.207.96@8729","169.254.207.96@8727"],"ip":"169.254.207.96","machineId":"169.254.207.96@8720","port":7717}]
        // machineId: <ip@commandPort>, commandPort for port exposed to Sentinel dashboard (transport module)
        ReadableDataSource<String, ClusterClientAssignConfig> clientAssignDs = new NacosDataSource<>(properties, groupId,
            clusterMapDataId, source -> {
            List<ClusterGroupEntity> groupList = JSON.parseObject(source, new TypeReference<List<ClusterGroupEntity>>() {});
            return Optional.ofNullable(groupList)
                // 主要在這裏
                .flatMap(this::extractClientAssignment)
                .orElse(null);
        });
        ClusterClientConfigManager.registerServerAssignProperty(clientAssignDs.getProperty());
    }

    // 這裏同樣很關鍵,通過map配置中提前設定好的clientSet,machineID來確定當前應用是server還是client
    private void initStateProperty() {
        // Cluster map format:
        // [{"clientSet":["169.254.207.96@8729","169.254.207.96@8727"],"ip":"169.254.207.96","machineId":"169.254.207.96@8720","port":7717}]
        // machineId: <ip@commandPort>, commandPort for port exposed to Sentinel dashboard (transport module)
        ReadableDataSource<String, Integer> clusterModeDs = new NacosDataSource<>(properties, groupId,
            clusterMapDataId, source -> {
            List<ClusterGroupEntity> groupList = JSON.parseObject(source, new TypeReference<List<ClusterGroupEntity>>() {});
            return Optional.ofNullable(groupList)
                // 主要在這裏
                .map(this::extractMode)
                .orElse(ClusterStateManager.CLUSTER_NOT_STARTED);
        });
        ClusterStateManager.registerProperty(clusterModeDs.getProperty());
    }

    private int extractMode(List<ClusterGroupEntity> groupList) {
        // If any server group machineId matches current, then it's token server.
        if (groupList.stream().anyMatch(this::machineEqual)) {
            return ClusterStateManager.CLUSTER_SERVER;
        }
        // If current machine belongs to any of the token server group, then it's token client.
        // Otherwise it's unassigned, should be set to NOT_STARTED.
        boolean canBeClient = groupList.stream()
            .flatMap(e -> e.getClientSet().stream())
            .filter(Objects::nonNull)
            .anyMatch(e -> e.equals(getCurrentMachineId()));
        return canBeClient ? ClusterStateManager.CLUSTER_CLIENT : ClusterStateManager.CLUSTER_NOT_STARTED;
    }

    private Optional<ServerTransportConfig> extractServerTransportConfig(List<ClusterGroupEntity> groupList) {
        return groupList.stream()
            .filter(this::machineEqual)
            .findAny()
            .map(e -> new ServerTransportConfig().setPort(e.getPort()).setIdleSeconds(600));
    }

    private Optional<ClusterClientAssignConfig> extractClientAssignment(List<ClusterGroupEntity> groupList) {
        if (groupList.stream().anyMatch(this::machineEqual)) {
            return Optional.empty();
        }
        // Build client assign config from the client set of target server group.
        for (ClusterGroupEntity group : groupList) {
            if (group.getClientSet().contains(getCurrentMachineId())) {
                String ip = group.getIp();
                Integer port = group.getPort();
                return Optional.of(new ClusterClientAssignConfig(ip, port));
            }
        }
        return Optional.empty();
    }

    private boolean machineEqual(/*@Valid*/ ClusterGroupEntity group) {
        return getCurrentMachineId().equals(group.getMachineId());
    }

    private String getCurrentMachineId() {
        // Note: this may not work well for container-based env.
//        return HostNameUtil.getIp() + SEPARATOR + TransportConfig.getRuntimePort();

        return HostNameUtil.getIp() + SEPARATOR + TransportConfig.getPort();
    }

    private static final String SEPARATOR = "@";
}

    2)啓動應用配置

        上述主代碼寫完之後,適用於集羣中的所有應用。接下來我們要去在應用啓動之前加載這個主類。

        通過在ClusterDemoApplication.main()方法中加載這個類,代碼如下(當然也可以配置在文件中,通過SPI的方式加載):

@SpringBootApplication
public class ClusterDemoApplication {

    public static void main(String[] args) {
        try {
            // 這裏我們要主動加載DemoClusterInitFuncSimple.init()方法
            // 也可以通過SPI的方式加載
            new DemoClusterInitFuncSimple().init();
        } catch (Exception e) {
            e.printStackTrace();
        }
        SpringApplication.run(ClusterDemoApplication.class, args);
    }
}

        * 下面就是啓動server,照常啓動SpringBoot項目的方式(也就是執行ClusterDemoApplication.main()方法)即可,啓動配置爲

-Dserver.port=7718 // 應用服務器啓動端口
-Dproject.name=clusterDemo // 應用名稱(也對應着我們在Nacos中配置的namespace)
-Dcsp.sentinel.dashboard.server=localhost:8080 // 指定Sentinel控制檯的地址(本地啓動)
-Dcsp.sentinel.api.port=8720 // 本地啓動 HTTP API Server 的端口號
-Dcsp.sentinel.log.use.pid=true // 日誌文件名中是否加入進程號,用於單機部署多個應用的情況

         這裏需要注意的是:

            我們的project.name一定要與Nacos中配置的namespace一致,否則會拉取不到配置信息

            csp.sentinel.api.port配置的8720端口需要與clusterDemo-cluster-map配置中machineID中的port保持一致,否則無法判斷當前爲server端;

 

        * 啓動client1,啓動配置爲

-Dserver.port=7719 // 應用服務器端口,其他與server基本類似
-Dproject.name=clusterDemo 
-Dcsp.sentinel.dashboard.server=localhost:8080 
-Dcsp.sentinel.api.port=8729 // 與上述的注意事項一致,這裏的port需要與map配置中的clientSet中的當前ip對應的port保持一致,否則無法判斷當前爲client
-Dcsp.sentinel.log.use.pid=true

        * 啓動client2,啓動配置爲

-Dserver.port=7720 // 應用服務器端口,其他與server基本類似
-Dproject.name=clusterDemo // 三個項目的project.name要保持一致
-Dcsp.sentinel.dashboard.server=localhost:8080 
-Dcsp.sentinel.api.port=8727 // 與上述的注意事項一致,這裏的port需要與map配置中的clientSet中的當前ip對應的port保持一致,否則無法判斷當前爲client
-Dcsp.sentinel.log.use.pid=true

    在這裏,把我們的Nacos中clusterDemo-cluster-map的配置項再回顧一下

[{
	"clientSet": ["169.254.207.96@8729", "169.254.207.96@8727"],// 這裏的ip:port與我們client1IP:csp.sentinel.api.port是保持一致的
	"ip": "169.254.207.96",// server ip
	"machineId": "169.254.207.96@8720",// serverIp:csp.sentinel.api.port
	"port": 7717 //server應用服務器port
}]

    3)Sentinel控制檯驗證

        在啓動上面三個應用後,我們可以在Sentinel控制檯看到以下展示

        可以看到項目名爲clusterDemo的機器列表中有我們啓動的三個應用ip和port,需要注意的是這裏的port是我們在應用啓動參數中指定的csp.sentinel.api.port。

        在集羣流控頁面中可以看到以下頁面:

    在管理信息中如下所示:可以看到token server和token client與我們在之前map配置中指定的是一致的

6.測試驗證

    下面我們通過請求server和client的接口來驗證下集羣限流是否生效

    1)測試代碼

// 新建類,用於測試
public class HttpRequestDemo {

    // 注意這裏的port爲我們應用服務器的啓動port,對應應用啓動參server.port
    public static final String requestPath = "http://localhost:7720/hello/jack";
    public static final String requestPath2 = "http://localhost:7719/hello/jack";
    public static final String requestPath3 = "http://localhost:7718/hello/jack";

    private static RestTemplate restTemplate = new RestTemplate();
    private static RestTemplate restTemplate2 = new RestTemplate();
    private static RestTemplate restTemplate3 = new RestTemplate();

    public static void main(String[] args) {

        int i = 0;
        while (true) {
            executeGet();
            i++;
            if (i % 10 == 0) {
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

            if (i == 2000) {
                break;
            }
        }
    }

    // 連續執行get請求
    private static void executeGet() {
        restTemplate.getForEntity(requestPath, String.class);
        restTemplate2.getForEntity(requestPath2, String.class);
        restTemplate3.getForEntity(requestPath3, String.class);
    }
}

// com.alibaba.csp.sentinel.demo.cluster.app.service.DemoService
@Service
public class DemoService {

    // 特別注意下,這裏的value值要修改爲我們定義的resource,也就是我們在Nacos clusterDemo-flow-rules配置項對應的resource
    @SentinelResource(value = "cluster-resource", blockHandler = "sayHelloBlockHandler")
    public String sayHello(String name) {
        return "Hello, " + name;
    }

    public String sayHelloBlockHandler(String name, BlockException ex) {
        // This is the block handler.
        ex.printStackTrace();
        return String.format("Oops, <%s> blocked by Sentinel", name);
    }
}

    2)Sentinel控制檯查看限流結果

    可以看到通過的QPS基本維持在10左右。

 

總結:

    筆者在參考Sentinel wiki進行集羣限流驗證的時候還是很痛苦的,因爲wiki中給的比較簡略,再看代碼,代碼後來可以慢慢看懂,但是在真正運行的時候卻總是失敗。終於在弄明白了代碼的含義尤其是如何確定應用是token server還是token client的時候才明白,這個demo缺少了運行時的參數指定,所以總也失敗。

    還有就是通過頁面去訪問的話,無法體現出集羣限流的功能,所以筆者補了一個用於連續發送請求的demo。用於驗證限流功能。

 

參考:

    https://github.com/alibaba/Sentinel/wiki/%E9%9B%86%E7%BE%A4%E6%B5%81%E6%8E%A7 

    https://www.jianshu.com/p/bb198c08b418  

 

 

 

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