通过 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 地址计算哈希值,然后根据哈希值选择实例。确保同一个客户端的请求始终路由到同一个实例,这种策略的衍生处理其实有很多,详情可以查看我之前的一篇文章,记录一次前端做请求负载处理的思考

其他优化处理

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

相关推荐
江号软件分享1 分钟前
从DNS到防火墙:NetDisabler多策略断网方法详解
前端
灵犀学长10 分钟前
解锁HTML5页面生命周期API:前端开发的新视角
前端·html·html5
江号软件分享18 分钟前
轻松解决Office版本冲突问题:卸载是关键
前端
致博软件F2BPM26 分钟前
Element Plus和Ant Design Vue深度对比分析与选型指南
前端·javascript·vue.js
慧一居士1 小时前
flex 布局完整功能介绍和示例演示
前端
DoraBigHead1 小时前
小哆啦解题记——两数失踪事件
前端·算法·面试
一斤代码7 小时前
vue3 下载图片(标签内容可转图)
前端·javascript·vue
中微子7 小时前
React Router 源码深度剖析解决面试中的深层次问题
前端·react.js
光影少年7 小时前
从前端转go开发的学习路线
前端·学习·golang
中微子7 小时前
React Router 面试指南:从基础到实战
前端·react.js·前端框架