P2M2_分布式集群架构场景化解决⽅案

144 阅读7分钟

文章内容输出来源:拉钩教育Java高薪训练营

分布式集群架构场景化解决方案

  • 分布式一定是集群,但集群不一定是分布式

    • 分布式:把一个系统拆分为多个子系统,每个子系统负责各自的功能,独立部署,各司其职

    • 集群:多个实例共同工作,最简单/常见的集群是把一个应用复制多份部署

一致性Hash算法

普通Hash算法

  • 安全加密MD5、SHA等加密算法

  • 数据存储和查找

    • 顺序查找->二分查找->直接寻址->除留余数法->开放寻址->拉链法
    • 查询效率高,时间复杂度接近于O(1)
    • 查询效率取决于Hash算法,好的算法能够让数据平均分布,既能够节省空间又能提高查询效率
  • 在分布式集群架构中的应用

    • 请求的负载均衡(比如nginx的ip_hash策略)

      • nginx-1.18.0/src/http/modules/ngx_http_upstream_ip_hash_module.c

        for (i = 0; i < (ngx_uint_t) iphp->addrlen; i++) {
            hash = (hash * 113 + iphp->addr[i]) % 6271;
        }
        
    • 分布式存储(redis、Hadoop、ElasticSearch、Mysql分库分表等)

      • <key1, value1>数据存储到哪个服务器?hash(key1)%3=index
  • 普通Hash算法存在的问题

    • 服务器缩容和扩容将引起巨大影响,大量用户的请求会被路由到其他目标服务器

一致性Hash算法

  • 算法思路
    1. 哈希环(0~2^32-1)
    2. 服务器的ip或主机名求hash值,对应到哈希环上的某个位置
    3. 客户端采用相同方式,对应哈希环上的位置
    4. 按顺时针方向找最近的服务器节点
  • 扩容和缩容
    • 小部分客户端收到影响,请求的迁移达到了最小
  • 虚拟节点
    • 当节点数较小时,存在数据(请求)倾斜的问题
    • 虚拟节点机制:对每一个服务节点计算多个hash值,每个计算结果位置都放置一个服务节点->虚拟节点

手写实现

  • 普通hash算法

    int hash = Math.abs(client.hashCode());
    int index = hash % serverCount;
    
  • 一致性hash算法

    // 1. 服务器ip的hash值对应到hash环
    String[] servers = new String[]{"123.111.0.0", "123.101.3.1", "111.20.35.2", "123.98.26.3"};
    
    SortedMap<Integer, String> serverHashMap = new TreeMap<>();
    for (String server : servers) {
        int serverHash = Math.abs(server.hashCode());
        serverHashMap.put(serverHash, server);
    }
    
    // 2. 客户端ip对应
    String[] clients = new String[]{"10.78.12.3", "113.25.63.1", "126.12.3.8"};
    
    for (String client : clients) {
        int clientHash = Math.abs(client.hashCode());
        // 3. 顺时针查找最近的服务器
        SortedMap<Integer, String> tailMap = serverHashMap.tailMap(clientHash);
        if (tailMap.isEmpty()) {
            Integer firstKey = serverHashMap.firstKey();
            System.out.println("=======>>>客户端:" + client + " 被路由到:" + serverHashMap.get(firstKey));
        } else {
            Integer firstKey = tailMap.firstKey();
            System.out.println("=======>>>客户端:" + client + " 被路由到:" + serverHashMap.get(firstKey));
        }
    }
    
  • 一致性hash算法+虚拟节点

    int virtualCount = 3;
    for (String server : servers) {
        int serverHash = Math.abs(server.hashCode());
        serverHashMap.put(serverHash, server);
        for (int i = 0; i < virtualCount; i++) {
            int virtualHash = Math.abs((server + "#" + i).hashCode());
            serverHashMap.put(virtualHash, "---虚拟节点" + i + "映射到" + server);
        }
    }
    

Nginx配置一致性Hash负责均衡策略

  • ngx_http_upstream_consistent_hash负载均衡器,内部使用一致性hash算法来选择合适的后端节点

    • consistent_hash $remote_addr:根据客户端ip映射

    • consistent_hash $request_uri:根据客户端请求的uri映射

    • consistent_hash $args:根据客户端携带的参数进行映射

      nginx.conf

      # 负载均衡的配置
      upstream_carolServer {
          consistent_hash $request_uri;
          server 127.0.0.1:8080;
          server 127.0.0.1:8082;
      }
      
  • 安装

    • 下载zip,上传到nginx服务器并解压

    • 进入nginx的源码目录,执行

      cd nginx-1.18.0
      ./configure --add-module=/root/ngx_http_consistent_hash-master
      make
      make install
      

