Nest.js Kafka 实现微服务

2,784 阅读17分钟

image.png

一、什么是Kafka?

Kafka 是一种开源的分布式流处理平台,最初由 LinkedIn 公司开发并于2011年开源。它被设计用于高吞吐量、低延迟的数据处理和消息传递。

Kafka 的设计目标是通过可扩展的、容错的、分布式的方式处理实时数据流。它的核心概念是一个分布式的发布-订阅消息系统,基于发布-订阅的模式可以实现高效的消息传递和处理

二、Kafka基础

1.Kafka整体架构

image.png 一个典型的 Kafka 体系架构包括若干 Producer、若干 Broker、若干 Consumer,以及一个 ZooKeeper 集群,如图所示。其中 ZooKeeper 是 Kafka 用来负责集群元数据的管理、控制器 的选举等操作的。Producer 将消息发送到 Broker,Broker 负责将收到的消息存储到磁盘中,而 Consumer 负责从 Broker 订阅并消费消息。

2.Kafka基础概念

我们知道Kafka 是一个分布式流处理平台,提供了一组丰富的基本概念,用于实现高性能、可靠的消息传递和处理。下面是 Kafka 的基础概念说明:

  1. Producer(生产者) :将消息发布到 Kafka 集群的客户端。生产者将消息发送到一个或多个主题。
  2. Consumer(消费者) :从 Kafka 集群中订阅并消费消息的客户端。消费者可以订阅一个或多个主题,并从分区中读取消息。
  3. Topic(主题) :消息的分类或类别。主题是 Kafka 中消息的逻辑容器。生产者将消息发布到特定的主题,而消费者可以订阅一个或多个主题以接收消息。
  4. Partition(分区) :主题可以被分成一个或多个分区。每个分区是主题的一个子集,具有自己的偏移量顺序。分区是 Kafka 实现高吞吐量和容错性的关键机制。
  5. Offset(偏移量) :表示分区中消息的唯一标识符,用于定位和跟踪消息。偏移量是消费者用来记录其消费位置的重要指标,确保消息的有序消费和可靠处理。
  6. Consumer Group(消费者组) :一组具有相同组 ID 的消费者实例的集合。每个消费者组都可以独立地消费一个或多个主题的消息,而不会相互干扰。Kafka 通过将分区分配给不同的消费者实例来实现负载均衡和并行处理。
  7. Broker(代理) :Kafka 集群中的服务器节点。每个代理负责管理一部分主题的分区和副本,并处理生产者和消费者的请求。
  8. Replication(副本) :Kafka 使用副本机制实现数据的冗余和容错性。每个分区可以有多个副本,其中一个副本被选为领导者(leader),负责处理读写请求,其他副本作为追随者(follower)进行备份。
  9. ISR(In-Sync Replicas,同步副本集) :ISR 是一组与领导者副本保持同步的副本集合。只有处于 ISR 中的副本才能被选举为新的领导者,保证数据的一致性和可用性。
  10. Commit Log(提交日志) :Kafka 使用持久化的提交日志来存储消息。每个分区都有一个对应的提交日志,用于顺序存储消息,并支持高效的顺序读写操作。

这些基础概念构成了 Kafka 的核心架构和工作原理,使其能够实现高性能、可靠的消息传递和处理。理解这些概念对于使用和配置 Kafka 集群以及开发 Kafka 应用程序都非常重要。

