通过 Node 中间层,实现后端微服务架构中的服务发现和负载均衡

通过 Node 中间层,实现后端微服务架构中的服务发现和负载均衡

要详细解释服务发现和负载均衡的意义,首先我们一定要从从「什么是微服务」,以及「微服务架构的意义」开始讲起

什么是微服务?

微服务架构是一种软件架构风格,它将一个大型的、复杂的应用程序,拆分成多组小型的、独立的服务单元,这些服务单元可以独立进行开发、部署和扩展。每个微服务都专注于执行一个特定的业务功能,并通过明确定义的接口和通信机制来与其他微服务进行交互。

微服务的意义

微服务架构的核心思想是将应用程序拆分成更小、更易于管理的部分,每个部分都由一个独立的团队开发和维护。这种拆分可以使团队更加灵活,能够独立地开发、测试、部署和维护其所负责的部分,而不会影响整体应用程序的其他部分。

举个例子,假如你正在开发一个 CICD 系统,在传统单体应用模式下,整个应用作为一个单一的代码库进行开发,测试和部署。任何更改都会涉及整个应用的重新构建和部署,那么这个时候你会发现,这个单体应用通常会随着时间的推移而变得越来越复杂,逐渐变大的代码库会让开发人员逐渐难以维护整个应用,并且在过长的开发周期、版本持续迭代风险等情况下的表现非常不好。而如果你使用的是微服务架构,那么你可以将你的系统拆分为用户、部署、监控告警、审批、自动化等模块,每个模块独立开发、独立部署、独立测试,这里面带来的好处非常多,独立开发意味着新功能的交付速度提升;独立部署意味着当服务出现不可控的 panic 的时候,不至于影响到其他服务的正常运行,并且可以分别控制功能服务的 CPU、内存配给量;独立测试意味着可以根据每个微服务的需求制定不同的测试策略,提高测试的针对性和效率。

服务注册与发现

当涉及到在后端微服务架构中实现服务发现和负载均衡时,一个常见的方法是使用一个 Node 中间层。这个中间层充当了请求的代理,负责处理服务发现和将请求分发到正确的微服务实例上,我们一般认为基础的步骤分为以下三步:

  1. 微服务注册

后端服务在启动的时候向注册中间提交注册信息,并在服务注销后对这个注册信息进行删除,也可以使用注册心跳的策略,运行过程中持续推送健康状态,一段时间不推送后认为已被卸载或不可用,从而清理对应的注册信息。最后会在这个注册中心维护一份完整的服务 Key -> 服务地址的映射表。

微服务进行服务注册的注册中心可以有很多,业内常用的有 etcd、ZooKeeper、Nacos 等服务注册平台,可以根据团队具体情况使用对应平台。

  1. 服务发现

在 Node 启动时,你可以根据需要被使用的服务,在内存中维护一份服务注册表,并监听对应平台的注册信息变更,这样可以在请求到达中间层后,直接获取对应服务的请求地址,并将请求转发过去,这一步骤里面可以做的有趣的东西有很多,比如根据服务 CPU 状态选择请求的策略、根据请求 headers 选择请求的其他链路(相同服务 Key 可能存在多个版本实例,如在 develop 环境可能存在多个不同版本的用户模块,用于各研发分别提交测试,这种模式也叫做请求分流)、对报错或者重试做统一的上报。

服务发现这一步是根据上一步来选择的,我们这里以 Node + Nacos 来举例,以下是 Nacos 实例的获取及相关配套函数

ts 复制代码
import { ClientOptions, CommonInputOptions, NacosConfigClient } from 'nacos'
import {
  DEFAULT_GROUP,
  SERVICE_NAMESPACE,
  NACOS_ADDRESS,
  RANDOM_PORT,
} from '../constant'

interface NacosInfo extends CommonInputOptions {
  [key: string]: any
}

// 封装的 Nacos 实例
export class NacosConfig {
  public configClient: NacosConfigClient
  public isReady = false

  constructor(options?: ClientOptions) {
    
    const OPTIONS = {
      serverAddr: NACOS_ADDRESS,
      namespace: SERVICE_NAMESPACE,
      endpoint: RANDOM_PORT,
    }

    this.configClient = new NacosConfigClient({
      ...OPTIONS,
      ...options,
    })
  }

  // 初始化,只使用同一个实例
  async ready() {
    if (this.isReady) return this.configClient
    await this.configClient.ready()
    this.isReady = true
    return this.configClient
  }

