关于负载均衡:深入浅出LB手把手带你实现一个负载均衡器

写作不易,未经作者容许禁止以任何模式转载!<br/>如果感觉文章不错,欢送关注、点赞和分享!
继续分享技术博文,关注微信公众号 👉🏻 前端LeBron
字节跳动校招进行中,校招内推码: 4FCV6BV 游戏部门前端团队可私聊直推
原文链接

简介

负载平衡,含意就是依据肯定算法将负载(工作工作)进行均衡,摊派到多个操作单元上运行、执行,常见的为Web服务器、企业外围应用服务器和其余次要工作服务器等,从而协同实现工作工作。负载平衡在原有的网络结构上提供了一种通明且无效的的办法扩大服务器和网络设备的带宽、增强网络数据处理能力、减少吞吐量、进步网络的可用性和灵活性,同时接受住更大的并发量级。

简略来说就是将大量的并发申请解决转发给多个后端节点解决,缩小工作响应工夫。

  • 防止资源节约
  • 防止服务不可用

一、分类

四层(传输层)

四层即OSI七层模型中的传输层,有TCP、UDP协定,这两种协定中蕴含源IP、指标IP以外,还蕴含源端口号及指标端口号。四层负载平衡在接管到客户端申请后,通过批改报文的地址信息(IP + PORT)将流量转发到应用服务器。

七层(应用层)

代理负载平衡

七层即OSI七层模型中的应用层,应用层协定较多,罕用的为HTTP/HTTPS。七层负载平衡能够给予这些协定来负载。这些应用层协定中会蕴含很多有意义的内容。比方同一个Web服务器的负载平衡,除了依据IP + PORT进行负载平衡,还能够依据七层的URL、Cookie、浏览器类别、语言、申请类型来决定。

四层负载平衡的实质是转发,七层负载平衡的实质是内容替换和代理。

四层负载平衡 七层负载平衡
基于 IP + PORT URL 或 主机IP
相似 路由器 代理服务器
复杂度
性能 高,无需解析内容 中,需算法辨认URL Header、Cookie等
安全性 低,无奈辨认DDoS攻打 高,可进攻SYN Flood攻打
扩大性能 内容缓存、图片防盗链等

二、常见算法

前置数据结构

interface urlObj{
  url:string,
  weight:number // 仅在权重轮询时失效
}
urlDesc: urlObj[]
​
interface urlCollectObj{
  count: number, // 连接数
  costTime: number, // 响应工夫
  connection: number, // 实时连接数
}
urlCollect: urlCollectObj[]

Random

随机

const Random = (urlDesc) => {
  let urlCollect = [];
​
  //  收集url
  urlDesc.forEach((val) => {
    urlCollect.push(val.url);
  });
​
  
  return () => {
    //  生成随机数下标返回相应URL
    const pos = parseInt(Math.random() * urlCollect.length);
    return urlCollect[pos];
  };
};
​
module.exports = Random;

Weighted Round Robin

权重轮询算法

const WeiRoundRobin = (urlDesc) => {
  let pos = 0,
    urlCollect = [],
    copyUrlDesc = JSON.parse(JSON.stringify(urlDesc));
​
  // 依据权重收集url
  while (copyUrlDesc.length > 0) {
    for (let i = 0; i < copyUrlDesc.length; i++) {
      urlCollect.push(copyUrlDesc[i].url);
      copyUrlDesc[i].weight--;
      if (copyUrlDesc[i].weight === 0) {
        copyUrlDesc.splice(i, 1);
        i--;
      }
    }
  }
  // 轮询获取URL函数
  return () => {
    const res = urlCollect[pos++];
    if (pos === urlCollect.length) {
      pos = 0;
    }
    return res;
  };
};
​
module.exports = WeiRoundRobin;

IP Hash & URL Hash

源IP / URL Hash

const { Hash } = require("../util");
​
const IpHash = (urlDesc) => {
  let urlCollect = [];
​
  for (const key in urlDesc) {
    // 收集url
    urlCollect.push(urlDesc[key].url);
  }
​
  return (sourceInfo) => {
    // 生成Hash十进制数值
    const hashInfo = Hash(sourceInfo);
    // 取余为下标
    const urlPos = Math.abs(hashInfo) % urlCollect.length;
    // 返回
    return urlCollect[urlPos];
  };
};
​
module.exports = IpHash;