集群时钟同步问题

  • 分布式集群中各个服务器节点都可以连接互联网

    • 思路:各个节点统一从国家授时中心/时间服务器同步

    • 步骤

      # 使用前需要检查是否安装
      rpm -qa|grep ntpdate
      
      # 没有安装则使用yum安装
      yum install ntp
      
      # 修改时间
      date -s '08:00:00'
      # 查看当前时间
      date
      
      # 使用 utpdate 网络时间同步命令
      ntpdate -u ntp.api.bz # 例如,从一个时间服务器同步时间
      
    • 定时任务:每10分钟/每天等

      • windows 计划任务
      • linux crond
  • 分布式集群中某一个/几个服务器节点可以访问互联网

    • 思路:可以访问互联网的节点A定期进行同步,其它节点与A同步(即A作为局域网内的时间服务器)

    • 步骤

      • 设置节点A的时间

      • 修改 /etc/ntp.conf

        • 如果有 restrict default ignore,注释掉

        • 添加如下几行内容

          # 开放局域网同步功能,172.17.0.0 是局域网网段
          restrict 172.17.0.0 mask 255.255.255.0 nomodify notrap
          server 127.127.1.0 # local clock
          fudge 127.127.1.0 stratum 10
          
        • 重启生效并配置ntpd服务开机自启动

          service ntpd restart
          chkconfig ntpd on
          
        • 其他节点从A服务器同步

          ntpdate 172.17.0.17
          
  • 所有节点都不能访问互联网

    • 思路:节点A手动设置,其它节点与A同步
    • 步骤见上

分布式ID解决方案

  • 分布式ID:分布式集群环境下的全局唯一ID

  • 解决方案

    • UUID(可以使用)

      UUID.randomUUID() // 66d3d1d4-7e33-46cd-9ada-42cf8a9ee904
      
      • 方便,可以作为主键(重复概率非常非常低),但是比较长且没有规律,建立索引将影响性能
    • 独立数据库的自增ID(不推荐)

      • 单独创建一个数据库,创建一张ID设置为自增的表。

      • 其它地方需要全局唯一ID的时候,就模拟向这个表中插入一条记录,获取自增生成的ID

        insert into DISTRIBUTE_ID(createtime) values (NOW());
        select LAST_INSERT_ID();
        
      • 性能和可靠性不高

    • SnowFlake雪花算法

      • Twitter公司推出的用于生成分布式ID的策略

      • 生成一个long型的id,java中8字节,64bit

        • 0:符号位

        • 1-41:当前时间戳(毫秒)

        • 42-51:机器id

        • 52-63:序列号

      • 互联网公司基于以上方案有一些自己的封装

        • 滴滴的tinyid(基于数据库)

        • 百度的uidgenerator(基于雪花算法)

        • 美团的leaf(基于数据库和雪花算法)

      • java 实现

        public synchronized long nextId() {
            long timestamp = System.currentTimeMillis();
        
            if (timestamp < lastTimestamp) {
                System.out.println("clock is moving backwards. Rejecting requests until %d.", lastTimestamp);
                throw new RuntimeException("Clock moved backwards.");
            }
        
            if (lastTimestamp == timestamp) {
                sequence = (sequence + 1) & sequenceMask;
                // 超过同一毫秒里的最大值(4095),等到下一毫秒
                if (sequence == 0) {
                    timestamp = tilNextMillis(lastTimestamp);
                } else {
                    sequence = 0;
                }
        
                lastTimestamp = timestamp;
        
                return ((timestamp - twepoch) << timestampLeftShift) |
                        (datacenterId << datacenterIdShift) |
                        (workerId << workerIdShift) |
                        sequence;
            }
        
            private long tilNextMillis(long lastTimestamp) {
                long timestamp = System.currentTimeMillis();
                while (timestamp <= lastTimestamp) {
                      timestamp = System.currentTimeMillis();
                }
                return timestamp;
            }
        }
        
    • Redis的Incr命令(推荐)

      • 将key中存储的数字值增一,如果key不存在,那么会初始化为0

      • redis安装

        1. 官网下载 redis-6.0.9.tar.gz,解压安装

          tar -zxvf redis-6.0.9.tar.gz
          cd redis-6.0.9
          make
          cd src/
          make install
          cd ..
          vim redis.conf
          
        2. 修改配置文件

          # bind 127.0.0.1
          protected-mode no
          
        3. 启动

          cd src/
          ./redis-server ../redis.conf
          ps -ef|grep redis
          
      • 使用jedis客户端调用redis命令

        pom.xml

        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>2.9.0</version>
        </dependency>
        

        java

        Jedis jedis = new Jedis("47.101.165.107", 6379);
        Long id = jedis.incr("id");
        

分布式调度问题

