一、NestJS微服务架构下服务发现与负载均衡的核心概念

1.1 为什么需要服务发现与负载均衡

我们先想一个常见的场景:假设你做了一个在线电商系统,一开始功能简单,只有一个“商品服务”和一个“订单服务”,两个服务直接写死对方的IP和端口就能调用,比如商品服务直接访问订单服务的http://192.168.1.100:3000,运行起来完全没问题。

但过了几个月,订单量暴涨,原来的单个订单服务扛不住了,你只能多部署几个订单服务的实例,比如部署在192.168.1.100:3000、192.168.1.101:3000、192.168.1.102:3000三个机器上。这时候问题来了:商品服务该调用哪个订单服务?如果随便选一个,万一那个实例挂了怎么办?要是所有请求都集中到第一个实例,其他两个闲着,不就浪费资源了?

这时候就需要两个核心能力:第一个是服务发现,就是让每个服务能自动知道其他服务有哪些可用实例,不用手动写死IP;第二个是负载均衡,就是把请求均匀分配到这些可用实例上,既避免单实例压力过大,也能在某个实例挂了的时候自动把请求切到其他正常实例。

1.2 适合NestJS的服务发现方案

NestJS本身是一个Node.js的服务端框架,专门为微服务设计了一套基础能力,但原生没有自带服务发现,需要搭配第三方组件。目前最常用的方案是用Consul作为服务注册与发现的中心,搭配NestJS的@nestjs/microservices包来实现。

Consul是一个专门做服务发现和配置管理的工具,用起来很简单:每个服务启动时,会主动把自己的名字、IP、端口等信息注册到Consul;其他服务想调用这个服务时,会先去Consul查这个服务的所有可用实例,然后再选择一个实例发起请求。

二、技术栈与环境准备

2.1 明确技术栈

所有示例统一使用以下技术栈:

  • 服务端框架:NestJS 9.x
  • 服务发现工具:Consul 1.15.x
  • 依赖包:@nestjs/microservices(NestJS微服务核心包)、@nestjs/consul(NestJS官方的Consul集成包)、consul(Consul的Node.js客户端)

2.2 环境准备步骤

首先你需要先安装Consul,Windows、Mac、Linux都有对应的安装包,这里以Mac为例,用Homebrew安装:

# 安装Consul
brew install consul
# 启动Consul,-dev参数是开发模式,会把数据存在内存里,重启就清空,适合测试
consul agent -dev

启动成功后,打开浏览器访问http://localhost:8500,就能看到Consul的控制台,现在里面还没有任何服务,后面我们部署服务后就能看到了。

然后创建一个新的NestJS项目,安装依赖:

# 创建NestJS项目
nest new nest-microservice-demo
cd nest-microservice-demo
# 安装需要的依赖
npm install @nestjs/microservices @nestjs/consul consul

三、实现服务注册(服务端配置)

3.1 什么是服务注册

服务注册就是服务启动时,主动把自己的信息告诉Consul的过程。比如订单服务启动时,会告诉Consul:“我叫order-service,我的IP是127.0.0.1,端口是3001,现在是健康的”。Consul会把这些信息存起来,供其他服务查询。

3.2 完整的服务注册示例

我们先写一个订单服务,负责处理订单相关的逻辑,同时把自己注册到Consul。

首先修改订单服务的入口文件src/main.ts,代码如下:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { ConsulModule } from '@nestjs/consul';

async function bootstrap() {
  // 1. 创建NestJS应用实例
  const app = await NestFactory.create(AppModule);

  // 2. 配置微服务的传输方式和Consul连接
  app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.TCP, // 用TCP协议作为微服务之间的通信协议
    options: {
      host: '127.0.0.1', // 订单服务自己的IP
      port: 3001, // 订单服务自己的端口
      // 配置Consul的服务注册信息
      consul: {
        host: '127.0.0.1', // Consul的地址
        port: 8500, // Consul的端口
        service: {
          name: 'order-service', // 服务的名称,其他服务调用时用这个名字
          id: 'order-service-1', // 服务实例的唯一ID,多实例时每个实例ID要不一样
          tags: ['微服务', '订单服务'], // 给服务加标签,方便分类查询
          check: { // 配置健康检查,Consul会定期检查服务是否正常
            http: 'http://127.0.0.1:3001/health', // 健康检查的接口地址
            interval: '10s', // 每10秒检查一次
            timeout: '5s', // 超时时间5秒
          },
        },
      },
    },
  });

  // 3. 启动微服务
  await app.startAllMicroservices();
  // 同时启动一个HTTP服务,用来做健康检查
  await app.listen(3001);
  console.log('订单服务已启动,端口:3001');
}
bootstrap();

