五大分散式場景解決方案

女友在高考發表於2022-01-02

一、一致性Hash演算法

Hash演算法,雜湊函式,顧名思義,它是一個函式。如果把它定義成 hash(key) ,其中 key 表示元素的鍵值,則 hash(key) 的值表示經過雜湊函式計算得到的雜湊值。

常見的Hash演算法如:MD5、SHA-1

Hash演算法在分散式場景中的應用,主要分為兩類:

  1. 請求的負載均衡

如nginx的ip_hash策略,可以讓同一個客戶端,每次都路由到同一個目標伺服器,可以實現會話粘滯,避免處理session共享問題。

具體步驟就是使用hash演算法根據ip計算hash值,然後根據目標伺服器數量取模。
2. 分散式儲存

以分散式儲存redis叢集為例,有redis1、redis2、redis3三臺伺服器,那麼對某一個key進行儲存呢?就需要針對key進行hash處理,index=hash(key)%3,使用index鎖定儲存伺服器的具體節點。

普通Hash演算法存在的問題

普通的Hash演算法存在一個問題,以ip_hash為例,如果說後臺有3臺tomcat,tomcat2當機了,那麼hash(ip)%3變成了hash(ip)%2,這樣就造成了所有的使用者都需要重新計算。原本路由到tomcat1和tomcat3的那部分ip使用者也會受影響。

一致性Hash演算法

首先我們想象有一個環,這個環起始點是0,結束點是2的32次方-1。這樣我們把伺服器的ip求hash取得一個值,就能對應到環上某一個位置。針對客戶端使用者也是一樣,根據客戶端ip進行hash求值,也能對應到環上某一個位置,然後如何確定一個客戶端路由到哪個伺服器呢?

就按照順時針放行找到最近的伺服器節點。

如果還是上面的場景,3臺tomcat應用,tomcat2當機了。那麼原來tomcat1和tomcat3的使用者不會受影響,而原本應該落到tomat2上的應用會全部落到tomcat1或者tomcat3上。

那這個演算法就沒有問題了嗎?

如果服務端節點比較少,如上圖所示,那麼就會出現資料傾斜問題,大量的請求會路由到節點1,只有少部分能路由到節點2.

為了解決這個問題,一致性hash演算法引入了虛擬節點機制。可以對每個伺服器節點計算多個hash。具體做法可以在每個伺服器ip或主機名後面增加編號來實現。

簡易的一致性hash演算法程式碼如下(僅供學習使用,不能用於生產):

/**
 * ⼀致性Hash演算法實現(含虛擬節點)
 */
public class ConsistentHashWithVirtual {

    public static void main(String[] args) {
        String[] clients=new String[]{"10.177.2.1","10.192.2.1","10.98.45.4"};

        String[] tomcatServers = new String[]
                {"123.111.0.0","123.101.3.1","111.20.35.2","123.98.26.3"};

        //虛擬節點數
        int virtualCount=20;

        TreeMap<Integer,String> serverMap=new TreeMap<>();
        for (String server:tomcatServers){
            int serverHash = Math.abs(server.hashCode());
            serverMap.put(serverHash,server);
            for (int i = 0; i < virtualCount; i++) {
                int virtualHash=Math.abs((server+"#"+virtualCount).hashCode());
                serverMap.put(virtualHash,"虛擬"+server);
            }

        }


        for (String client:clients){
            int clientHash = Math.abs(client.hashCode());
            //獲取一個子集。其所有物件的 key 的值大於等於 fromKey
            SortedMap<Integer, String> sortedMap =serverMap.tailMap(clientHash);
            if(sortedMap.isEmpty()){
                Integer firstKey = serverMap.firstKey();
                String server = serverMap.get(firstKey);
                System.out.println("客戶端:"+client+" 路由到:"+server);
            }else {
                Integer firstKey = sortedMap.firstKey();
                String server = sortedMap.get(firstKey);
                System.out.println("客戶端:"+client+" 路由到:"+server);
            }
        }
    }
}

二、叢集時鐘同步

叢集時鐘不同步,指的是叢集裡各個伺服器的時間不一致。因為系統時鐘不一致,資料就會混亂。

叢集時鐘同步思路:

  1. 伺服器都能聯網的情況

使用linux的定時任務,每隔一段時間執行一次ntpdate命令同步時間。

#使⽤ ntpdate ⽹絡時間同步命令
ntpdate -u ntp.api.bz #從⼀個時間伺服器同步時間

如果ntpdate 命令不存在,可以用如下命令安裝