Consistent Hash

一致性Hash

const { Hash } = require("../util");
​
const ConsistentHash = (urlDesc) => {
  let urlHashMap = {},
    hashCollect = [];
​
  for (const key in urlDesc) {
    // 收集urlHash进数组和生成HashMap
    const { url } = urlDesc[key];
    const hash = Hash(url);
    urlHashMap[hash] = url;
    hashCollect.push(hash);
  }
  // 将hash数组从小到大排序
  hashCollect = hashCollect.sort((a, b) => a - b);
​
  return (sourceInfo) => {
    // 生成Hash十进制数值
    const hashInfo = Hash(sourceInfo);
    // 遍历hash数组找到第一个比源信息hash值大的,并通过hashMap返回url
    hashCollect.forEach((val) => {
      if (val >= hashInfo) {
        return urlHashMap[val];
      }
    });
    // 没找大则返回最大的
    return urlHashMap[hashCollect[hashCollect.length - 1]];
  };
};
​
module.exports = ConsistentHash;

Least Connections

最小连接数

const leastConnections = () => {
  return (urlCollect) => {
    let min = Number.POSITIVE_INFINITY,
      url = "";
​
    // 遍历对象找到起码连接数的地址
    for (let key in urlCollect) {
      const val = urlCollect[key].connection;
      if (val < min) {
        min = val;
        url = key;
      }
    }
    // 返回
    return url;
  };
};
​
module.exports = leastConnections;

注:urlCollect为负载均属数据统计对象,有以下属性

  • connection实时连接数
  • count解决申请次数
  • costTime响应工夫。

FAIR

最小响应工夫

const Fair = () => {
  return (urlCollect) => {
    let min = Number.POSITIVE_INFINITY,
      url = "";
​
     // 找到耗时起码的url 
    for (const key in urlCollect) {
      const urlObj = urlCollect[key];
      if (urlObj.costTime < min) {
        min = urlObj.costTime;
        url = key;
      }
    }
    // 返回
    return url;
  };
};
​
module.exports = Fair;

看到这里是不是感觉算法都挺简略的 🥱

期待一下模块五的实现吧😏

三、衰弱监测

衰弱监测即对应用服务器的衰弱监测,为避免把申请转发到异样的应用服务器上,应应用衰弱监测策略。应答不同的业务敏感水平,可相应调整策略和频率。

HTTP / HTTPS 衰弱监测步骤(七层)

  1. 负载平衡节点向利用服务器发送HEAD申请。
  2. 应用服务器接管到HEAD申请后依据状况返回相应状态码。
  3. 若在超时工夫内未收到返回的状态码,则判断为超时,健康检查失败。
  4. 若在超时工夫内收到返回的状态码,负载平衡节点进行比对,判断健康检查是否胜利。

TCP健康检查步骤(四层)

  1. 负载平衡节点向内网应用服务器IP + PORT 发TCP SYN申请数据包。
  2. 内网应用服务器收到申请后,若在失常监听,则返回SYN + ACK数据包。
  3. 若在超时工夫内未收到返回的数据包,则判断服务无响应、健康检查失败,并向内网利用服务器发送RST数据包中断TCP连贯。
  4. 若在超时工夫内收到返回的数据包,则断定服务衰弱运行,发动RST数据包中断TCP连贯。

UDP健康检查步骤(四层)

  1. 负载平衡节点向内网应用服务器IP + PORT发送UDP报文。
  2. 若内网应用服务器未失常监听,则返回PORT XX unreachable的ICMP报错信息,反之为失常。
  3. 若在超时工夫内收到了报错信息,则判断服务异样,健康检查失败。
  4. 若在超时工夫内未收到报错信息,则判断服务衰弱运行。

四、VIP技术

Vrtual IP

虚构IP

  • 在TCP / IP架构下,所有想上网的电脑,不管以何种模式连上网络,都不须要有一个惟一的IP地址。事实上IP地址是主机硬件物理地址的一种形象。
  • 简略来说地址分为两种

    • MAC物理地址
    • IP逻辑地址
  • 虚构IP是一个未调配给实在主机的IP,也就是说对外提供的服务器的主机除了有一个实在IP还有一个虚IP,这两个IP中的任意一个都能够连贯到这台主机。

    • 通过虚构IP对应实在主机的MAC地址实现
  • 虚构IP个别用作达到高可用的目标,比方让所有我的项目中的数据库链接配置都是这个虚构IP,当主服务器产生故障无奈对外提供服务时,动静将这个虚IP切换到备用服务器。