3.Kafka相关特性

  1. 高吞吐量:Kafka 能够处理大规模数据流并提供高吞吐量。它在分布式环境中并行处理消息,支持每秒数百万的消息读写操作。
  2. 可扩展性:Kafka 的设计允许水平扩展,可以通过添加更多的代理(broker)和分区来增加吞吐量和存储容量。它能够处理大规模的数据流和高并发的消息传递需求。
  3. 持久性:Kafka 使用持久化的日志存储消息,保证消息的持久性和可靠性。消息被写入磁盘并复制到多个副本,以防止数据丢失。
  4. 容错性:Kafka 使用分布式副本机制来提供容错性。当某个代理(broker)或分区发生故障时,系统可以继续工作,并且副本可以被选举为新的领导者。
  5. 多订阅者和多发布者:Kafka 支持多个生产者和多个消费者,并且可以将一个主题的消息广播给多个消费者,实现发布-订阅模式。
  6. 持续流式处理:Kafka 可以与流处理框架(如 Kafka Streams、Spark Streaming、Flink 等)集成,支持实时的流式数据处理和分析。
  7. 精确一次交付:Kafka 提供了至少一次的消息传递保证。生产者可以选择等待消息被复制到多个副本后再进行确认,以确保消息的可靠性。
  8. 灵活的消息保留策略:Kafka 允许根据时间、大小或配置的策略来保留消息。这使得可以根据需求保留消息的时间段或大小,并自动删除过期的消息。
  9. 可插拔的消息处理器:Kafka 的消费者可以使用各种编程语言和技术来开发,使其灵活性更高。消费者可以根据应用程序的需要进行自定义的消息处理。
  10. 生态系统丰富:Kafka 生态系统提供了许多与其集成的工具和库,如 Kafka Connect(用于连接外部数据源和目标)、Kafka Streams(用于流处理)、KSQL(用于流SQL查询)等。

4.kafka 使用场景

  1. 消息传递和异步通信:Kafka 可以作为消息传递系统,将消息从一个应用程序传递到另一个应用程序。它提供了高吞吐量和可靠的消息传递机制,适用于构建分布式系统、微服务架构、事件驱动架构等场景。
  2. 日志收集和数据管道:Kafka 可以用作日志收集系统,集中收集和存储分布在各个应用程序和服务器上的日志数据。它可以与日志收集工具(如 Logstash、Fluentd)结合使用,实现实时的日志处理和数据管道。
  3. 流式数据处理:Kafka 可以与流处理框架(如 Kafka Streams、Spark Streaming、Flink 等)集成,支持实时的流式数据处理和分析。它可以作为输入和输出的可靠数据源,用于构建实时分析、实时推荐、欺诈检测等应用。
  4. 指标和事件处理:Kafka 可以用于收集和处理应用程序的指标数据和事件流。它可以接收来自多个应用程序或服务的指标数据,并将其发送到监控系统或实时分析平台进行处理和可视化。
  5. 削峰限流:Kafka 可以在一定程度上用于削峰限流。通过将消息缓冲在 Kafka 的主题中,可以实现生产者和消费者之间的解耦。生产者可以将消息以较高的速率发送到 Kafka,而消费者可以根据自身的处理能力从 Kafka 中以较低的速率消费消息,从而实现削峰限流的效果。

总的来说,Kafka 适用于需要高吞吐量、低延迟、可靠性和可扩展性的实时数据处理和消息传递场景。它在大数据、云计算、微服务架构等领域得到广泛应用,并成为许多现代数据架构的核心组件之一。

三、Kafka DEMO

从上文中,我们简单了解 Kafka 原理。接下来我们从简单的 DEMO 入手。

1.简单应用

首先我们要创建一个应用程序:

nest new nest-kafka

安装程序所需要的包:

yarn add @nestjs/microservices kafkajs 

定义一个TestController,定义test1接口

@Controller()
export class TestController {
  constructor(private readonly kafkaService: TestService) {}
  private fibonacci(n: number) {
    return n < 1
      ? 0
      : n <= 2
        ? 1
        : this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }

  @Get('test1')
  getTestKafka(): string {
    const value = this.fibonacci(40);
    return value;
  }
}

运行应用程序后,我们通过loadtests进行压测。如果没有安装可以执行这个命令:npm i -g loadtest

loadtest -c 10 -n 100 http://localhost:3000/test1

通过运行该命令,loadtest工具将模拟并发用户并发送100个请求给目标URL,以测试服务器的性能和响应能力。其中,并发用户数为10,意味着每个时间点上最多有10个请求同时发送。总共发送100个请求,可以用来评估服务器在负载条件下的处理能力。

image.png