  // 获取配置
  async getConfig(dataId: string, group = DEFAULT_GROUP) {
    return await this.configClient.getConfig(dataId, group)
  }

  // 发布配置
  async publishConfig(info: NacosInfo, content: string) {
    const { dataId, group, ...options } = info
    let _group = group ? group : DEFAULT_GROUP
    return await this.configClient.publishSingle(
      dataId,
      _group,
      content,
      options,
    )
  }

  // 移除配置
  async removeConfig(dataId: string, group: string) {
    return await this.configClient.remove(dataId, group)
  }

  // 监听配置变更
  subscribe(info: CommonInputOptions, listener: (content: any) => void) {
    let { dataId, group, ...options } = info
    group = group ? group : DEFAULT_GROUP
    this.configClient.subscribe(
      {
        dataId,
        group,
        ...options,
      },
      listener,
    )
  }

  // 移除监听
  unSubscribe(info: CommonInputOptions, listener?: () => void) {
    let { dataId, group, ...options } = info
    group = group ? group : DEFAULT_GROUP
    this.configClient.unSubscribe(
      {
        dataId,
        group,
        ...options,
      },
      listener,
    )
  }
}

有了以上 Nacos 获取服务 IP 配置的函数,我们就可以实时获取对应服务的请求地址了,以下是获取用户服务 IP 的示例。

ts 复制代码
function getServiceAddress(key, nacosClient) {
  try {
    const ips = JSON.parse(nacosClient.getConfig(key, 'DEFAULT_SERVICE_GROUP'))?.ips || []
    // 这里可以做很多事情,比如基于连接数或者是基于权重的 IP 选择策略。
    return ips.length > 1 ? ips[Math.floor(Math.random() * ips.length)] : ips[0]

  } catch {
    return undefined
  }
}

// 使用
const nacosClient = new NacosConfig(options)
getServiceAddress('USER', nacosClient)

到这里你已经使用 Node,实现了一个基于微服务的,最简单的服务发现功能,接下来你还可以进一步完善你的 Node 中间层,以确保服务发现和负载均衡的功能在生产环境中得到有效的运用。

比如你可以对你维护的映射关系进行监听,当后端实例存在问题后,及时从 IPS 池中剔除,避免将请求发送到不可用的实例上。

ts 复制代码
// 实例 Map,每次的请求都使用这个 Map 而不是走请求获取
const instanceMap = {}
const nacosClient = new NacosConfig(options)
nacosClient.subscribe({
  dataId,
  group,
}, async (res: string) => {
  log('nacos subscribe change: ', dataId, ', content: ', res)
  // 触发监听后更新 Map
  instanceMap[dataId] = res
})

你甚至还可以用过这种方案实现「灰度发布 」功能。灰度功能 让你可以逐步将新版本的微服务引入系统,从而降低新功能发布可能带来的风险。灰度发布可以基于请求头、用户 ID 等条件进行分流。比如在前端的请求 client 中,置入特定的用户 ID 或者是组织 ID,当查到存在这个特定请求头的时候,将原本需要去查询dataId、group的请求,转为请求 ${dataId}_grey、${group}_grey的服务地址配置,可以做到访问某个用户或者组织的时候,请求到一个测试地址去。这种方案优势在于部分系统开发代码需要在线上测试验证,或者需要和线上环境并行运行的情况。

ts 复制代码
private getGreyReq = (dataId: string, group: string) => {
  return {
    dataId_grey: `${dataId}_grey`,
    group_grey: `${group}_grey`,
  }
}

async getConfig(headers: any, dataId: string, group = DEFAULT_CONFIG_GROUP) {
  let res;
  // 这个 checkIsGrey 留空,可以自己决定转发策略
  if (this.checkIsGrey(headers)) {
    const { dataId_grey, group_grey } = this.getGreyReq(dataId, group)
    res = await this.configClient.getConfig(dataId_grey, group_grey)
  }
  return res || await this.configClient.getConfig(dataId, group)
}

值得注意的是,本文所用代码都不可直接食用🍔,请作为学习工作的思路参考,例如本步骤中,还可以使用 Nacos 自带的服务注册功能,而不直接使用获取 Nacos 配置的能力。

  1. 检查服务健康状态