虚构IP原理

  1. ARP是地址解析协定,作用为将一个IP地址转换为MAC地址。
  2. 每台主机都有ARP高速缓存,存储同一个网络内IP地址与MAC地址的映射关系,主机发送数据会先从这个缓存中查3指标IP对应MAC地址,向这个MAC地址发送数据。操作系统主动保护这个缓存。
  3. Linux下可用ARP命令操作ARP高速缓存
  • 比方存在主机A(192.168.1.6)和主机B(192.168.1.8)。A作为对外服务的主服务器,B作为备份机器,两台服务器之间通过HeartBeat通信。
  • 即主服务器会定时给备份服务器发送数据包,告知主服务器失常,当备份服务器在规定工夫内没有收到主服务器的HeartBeat,会认为主服务器宕机。
  • 此时备份服务器就降级为主服务器。

    • 服务器B将本人的ARP缓存发送进来,告知路由器批改路由表,告知虚构IP地址应该指向192.168.1.8.
    • 这时外接再次拜访虚构IP的时候,机器B就会变成主服务器,而A降级为备份服务器。
    • 这样就实现了主从机器的切换,这所有对外都是无感知、通明的。

五、基于 NodeJS 实现一个简略的负载平衡

想手动实现一下负载均衡器 / 看看源码的同学都能够看看 👉🏻 代码仓库

预期成果

编辑config.js后npm run start即可启动均衡器和后端服务节点

  • urlDesc:后端服务节点配置对象,weight仅在WeightRoundRobin算法时起作用
  • port:均衡器监听端口
  • algorithm:算法名称(模块二中的算法均已实现)
  • workerNum:后端服务端口开启过程数,提供并发能力。
  • balancerNum:均衡器端口开启过程数,提供并发能力。
  • workerFilePath:后端服务节点执行文件,举荐应用绝对路径。
const {ALGORITHM, BASE_URL} = require("./constant");
​
module.exports = {
    urlDesc: [
        {
            url: `${BASE_URL}:${16666}`,
            weight: 6,
        },
        {
            url: `${BASE_URL}:${16667}`,
            weight: 1,
        },
        {
            url: `${BASE_URL}:${16668}`,
            weight: 1,
        },
        {
            url: `${BASE_URL}:${16669}`,
            weight: 1,
        },
        {
            url: `${BASE_URL}:${16670}`,
            weight: 2,
        },
        {
            url: `${BASE_URL}:${16671}`,
            weight: 1,
        },
        {
            url: `${BASE_URL}:${16672}`,
            weight: 4,
        },
    ],
    port: 8080,
    algorithm: ALGORITHM.RANDOM,
    workerNum: 5,
    balancerNum: 5,
    workerFilePath:path.resolve(__dirname, "./worker.js")
}

架构设计图

先来看看主流程 main.js

  1. 初始化负载平衡统计对象balanceDataBase

    • balanceDataBase是一个DataBase类实例,用于统计负载平衡数据(后续会讲到).
  2. 运行均衡器

    • 多过程模型,提供并发能力。
  3. 运行后端服务节点

    • 多线程+多过程模型,运行多个服务节点并提供并发能力。
const {urlDesc, balancerNum} = require("./config")
const cluster = require("cluster");
const path = require("path");
const cpusLen = require("os").cpus().length;
const {DataBase} = require("./util");
const {Worker} = require('worker_threads');
​
const runWorker = () => {
    // 避免监听端口数 > CPU核数
    const urlObjArr = urlDesc.slice(0, cpusLen);
    // 初始化创立子线程
    for (let i = 0; i < urlObjArr.length; i++) {
        createWorkerThread(urlObjArr[i].url);
    }
}
​
const runBalancer = () => {
    // 设置子过程执行文件
    cluster.setupMaster({exec: path.resolve(__dirname, "./balancer.js")});
    // 初始化创立子过程
    let max
    if (balancerNum) {
        max = balancerNum > cpusLen ? cpusLen : balancerNum
    } else {
        max = 1
    }
    for (let i = 0; i < max; i++) {
        createBalancer();
    }
}
​
// 初始化负载平衡数据统计对象
const balanceDataBase = new DataBase(urlDesc);
// 运行均衡器
runBalancer();
// 运行后端服务节点
runWorker();