2.Kafka Client

创建实例的一种方法是使用 ClientsModule。要使用 ClientsModule 创建一个客户端实例,导入它并使用 register() 方法传递一个选项对象,该对象具有上面在 createMicroservice() 方法中显示的相同属性,以及一个用作注入令牌的 name 属性。

import { CustomClientOptions } from '@nestjs/microservices';

export const kafkaOptions: CustomClientOptions['options'] = {
  producerOnlyMode: false,

  // Kafka 客户端配置
  client: {
    clientId: 'markiMonitorId',
    // Kafka 服务器的地址和端口列表
    brokers: [
      '', // 在此处填写 Kafka 服务器的地址和端口
    ],
  },

  subscribe: {
    topics: [MONITOR_TOPIC], // 订阅的主题列表
  },

  send: {
    // 默认值为-1
    // acks: 生产者发送消息后需要等待的确认级别。可选值为:
    // 0: 生产者不等待任何确认,直接将消息发送出去。
    // 1: 生产者等待 Leader 分区确认消息后返回。
    // -1 或 all: 生产者等待 Leader 分区和所有副本确认消息后返回。
    // acks: 0, 
  },

  // 生产者配置选项,用于配置生产者的行为
  producer: {
    transactionalId: MONITOR_SERVICE, // 事务 ID
  },

  // 消费者配置选项,用于配置消费者的行为
  consumer: {
    groupId: 'monitor-consumer', // 消费者组 ID
    sessionTimeout: 180000, // 会话超时时间(以毫秒为单位)
    rebalanceTimeout: 20000, // 重新平衡超时时间(以毫秒为单位)
    heartbeatInterval: 10000, // 心跳间隔时间(以毫秒为单位)
  },
};

具体 Kafka 配置可以查看 nest.js官网

import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { kafkaOptions, MONITOR_SERVICE } from 'config';
import { KafkaService } from './kafka.service';
import { KafkaController } from './kafka.controller';

@Module({
  imports: [
    // 生产者注册
    ClientsModule.register([
      {
        name: MONITOR_SERVICE,
        transport: Transport.KAFKA,
        options: kafkaOptions,
      },
    ]),
  ],
  controllers: [KafkaController],
  providers: [KafkaService],
  exports: [KafkaService],
})
export class KafkaModule {}

由于实现消息模式,我们应该在控制器中订阅各自的主题,NestJs使用kafkajs,我们可以使用管理客户端处理主题,因此我们应该在kafka.controller.ts中。

ClientKafka 类提供了 subscribeToResponseOf() 方法。subscribeToResponseOf() 方法将请求的主题名称作为参数,并将派生的响应主题名称添加到响应主题集合中。实现消息模式时需要此方法。

import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { ClientKafka } from '@nestjs/microservices';
import { MONITOR_SERVICE, MONITOR_TOPIC } from 'config';

export interface Item {
  id: number;
  name: string;
}

@Injectable()
export class KafkaService implements OnModuleInit {
  // topic 话题
  private topic = MONITOR_TOPIC;

  // 注入kafka
  constructor(@Inject(MONITOR_SERVICE) private readonly client: ClientKafka) {}

  async onModuleInit() {
    this.client.subscribeToResponseOf(this.topic);
    await this.client.connect();
  }

  /**
   * 生产者,发送
   * @param {Item} data
   * @memberof KafkaService
   */
  public sendMessage(data: Item) {
    return this.client.send<Item>(this.topic, JSON.stringify(data));
  }
}

用于连接到 Kafka 服务器并发送消息到指定的主题。在模块初始化时,它会订阅指定主题的响应消息。

  • 在 KafkaService 类中,使用 @Inject 装饰器将 MONITOR_SERVICE 注入到 client 私有属性中。MONITOR_SERVICE是 Kafka 服务充当注入令牌。

  • 在 KafkaService 类中,定义了一个私有属性 topic

  • KafkaService 类实现了 OnModuleInit 接口,并在 onModuleInit 方法中进行了一些操作:

    • 使用 subscribeToResponseOf 方法订阅了 topic 主题的响应消息。
    • 调用 connect 方法连接到 Kafka 服务器。