yum install -y ntp
  1. 選取叢集中的一個伺服器節點A作為時間伺服器(如果這臺伺服器能夠訪問網際網路,可以讓這臺伺服器和網路時間保持同步,如果不能就手動設定一個時間。)

    2.1 設定好伺服器A的時間
    2.2 把伺服器A配置為時間伺服器(修改/etc/ntp.conf檔案)

    1、如果有 restrict default ignore,註釋掉它
    2、新增如下⼏⾏內容
    
    restrict 172.17.0.0 mask 255.255.255.0 nomodify notrap # 放開局
    域⽹同步功能,172.17.0.0是你的局域⽹⽹段
    server 127.127.1.0 # local clock
    fudge 127.127.1.0 stratum 10   
    
    
    3、重啟⽣效並配置ntpd服務開機⾃啟動
    service ntpd restart
    chkconfig ntpd on
    

    2.2 叢集中其他節點就可以從A伺服器同步時間了

    ntpdate 172.17.0.17
    

三、分散式ID

  1. 資料庫方式
  2. redis方式
  3. UUID方式
  4. 雪花演算法

雪花演算法程式碼:

/**
 * 官方推出,Scala程式語言來實現的
 * Java前輩用Java語言實現了雪花演算法
 */
public class IdWorker{

    //下面兩個每個5位,加起來就是10位的工作機器id
    private long workerId;    //工作id
    private long datacenterId;   //資料id
    //12位的序列號
    private long sequence;

    public IdWorker(long workerId, long datacenterId, long sequence){
        // sanity check for workerId
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0",maxWorkerId));
        }
        if (datacenterId > maxDatacenterId || datacenterId < 0) {
            throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0",maxDatacenterId));
        }
        System.out.printf("worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d",
                timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId);

        this.workerId = workerId;
        this.datacenterId = datacenterId;
        this.sequence = sequence;
    }

    //初始時間戳
    private long twepoch = 1288834974657L;

    //長度為5位
    private long workerIdBits = 5L;
    private long datacenterIdBits = 5L;
    //最大值
    private long maxWorkerId = -1L ^ (-1L << workerIdBits);
    private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
    //序列號id長度
    private long sequenceBits = 12L;
    //序列號最大值
    private long sequenceMask = -1L ^ (-1L << sequenceBits);
    
    //工作id需要左移的位數,12位
    private long workerIdShift = sequenceBits;
   //資料id需要左移位數 12+5=17位
    private long datacenterIdShift = sequenceBits + workerIdBits;
    //時間戳需要左移位數 12+5+5=22位
    private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
    
    //上次時間戳,初始值為負數
    private long lastTimestamp = -1L;

    public long getWorkerId(){
        return workerId;
    }

    public long getDatacenterId(){
        return datacenterId;
    }

    public long getTimestamp(){
        return System.currentTimeMillis();
    }

     //下一個ID生成演算法
    public synchronized long nextId() {
        long timestamp = timeGen();

        //獲取當前時間戳如果小於上次時間戳,則表示時間戳獲取出現異常
        if (timestamp < lastTimestamp) {
            System.err.printf("clock is moving backwards.  Rejecting requests until %d.", lastTimestamp);
            throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds",
                    lastTimestamp - timestamp));
        }

        //獲取當前時間戳如果等於上次時間戳
        //說明:還處在同一毫秒內,則在序列號加1;否則序列號賦值為0,從0開始。
        if (lastTimestamp == timestamp) {  // 0  - 4095
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0;
        }
        
        //將上次時間戳值重新整理
        lastTimestamp = timestamp;

        /**
          * 返回結果:
          * (timestamp - twepoch) << timestampLeftShift) 表示將時間戳減去初始時間戳,再左移相應位數
          * (datacenterId << datacenterIdShift) 表示將資料id左移相應位數
          * (workerId << workerIdShift) 表示將工作id左移相應位數
          * | 是按位或運算子,例如:x | y,只有當x,y都為0的時候結果才為0,其它情況結果都為1。
          * 因為個部分只有相應位上的值有意義,其它位上都是0,所以將各部分的值進行 | 運算就能得到最終拼接好的id
        */
        return ((timestamp - twepoch) << timestampLeftShift) |
                (datacenterId << datacenterIdShift) |
                (workerId << workerIdShift) |
                sequence;
    }

    //獲取時間戳,並與上次時間戳比較
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }

    //獲取系統時間戳
    private long timeGen(){
        return System.currentTimeMillis();
    }




    public static void main(String[] args) {
        IdWorker worker = new IdWorker(21,10,0);
        for (int i = 0; i < 100; i++) {
            System.out.println(worker.nextId());
        }
    }

}

關於分散式ID的更多詳細內容,可以看我的另一篇部落格:分散式主鍵

四、分散式排程

排程,也就是我們所說的定時任務。定時任務的使用場景很多,如訂單超時取消,定時備份資料等等。

那麼分散式排程是什麼意思呢?

有兩層含義:

  1. 執行在分散式叢集環境下的排程任務(同一個定時任務程式部署多份,只應該有一個定時任務在執行)
  2. 同一個大的定時任務可以拆分為多個小任務在多個機器上同時執行

