通信层是少数几个会影响到应用程序所有方面的架构决策之一。它决定了系统的延迟上限、团队们能够以多大的独立性进行开发、故障会如何传播,以及每次需要修改接口协议时会给开发者带来多大的麻烦。

目前主要有三种主流的通信模式:基于HTTP的REST框架、使用Protocol Buffers的gRPC,以及通过中间件实现的事件驱动型消息传递机制。大多数生产环境都会结合这三种模式进行使用。关键在于要明白哪种模式最适合特定的交互场景。

在本文中,你将了解每种通信方式的核心工作原理,它们之间在五个维度(延迟、耦合程度、数据结构演变能力、调试难度以及运营复杂性)上的实际权衡,同时还会学习如何为不同的服务交互场景选择合适的通信模式。

先决条件

为了充分理解本文的内容,你需要具备以下基础知识:

  • 基本的HTTP概念(请求/响应、状态码、头部信息等)

  • 能够使用任何后端语言操作API(示例中使用了TypeScript和Node.js)

  • 对微服务架构有基本的了解

  • 熟悉JSON这种数据交换格式

目录

三种通信模式概览

在深入探讨具体内容之前,先来了解一下这三种模式的总体特点:

REST gRPC 事件驱动型消息传递
通信方式 同步 同步+流式传输 异步
协议栈 HTTP/1.1或HTTP/2 HTTP/2 依赖中间件(TCP协议)
数据序列化格式 JSON(通常使用) Protocol Buffers(二进制格式) JSON、Avro或Protobuf
耦合程度 请求时生成数据 请求时生成数据+依赖数据结构 时间上的解耦
适用场景 公共API、CRUD操作 高吞吐量的内部系统 工作流处理、事件驱动型应用

每种技术都有其优势,没有哪一种在所有情况下都是更优的选择。本文的后续部分将探讨其中的原因。

REST:默认的选择

基于HTTP的REST是一种被广泛理解的通信模式。服务通过URL端点来暴露资源,客户端则通过标准的HTTP方法与这些资源进行交互。