创立均衡器(createBalancer函数)

  1. 创立过程
  2. 监听过程通信音讯

    • 监听更新响应工夫事件并执行更新函数

      • 用于FAIR算法(最小响应工夫)。
    • 监听获取统计对象事件并返回
  3. 监听异样退出并从新创立,过程守护。
const createBalancer = () => {
    // 创立过程
    const worker = cluster.fork();
    worker.on("message", (msg) => {
        // 监听更新响应工夫事件
        if (msg.type === "updateCostTime") {
            balanceDataBase.updateCostTime(msg.URL, msg.costTime)
        }
        // 监听获取url统计对象事件并返回
        if (msg.type === "getUrlCollect") {
            worker.send({type: "getUrlCollect", urlCollect: balanceDataBase.urlCollect})
        }
    });
    // 监听异样退出事件并从新创立过程
    worker.on("exit", () => {
        createBalancer();
    });
}

创立后端服务节点(createWorkerThread函数)

  1. 创立线程
  2. 解析须要监听的端口
  3. 向子线程通信,发送须要监听的端口
  4. 通过线程通信,监听子线程事件

    • 监听连贯事件,并触发处理函数。
    • 监听断开连接事件并触发处理函数。
    • 用于统计负载平衡散布和实时连接数。
  5. 监听异样退出并从新创立,线程守护。
const createWorkerThread = (listenUrl) => {
    // 创立线程
    const worker = new Worker(path.resolve(__dirname, "./workerThread.js"));
    // 获取监听端口
    const listenPort = listenUrl.split(":")[2];
    // 向子线程发送要监听的端口号
    worker.postMessage({type: "port", port: listenPort});
​
    // 接管子线程音讯统计过程被拜访次数
    worker.on("message", (msg) => {
        // 监听连贯事件并触发计数事件
        if (msg.type === "connect") {
            balanceDataBase.add(msg.port);
        }
        // 监听断开连接事件并触发计数事件
        else if (msg.type === "disconnect") {
            balanceDataBase.sub(msg.port);
        }
    });
    // 监听异样退出事件并从新创立过程
    worker.on("exit", () => {
        createWorkerThread(listenUrl);
    });
}

再来看看均衡器工作流程 balancer.js

  1. 获取getURL工具函数
  2. 监听申请并代理

    • 获取须要传入getURL工具函数的参数。
    • 通过getURL工具函数获取平衡代理目标地址URL
    • 记录申请开始工夫
    • 解决跨域
    • 返回响应
    • 通过过程通信,触发响应工夫更新事件。

注1:LoadBalance函数即通过算法名称返回不同的getURL工具函数,各算法实现见模块二:常见算法

注2:getSource函数即解决参数并返回,getURL为下面讲到的获取URL工具函数。

const cpusLen = require("os").cpus().length;
const LoadBalance = require("./algorithm");
const express = require("express");
const axios = require("axios");
const app = express();
const {urlFormat, ipFormat} = require("./util");
const {ALGORITHM, BASE_URL} = require("./constant");
const {urlDesc, algorithm, port} = require("./config");
​
const run = () => {
    // 获取转发URL工具函数
    const getURL = LoadBalance(urlDesc.slice(0, cpusLen), algorithm);
    // 监听申请并平衡代理
    app.get("/", async (req, res) => {
        // 获取须要传入的参数
        const source = await getSource(req);
        // 获取URL
        const URL = getURL(source);
        // res.redirect(302, URL) 重定向负载平衡
        // 记录申请开始工夫
        const start = Date.now();
        // 代理申请
        axios.get(URL).then(async (response) => {
            // 获取负载平衡统计对象并返回
            const urlCollect = await getUrlCollect();
            // 解决跨域
            res.setHeader("Access-Control-Allow-Origin", "*");
            response.data.urlCollect = urlCollect;
            // 返回数据
            res.send(response.data);
            // 记录相应工夫并更新
            const costTime = Date.now() - start;
            process.send({type: "updateCostTime", costTime, URL})
        });
    });
    // 负载平衡服务器开始监听申请
    app.listen(port, () => {
        console.log(`Load Balance Server Running at ${BASE_URL}:${port}`);
    });
};
​
run();
​
​
const getSource = async (req) => {
    switch (algorithm) {
        case ALGORITHM.IP_HASH:
            return ipFormat(req);
        case ALGORITHM.URL_HASH:
            return urlFormat(req);
        case ALGORITHM.CONSISTENT_HASH:
            return urlFormat(req);
        case ALGORITHM.LEAST_CONNECTIONS:
            return await getUrlCollect();
        case ALGORITHM.FAIR:
            return await getUrlCollect();
        default:
            return null;
    }
};