在介紹分散式排程框架之前,我們先來回顧一下普通的定時任務框架Quartz.

任務排程框架Quartz回顧

  1. 引入pom依賴
 <dependency>
    <groupId>org.quartz-scheduler</groupId>
    <artifactId>quartz</artifactId>
    <version>2.3.2</version>
</dependency>
  1. 程式碼編寫
public class QuartzMain {

    public static void main(String[] args) throws SchedulerException {
        Scheduler scheduler = createScheduler();
        JobDetail job = createJob();
        Trigger trigger = createTrigger();
        scheduler.scheduleJob(job,trigger);
        scheduler.start();
    }

    /**
     * 建立任務排程器
     */
    public static Scheduler createScheduler() throws SchedulerException {
        SchedulerFactory schedulerFactory=new StdSchedulerFactory();
        return schedulerFactory.getScheduler();
    }


    /**
     * 建立一個任務
     * @return
     */
    public static JobDetail createJob(){
        JobBuilder jobBuilder=JobBuilder.newJob(DemoJob.class);
        jobBuilder.withIdentity("jobName","myJob");
        return jobBuilder.build();
    }

    /**
     * 建立一個作業任務時間觸發器
     * @return
     */
    public static Trigger createTrigger(){
        CronTrigger trigger=TriggerBuilder.newTrigger()
                .withIdentity("triggerName","myTrigger")
                .startNow()
                .withSchedule(CronScheduleBuilder.cronSchedule("0/2 * * * * ?")).build();
        return trigger;
    }
}