在实际应用程序中,topic 是通过内部申请获取的,并且在申请时设置了分区为32。应用程序的部署配置为最小2台机器,最大4台机器,机器会自动伸缩,每台机器上部署4个进程。这样设计能够充分发挥应用程序的并发处理能力和可扩展性,以满足高负载环境下的需求。但是最少要保证有16个分区。

如果项目是自行设置分区,可以参考如下代码:

async onModuleInit() {
    this.client.subscribeToResponseOf('fibo');
    const kafka = new Kafka({
      clientId: 'my-app',
      brokers: ['localhost:9092'],
    });
    this.admin = kafka.admin();
    const topics = await this.admin.listTopics();

    const topicList = [];
    if (!topics.includes('fibo')) {
      topicList.push({
        topic: 'fibo',
        numPartitions: 10,
        replicationFactor: 1,
      });
    }

    if (!topics.includes('fibo.reply')) {
      topicList.push({
        topic: 'fibo.reply',
        numPartitions: 10,
        replicationFactor: 1,
      });
    }

    if (topicList.length) {
      await this.admin.createTopics({
        topics: topicList,
      });
    }
  }

3.Kafka Consumer

现在使用 Nest.js 框架创建一个 Kafka 消费者微服务,并将其与应用程序集成。通过连接到 Kafka 代理,应用程序可以消费来自 Kafka 主题的消息,并执行相应的处理逻辑.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { kafkaOptions } from 'config';
import { networkInterfaces } from 'os';

export function getServerIp(): string | undefined {
  const interfaces = networkInterfaces();
  for (const devName in interfaces) {
    const iface = interfaces[devName] as Array<any>;
    for (let i = 0; i < iface.length; i++) {
      const alias = iface[i];
      if (
        alias.family === 'IPv4' &&
        alias.address !== '127.0.0.1' &&
        !alias.internal
      ) {
        return alias.address;
      }
    }
  }
}

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 消费服务创建
  const microservice = app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.KAFKA,
    options: kafkaOptions,
  });
  // 开始微服务
  await app.startAllMicroservices();
  // 监听端口
  await app.listen(3000).then(() => {
    console.log(`Application is running on: http://${getServerIp()}:3000`);
  });

  microservice.listen().then((port) => {
    console.log('Microservice is listening');
  });
}
bootstrap();

KafkaController 类是一个 Nest.js 控制器,处理了两个路由处理器方法。其中,getHello() 方法处理了 /producer 路径的 GET 请求,向 Kafka 发送消息,并返回结果。handleMessage() 方法则处理了来自 MONITOR_TOPIC 主题的消息,对接收到的数据进行处理,并返回通过斐波那契数列计算得到的结果。

import { Controller, Get } from '@nestjs/common';
import { KafkaService, Item } from './kafka.service';
import { lastValueFrom } from 'rxjs';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { MONITOR_TOPIC } from 'config';

@Controller()
export class KafkaController {
  constructor(private readonly kafkaService: KafkaService) {}

  private fibonacci(n: number) {
    return n < 1
      ? 0
      : n <= 2
        ? 1
        : this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }

  @Get('producer')
  getHello() {
    return lastValueFrom(
      this.kafkaService.sendMessage({
        id: 40,
        name: `kafka-40`,
      }),
    );
  }

  @MessagePattern(MONITOR_TOPIC)
  handleMessage(@Payload() data: Item) {
    console.log(data, 'data');
    return this.fibonacci(data.id);
  }
}

  • @MessagePattern(MONITOR_TOPIC) 装饰器指定了该方法将处理来自 MONITOR_TOPIC 主题的消息。
  • handleMessage() 方法接收一个 data 参数,代表接收到的消息负载。
  • 在方法中,该代码简单地输出接收到的数据,并返回通过调用 fibonacci() 方法计算得到的结果。

现在是时候 /producer 端点运行测试。

