五大分佈式場景解決方案

一、一致性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

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