如何在均衡器中获取负载平衡统计对象 getUrlCollect

  1. 通过过程通信,向父过程发送获取音讯。
  2. 同时开始监听父过程通信音讯,接管后应用Promise resovle返回。
// 获取负载平衡统计对象
const getUrlCollect = () => {
    return new Promise((resolve, reject) => {
        try {
            process.send({type: "getUrlCollect"})
            process.on("message", msg => {
                if (msg.type === "getUrlCollect") {
                    resolve(msg.urlCollect)
                }
            })
        } catch (e) {
            reject(e)
        }
    })
}

如何实现服务节点并发 workerThread.js

应用多线程+多过程模型,为每个服务节点提供并发能力。

主过程流程

  1. 依据配置文件,创立相应数量服务节点。

    • 创立过程
    • 监听父线程音讯(服务节点监听端口),并转发给子过程。
    • 监听子过程音讯,并转发给父线程(建设连贯、断开连接事件)。
    • 监听异样退出并从新建设。
const cluster = require("cluster");
const cpusLen = require("os").cpus().length;
const {parentPort} = require('worker_threads');
const {workerNum, workerFilePath} = require("./config")
​
if (cluster.isMaster) {
    // 创立工作过程函数
    const createWorker = () => {
        // 创立过程
        const worker = cluster.fork();
        // 监听父线程音讯,并转发给子过程。
        parentPort.on("message", msg => {
            if (msg.type === "port") {
                worker.send({type: "port", port: msg.port})
            }
        })
        // 监听子过程音讯并转发给父线程
        worker.on("message", msg => {
            parentPort.postMessage(msg);
        })
        // 监听过程异样退出并从新创立
        worker.on("exit", () => {
            createWorker();
        })
    }
    // 按配置创立过程,但不可大于CPU核数
    let max
    if (workerNum) {
        max = workerNum > cpusLen ? cpusLen : workerNum
    } else {
        max = 1
    }
    for (let i = 0; i < max; i++) {
        createWorker();
    }
} else {
    // 后端服务执行文件
    require(workerFilePath)
}
​

子过程流程 worker.js(config.workerFilePath)

  1. 通过过程间通信,向父过程发送音讯,触发建设连贯事件。
  2. 返回相应。
  3. 通过过程间通信,向父过程发送音讯,触发断开连接事件。
var express = require("express");
var app = express();
let port = null;
​
app.get("/", (req, res) => {
    // 触发连贯事件
    process.send({type: "connect", port});
    // 打印信息
    console.log("HTTP Version: " + req.httpVersion);
    console.log("Connection PORT Is " + port);
​
    const msg = "Hello My PORT is " + port;
​
    // 返回响应
    res.send({msg});
    // 触发断开连接事件
    process.send({type: "disconnect", port});
});
​
// 接管主进通信音讯中的端口口并监听
process.on("message", (msg) => {
    if (msg.type === "port") {
        port = msg.port;
        app.listen(port, () => {
            console.log("Worker Listening " + port);
        });
    }
});

最初来看看DataBase类

  • 成员:
  1. status:工作队列状态
  2. urlCollect:数据统计对象(提供给各算法应用 / 展现数据)

    • count:解决申请数
    • costTime:响应工夫
    • connection:实时连接数
  3. add办法

    • 减少连接数和实时连接数
  4. sub办法

    • 缩小实时连接数
  5. updateCostTime办法

    • 更新响应工夫