public class DemoJob implements Job {

    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        System.out.println("任務執行");
        System.out.println(new Date().toLocaleString());
        System.out.println(Thread.currentThread().getName());
        try {
            Thread.sleep(30000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

分散式排程框架Elastic-Job

Elastic-Job介紹

Elastic-Job是噹噹網開源的一個分散式排程解決方案,是基於Quartz二次開發的,由兩個相互獨立的子專案Elastic-Job-Lite和Elastic-Job-Cloud組成。我們要學習的是 Elastic-Job-Lite,它定位為輕量級⽆中⼼
化解決⽅案,使⽤Jar包的形式提供分散式任務的協調服務,⽽Elastic-Job-Cloud⼦項⽬需要結合Mesos以及Docker在雲環境下使⽤

Elastic-Job的github地址:https://github.com/elasticjob

主要功能介紹:

  • 分散式排程協調

在分散式環境中,任務能夠按照指定的排程策略執行,並且能夠避免同一個任務多例項的重複執行。

  • 豐富的排程策略
  • 彈性擴容縮容

當叢集中增加一個例項,塔應當也能夠被選舉並執行任務;當叢集中減少一個例項時,它所執行的任務能被轉移到別的例項來執行。

  • 失效轉移

某例項在任務執行失敗後,會被轉移到其他例項執行

  • 錯過執行作業重觸發

若因某種原因導致作業錯過執行,自動記錄錯過執行的作業,並在上次作業完成後自動觸發。

  • 支援並行排程

支援任務分片,任務分片是指將一個任務分為多個小任務在多個例項中執行

  • 作業分片一致性

當任務被分片後,保證同一分片在分散式環境中僅一個執行例項

Elastic-Job-Lite應用

Elastic-Job依賴於Zookeeper進行分散式協調,需要安裝Zookeeper軟體(3.4.6版本以上)。

引入pom

 <dependency>
    <groupId>com.dangdang</groupId>
    <artifactId>elastic-job-lite-core</artifactId>
    <version>2.1.5</version>
</dependency>
  1. 只有一個分片任務的場景
public class ElasticJobMain {



    public static void main(String[] args) throws SQLException {
        ZookeeperConfiguration zookeeperConfiguration=new ZookeeperConfiguration("localhost:2181","data-archive-job");
        CoordinatorRegistryCenter coordinatorRegistryCenter=new ZookeeperRegistryCenter(zookeeperConfiguration);
        coordinatorRegistryCenter.init();
        //shardingTotalCount設定為1時,啟動多個例項,只能有一個例項執行
        JobCoreConfiguration jobCoreConfiguration=JobCoreConfiguration.newBuilder("jobName",
                "0/2 * * * * ?",1).build();
        SimpleJobConfiguration simpleJobConfiguration=new SimpleJobConfiguration(jobCoreConfiguration,BackupJob.class.getName());
        JobScheduler jobScheduler = new JobScheduler(coordinatorRegistryCenter, LiteJobConfiguration.newBuilder(simpleJobConfiguration).build());
        jobScheduler.init();

    }


}
public class BackupJob implements SimpleJob {

    @Override
    public void execute(ShardingContext shardingContext) {
        String sql="select * from t_order  limit 1";
        try {
            List<Map<String, Object>> list = JdbcUtils.executeQuery(InitData.dataSource, sql);
            if(!list.isEmpty()){
                Map<String, Object> objectMap = list.get(0);

                System.out.println(objectMap);
                String insertSql="insert into t_order_bak (id,code,amt,create_time,user_id) values(?,?,?,?,?)";
                Collection<Object> values = objectMap.values();
                List<Object> params=new ArrayList<>();
                params.addAll(values);
                JdbcUtils.execute(InitData.dataSource,insertSql,params);

                //刪除原來的
                String deleteSql="delete from t_order where id=?";
                Object id = objectMap.get("id");
                JdbcUtils.execute(InitData.dataSource,deleteSql, Arrays.asList(id));
                System.out.println("資料:"+id+"備份完成");
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}
  1. 任務分片的情況
public class ElasticJobMain {



    public static void main(String[] args) throws SQLException {
        ZookeeperConfiguration zookeeperConfiguration=new ZookeeperConfiguration("localhost:2181","data-archive-job");
        CoordinatorRegistryCenter coordinatorRegistryCenter=new ZookeeperRegistryCenter(zookeeperConfiguration);
        coordinatorRegistryCenter.init();
        //shardingTotalCount設定為3,shardingItemParameters為傳入的分片引數,0=後面的值就是0分片將會取到的引數。如0=abc,那麼0分片
        //對應shardingContext.getShardingParameter()取到的就是abc
        JobCoreConfiguration jobCoreConfiguration=JobCoreConfiguration.newBuilder("jobName2",
                "0/2 * * * * ?",3).shardingItemParameters("0=0,1=1,2=2")
                .build();
        SimpleJobConfiguration simpleJobConfiguration=new SimpleJobConfiguration(jobCoreConfiguration,BackupJob.class.getName());
        JobScheduler jobScheduler = new JobScheduler(coordinatorRegistryCenter, LiteJobConfiguration.newBuilder(simpleJobConfiguration).build());
        jobScheduler.init();

    }


}
public class BackupJob implements SimpleJob {

    @Override
    public void execute(ShardingContext shardingContext) {

        int shardingItem = shardingContext.getShardingItem();
        System.out.println("當前分片:"+shardingItem);
        String shardingParameter = shardingContext.getShardingParameter();
        System.out.println("獲取分片引數:"+shardingParameter);
        String sql="select * from t_order where user_id = ? limit 1";
        try {
            List<Map<String, Object>> list = JdbcUtils.executeQuery(InitData.dataSource, sql,shardingParameter);
            if(!list.isEmpty()){
                Map<String, Object> objectMap = list.get(0);

                System.out.println(objectMap);
                String insertSql="insert into t_order_bak (id,code,amt,create_time,user_id) values(?,?,?,?,?)";
                Collection<Object> values = objectMap.values();
                List<Object> params=new ArrayList<>();
                params.addAll(values);
                JdbcUtils.execute(InitData.dataSource,insertSql,params);

                //刪除原來的
                String deleteSql="delete from t_order where id=?";
                Object id = objectMap.get("id");
                JdbcUtils.execute(InitData.dataSource,deleteSql, Arrays.asList(id));
                System.out.println("資料:"+id+"備份完成");
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

五、Session共享

Session問題原因分析

出現這個問題的原因,從根本上來說是因為HTTP協議是無狀態的協議。客戶端和服務端在某次會話中產生的資料不會被保留下來,所以第二次請求服務端無法認識到你曾經來過。後來出現了兩種用於保持Http狀態的技術,就是Cookie和Session。

當叢集中有多臺伺服器時,你在伺服器1上登入了,伺服器1的session裡有了你的資料,下一次請求如果nginx把你路由到其他伺服器,那你又需要登入了,因為其他伺服器上沒有存得有你的資料。

解決Session一致性方案

  1. 方案一:Nginx的ip_hash策略

同一個客戶端ip的請求都會被路由到同一個目標伺服器

  • 優點:配置簡單,不入侵應用
  • 缺點:伺服器重啟Session丟失,單點故障問題
  1. 方案二:Session複製(不推薦)

多個Tomcat之間通過修改配置檔案,達到Session之間的複製

  • 優點:不入侵應用,便於擴充套件,伺服器重啟不會造成Session丟失
  • 缺點:效能低,記憶體消耗,延遲性
  1. 方案三:Session集中儲存(推薦)
  • 優點:能適應各種負載均衡策略,伺服器重啟不會造成Session丟失
  • 缺點:對應用有入侵,引入了和Redis的互動程式碼

Redis Session共享

Spring Session使得基於Redis的Session共享非常簡單。

  1. 引入依賴
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
  1. 配置redis
spring.redis.database=0
spring.redis.host=127.0.0.1
spring.redis.port=6379
  1. 新增註解

在啟動類上增加@EnableRedisHttpSession

相關文章