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 事件结构设计:头、体、元数据
事件的结构,我建议分成三部分:事件头、事件体、元数据。这就像一封信——信封上写收件人和路由信息,信纸里写正文,邮戳记录什么时候寄出的。三者各司其职,不要混在一起。
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 小结
事件分类和结构设计,说白了就是给事件「定规矩」。领域事件管业务内部,集成事件管服务之间,系统事件管基础设施。头、体、元数据三层分离,各司其职。版本管理做好兼容,别让事件变成定时炸弹。
嗯,这一章的内容就到这里。记住一句话:事件结构设计得越清晰,后面的事件驱动架构就越稳。