检查服务状态是后端微服务需要处理的事情,意义在于可以确保只有可用的服务实例被路由到,在 Nacos 中你可以通过提供 /health 接口来提供健康检查功能,Nacos 将定期发送 HTTP 请求到注册的服务实例,检查其健康状态。如果服务实例的健康检查失败,Nacos 将认为该实例不可用,并将其从服务注册表中移除,确保请求不再被发送到不可用的实例上。假如将你的 Node 中间层作为一个后端服务,那么预期的接口应该是类似这样的,以下是一个简单的 express 示例

ts 复制代码
const express = require('express');
const app = express();
const port = 3001;

let isHealthy = true;

app.get('/health', (req, res) => {
  if (isHealthy) {
    res.status(200).send('Healthy');
  } else {
    res.status(500).send('Unhealthy');
  }
});

// 后面可以自定义错误捕捉,如数据库连接问题、外部依赖、未初始化等情况,以此更改 isHealthy

app.listen(port, () => {
  console.log(`Service is running on port ${port}`);
});

负载均衡

负载均衡简单来说呢,是指在一个系统中分配任务或网络流量,以使系统的负载在多个资源之间均匀分布,从而提高系统的性能、可靠性和可用性。在我们所说的微服务架构中,负载均衡用于将请求分发到多个微服务实例,以确保每个实例都能平均地处理请求,避免某个实例过载而影响整体性能。通常我们使用负载均衡的目的是为了提高性能,保证突发情况下服务的可靠性,避免负载雪崩等情况。

常见的负载均衡策略有以下几种:

  1. 轮询

适用于多个微服务实例性能差距不大,如你的微服务都是 1G CPU、1G memory,那么可以考虑使用轮询,循环往复的请求微服务。

  1. 加权轮询

适用于多个微服务实例存在性能差距,例如你存在四个微服务为 1G CPU、1G memory,第五个微服务为主服务,为 5G CPU、5G memory时,可以考虑为第五个服务的权重增加,根据权重来分配请求。

  1. 最小连接数或最小响应时间

根据服务的最小连接数或最小响应时间来分配 IP,这种情况出现在当不同的服务实例处理请求的速度不一致时,如为微服务分配的带宽不一致,或者是部分机器存在资源密集型任务的情况下,可以考虑这种策略。

  1. 随机

顾名思义,随机选择 IP,这种策略较为通用性,如之前代码中的 ips.length > 1 ? ips[Math.floor(Math.random() * ips.length)] : ips[0],就是典型的随机请求。这种策略在实例性能差异大、精细控制要求高(需要考虑响应时间)、可预测性要求高(如灰度)等情况下表现不好,是一种通用策略。

  1. IP Hash

简单来说就是,根据客户端的 IP 地址计算哈希值,然后根据哈希值选择实例。确保同一个客户端的请求始终路由到同一个实例,这种策略的衍生处理其实有很多,详情可以查看我之前的一篇文章,记录一次前端做请求负载处理的思考

其他优化处理

在日常的生产开发中,实际上做到这里是远远不够的,微服务架构的优化不仅仅局限于服务发现和负载均衡,还需要在各个方面进行深入思考和优化,如日志记录和告警、服务容器化和自动部署、中间层鉴权、容错机制等等。在实际应用中,需要大家根据业务需求和团队实际情况,有针对性地选择和实施这些优化措施。

相关推荐
M_emory_5 分钟前
解决 git clone 出现:Failed to connect to 127.0.0.1 port 1080: Connection refused 错误
前端·vue.js·git
Ciito8 分钟前
vue项目使用eslint+prettier管理项目格式化
前端·javascript·vue.js
成都被卷死的程序员42 分钟前
响应式网页设计--html
前端·html
mon_star°1 小时前
将答题成绩排行榜数据通过前端生成excel的方式实现导出下载功能
前端·excel
Zrf21913184551 小时前
前端笔试中oj算法题的解法模版
前端·readline·oj算法
爱编程的鱼2 小时前
Node.js事件循环:解锁异步编程的奥秘
node.js
南暮思鸢2 小时前
Node.js is Web Scale
经验分享·web安全·网络安全·node.js·ctf题目·hackergame 2024
文军的烹饪实验室2 小时前
ValueError: Circular reference detected
开发语言·前端·javascript
程序员小杰@2 小时前
Playwright 快速入门:Playwright 是一个用于浏览器自动化测试的 Node.js 库
node.js
Martin -Tang3 小时前
vite和webpack的区别
前端·webpack·node.js·vite