loadtest -c 10 -n 100 http://localhost:3000/producer

image.png

以上就是 Kafka 简单用例, 演示了如何在 Nest.js 应用程序中使用 Kafka 模块进行消息的生产和消费。

  1. 安装和配置 Kafka 模块:

    • 在 Nest.js 项目中安装 @nestjs/microservices 和 kafkajs 模块。
    • 在应用程序模块中导入 KafkaModule 并配置 Kafka 连接选项。
  2. 生产者(Producer):

    • 创建一个生产者服务,用于向 Kafka 主题发送消息。
    • 在生产者服务中注入 ClientKafka 实例。
    • 使用 send() 方法发送消息到指定的主题。
  3. 消费者(Consumer):

    • 创建一个消费者服务,用于从 Kafka 主题接收消息。
    • 使用 @MessagePattern() 装饰器指定要处理的主题,并定义相应的处理方法。
    • 在处理方法中处理接收到的消息记录,并进行相应的业务逻辑处理。
  4. 启动应用程序:

    • 在应用程序的入口文件中创建 Nest.js 应用程序实例。
    • 使用 app.connectMicroservice() 方法创建 Kafka 消费者微服务实例。
    • 使用 microservice.listen() 方法启动监听 Kafka 主题。
    • 使用 app.startAllMicroservicesAsync() 方法启动所有微服务。
    • 使用 app.listen() 方法启动 HTTP 服务器。

四.Kafka实际应用

在开发日志上报功能,在使用 Nest.js 和 Kafka 构建实时上报的微服务架构时,多机部署到正式环境时依次出现了两个比较严重的问题。

1.线上 Kafka 版本比较低,导致启动连接 Kafka 会报错,导致应用程序运行不起来。

通过仔细分析错误提示并对源码进行深入研究, 发现 getReplyTopicPartition 运行时获取 minimumPartition 为空导致异常。

    const minimumPartition = this.consumerAssignments[topic];

然后在 setConsumerAssignments 方法中加入打印日志,启动后发现没有打印。

image.png image.png

研究了 ClientKafka 的源码,决定覆盖 getReplyTopicPartition 方法。

import { Injectable } from '@nestjs/common';
import {
  ClientKafka,
  KafkaHeaders,
  KafkaOptions,
  ReadPacket,
  WritePacket,
} from '@nestjs/microservices';

export type KafkaOption = KafkaOptions['options'];

export interface KafkaRequest<T = any> {
  key: Buffer | string | null;
  value: T;
  headers: Record<string, any>;
}

/**
 * 覆盖
 * @export
 * @class CustomKafkaClient
 * @extends {ClientKafka}
 */
@Injectable()
export class CustomKafkaClient extends ClientKafka {
  constructor(options: KafkaOption) {
    super(options);
  }

  /**
   * 覆盖,防止低版本kafka 没有触发on监听事件
   * @protected
   * @param {string} topic
   * @return {*}  {string}
   * @memberof CustomKafkaClient
   */
  protected getReplyTopicPartition(topic: string): string {
    const minimumPartition = this.consumerAssignments[topic];
    return !!minimumPartition ? minimumPartition.toString() : '0'; // 设置最小分区
  }
}

在修改代码成功启动后,我们发现内存使用量持续增长,直到达到告警线。

2. Kafka 运行内存持续上涨。

在本地通过pm2 运行了四个进程排查也没有发现问题,能正常的生产和消费。但是到了线上内存会一直上涨。后面排查发现,源码中ClientKafka 类中使用 routingMap 的映射(Map)对象,routingMap 存在本地内存中,在多台机器之间,本地内存无法直接共享。当应用程序运行在多台机器上时,彼此之间无法直接访问或共享这些内存空间。导致 routingMap 没有消耗一直积压,导致内存飚高。

image.png

这段代码的作用是将消息包的唯一标识符(packet.id)与回调函数(callback)关联起来,并将它们存储在路由映射表(routingMap)中。在消息发布过程中,通过调用this.routingMap.set(packet.id, callback)将消息的唯一标识符与回调函数进行关联,以便在接收到相应的响应消息时能够通过唯一标识符找到对应的回调函数进行处理。

