通过 Node 中间层,实现后端微服务架构中的服务发现和负载均衡
要详细解释服务发现和负载均衡的意义,首先我们一定要从从「什么是微服务」,以及「微服务架构的意义」开始讲起
什么是微服务?
微服务架构是一种软件架构风格,它将一个大型的、复杂的应用程序,拆分成多组小型的、独立的服务单元,这些服务单元可以独立进行开发、部署和扩展。每个微服务都专注于执行一个特定的业务功能,并通过明确定义的接口和通信机制来与其他微服务进行交互。
微服务的意义
微服务架构的核心思想是将应用程序拆分成更小、更易于管理的部分,每个部分都由一个独立的团队开发和维护。这种拆分可以使团队更加灵活,能够独立地开发、测试、部署和维护其所负责的部分,而不会影响整体应用程序的其他部分。
举个例子,假如你正在开发一个 CICD 系统,在传统单体应用模式下,整个应用作为一个单一的代码库进行开发,测试和部署。任何更改都会涉及整个应用的重新构建和部署,那么这个时候你会发现,这个单体应用通常会随着时间的推移而变得越来越复杂,逐渐变大的代码库会让开发人员逐渐难以维护整个应用,并且在过长的开发周期、版本持续迭代风险等情况下的表现非常不好。而如果你使用的是微服务架构,那么你可以将你的系统拆分为用户、部署、监控告警、审批、自动化等模块,每个模块独立开发、独立部署、独立测试,这里面带来的好处非常多,独立开发意味着新功能的交付速度提升;独立部署意味着当服务出现不可控的 panic
的时候,不至于影响到其他服务的正常运行,并且可以分别控制功能服务的 CPU、内存配给量;独立测试意味着可以根据每个微服务的需求制定不同的测试策略,提高测试的针对性和效率。
服务注册与发现
当涉及到在后端微服务架构中实现服务发现和负载均衡时,一个常见的方法是使用一个 Node
中间层。这个中间层充当了请求的代理,负责处理服务发现和将请求分发到正确的微服务实例上,我们一般认为基础的步骤分为以下三步:
- 微服务注册
后端服务在启动的时候向注册中间提交注册信息,并在服务注销后对这个注册信息进行删除,也可以使用注册心跳的策略,运行过程中持续推送健康状态,一段时间不推送后认为已被卸载或不可用,从而清理对应的注册信息。最后会在这个注册中心维护一份完整的服务 Key -> 服务地址的映射表。
微服务进行服务注册的注册中心可以有很多,业内常用的有 etcd、ZooKeeper、Nacos 等服务注册平台,可以根据团队具体情况使用对应平台。
- 服务发现
在 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 配置的能力。
- 检查服务健康状态
检查服务状态是后端微服务需要处理的事情,意义在于可以确保只有可用的服务实例被路由到,在 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}`);
});
负载均衡
负载均衡简单来说呢,是指在一个系统中分配任务或网络流量,以使系统的负载在多个资源之间均匀分布,从而提高系统的性能、可靠性和可用性。在我们所说的微服务架构中,负载均衡用于将请求分发到多个微服务实例,以确保每个实例都能平均地处理请求,避免某个实例过载而影响整体性能。通常我们使用负载均衡的目的是为了提高性能,保证突发情况下服务的可靠性,避免负载雪崩等情况。
常见的负载均衡策略有以下几种:
- 轮询
适用于多个微服务实例性能差距不大,如你的微服务都是 1G CPU、1G memory
,那么可以考虑使用轮询,循环往复的请求微服务。
- 加权轮询
适用于多个微服务实例存在性能差距,例如你存在四个微服务为 1G CPU、1G memory
,第五个微服务为主服务,为 5G CPU、5G memory
时,可以考虑为第五个服务的权重增加,根据权重来分配请求。
- 最小连接数或最小响应时间
根据服务的最小连接数或最小响应时间来分配 IP,这种情况出现在当不同的服务实例处理请求的速度不一致时,如为微服务分配的带宽不一致,或者是部分机器存在资源密集型任务的情况下,可以考虑这种策略。
- 随机
顾名思义,随机选择 IP,这种策略较为通用性,如之前代码中的 ips.length > 1 ? ips[Math.floor(Math.random() * ips.length)] : ips[0]
,就是典型的随机请求。这种策略在实例性能差异大、精细控制要求高(需要考虑响应时间)、可预测性要求高(如灰度)等情况下表现不好,是一种通用策略。
- IP Hash
简单来说就是,根据客户端的 IP 地址计算哈希值,然后根据哈希值选择实例。确保同一个客户端的请求始终路由到同一个实例,这种策略的衍生处理其实有很多,详情可以查看我之前的一篇文章,记录一次前端做请求负载处理的思考
其他优化处理
在日常的生产开发中,实际上做到这里是远远不够的,微服务架构的优化不仅仅局限于服务发现和负载均衡,还需要在各个方面进行深入思考和优化,如日志记录和告警、服务容器化和自动部署、中间层鉴权、容错机制等等。在实际应用中,需要大家根据业务需求和团队实际情况,有针对性地选择和实施这些优化措施。