然后修改src/app.controller.ts,添加健康检查接口:

import { Controller, Get } from '@nestjs/common';
import { MessagePattern } from '@nestjs/microservices';

@Controller()
export class AppController {
  // 健康检查接口,返回200状态码表示服务正常
  @Get('health')
  healthCheck() {
    return 'ok';
  }

  // 订单服务的核心方法:创建订单,供其他服务调用
  @MessagePattern('create_order')
  createOrder(data: { userId: number; productId: number }) {
    console.log(`收到创建订单请求:用户${data.userId}购买商品${data.productId}`);
    return { code: 200, message: '订单创建成功', orderId: Date.now() };
  }
}

现在启动订单服务:

npm run start

启动成功后,打开Consul的控制台http://localhost:8500,就能看到order-service这个服务已经注册上去了,状态是健康的。

接下来我们再部署一个订单服务的实例,用来测试负载均衡。先改一下src/main.ts里的实例ID和端口,比如把port改成3002,service.id改成order-service-2,然后再启动一个终端运行npm run start,现在Consul控制台里就能看到两个order-service的实例了。

四、实现服务发现与负载均衡(客户端配置)

4.1 什么是服务发现与负载均衡

服务发现是指调用方(比如商品服务)想调用订单服务时,先去Consul查询order-service有哪些可用的健康实例;负载均衡是指从这些实例里选一个,选的策略有很多种,比如随机选、轮询选、最少连接数选等,NestJS默认用的是轮询策略,就是每个实例轮流被调用。

4.2 完整的服务发现与负载均衡示例

我们写一个商品服务,用来调用订单服务的create_order方法,商品服务会自动去Consul查询订单服务的实例,然后均匀分配请求。

首先创建一个新的NestJS应用作为商品服务,命名为product-service:

nest new product-service
cd product-service
npm install @nestjs/microservices @nestjs/consul consul

修改商品服务的入口文件src/main.ts:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ClientProxyFactory, Transport } from '@nestjs/microservices';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 1. 配置商品服务自己的HTTP端口
  await app.listen(3000);
  console.log('商品服务已启动,端口:3000');
}
bootstrap();

修改src/app.module.ts,配置商品服务调用订单服务的客户端:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ClientsModule, Transport } from '@nestjs/microservices';