image.png 通过在eachMessage方法中监听 Kafka 消费者接收到的每条消息的回调函数,并触发routingMap中相应的回调函数,可以实现根据消息ID进行消息路由和分发的功能。

我们知道具体的原因后,可以重写 publish 方法。这里删除了 routingMap。我在这里用了比较方法处理,不需要处理回调。

如果你想在多机下共享,可以用 Redis。通过将数据存储在共享缓存中,不同机器上的应用程序可以共享相同的数据,避免了内存占用过高的问题。或者基于 Kafka 基于事件的方法

import { Injectable } from '@nestjs/common';
import {
  ClientKafka,
  KafkaHeaders,
  KafkaOptions,
  ReadPacket,
  WritePacket,
} from '@nestjs/microservices';

export type KafkaOption = KafkaOptions['options'];

export interface KafkaRequest<T = any> {
  key: Buffer | string | null;
  value: T;
  headers: Record<string, any>;
}

/**
 * 覆盖 
 * @export
 * @class CustomKafkaClient
 * @extends {ClientKafka}
 */
@Injectable()
export class CustomKafkaClient extends ClientKafka {
  constructor(options: KafkaOption) {
    super(options);
  }

  /**
   * 覆盖,防止低版本kafka 没有触发on监听事件
   * @protected
   * @param {string} topic
   * @return {*}  {string}
   * @memberof CustomKafkaClient
   */
  protected getReplyTopicPartition(topic: string): string {
    const minimumPartition = this.consumerAssignments[topic];
    return !!minimumPartition ? minimumPartition.toString() : '0'; // 设置最小分区
  }

  /**
   * 覆盖publish ,里面使用routingMap来存储和处理回调(是本地缓存),在多个服务中,就会导致内存溢出问题。
   * @protected
   * @param {ReadPacket} partialPacket
   * @param {(packet: WritePacket) => any} callback
   * @return {*}  {() => void}
   * @memberof CustomKafkaClient
   */
  protected publish(
    partialPacket: ReadPacket,
    callback: (packet: WritePacket) => any,
  ): () => void {
    const packet = this.assignPacketId(partialPacket);
    try {
      const pattern = this.normalizePattern(partialPacket.pattern);
      const replyTopic = this.getResponsePatternName(pattern);
      const replyPartition = this.getReplyTopicPartition(replyTopic);

      Promise.resolve(this.serializer.serialize(packet.data, { pattern }))
        .then((serializedPacket: KafkaRequest) => {
          serializedPacket.headers[KafkaHeaders.CORRELATION_ID] = packet.id;
          serializedPacket.headers[KafkaHeaders.REPLY_TOPIC] = replyTopic;
          serializedPacket.headers[KafkaHeaders.REPLY_PARTITION] =
            replyPartition;

          const message = Object.assign(
            {
              topic: pattern,
              messages: [serializedPacket],
            },
            this.options?.send || {},
          );

          return this.producer?.send(message);
        })
        .catch((err) => {
          callback({ err });
        });
      return callback({});
    } catch (err) {
      return () => {
        callback({ err });
      };
    }
  }
}

在使用其他微服务时也要注意源码中是否使用 routingMap 存储回调。或者使用 emit 事件模式触发,不会触发响应。是因为不使用 routingMap 存储响应回调。

总结

本文深入介绍了Kafka的工作原理和基本概念,并探讨了如何将Kafka与Nest.js应用程序集成,以实现基于请求-响应方法的开发。文章还涉及了实际应用中可能遇到的问题,并提供了解决这些问题的思路。通过本文的阐述,能够全面了解Kafka在微服务架构中的应用,以及如何优化和解决实际场景中的挑战。

在写这篇文章的过程中,不仅能够分享自己的知识和经验, 还能在写作中认真思考和学习相关知识。然后通过记录自己的思考、实践和经验,能够深入理解技术领域的知识。

代码: github.com/huazai128/n…