3. 事件类型与结构:事件分类与结构设计

事件模型里,最让人头疼的往往不是「怎么发」,而是「发什么」。我见过不少团队,一开始觉得事件就是个简单的消息,结果系统一复杂,事件满天飞,根本分不清谁是谁。说白了,事件得有分类,得有结构,不然就是一团乱麻。

3.1 事件分类:三种核心类型

我个人习惯把事件分成三大类:领域事件、集成事件、系统事件。这三者职责不同,生命周期也不同。你想想看,一个订单创建了,这是业务逻辑的一部分,属于领域事件;订单创建后要通知库存服务扣减库存,这是跨系统的,属于集成事件;而订单服务启动时加载配置,这是基础设施行为,属于系统事件。

类型 触发场景 消费者范围 典型例子
领域事件 业务聚合根状态变更 同一限界上下文内 OrderCreated, PaymentSucceeded
集成事件 跨服务/跨系统协作 不同限界上下文 InventoryReserved, InvoiceGenerated
系统事件 基础设施或生命周期变化 平台/运维层 ServiceStarted, ConfigReloaded

3.2 领域事件:业务的核心心跳

领域事件是 DDD 里的老熟人。它记录的是「已经发生的事实」。我在项目中遇到过一个问题:有人把「将要发生」的事情也当事件发了,结果下游收到后,业务还没执行完,状态对不上。嗯,这里要注意——领域事件一定是过去时,比如 OrderPlaced 而不是 PlaceOrder。

关键原则:领域事件由聚合根发布,代表聚合根状态的一次原子变更。一个事务内,事件必须和业务数据一起持久化。

// 领域事件示例
public class OrderPlacedEvent : IDomainEvent
{
    public string EventId { get; set; }
    public string OrderId { get; set; }
    public decimal TotalAmount { get; set; }
    public DateTime OccurredOn { get; set; }
}

3.3 集成事件:跨系统的桥梁

集成事件是微服务架构里的主角。它要解决的问题很简单:服务 A 做了件事,服务 B 怎么知道?我曾经踩过一个坑——直接把领域事件通过消息队列发给另一个服务。结果对方拿到的数据里带着内部实体 ID,根本没法用。后来我学乖了,集成事件必须设计成「对外的契约」,不能暴露内部细节。

避坑指南:集成事件不要包含内部实体引用,只传递业务标识和必要数据。我曾经因为传了内部主键,导致重构时改了主键类型,所有下游都崩了。

// 集成事件示例(对外契约)
public class OrderPaidIntegrationEvent : IIntegrationEvent
{
    public string EventId { get; set; }
    public string OrderNumber { get; set; }  // 业务编号,不是主键
    public string CustomerEmail { get; set; }
    public decimal PaidAmount { get; set; }
    public DateTime PaidAt { get; set; }
}

3.4 系统事件:基础设施的晴雨表

系统事件往往被忽略,但它其实很重要。服务启动、配置变更、连接断开……这些不是业务逻辑,但会影响整个系统的稳定性。我习惯把系统事件单独放一个通道,不和业务事件混在一起。为什么呢?因为系统事件的优先级通常更高,需要快速响应。

  • 生命周期事件:服务启动/停止、节点加入/离开
  • 配置事件:配置项变更、特性开关切换
  • 监控事件:熔断触发、限流告警、连接池耗尽

3.5 事件结构设计:头、体、元数据

事件的结构,我建议分成三部分:事件头、事件体、元数据。这就像一封信——信封上写收件人和路由信息,信纸里写正文,邮戳记录什么时候寄出的。三者各司其职,不要混在一起。

事件结构三层模型 事件头(Event Header) EventId | EventType | Source | Version | Timestamp 事件体(Event Body) 业务数据载荷(Payload) 例如:OrderId, CustomerId, TotalAmount 元数据(Metadata) CorrelationId | TenantId | UserId | TraceId 头决定路由,体决定业务,元数据决定上下文