class DataBase {
    urlCollect = {};
​
    // 初始化
    constructor (urlObj) {
        urlObj.forEach((val) => {
            this.urlCollect[val.url] = {
                count: 0,
                costTime: 0,
                connection: 0,
            };
        });
    }
​
    //减少连接数和实时连接数
    add (port) {
        const url = `${BASE_URL}:${port}`;
        this.urlCollect[url].count++;
        this.urlCollect[url].connection++;
    }
​
    // 缩小实时连接数
    sub (port) {
        const url = `${BASE_URL}:${port}`;
        this.urlCollect[url].connection--;
    }
​
    // 更新响应工夫
    updateCostTime (url, time) {
        this.urlCollect[url].costTime = time;
    }
}

最终成果

做了个可视化图表来看平衡成果(Random)✔️

看起来平衡成果还不错🧐

小作业

想手动实现一下负载均衡器 / 看看源码的同学都能够看看 👉🏻 代码仓库

六、常识扩大

cluster多过程为什么能够监听一个端口?

  1. 通过cluster.isMaster判断是否为主过程,主过程不负责工作解决,只负责管理和调度工作子过程。
  2. master主过程启动了一个TCP服务器,真正监听端口的只有这个TCP服务器。申请触发了这个TCP服务器的connection事件后,通过句柄转发(IPC)给工作过程解决。

    1. 句柄转发可转发TCP服务器、TCP套接字、UDP套接字、IPC管道
    2. IPC只反对传输字符串,不反对传输对象(可序列化)。
    3. 转发流程:父过程发送 -> stringfy && send(fd) -> IPC -> get(fd) && parse -> 子过程接管
    4. fd为句柄文件描述符。
  3. 如何抉择工作过程?

    1. cluster模块内置了RoundRobin算法,轮询抉择工作过程。
  4. 为什么不间接用cluster进行负载平衡?

    1. 手动实现可依据不同场景抉择不同的负载平衡算法。

Node怎么实现过程间通信的?

  1. 常见的过程间通信形式

    • 管道通信

      • 匿名管道
      • 命名管道
    • 信号量
    • 共享内存
    • Socket
    • 音讯队列
  2. Node中实现IPC通道是依赖于libuv。Windows下由命名管道实现,*nix零碎则采纳Domain Socket实现。
  3. 体现在应用层上的过程间通信只有简略的message事件和send()办法,接口非常简洁和音讯化。
  4. IPC管道是如何建设的?

    • 父过程先通过环境变量告知子过程管道的文件描述符
    • 父过程创立子过程
    • 子过程启动,通过文件描述符连贯已存在的IPC管道,与父过程建设连贯。

多过程 VS 多线程

多过程

  1. 数据共享简单,须要IPC。数据是离开的,同步简略。
  2. 占用内存多,CPU利用率低。
  3. 创立销毁简单,速度慢
  4. 过程独立运行,不会相互影响
  5. 可用于多机多核分布式,易于扩大

多线程

  1. 共享过程数据,数据共享简略,同步简单。
  2. 占用内存少,CPU利用率高。
  3. 创立销毁简略,速度快。
  4. 线程同呼吸共命运。
  5. 只能用于多核分布式。

七、由本次分享产生的一些想法

欢送留言探讨

  1. Node.js非阻塞异步I/O速度快,前端扩大服务端业务?
  2. 企业实际,阐明Node还是牢靠的?

    • 阿里Node中台架构
    • 腾讯CloudBase云开发Node
    • 大量Node.js全栈工程师岗位
  3. Node计算密集型不敌对?

    • Serverless流行,计算密集型用C++/Go/Java编写,以Faas / RPC 的形式调用。
  4. Node生态不如其余成熟的语言

    • 阿里输入了Java生态
    • 是不是能够看准趋势,打造Node生态以加强团队影响力。
  5. 探讨

    • Node.js 做 Web 后端劣势为什么这么大?

八、参考资料

  1. 健康检查概述 – 负载平衡
  2. 《深入浅出Node.js》
  3. Node.js (nodejs.cn)
  4. 深刻了解Node.js 中的过程与线程
  • 原文链接
  • 掘金:前端LeBron
  • 知乎:前端LeBron
  • 继续分享技术博文,关注微信公众号 👉🏻 前端LeBron

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理