// 订单服务会调用库存服务
async function checkInventory(productId: string): Promise {
  const response = await fetch(
    `https://inventory-service/api/v1/products/${productId}/stock`,
    {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${getServiceToken()}`,
      },
    }
  );

  if (!response.ok) {
    throw new HttpError(response.status, await response.text());
  }

  return response.json();
}

REST的优势所在

任何语言、框架或平台都支持HTTP协议。你的前端应用、移动应用、合作伙伴提供的集成服务,以及内部服务,都可以使用相同的通信协议。

相关的开发工具也非常成熟:负载均衡器、API网关、缓存代理以及调试工具,这些工具都能原生地处理HTTP请求。

REST的架构也相对简单。新开发者只需阅读一次REST请求的代码,就能理解它的功能。URL用于描述资源,HTTP方法说明要执行的操作,状态码则用来表示请求的结果。整个过程不需要进行任何模式编译或代码生成,也不需要使用特殊的客户端库。

此外,HTTP本身就具备内置的缓存机制。例如,一个带有`Cache-Control: max-age=60`头的`GET /products/123`请求,可以被调用者与服务器之间的所有代理服务器进行缓存。而gRPC或事件驱动型架构则没有这样的内置功能。

// 带有缓存头的REST响应
app.get("/api/v1/products/:id", async (req, res) => {
  const product = await getProduct(req.params.id);

  res.set("Cache-Control", "public, max-age=60");
  res.set("ETag", computeETag(product));

  res.json(product);
});

REST的局限性

REST以资源为中心的设计模式,往往需要多次请求才能完成数据的获取。例如,要获取一个订单的所有信息,包括商品详情、客户资料和运输状态,可能就需要进行三次独立的HTTP请求。每次请求都会增加网络延迟、TCP握手的开销以及数据序列化的成本。

// 通过三次连续的请求来获取完整的数据
async function getOrderDetails(orderId: string): Promise {
  const order = await fetch `/api/orders/${orderId?).then((r) => r.json());
  const customer = await fetch `/api/customers/${order.customerId?).then((r) => r.json());
  const shipment = await fetch `/api/shipments/${order.shipmentId?).then((r) => r.json());

  return { order, customer, shipment };
}

你可以通过使用复合端点或GraphQL来缓解这个问题,但这样做会增加复杂性。而gRPC通过消息组合的方式,能够更自然地解决这一问题。

序列化带来的开销也是一个问题。JSON格式虽然便于人类阅读,但其解析过程相对耗时。对于那些不需要人类阅读数据内容的的高吞吐量内部通信场景来说,人们实际上是在为这种不必要的可读性付出额外的CPU资源和带宽成本。

最后,gRPC不支持流式传输。标准的REST模型采用的是请求-响应模式。如果你需要服务器主动向客户端推送更新信息(比如实时订单追踪、实时指标数据),那么REST就需要通过轮询、服务器发送事件或WebSockets等变通方式来实现这一功能,而这些方法其实并不属于REST模型的核心组成部分。

gRPC:性能之选

gRPC是一种基于HTTP/2和Protocol Buffers构建的远程过程调用框架。与使用URL和JSON不同,你可以通过`.proto`文件来定义服务及消息结构,而该框架会自动生成类型严格的客户端和服务器代码。

定义契约


// inventory.proto
syntax = "proto3";

package inventory;

service InventoryService {
  // 单次请求,返回单一响应
  rpc CheckStock(StockRequest) returns (StockResponse);

  // 服务器端流式传输:单次请求,返回多条响应
  rpc WatchStockLevels(WatchRequest) returns (stream StockUpdate);

  // 客户端流式传输:多次请求,返回单一响应
  rpc BulkUpdateStock(stream StockAdjustment) returns (BulkUpdateResult);
}

message StockRequest {
  string product_id = 1;
  string warehouse_id = 2;
}

message StockResponse {
  string product_id = 1;
  int32 available = 2;
  int32 reserved = 3;
  google.protobuf.Timestamp last_updated = 4;
}

message StockUpdate {
  string product_id = 1;
  int32 available = 2;
  string warehouse_id = 3;
}

运行`protoc`命令后,你就可以得到目标语言版本的客户端和服务器代码片段。以下是TypeScript版本的示例:


import { InventoryServiceClient } from "./generated/inventory";
import { credentials } from "@grpc/grpc-js";

const client = new InventoryServiceClient(
  "inventory-service:50051",
  credentials.createInsecure()
);

async function checkStock(productId: string): Promise {
  return new Promise((resolve, reject) => {
    client.checkStock(
      { productId, warehouseId: "warehouse-eu-1" },
      (error, response) => {
        if (error) reject(error);
        else resolve(response);
      }
    );
  });
}

gRPC的优势所在

Protocol Buffers能够将数据序列化为紧凑的二进制格式。例如,一个在JSON中占用1KB大小的消息,使用Protocol Buffers编码后可能只有300字节。再加上HTTP/2的多路复用技术(即多个请求可以通过同一个TCP连接发送),gRPC在处理内部服务调用时,能够显著降低延迟并提高吞吐量。而我们都知道,性能才是最重要的因素。

指标 REST(JSON/HTTP 1.1) gRPC(Protobuf/HTTP 2)
序列化大小 较大(基于文本的JSON格式) 显著更小(二进制Protobuf格式)
序列化时间 较慢(JSON解析/字符串化操作) 更快(二进制编码/解码操作)
每个连接支持的请求数量 1个(不支持多路复用时) 可支持多个请求(支持多路复用时)
连接开销 每次请求都需要建立新连接(HTTP/1.1协议) 通过多路复用可保持连接持续有效

实际带来的性能提升程度取决于数据量大小、网络拓扑结构以及服务器实现方式。在测试中,这种差异的表现范围从微不足道(在快速网络环境下处理少量数据时)到非常显著(在处理大量数据或高并发场景下)不等。

总之,gRPC的二进制序列化机制和HTTP/2的多路复用功能使其在内部通信中具有明显优势;但在实际应用之前,你还是需要在自己的环境中进行测试,才能得出关于延迟情况的准确结论。

此外,gRPC原生支持四种通信模式:单向请求响应模式、服务器端流式传输模式、客户端流式传输模式以及双向流式传输模式。因此,它非常适合用于实时数据更新、日志监控或进度报告等场景。

// 服务器端流式传输示例:实时获取库存变化信息
function watchStockLevels(warehouseId: string): void {
  const stream = client.watchStockLevels({ warehouseId });

  stream.on("data", (update: StockUpdate) => {
    console.log(`产品 \({updateproductId}: \){update.available} 有货`);
  });

  stream.on("error", (error) => {
    console.error("流式传输出现错误:", error.message);
    // 可在此处添加重新连接的逻辑
  });

  stream.on("end", () => {
    console.log("流式传输结束");
  });
}

最后,gRPC还具备跨服务的强类型特性。`.proto`文件是所有代码生成的唯一依据,客户端和服务器都是根据这个文件生成的。如果库存服务修改了`StockResponse`消息的结构,那么订单服务的构建过程就会失败,直到重新生成相应的客户端代码为止。这样一来,错误就能在编译阶段就被发现,而不会在半夜才被发现。

gRPC的不足之处

第一个主要问题是浏览器的支持情况。由于浏览器的`fetch` API并不提供gRPC所需的HTTP/2协议功能(例如状态码相关的信息以及对双向数据流的精细控制),因此浏览器无法直接使用gRPC进行通信。

为了解决这个问题,你可以使用gRPC-Web,它通过Envoy这样的代理服务器将gRPC协议转换为浏览器能够识别的格式;或者你也可以在gRPC服务之前添加REST或GraphQL网关。

无论采用哪种方式,对于那些需要被浏览器直接访问的接口来说,gRPC都不是一个合适的选择。正因如此,本文在讨论公开API的设计时,默认推荐使用REST协议。

此外,这种架构也很难进行调试。你无法使用`curl`来测试gRPC端点,而且二进制数据格式人类难以理解。虽然像`grpcurl`这样的工具以及Postman的gRPC支持功能能提供一些帮助,但实际的调试体验仍然不如在浏览器的网络选项卡中查看JSON响应那么方便。

# 调试REST端点
curl -s https://inventory-service/api/v1/products/abc-123/stock | jq

# 调试gRPC端点(需要使用grpcurl)
grpcurl -plaintext -d '{"product_id": "abc-123"}' \
  inventory-service:50051 inventory.InventoryService/CheckStock

最后,运营维护方面也存在一些问题。你需要管理`.proto`文件,在构建流程中执行代码生成操作,为这些协议定义版本控制,并确保在模式发生变化时所有依赖这些协议的组件都能自动重新生成代码。

对于只有两个服务的团队来说,这些工作还可以应付得来;但当服务数量达到二十个时,就需要建立专门的协议注册系统并制定相应的管理流程了。

事件驱动型通信:解耦的最佳选择

事件驱动型通信模式颠覆了传统的交互方式。服务A不再直接调用服务B,而是将事件发布到中介系统(如Kafka、RabbitMQ、Amazon SNS/SQS等),然后服务B再异步地处理这些事件。

// 订单确认后,订单服务会发布一个事件
import { Kafka } from "kafkajs";

const kafka = new Kafka({ brokers: ["kafka:9092"] });
const producer = kafka.producer();

async function publishOrderConfirmed(order: Order): Promise {
  await producer.send({
    topic: "order.confirmed",
    messages: [
      {
        key: order.id,
        value: JSON.stringify({
          eventType: "order.confirmed",
          eventId: crypto.randomUUID(),
          timestamp: new Date().toISOString(),
          data: {
            orderId: order.id,
            customerId: order.customerId,
            items: order.items.map((item) => ({
              productId: itemproductId,
              quantity: item.quantity,
            }),
            total: order.total,
          },
        }),
      },
    ],
  });
}
// 库存服务会独立地处理这些事件
const consumer = kafka.consumer({ groupId: "inventory-service" });

async function startInventoryConsumer(): Promise {
  await consumer.subscribe({ topic: "order.confirmed" });

  await consumer.run({
    eachMessage: async ({ message }) => {
      const event = JSON.parse(message.value.toString());

      for (const item of event.data.items) {
        await decrementStock(itemproductId, item.quantity);
      }

      logger.info("库存信息已更新", {
        orderId: event.data.orderId,
      });
    },
  });
}

事件驱动型通信的优势所在

首先,这种架构实现了时间上的解耦。生产者不需要等待消费者来处理事件;订单服务在确认订单后即可继续执行其他操作。即使库存服务出现故障,事件也会被暂时存储在中介系统中,直到该服务恢复运行。因此,既没有超时问题,生产者也不需要采用重试机制,更不会出现连锁故障的情况。

一个事件也可能触发多个独立的反应。当订单被确认后,库存服务会减少相应的库存数量,通知服务会发送确认邮件,分析服务会记录这一交易行为,而运输服务则会开始处理发货事宜。订单服务本身并不了解这些其他服务的运作情况,也不需要关心它们。

order.confirmed事件
├── inventory-service → 减少库存数量
├── notification-service → 发送确认邮件
└── analytics-service → 记录交易行为

新增一个消费者服务时,生产者端完全不需要进行任何修改。这就是服务之间所能实现的最低耦合度了。

这种架构还天然具备审计追踪功能。如果你的消息中间件能够保留这些事件记录(Kafka默认就会这么做),那么你就能获得所有发生过的操作的完整历史记录。你可以重新执行这些事件来恢复系统状态,通过分析事件的先后顺序来排查故障,或者创建一个新的服务来处理历史数据,从而补全现有系统的信息。

事件驱动架构的局限性

在订单服务发布“订单已确认”这一消息后,会有一段时间间隔,在这段时间内库存服务尚未处理这个事件。如果在此期间有其他的请求被发送,它们看到的可能会是过时的库存数据。因此,如果你的应用需要保证“自己发出的操作能够被及时处理”的一致性,那么仅依靠事件驱动的通信机制是不够的。

// 问题在于:订单已确认,但库存数量尚未减少
async function handleCheckout(cart: Cart): Promise {
const order = await createOrder/cart);
await publishOrderConfirmed(order);

// 如果此时有其他的请求检查库存情况,
// 它们看到的将会是之前的库存数值。
// 因为库存服务还没有处理这个订单确认事件。
return order;
}

调试过程也会变得更加复杂。在同步调用链中出现问题时,你可以直接查看堆栈追踪信息;而在事件驱动的架构中,出现问题时你可能会在死信队列中看到相关消息,但你需要弄清楚是哪个服务在什么时间发送了这条消息,以及当时系统的状态如何。分布式跟踪技术确实能提供帮助,但跨服务关联事件的信息相比追踪同步调用过程要困难得多。

此外,在保证事件的执行顺序方面也会存在问题。大多数消息中间件只能保证在同一分区或同一队列内的事件按顺序处理,而不能保证全局范围内的顺序。因此,如果订单服务先发布了“订单已确认”然后又发布了“订单取消”的消息,而这两个事件被分配到了不同的分区中,那么库存服务可能会先处理取消订单的操作。

// 使用一致的分区键来确保每个实体的操作按顺序处理
await producer.send({
topic: "order.events",
messages: [
{
// 同一订单的所有事件都会被发送到同一个分区
key: order.id,
value: JSON.stringify(event),
},
],
});
<通过实体ID(订单编号、客户编号)来标识消息,这样可以确保同一实体的相关事件能够按顺序被处理;而不同实体的事件则可以并行处理。

最终,你的运营会变得更为复杂。运行消息代理并非免费之事。Kafka需要ZooKeeper(或KRaft)来支持主题管理、分区重新平衡、消费者组协调以及检测消费者的延迟情况。像Amazon MSK、Confluent Cloud或Amazon SQS这样的托管服务虽然可以减轻这些负担,但也会增加成本。

处理代理故障

当代理无法正常工作时会发生什么?如果你的服务先将数据写入数据库,然后再发布事件,那么代理发生故障意味着该事件将会丢失,即使数据库中的写操作已经成功完成。

以下这些方法可以帮助你应对这种情况:

1. “出箱”模式

不要直接将事件发布到代理上,而是将其与业务数据一起在同一数据库事务中写入到一个“出箱”表中。然后由另一个独立的进程(例如轮询器或像Debezium这样的变更数据捕获工具)读取这个出箱表,并将数据发布到代理上。

// “出箱”模式:将事件写入数据库,而非代理
// 通过依赖注入获取数据库实例
async function confirmOrder(order: Order, db: Database): Promise {
  await db.transaction(async (tx) => {
    // 在同一事务中同时完成业务数据写入和事件发布
    await tx.update("orders", { id: order.id, status: "confirmed" });
    await tx.insert("outbox", {
      id: crypto.randomUUID(),
      topic: "order.confirmed",
      key: order.id,
      payload: JSON.stringify({
        orderId: order.id,
        customerId: order.customerId,
        items: order.items,
        total: order.total,
      }),
      created_at: new Date(),
    });
  });
  // 由另一个独立进程从出箱表中读取数据并发布到Kafka
}

由于事件数据和业务数据是原子性地被写入数据库的,因此即使代理发生故障,你也不会丢失任何事件。那个负责转发数据的进程会不断重试,直到代理恢复正常运行为止。

2. 至少一次交付机制

大多数消息代理都提供至少一次交付保证,这意味着消费者可能会多次看到同一条事件(例如在分区重新分配或重试操作之后)。因此,你的消费者程序必须具备幂等性:处理同一条事件两次应该会产生与处理一次相同的结果。

// 幂等消费者:利用eventoId来避免重复处理
async function handleOrderConfirmed(event: EventEnvelope): Promise {
  const alreadyProcessed = await db.query(
    "SELECT 1 FROM processed_events WHERE event_id = $1",
    [event.eventId]
  );

  if (alreadyProcessed.rows.length > 0) {
    logger.info("重复的事件,跳过处理", { eventId: event.eventId });
    return;
  }

  await db.transaction(async (tx) => {
    await decrementStock(tx, event.data.items);
    await tx.insert("processed_events", {
      event_id: event.eventId,
      processed_at: new Date(),
    });
  });
}

<出站消息处理模式(生产者端)与具有幂等性的消费者(消费者端)相结合,能够确保即使在中间件出现间歇性故障的情况下,通信过程依然能够可靠地进行。>

五种权衡维度

选择某种通信模式,并不是为了找出哪种方案“最好”,而是要确定在特定的交互场景中,你能接受哪些权衡。以下是五个最为重要的维度。

1. 延迟

通信模式 相对延迟 原因
gRPC 最低 采用二进制序列化技术、HTTP/2多路复用机制以及持久连接
REST 中等偏低 需要解析JSON数据,且通常涉及HTTP/1.1连接建立过程
事件驱动型 最高(设计初衷如此) 涉及代理写入操作、数据复制以及消费者轮询机制

具体的延迟数值会受数据量大小、网络传输环节数量以及基础设施配置的影响。不过这些模式的排序规则是固定的:对于同步调用而言,gRPC速度最快;REST紧随其后;而事件驱动型通信模式则通过牺牲延迟来实现解耦。

如果调用方需要立即得到响应(例如用户进行结账操作或进行实时搜索),应选择gRPC或REST。但如果调用方目前并不急需结果(比如发送电子邮件或更新分析数据),那么使用事件驱动型通信模式会更为合适。

2. 耦合程度

耦合程度包含两个维度:时间耦合性(调用方是否需要等待接收方的响应?)以及数据结构耦合性(双方是否使用相同的数据结构进行通信?)。

通信模式 时间耦合性 数据结构耦合性
REST 高(调用方会等待接收方的响应) 低(JSON数据格式具有灵活性)
gRPC 高(调用方会等待接收方的响应) 高(双方需要使用相同的`.proto`文件定义数据结构)
事件驱动型 无(发送事件后即可忽略后续处理) 中等(事件处理时需要遵循相同的数据结构规范)

REST采用松散的数据类型,这把双刃剑:你可以向JSON响应中添加字段而不会影响消费者的正常使用(因为这些新增字段在运行时仍可以被解析)。但你也可能会不小心删除某个字段,从而导致消费者在运行时出现错误,而不是在编译阶段就被发现。

gRPC采用严格的数据类型定义,因此可以在编译阶段就发现那些会导致程序出错的变更。不过这也意味着每次数据结构发生变动时,都需要重新生成客户端代码。对于仅有两个服务的情况来说,这并不成问题;但如果有二十个服务都在使用相同的`.proto`文件,那么就需要进行协调工作了。

事件驱动型通信模式在时间上实现了解耦,但在数据结构层面仍然存在耦合关系。如果order.confirmed事件的数据结构发生了变化,那么所有消费者在过渡期间都必须同时处理旧格式和新格式的数据。

3. 数据结构演变

数据结构的演变是指如何在不影响现有消费者正常使用的情况下修改服务之间的通信规范。正是在这一点上,这三种通信模式出现了最明显的差异。

REST(JSON):


// 版本1:价格以数字形式表示
{ "productId": "abc-123", "price": 49.99 }

// 版本2:价格以对象形式表示(属于破坏性变更)
{ "productId": "abc-123", "price": { "amount": 49.99, "currency": "USD" } }

JSON本身并不具备内置的版本控制机制。你可以通过以下三种策略之一来管理版本的切换:

策略 工作原理 优缺点
URL版本控制/api/v1/ vs /api/v2/ 每个版本对应一个独立的接口地址。消费者需要主动选择使用新版本。 这种机制最容易理解,但会导致路由处理函数的重复。如果许多消费者仍然使用/v1/路径,那么旧版本就很难被淘汰。
头部字段版本控制Accept: application/vnd.myapi.v2+json 使用同一个URL地址,通过请求头来指定版本号。 这种方式可以使URL更加简洁,且不会导致路由重复。不过测试起来相对困难(因为不能直接在浏览器中输入URL进行测试),同时代理和缓存机制也会因此变得复杂,因为响应内容会根据头部字段的不同而有所变化。
防御性解析(消费者端自行适应版本变化) 不使用明确的版本标识。消费者会忽略未知的字段,对于缺失的字段则使用默认值。 这种机制适用于简单的添加性修改,但面对结构性的变更(如字段名称或类型的改变),消费者将无法理解这些变化的意图,因此这种方案不适用。

无论是哪种策略,对于添加新的字段这样的操作来说都是安全的。但对于结构性变更(如字段重命名或类型更改),则必须使用明确的版本控制机制——无论是通过URL还是请求头——这样才能确保消费者能够按照自己的节奏进行迁移。

gRPC与Protocol Buffers:

// Protocol Buffers内置了版本演进规则
message StockResponse {
  string product_id = 1;
  int32 available = 2;
  int32 reserved = 3;
  // 字段4已被移除(切勿重复使用相同的字段编号)
  string warehouse_id = 5;       // 新添加的字段:旧客户端会忽略它
  optional string region = 6;    // 可选字段:旧客户端不会发送这个字段
}

从设计上来说,Protocol Buffers能够很好地应对版本演进的需求。你可以添加新的字段(旧客户端会忽略这些新字段),也可以将某些字段标记为过时字段(停止使用这些字段,但保留其编号),而对于那些可能不存在的字段,可以使用optional属性来处理。

不过,你不能重命名字段、更改字段类型,也不能重复使用相同的字段编号。这些规则是由相关的工具强制执行的。

基于事件驱动的模式与Avro/JSON Schema:

在处理事件数据时,像Confluent Schema Registry这样的模式注册系统会强制执行兼容性规则:

// 注册一个具有向后兼容性的模式
// 新消费者可以读取旧格式的事件数据,旧消费者也可以读取新格式的事件数据
const schema = {
  type: "record",
  name: "OrderConfirmed",
  fields: [
    { name: "orderId", type: "string" },
    { name: "customerId", type: "string" },
    { name: "total", type: "double" },
    // 新添加的字段,并设置了默认值:因此具有向后兼容性
    { name: "currency", type: "string", default: "USD" },
  ],
};

通过使用模式注册系统,生产者就无法发布违反兼容性规则的事件数据。这种机制是最严格的治理方式:在事件数据到达消费者之前,注册系统就会拒绝那些不合规的模式。

4. 调试与可观测性

通信模式 调试难度
REST 最容易调试。数据以人类可读的形式传输,可以使用浏览器的开发者工具、`curl`命令以及标准的HTTP跟踪机制。
gRPC 中等难度。数据采用二进制格式传输,因此需要使用`grpcurl`工具或Postman来进行调试;元数据也可以被查看,分布式跟踪功能也表现良好。
事件驱动型 最难调试。异步处理流程需要关联ID、死信队列的监控机制,以及特定于该通信模式的工具才能进行有效调试。

对于事件驱动型系统而言,关联ID是不可或缺的:

// 在所有事件中都必须包含关联ID
interface EventEnvelope {
  eventId: string;
  eventType: string;
  correlationId: string; // 用于连接不同服务中的相关事件
  causationId: string;   // 引发当前事件的那个事件
  timestamp: string;
  source: string;
  data: T;
}

async function publishEvent( 
  topic: string,
  type: string,
  data: T,
  correlationId: string,
  causationId?: string
): Promise {
  const event: EventEnvelope = {
    eventId: crypto.randomUUID(),
    eventType: type,
    correlationId,
    causationId: causationId ?? correlationId,
    timestamp: new Date().toISOString(),
    source: SERVICE_NAME,
    data,
  };

  await producer.send({
    topic,
    messages: [{ key: data.entityId, value: JSON.stringify(event) }],
  });
}

在排查问题时,需要在所有服务中查找关联ID,从而重建完整的事件链。如果没有关联ID,就相当于在大海捞针。

5. 运维复杂性

通信模式 需要维护的系统组件
REST HTTP服务器、负载均衡器、API网关
gRPC gRPC服务器、协议编译工具、代码生成流程、gRPC-Web代理(如果存在浏览器客户端的话)
事件驱动型 消息中间件(如Kafka/RabbitMQ/SQS)、模式注册中心、死信队列、消费者延迟监控系统

REST的运维开销最低。每个团队都知道如何运行HTTP服务器。

gRPC需要在编译阶段添加额外的依赖项,同时团队也需要学习新的工具才能使用它。

事件驱动型通信模式在运行时需要依赖消息中间件,而这些中间件必须具备高可用性,因为如果它们出现故障,服务之间的通信就会中断。

决策框架

在决定某对服务应如何进行通信时,可以参考这个框架。实际上,对于整个系统来说,很少有一种固定的通信模式适用于所有情况。

调用方是否需要立即得到响应?
├── 是 → 这是一个面向公众的API还是可以通过浏览器访问的API?
│         ├── 是 → 使用REST协议
│         └── 否  → 流量或延迟是否至关重要?
│                   ├── 是 → 使用gRPC协议
│                   └── 否  → 仍然使用REST协议(更简单,也足够满足需求)
└── 否  → 调用方能否接受最终一致性?
          ├── 否  → 使用同步调用方式(REST或gRPC),然后通过异步操作进行后续处理
          └── 是  → 该事件是否需要触发多个消费者进行处理?
                    ├── 是 → 采用事件驱动型通信模式
                    └── 否  → 顺序是否重要?
                              ├── 是 → 使用带有分区键的事件驱动型通信模式
                              └── 否  → 采用简单的队列机制(如SQS)

一些具体的例子:

>

交互方式 技术框架 原因
浏览器获取产品详情 REST 浏览器无法直接使用gRPC,而REST支持缓存机制
结账时实时验证支付信息 gRPC 延迟低、类型检查严格、仅在内部系统中使用(不涉及浏览器)
订单确认后触发配送流程 事件驱动模型 多个接收方可以独立处理,实现时间解耦
前端获取用户信息 REST 简单的CRUD操作、支持缓存、适用于浏览器环境
机器学习服务生成推荐结果 gRPC 吞吐量高、数据以二进制格式传输、支持流式处理
用户注册后系统发送欢迎邮件 事件驱动模型 异步处理,无需立即响应
服务健康检查 REST 操作简单、适用范围广泛
实时监控库存水平 gRPC流式传输 数据可实时更新,必要时支持双向通信

混合架构:同时使用三种技术框架

大多数生产系统都会结合使用这三种技术。以下是一种效果良好的应用方案:

┌──────────┐    REST     ┌──────────────┐    gRPC    ┌──────────────┐
│ 浏览器  │────────────▶│  API网关 │───────────▶│ 订单服务│
└──────────┘             └──────────────┘            └──────┬───────┘
                                                           │
                                                    发布事件
                                                           │
                                                           ▼
                                                    ┌─────────────┐
                                                    │    Kafka     │
                                                    └──────┬──────┘
                                          ┌────────────────┼────────────────┐
                                          ▼                ▼                ▼
                                   ┌────────────┐  ┌────────────┐  ┌────────────┐
                                   │ 库存管理  │  │ 通知系统  │  │ 分析系统  │
                                   │ 服务A   │  │ 服务B    │  │ 服务C   │
                                   └────────────┘  └────────────┘  └────────────┘
  • REST适用于前端与API网关的交互:浏览器使用标准HTTP协议与网关通信,支持缓存、便于调试,且被广泛支持。

  • gRPC用于网关与内部服务之间的通信:延迟低、类型检查严格、数据序列化效率高等优点。

  • 事件驱动模型

    用于处理后续操作:订单服务发布事件后,多个接收方可以独立作出响应。

异步交互中的陷阱

一个常见的错误是在那些更适合使用事件驱动机制的场景中仍然使用同步调用(无论是REST还是gRPC)。其表现形式是:某个服务在处理单个请求时会进行五次同步调用,并且会等待所有这些调用完成之后才会向调用方做出响应。

// 这是一种不良的设计模式:对关键路径采用同步处理,而对其他非关键操作则使用事件驱动机制
async function confirmOrder(order: Order): Promise {
await inventoryService.decrementStock(order.items); // 需要50毫秒
await paymentService.capturePayment(order.paymentId); // 需要200毫秒
await notificationService.sendConfirmation(order); // 需要100毫秒
await analyticsService.recordConversion(order); // 需要80毫秒
await shippingService.createShipment(order); // 需要150毫秒
// 总耗时:580毫秒。如果其中任何一次调用失败,整个订单处理流程就会失败。
}

实际上,只有前两次调用(库存检查和支付处理)对于完成订单确认操作来说是至关重要的。其余的步骤都可以异步进行。

// 更好的设计方式是:对关键路径使用同步处理,对非关键操作则使用事件驱动机制
async function confirmOrder(order: Order): Promise {
// 关键路径:这些操作必须成功,订单才能完成确认
await inventoryService.decrementStock(order.items);
await paymentService.capturePayment(order.paymentId);

// 非关键步骤:发布事件,让其他系统负责处理后续流程
await publishOrderConfirmed(order);
// 总耗时:250毫秒。即使通知、分析或配送环节出现故障,也不会影响订单的确认过程。
}

这种分层设计方式与我之前在“设计具有弹性的API”这篇文章中介绍的方法是一样的。关键操作采用同步处理,而非关键步骤则通过事件驱动机制来执行。这种方式能够提高响应速度,同时也能避免下游环节的故障对整个系统造成连锁反应。

大规模环境下的模式治理

随着服务数量的增加,模式管理就成为了亟需关注的重要问题。下面为每种设计模式提供了一些实际可行的实现方法。

REST:将OpenAPI作为契约规范

# openapi/inventory-service.yaml
openapi: "3.1.0"
info:
title: Inventory Service
version: "1.2.0"
paths:
/api/v1/products/{productId}/stock:
get:
operationId: getStock
parameters:
- name: productId
in: path
required: true
schema:
type: string
responses:
"200":
description: Product inventory information
content:
application/json:
schema:
$ref: "#/components/schemas/StockResponse"
components:
schemas:
StockResponse:
type: object
required: [productId, available, reserved]
properties:
productId:
type: string
available:
type: integer
reserved:
type: integer

可以使用像openapi-typescriptopenapi-generator这样的工具,根据OpenAPI规范生成客户端SDK。这样就能确保类型安全,同时避免出现gRPC那种在编译时产生的耦合问题。

gRPC:Proto注册表

将`.proto`文件存储在共享仓库或专用的Proto注册表中(Buf Schema Registry是一个不错的选择)。在持续集成过程中使用Buf提供的破坏性变更检测功能:

# 在变更合并之前检测破坏性变更
buf breaking --against ".git#branch=main"

执行此命令需要在一个名为`proto`的目录根目录下存在一个`buf.yaml`配置文件。该文件用于定义模块名称以及任何代码检查规则或破坏性变更检测规则。有关设置细节,请参阅Buf文档

如果你修改了字段名称、改变了数据类型,或者重新使用了相同的字段编号,这个命令会拒绝你的拉取请求;而那些不会导致系统功能异常的变更(例如添加新字段或新增服务)则可以正常通过检测。

事件:具有兼容性模式的Schema注册表

对于基于事件驱动的系统而言,Schema注册表能够在数据发布时确保系统的兼容性。Confluent Schema Registry支持四种兼容性模式:

模式 规则 适用场景
BACKWARD 新格式的文档可以读取旧格式的数据 适用于以消费者为中心的系统演变过程
FORWARD 旧格式的文档也可以读取新格式的数据 适用于以生产者为中心的系统演变过程
FULL 双向兼容 是最安全、限制最严格的模式
NONE 不进行任何格式检查 仅适用于开发环境

对于生产环境中的主题来说,应使用`FULL`兼容性模式。这种模式可以确保无论消费者使用的是哪种格式版本的文档,都能正常读取该主题上的所有数据。

结论

在本文中,你了解了REST、gRPC以及基于事件驱动的消息传递机制的核心原理;也知道了在它们之间进行选择时需要考虑的五个关键因素(延迟、耦合程度、文档格式的演变能力、调试的便利性以及系统的运营复杂性);同时还学习到了如何根据具体的服务交互需求来选择合适的通信技术。

主要结论如下:

  1. REST适用于边缘场景: 适用于浏览器客户端、公共API以及简单的CRUD操作。REST具有可缓存性、便于调试的特点,并且得到了广泛的支持。

  2. gRPC适用于内部的高吞吐量服务间通信: 当延迟是一个关键因素,且双方都能控制通信过程时,gRPC是理想的选择。

  3. 事件驱动机制适用于需要实时响应的场景: 例如当生产者不能等待响应时,或者当多个消费者需要接收相同的信号时,事件驱动机制能够确保系统的高效运行。

  4. 三种技术应结合使用: 大多数生产环境中的系统都会结合使用REST、gRPC和事件驱动机制。在外部接口使用REST,内部服务之间使用gRPC,而对于异步处理流程,则可以使用事件驱动机制。

  5. 合理的文档格式管理是系统可扩展性的关键: 对于REST接口来说,使用OpenAPI进行管理;对于gRPC和基于事件的系统来说,则需要使用专门的Schema注册表来维护文档格式的一致性。如果没有有效的管理机制,文档格式的变更很可能会成为导致生产环境出现问题的主要原因。

正确的沟通方式并不是一个需要全局统一决定的事项。它应该是针对每一次具体的互动来有意识地做出的选择,这种选择需要基于你能够接受哪些权衡因素,才能使那种特定的数据交流方式得以顺利实施。

Comments are closed.