在请求-响应式的系统中,图上的箭头就是调用图。在事件驱动系统中却不是这样。生产者把事件写入 topic,并不知道谁会读取它。如果简单粗暴地画,把每个服务都连到一个大大的 Kafka 方框上,图就会把你需要的一切都藏起来:有哪些事件、谁对它们做出响应、按什么顺序,以及消费者失败时会发生什么。
本文给出一套能让流程保持可读的事件驱动架构图记法,以 Kafka 上的订单处理为例逐步讲解,并介绍大家常常忘记画的细节:key、消费者组、outbox 和死信路径。
图上必须表达什么
一张事件驱动架构图应当无需另附文档,就能回答以下问题:
- 有哪些事件? 用过去时命名,例如
OrderPlaced或PaymentFailed。 - 每个事件由谁产生? 健康的默认做法是:每种事件类型恰好只有一个所有者。
- 谁消费它,以哪个消费者组的身份? 在 Kafka 中,每个消费者组都会收到每一条事件,所以每个组都是一个独立的读者。
- 由什么决定顺序? Kafka 只在分区内保持顺序,而由 key 决定消息进入哪个分区。
- 失败时会发生什么? 重试、死信 topic,以及由谁来重放。
一套好用的记法
| 元素 | 画法 |
|---|---|
| 生产者 | 一个服务节点,用虚线连到 topic,连线上标注事件类型 |
| Topic | 单独的节点,名称与集群中一致(orders.v1),并在标签中写上 key |
| 消费者组 | 一条从 topic 指向消费者的虚线,标注 group id |
| 事件与命令 | 事件用过去时(OrderPlaced),命令用祈使语气(ReserveStock) |
| 死信 topic | 一个单独的 topic 节点,由放弃处理的消费者写入 |
| 连接器 | 位于 topic 与外部存储之间的节点,例如写入数据仓库的 sink |
| Broker 集群 | 围住各个 topic 的框;broker 和分区放到运维图上 |
实线只用于同步调用,例如向支付服务商发起的 HTTP 请求。这样读者一眼就能看出,流程中哪些部分发生在同一个请求里,哪些是之后才发生的。
示例:Kafka 上的订单事件
一家网店用事件来处理订单:
- orders 服务在同一个数据库事务中写入订单和一行 outbox 记录。
- outbox relay(运行在 Kafka Connect 上的 Debezium)通过数据库日志读取 outbox,并以
order_id为 key,把OrderPlaced发布到orders.v1。 - 三个消费者组读取
orders.v1:payments 从卡上扣款,inventory 预留库存,warehouse sink 把每一条事件加载到数据仓库。 - payments 把
PaymentSucceeded或PaymentFailed发布到payments.v1,同样以order_id为 key;orders 服务消费这些事件来更新订单状态。 - 如果 inventory 尝试三次后仍无法处理某条消息,就把它写入
inventory.dlq,然后继续处理后面的消息。
下面是画在同一张画布上的完整流程:
图表内容
- Orders
- Orders DB (orders + outbox)
- Outbox relay
- Kafka cluster
- payments.v1 · key order_id
- orders.v1 · key order_id
- inventory.dlq
- Payments
- Warehouse sink
- Inventory
- Data warehouse
- order + outbox row, one transaction
- reads WAL
- OrderPlaced
- group: payments
- group: warehouse-sink
- group: inventory
- PaymentSucceeded/Failed
- group: orders
- load
- after 3 failed attempts
注意这张图让哪些事一目了然:没有谁调用 payments;新增第四个消费者组不会改变上游的任何东西;由于两个 topic 都以 order_id 为 key,同一订单的事件在每个 topic 内都保持顺序(Kafka 不保证跨 topic 的顺序)。
事件驱动架构图的五条规则
1. Topic 是节点,不是箭头
从 orders 指向 payments、标着“Kafka”的箭头,暗示了一个并不存在的调用。把 topic 画成两者之间的节点。它是真实存在的东西,有名称、所有者、保留期和 schema。
2. 箭头跟着数据走
按数据流动的方向画:从生产者到 topic,再到消费者。Kafka 消费者是主动拉取消息的,但箭头从消费者指向 topic 的图,几乎所有人都会读反。如果拉取语义对讨论很重要,就加一条注释。
3. 标注事件和消费者组
在生产者的连线上写事件类型,在每条消费者连线上写消费者组。当有人问“还有谁在读 OrderPlaced?”时,答案就是从这个 topic 出发的那些带标签的连线。
4. 顺序重要时,标出 key
Kafka 保证分区内的顺序,而 key 相同的事件会进入同一个分区。如果你的消费者依赖于先看到 OrderPlaced、再看到 OrderCancelled,就把 key 写在 topic 上:orders.v1 · key order_id。标签里没有 key 的 topic,难免会招来这个问题。
5. 画出异常路径
Kafka 的消费者组没有内置的死信队列:重试几次、无法处理的消息放到哪里,都由你的消费者代码或框架决定(Kafka Connect 的 sink 连接器倒是有死信 topic 的配置项)。把这个决定画出来。一个由消费者连过去的死信 topic,再加一条注释说明谁在监控它、消息如何重放,就能省掉一次事故。
画出 outbox,而不是捷径
如果一个服务分两步走,先写数据库、再发布到 Kafka,两步之间一旦崩溃,要么丢失一条事件,要么为一个从未提交的变更发布了事件。Outbox 模式避免了这种双写:服务在同一个事务中把事件写入 outbox 表,再由中继负责发布。
在图上画出真实路径:服务 → 数据库 → 中继 → topic。从服务直接指向 topic 的箭头描述的是另一个可靠性更差的系统,而人们会据此做出决策。
协同(choreography)还是编排(orchestration)
示例采用的是协同方式:各服务对彼此的事件做出响应,没有谁统管整个流程。如果你改用编排器,也就是一个发送 ReserveStock 之类的命令并等待回复的工作流引擎,就把编排器画成位于中间的服务,并把它的连线标注为命令。无论哪种方式,都要在连线上为主流程的步骤编号,方便读者按顺序跟踪整个 Saga。
别把集群画进逻辑视图
Broker、分区、副本因子和控制器节点关乎容量和运维,与理解流程无关。把它们放到单独的运维图上。在逻辑视图中,用一个标着“Kafka cluster”的框把各个 topic 围起来就够了。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 从 C4 模型节点包中添加 容器 节点来表示各个服务,再从数据节点包中添加 orders 的数据库(Postgres)。
- 添加一个基础节点包中的 框架,标签设为
Kafka cluster,再把数据节点包中的 主题 节点放进去。 - 把 outbox relay 和 warehouse sink 添加为容器,在“技术”字段中填写
Kafka Connect,再从数据节点包中添加一个 数据仓库 节点。 - 连接端口,把事件连线设为虚线,并标注事件类型和消费者组。
- 评审时用演示模式打开,分享实时链接,或导出 SVG 和 PNG。
这个示例用了 11 个图表节点,在 Free 方案的 15 个之内。另一种流式处理布局见 /examples/streaming-data-platform;类似网店的同步部分,可参阅微服务架构图指南。
常见问题
要画分区吗? 逻辑视图上不画。如果分区数很重要,就写在 topic 的标签里,再在运维图上画出分区和 broker。
在图上,队列和 topic 有什么区别? 在队列中,每条消息只交给一个消费者;在 Kafka topic 中,每个消费者组都会收到每一条消息。SQS 或 RabbitMQ 这类队列用队列节点表示,并写明你依赖的是哪种语义。
事件 schema 放在哪里? 放在注释或 topic 标签中,并附上指向 schema registry 或拥有该 schema 的代码仓库的链接。图应当表明契约存在,以及它在哪里。
如何表示重放?
在 topic 上写明保留期(例如 retention 7d),并加一条注释说明消费者组如何重置 offset。重放是一项设计特性,要让它可见。
每个事件都需要一张图吗? 不需要。每个业务流程一张图就很好用。在连线上列出事件,完整列表则维护在事件目录里。