@Module({
  imports: [
    // 配置客户端,用来调用其他微服务
    ClientsModule.register([
      {
        name: 'ORDER_SERVICE', // 客户端的名字,注入时用这个
        transport: Transport.TCP,
        options: {
          // 配置Consul的服务发现
          consul: {
            host: '127.0.0.1',
            port: 8500,
            service: {
              name: 'order-service', // 要调用的服务的名字,和订单服务注册的名字一致
              tags: ['微服务', '订单服务'], // 可以根据标签筛选实例,可选
            },
          },
          // 配置负载均衡策略,这里用默认的轮询策略,也可以自定义
          loadBalancer: {
            strategy: 'round-robin', // 轮询策略,还有random(随机)、least-connections(最少连接)等
          },
        },
      },
    ]),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

修改src/app.controller.ts,添加一个接口,用来测试调用订单服务:

import { Controller, Get, Inject, Query } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';

@Controller()
export class AppController {
  // 注入刚才配置的ORDER_SERVICE客户端
  constructor(
    @Inject('ORDER_SERVICE') private readonly orderClient: ClientProxy,
  ) {}

  // 测试接口:调用订单服务创建订单
  @Get('create-order')
  async createOrder(
    @Query('userId') userId: number,
    @Query('productId') productId: number,
  ) {
    // 调用订单服务的create_order方法,传递参数
    const result = await this.orderClient
      .send('create_order', { userId, productId })
      .toPromise();
    return result;
  }
}

现在启动商品服务:

npm run start

然后我们多次访问http://localhost:3000/create-order?userId=1&productId=100,每次访问时,看两个订单服务终端的输出:第一次请求会输出在3001端口的终端,第二次输出在3002端口的终端,第三次又回到3001,以此类推,说明负载均衡已经生效了。

如果我们把3001端口的订单服务停掉,再多次访问这个接口,会发现所有请求都只会输出在3002端口的终端,因为Consul会自动把不健康的实例(停掉的3001)从可用列表里移除,商品服务只会调用健康的3002实例。

五、技术优缺点分析

5.1 优点

  1. 不用手动维护服务地址:不管服务部署多少个实例,调用方都不用改代码,Consul会自动管理可用实例,适合大规模微服务集群。
  2. 高可用性:如果某个服务实例挂了,Consul会自动把它标记为不健康,调用方不会再给它发请求,保证系统整体可用。
  3. 负载均衡灵活:可以根据业务需求选择不同的负载均衡策略,比如对CPU密集型的服务用最少连接数策略,对普通服务用轮询策略。
  4. 配置简单:NestJS和Consul的集成非常方便,不需要写复杂的代码就能实现服务发现和负载均衡。

5.2 缺点

  1. 依赖Consul:如果Consul本身挂了,整个服务发现体系就会瘫痪,所以生产环境需要部署Consul集群来保证高可用,增加了部署复杂度。
  2. 有一定的性能损耗:每次调用服务时,都需要先去Consul查询实例列表,虽然NestJS会缓存实例列表,但还是会比直接写死IP多一层请求,不过这个损耗很小,一般可以忽略。
  3. 对健康检查的配置要求高:如果健康检查配置不合理,比如间隔时间太长,可能会导致服务已经挂了,还被调用方调用;如果间隔时间太短,又会增加Consul和服务的压力。

六、应用场景与注意事项

6.1 适用的应用场景

  1. 中大型微服务集群:当服务数量超过5个,每个服务部署的实例数超过2个时,手动维护服务地址的成本会很高,这时候服务发现和负载均衡就非常有必要。
  2. 弹性伸缩的服务:比如电商系统的秒杀场景,秒杀时订单服务需要临时部署很多实例,秒杀结束后再缩容,用服务发现可以自动管理这些实例的上下线。
  3. 高可用要求高的系统:比如金融、医疗等领域的系统,需要保证某个服务实例挂了,不影响整个系统的运行。

6.2 注意事项

  1. 生产环境部署Consul集群:开发环境可以用单节点的Consul,但生产环境必须部署至少3个节点的Consul集群,避免单点故障。
  2. 合理配置健康检查:健康检查的接口要能真实反映服务的状态,比如服务的数据库连接断了,健康检查接口应该返回失败;健康检查的间隔时间建议设置为5-10秒,超时时间设置为1-2秒。
  3. 给服务实例设置唯一ID:每个服务实例的ID必须唯一,否则Consul会认为是同一个实例,导致服务发现出现问题。
  4. 选择合适的负载均衡策略:轮询策略适合每个实例的性能和处理能力差不多的场景;随机策略适合请求的处理时间比较均匀的场景;最少连接数策略适合每个请求的处理时间差异很大的场景。

七、文章总结

服务发现和负载均衡是微服务架构中非常核心的两个能力,NestJS搭配Consul的方案可以非常简单地实现这两个能力,既解决了手动维护服务地址的麻烦,又保证了系统的高可用性和资源的合理利用。

从本文的示例可以看到,整个实现过程并不复杂,核心就是两个步骤:服务端把自己注册到Consul,客户端通过Consul查询服务实例并进行负载均衡。对于刚开始接触微服务的开发者来说,这个方案容易上手,而且扩展性很好,适合从简单的微服务架构逐步扩展到复杂的集群架构。

最后需要提醒的是,服务发现和负载均衡只是微服务架构的一部分,实际生产环境还需要考虑服务的监控、日志、限流、熔断等能力,这些能力结合起来才能构建一个稳定、可靠的微服务系统。