定时任务的场景

  • 订单审核、出库
  • 订单超时自动取消、支付退款
  • 礼券同步、生成、发放作业
  • 物流信息推送、抓取作业、退换货处理作业
  • 数据积压监控、日志监控、服务可用性探测作业
  • 定时备份数据
  • 金融系统每天的定时结算
  • 数据归档、清理作业
  • 报表、离线数据分析作业

什么是分布式调度

  • 运行在分布式集群环境下的调度任务
  • 定时任务的拆分

定时任务与消息队列的区别

  • 相同点
    • 异步处理
    • 应用解耦
      • 两个应用之间的齿轮,中转数据
    • 流量削峰
  • 不同点
    • 定时任务是时间驱动,MQ是事件驱动
    • 定时作业更倾向于批处理,MQ倾向于逐条处理

定时任务的实现方式

  • JDK中的Timer机制和多线程机制(早期没有框架时)

  • Quartz任务调度框架

    pom.xml

    <dependencies>
        <dependency>
            <groupId>org.quartz-scheduler</groupId>
            <artifactId>quartz</artifactId>
            <version>2.3.2</version>
        </dependency>
    </dependencies>
    

    QuartzMain.java

    public static void main(String[] args) throws SchedulerException {
        // 任务调度器
        Scheduler scheduler = QuartzMain.createScheduler();
        // 任务
        JobDetail job = QuartzMain.createJob();
        // 时间触发器
        Trigger trigger = QuartzMain.createTrigger();
        // 使用任务调度器,根据时间触发器执行任务
        scheduler.scheduleJob(job, trigger);
        scheduler.start();
    }
    
    public static Scheduler createScheduler() throws SchedulerException {
        SchedulerFactory sf = new StdSchedulerFactory();
        return sf.getScheduler();
    }
    
    public static JobDetail createJob() {
        JobBuilder jobBuilder = JobBuilder.newJob(DemoJob.class);
        jobBuilder.withIdentity("jobName", "myJob");
        return jobBuilder.build();
    }
    
    /**
     * cron表达式由七个位置组成,空格分隔
     * 1、 Seconds(秒) 0~59
     * 2、 Minutes(分) 0~59
     * 3、 Hours(小时) 0~23
     * 4、 Day of Month(天) 1~31
     * 5、 Month(月) 0~11 或 JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC
     * 6、 Day of Week(周) 1~7 或 SUN,MON,TUE,WED,THU,FRI,SAT
     * 7、 Year(年) 1970~2099 可选项
     * 示例:
     * 0 0 11 * * ?每天的11点触发一次
     * 0 30 10 1 * ? 每月1号上午10点半触发执行一次
     */
    public static Trigger createTrigger() {
         return TriggerBuilder.newTrigger()
                .withIdentity("triggerName", "myTrigger")
                .startNow()
                 // 每隔2秒
                .withSchedule(CronScheduleBuilder.cronSchedule("*/2 * * * * ?"))
                .build();
    }
    

    DemoJob.java

    public class DemoJob implements Job {
        @Override
        public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
            System.out.println("定时任务执行逻辑");
        }
    }
    

分布式调度框架Elastic-Job

Elastic-Job介绍

  • 当当网的一个分布式调度解决方案,基于Quartz二次开发,由两个相互独立的子项目Elastic-Job-Lite和Elastic-Job-Cloud组成。Lite定位是轻量级无中心化解决方案,使用jar包的形式提供分布式任务的协调服务;Cloud要结合Mesos以及Docker在云环境下使用。
  • github地址:github.com/elasticjob
  • 主要功能
    • 分布式调度协调:在分布式环境中,任务能够按照指定的调度策略执行,并且能够避免同一任务多实例重复执行
    • 丰富的调度策略:cron表达式执行定时任务
    • 弹性扩容缩容:例如减少实例,所执行的任务能被转移到其他实例
    • 失效转移:实例在任务执行失败后,会被转移到其他实例执行
    • 错过执行任务重触发:自行记录错过执行的作业,并在上次作业完成后自动触发
    • 支持并行调度:任务分片,将一个任务分为多个小任务在多个实例同时执行
    • 作业分片一致性:当任务被分片后,保证同一分片在分布式环境中仅一个执行实例