3.6 事件头:路由与溯源

事件头是事件的身份证。我见过有人把事件头写得特别简单,就一个 ID 和类型。结果出了问题,根本查不到是谁发的、什么时候发的。我个人习惯,事件头至少包含以下字段:

字段 说明 是否必填
EventId 全局唯一标识,用于去重和追踪 是
EventType 事件类型名,如 OrderPlaced 是
Source 事件来源,如 order-service 是
Version 事件结构版本号,用于兼容 是
Timestamp 事件发生时间(UTC) 是

小技巧:EventId 我习惯用 UUID v7,它带时间戳,天然有序,数据库索引友好。别用自增 ID,分布式环境下根本没法保证唯一。

3.7 事件体:业务数据的载体

事件体就是业务数据本身。这里有个常见的误区:有人把整个聚合根序列化后扔进去。你想想看,下游可能只需要订单金额和客户 ID,你把几百个字段全传过去,不仅浪费带宽,还暴露了内部结构。

我建议事件体遵循「最小化原则」——只包含消费者需要的数据。怎么判断?看消费者订阅这个事件是为了做什么。如果只是为了更新缓存,那传个 ID 就够了。

// 事件体设计示例
{
  "header": {
    "eventId": "0190a1b2-c3d4-5678-9abc-def012345678",
    "eventType": "OrderShipped",
    "source": "order-service",
    "version": "1.0",
    "timestamp": "2025-03-15T10:30:00Z"
  },
  "body": {
    "orderNumber": "ORD-2025-0001",
    "shippingAddress": {
      "street": "123 Main St",
      "city": "Beijing",
      "zipCode": "100000"
    },
    "carrier": "SF-Express",
    "trackingNumber": "SF1234567890"
  },
  "metadata": {
    "correlationId": "corr-abc-123",
    "tenantId": "tenant-001",
    "userId": "user-456"
  }
}

3.8 元数据:跨横切关注点的上下文

元数据不直接参与业务,但它决定了事件在系统里怎么流转。我记得有一次排查线上问题,发现某个事件被重复消费了三次。后来一查,是因为没有 CorrelationId,没法把多次消费关联到同一个请求链路。

元数据里我通常会放这些东西:

  • CorrelationId:用于追踪一个业务请求的完整链路
  • TenantId:多租户场景下标识租户
  • UserId:触发事件的用户
  • TraceId:与分布式追踪系统集成
  • CausationId:由哪个事件导致(用于事件溯源)

避坑指南:元数据不要放在事件体里。我曾经见过有人把 CorrelationId 塞进 body,结果下游解析 body 时改了结构,CorrelationId 丢了,整个链路追踪全废了。元数据应该由事件基础设施自动填充,业务代码不要碰。

3.9 版本管理:事件结构的兼容性

事件结构会变。这是必然的。我见过最惨的一次,上游改了事件体字段名,下游反序列化直接抛异常,整个消费链路断了半小时。从那以后,我强制要求所有事件必须带版本号。

版本管理有两种策略:

策略 做法 适用场景
向前兼容 只加字段,不改字段,不删字段 大多数业务场景
版本共存 同时发布 v1 和 v2 两个事件 重大结构变更

我个人更推荐向前兼容。加字段时给个默认值,下游没升级也能继续跑。实在要改结构,那就发新版本事件,老版本保留一段时间,等所有消费者都升级了再下线。

核心原则:事件是契约,一旦发布,就不能随意修改。消费者可能比你想象的更多,你永远不知道谁在订阅你的事件。

3.10 小结

事件分类和结构设计,说白了就是给事件「定规矩」。领域事件管业务内部,集成事件管服务之间,系统事件管基础设施。头、体、元数据三层分离,各司其职。版本管理做好兼容,别让事件变成定时炸弹。

嗯,这一章的内容就到这里。记住一句话:事件结构设计得越清晰,后面的事件驱动架构就越稳。