Elastic-Job-Lite应用

  • 安装Zookeeper(需要3.4.6版本以上):存储(树形节点结构)+ 通知(监听节点,取值变化/子节点变化)

    tar -zxvf apache-zookeeper-3.6.2-bin.tar.gz
    cd apache-zookeeper-3.6.2-bin/
    cd conf/
    cp zoo_sample.cfg zoo.cfg
    cd ../bin/
    ./zkServer.sh start # 启动
    ./zkServer.sh status # 查看状态
    ./zkServer.sh stop # 停止
    
  • 示例程序

    pom.xml

    <dependency>
        <groupId>com.dangdang</groupId>
        <artifactId>elastic-job-lite-core</artifactId>
        <version>2.1.5</version>
    </dependency>
    

    ArchiveJob.java 定时任务类

    public class ArchiveJob implements SimpleJob {
        @Override
        public void execute(ShardingContext shardingContext) {
            // 省略
        }
    }
    

    ElasticJobMain.java

    ZookeeperConfiguration zookeeperConfiguration =
        new ZookeeperConfiguration("47.101.165.107:2181", "data-archive-job");
    CoordinatorRegistryCenter coordinatorRegistryCenter = newZookeeperRegistryCenter(zookeeperConfiguration);
    coordinatorRegistryCenter.init();
    
    JobCoreConfiguration jobCoreConfiguration = JobCoreConfiguration
            .newBuilder("archive-job", "*/2 * * * * ?", 1).build();
    SimpleJobConfiguration simpleJobConfiguration = new SimpleJobConfiguration(jobCoreConfiguration,
            ArchiveJob.class.getName());
    
    JobScheduler jobScheduler = new JobScheduler(coordinatorRegistryCenter,
            LiteJobConfiguration.newBuilder(simpleJobConfiguration).overwrite(true).build());
    jobScheduler.init();
    

Elastic-Job-Lite轻量级去中心化的特点

  • 轻量级
    • All in jar, 必要依赖仅需要zookeeper
    • 并非独立部署的中间件
  • 去中心化
    • 执行节点对等
    • 定时调度自触发(没有中心调度节点)
    • 服务自发现
    • 主节点非固定

任务分片

  • 将大的耗时作业分为多个子task(即任务分片),每一个task交给一个实例(一个实例可以处理多个task)

  • 每个task执行什么逻辑由我们自己来定

  • Strategy策略可以定义这些分片如何分配到各个机器,默认是平分

  • 修改代码

    ElasticJobMain.java

    JobCoreConfiguration jobCoreConfiguration = JobCoreConfiguration
            .newBuilder("archive-job", "*/2 * * * * ?", 3)
            .shardingItemParameters("0=本科,1=研究生,2=博士").build();
    

    ArchiveJob.java

    public class ArchiveJob implements SimpleJob {
        @Override
        public void execute(ShardingContext shardingContext) {
            int shardingItem = shardingContext.getShardingItem();
            System.out.println("=========> 当前分片:" + shardingItem);
            //本科or研究生or博士
            String shardingParameter = shardingContext.getShardingParameter();
    
            String selectSql = "select * from resume where state='未归档' and education='" + shardingParameter+ "' limit 1";
            // 省略
        }
    }
    

Session共享问题

  • session共享及session保持,又叫做session一致性
  • 本质上说是因为http协议是无状态的协议,客户端和服务端在某次的会话中产生的数据不会被保留下来。
  • 两种用于保持Http状态的技术,Cookie(客户端)和Session(服务器)

解决Session一致性的方案

  1. Nginx的IP_Hash策略(可以使用)

    • 同一个客户端IP的请求会被路由到同一个目标服务器,也叫做会话粘滞
    • 优点
      • 配置简单
      • 不入侵应用,不额外修改代码
    • 缺点
      • 服务器重启,Session丢失
      • 单点负载高
      • 单点故障
  2. Session复制(不推荐)

    • 多个tomcat之间通过修改配置文件,达到Session之间的复制,
    • 通过网络通信的组播机制实现Session同步(TCP)
    • 优点
      • 不入侵应用
      • 便于服务器水平扩展
      • 适应各种负载均衡策略
      • 服务器重启或宕机不会造成Session丢失
    • 缺点
      • 性能低
      • 内存消耗,不能存储大多数据
      • 延迟高
  3. Session共享,Session集中储存(推荐)

    • 缓存,Redis

    • 优点

      • 能适应各种负载均衡策略
      • 服务器重启或者宕机不会造成Session丢失
      • 扩展能力强
      • 适合大集群数量使用
    • 缺点

      • 对应用入侵,引入了和Redis的交互代码
    • 使用

      pom.xml

      <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>
      

      application.properties

      # redis
      spring.redis.database=0
      spring.redis.host=47.101.165.107
      spring.redis.port=6379
      

      SpringBootApplication

      @SpringBootApplication
      @EnableRedisHttpSession
      public class SpringbootSssApplication extends SpringBootServletInitializer {
          public static void main(String[] args) {
              SpringApplication.run(SpringbootSssApplication.class, args);
          }
      
          @Override
          protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
              return builder.sources(SpringbootSssApplication.class);
          }
      }
      
    • 源码解析

      请求通过tomcat到达servlet容器时,通过过滤器对请求做了一次封装。在Redis中取Session,取不到则创建并提交

      EnableRedisHttpSession
        - RedisHttpSessionConfiguration
        - SessionRepositoryFilter#doFilterInternal