29.4 领域事件
一句话
领域事件是“业务上已经发生、别人需要知道的事实”:用过去式命名、由聚合在状态变更时产生、与状态在同一个事务里落库、再异步投递;它不是 RPC 的回调,也不是日志。
领域事件 vs 集成事件
| 维度 | 领域事件 Domain Event | 集成事件 Integration Event |
|---|---|---|
| 范围 | 限界上下文内 | 跨上下文 / 跨系统 |
| 命名 | OrderPlaced(业务语言、过去式) | order.placed.v1(契约语言、带版本) |
| 结构 | 可以带领域对象,跟随模型演进 | 扁平、显式版本、只放必要字段 |
| 消费者 | 同一上下文内的其他聚合、应用服务 | 其他上下文、外部合作方 |
| 变更自由度 | 高:跟着模型改,重构不心疼 | 低:对外契约,只加不删 |
| 投递方式 | 进程内(同一事务内同步)或 outbox | MQ / Webhook / CDC |
💡 命令与消息的区别(祈使句 vs 陈述句、点对点 vs 发布订阅)是 31.1 事件、命令、消息的区别 的主场。这里只记一句:事件是过去式,不能被拒绝;命令是祈使句,可以被拒绝。一条“取消订单”的请求是命令,被拒绝了就是业务结果;一个“订单已取消”的事件已经发生,消费者只能补偿。
一个事件必须带的七个字段
| 字段 | 例子 | 缺了会怎样 |
|---|---|---|
event_id | evt-01HX7... | 消费端无法幂等去重,at-least-once 下重复发货 |
type | OrderPlaced | 无法路由、无法反序列化 |
aggregate_id | ord-1001 | 消费端定位不到聚合 |
aggregate_version | 3 | 无法排序、无法判断“这是第几版”、无法做乐观并发 |
occurred_at | 2026-10-29T20:43:57+08:00 | 无法审计、无法处理乱序、重放时无法还原时间线 |
payload | {"sku":"sku-9","qty":2} | 消费者只能回查(见下面的薄/厚取舍) |
trace_id | 4bf92f... | 跨服务排查断链(50.3 链路) |
🔑
aggregate_version与 29.3 聚合与事务边界 里的乐观锁版本号是同一个值。一个聚合的第 N 版产生的事件,版本号就是 N——这让“事件顺序”与“数据版本”天然对齐,消费端可以据此丢弃过期事件。
薄事件、中厚事件、厚事件:payload 放多少
| 方案 | payload 内容 | 优点 | 代价 |
|---|---|---|---|
| 薄事件 | 只放 aggregate_id | 事件小、生产者与数据解耦 | 每个消费者都要回查;回查读到的是最新状态,不是事件发生时的状态(时序错乱) |
| 中厚事件(推荐) | 放消费者决策所需的业务字段快照 | 消费端零回查、可重放、时序正确 | payload 会随需求增长,需要治理 |
| 厚事件 | 序列化整个聚合 | 消费端什么都有 | PII 扩散、体积膨胀、字段强耦合,聚合改字段就破坏契约 |
算一笔账(量级估算):1000 事件/秒、5 个消费者、每个消费者回查 1 次 DB → 额外 5000 QPS 的读压力,全部压在生产者库上,还会因为“读到的不是当时的状态”产生偶发的不一致。中厚事件的代价是每条 1–2 KB,1000/s 即约 2 MB/s 的事件流——这个量级对一个 Kafka 分区毫无压力。
🔑 结论:默认中厚事件。只有“payload 含敏感数据”或“字段极大(图片、长文本)”时才退回薄事件,并接受回查成本。
代码:状态与事件同一个事务,再异步投递
package main
import (
"fmt"
"time"
)
type Event struct {
ID string
Type string
AggregateID string
Version int
OccurredAt time.Time
Payload map[string]any
}
var (
seq int
clock = func() time.Time { return time.Date(2026, 10, 29, 20, 43, 57, 0, time.UTC) }
)
func newEvent(t, agg string, ver int, p map[string]any) Event {
seq++
return Event{ID: fmt.Sprintf("evt-%03d", seq), Type: t, AggregateID: agg,
Version: ver, OccurredAt: clock(), Payload: p}
}
// Order 聚合:只“记录”事件,不做任何 I/O(领域层不发布、不写库)
type Order struct {
ID string
Version int
Status string
events []Event
}
func (o *Order) Place(sku string, qty int) {
o.Version++
o.Status = "placed"
o.events = append(o.events, newEvent("OrderPlaced", o.ID, o.Version,
map[string]any{"sku": sku, "qty": qty}))
}
func (o *Order) Events() []Event { return o.events }
// Store 模拟数据库:orders 与 outbox 在同一个本地事务里提交
type Store struct {
orders map[string]*Order
outbox []Event
}
func (s *Store) Save(o *Order) {
ev := o.Events()
s.orders[o.ID] = o // ① 状态
s.outbox = append(s.outbox, ev...) // ② 事件,同一事务
o.events = nil
}
// Relay 是独立的投递进程:读 outbox -> 发 MQ -> 标记已发
func (s *Store) Relay(pub func(Event)) int {
n := len(s.outbox)
for _, e := range s.outbox {
pub(e)
}
s.outbox = nil
return n
}
// Consumer 必须自己幂等:at-least-once 意味着“至少一次”,不是“只一次”
type Consumer struct {
seen map[string]bool
handled int
}
func (c *Consumer) Handle(e Event) {
if c.seen[e.ID] {
fmt.Printf("[consumer] %s 已处理过,跳过(幂等)\n", e.ID)
return
}
c.seen[e.ID] = true
c.handled++
fmt.Printf("[consumer] 处理 %s v%d -> 初始化履约单\n", e.Type, e.Version)
}
func main() {
st := &Store{orders: map[string]*Order{}}
c := &Consumer{seen: map[string]bool{}}
o := &Order{ID: "ord-1001", Status: "created"}
o.Place("sku-9", 2)
ev := o.Events()[0]
fmt.Printf("[1] Place() 产生 1 个领域事件: %s v%d @ %s\n",
ev.Type, ev.Version, ev.OccurredAt.Format(time.RFC3339))
st.Save(o)
fmt.Println("[2] Save(): 订单状态与 1 个事件在同一事务落库(outbox)")
n := st.Relay(c.Handle)
fmt.Printf("[3] Relay(): 投递 %d 个事件,outbox 清空\n", n)
fmt.Println("[4] 模拟 MQ 重投同一个事件(at-least-once)")
c.Handle(ev)
fmt.Printf("\n消费者实际处理 %d 次,outbox 剩余 %d 条\n", c.handled, len(st.outbox))
}
本机实测(macOS,Apple M1,Go 1.27):
[1] Place() 产生 1 个领域事件: OrderPlaced v1 @ 2026-10-29T20:43:57Z
[2] Save(): 订单状态与 1 个事件在同一事务落库(outbox)
[consumer] 处理 OrderPlaced v1 -> 初始化履约单
[3] Relay(): 投递 1 个事件,outbox 清空
[4] 模拟 MQ 重投同一个事件(at-least-once)
[consumer] evt-001 已处理过,跳过(幂等)
消费者实际处理 1 次,outbox 剩余 0 条
这段代码里有四个不能省的约束:
| 约束 | 代码位置 | 省掉的后果 |
|---|---|---|
| 聚合只记录事件,不发布 | Order.Place 只 append | 领域层里出现 MQ 客户端,单测必须连中间件,事务回滚后事件已发出(幽灵事件) |
| 状态与事件同一事务 | Store.Save 同时写 orders 与 outbox | 事务提交失败但消息已发 → 下游按“已下单”履约;或消息丢失 → 下游永远不知道 |
| 投递是独立进程 | Relay 扫 outbox | 把发送逻辑塞进请求链路,MQ 抖动直接拖垮下单接口 |
| 消费端幂等 | Consumer.seen | MQ 重投 → 重复发货、重复扣款(25.4 幂等设计与去重) |
可靠投递的三条路
| 方案 | 做法 | 适用 | 主场 |
|---|---|---|---|
| 本地消息表 / outbox | 状态 + 事件同库同事务,独立 relay 投递 | 关系型库、已有业务库 | 24.6 本地消息表 |
| 事务消息 | 半消息 → 本地事务 → 提交/回查 | 中间件支持(RocketMQ 等) | 24.7 事务消息 |
| CDC | 订阅 binlog,把数据变更转成事件 | 不想改业务代码、可接受 schema 耦合 | 46.4 CDC (变更数据捕获) |
三者都只保证 at-least-once,区别只在“投递失败时谁兜底”。exactly-once 是消费端的幂等 + 事务性写入做出来的效果,不是投递端能承诺的属性(43.2 投递语义)。
事件契约怎么演进
| 规则 | 说明 |
|---|---|
| 只加字段,不删、不改语义、不复用字段名 | 老消费者必须能继续解析;删字段等于对外 breaking change(55.5 向后兼容) |
版本放在 type 里 | order.placed.v1 → v2,让消费者能按版本路由,而不是靠“猜字段” |
| 消费者必须忽略未知字段 | 否则加字段就是 breaking change;JSON 解析器默认行为即可,别写 DisallowUnknownFields |
| 事件 schema 单独进仓库 | 与代码同源,改 schema 走 PR + 契约测试(53.5 契约测试) |
反模式
| 反模式 | 后果 |
|---|---|
| 用事件做 RPC(发命令后同步等回事件) | 变成分布式同步调用,超时语义丢失,链路一断全断 |
事件命名用命令式(SendEmail、NotifyUser) | 丢失“已发生”语义,消费者无法区分“要做什么”和“已经发生什么” |
| 事务提交前就发 MQ | 回滚 → 幽灵事件;提交前发成功、事务失败 → 下游已经动了 |
| payload 塞全量聚合,含 PII | 数据扩散、合规风险(38.5 数据分级与脱敏)、聚合改字段即破坏契约 |
| 消费端不做幂等 | 重复发货、重复扣款、重复加积分 |
| 把领域事件直接当对外 API 发布 | 内部模型一重构,外部全部断裂;应该另设集成事件 |
| 事件不带版本号与时间 | 无法排序、无法重放、无法排查“为什么下游处理顺序反了” |
| 用事件替代所有同步查询 | 用户下单后要立刻看到结果,却让他等事件绕一圈;“把事件当唯一事实来源”是 32.4 事件溯源的适用范围问题,不是默认解 |
自检
Q1 一个团队在下单接口里先发 MQ 消息,再提交数据库事务,理由是“这样延迟更低,用户不用等”。会出什么问题?
▸ 答案:两种失败都会发生。① 事务失败:消息已发出,下游按“订单已创建”去锁库存、发短信,而订单根本不存在,需要人工对账冲正;② 消息发送成功但消费端先到:消费端回查订单查不到,事件被丢弃或进入死信。正确顺序是“状态与事件同库同事务落库(outbox),再由独立 relay 投递”;代价是端到端延迟多几十毫秒,收益是事件永不丢、永不假。如果确实要“更快”,优化的是事务时长与 relay 的扫描间隔,不是把顺序反过来。
Q2 为什么“薄事件(只带 ID)+ 消费端回查”在文档里看起来更解耦,实际却更糟?
▸ 答案:因为回查拿到的是当前状态,不是事件发生时的状态。订单在 10:00 创建(事件 OrderPlaced),10:05 被取消;消费端 10:10 才处理这条事件并回查,读到的是“已取消”,于是它按“已取消”初始化履约单——因果错乱。同时 N 个消费者 × M 条事件会放大成 M×N 次回查,把读压力全部压到生产者库上。中厚事件把“决策所需的当时快照”随事件带走,既解决时序问题,也消掉回查风暴。
Q3 事件已经发出去了,才发现聚合里有一条业务规则算错了。能“撤回”这条事件吗?
▸ 答案:不能。事件是“已发生的事实”,只能补偿,不能撤回。做法是:发布一条新的补偿事件(OrderCancelled、PaymentRefunded),由消费者按自己的业务语义处理,并在事件流里保留原始事件。这也是为什么事件的 payload 一旦发布就只加不删——历史事件永远不会被“修正”,只能被后续事件覆盖语义。
Q4 aggregate_version 与乐观锁版本号是同一个值,这个设计带来什么能力?
▸ 答案:三件事。① 顺序:消费端可以按 (aggregate_id, version) 排序,识别乱序与重复;② 过期检测:收到 version 5 后,再收到 version 3 可以直接丢弃,不必处理;③ 因果校验:消费者可以检查“我期望的版本是不是 N”,不是就说明中间漏了事件,应报警而不是硬处理。若版本号与事件版本各搞一套,就需要额外维护一份映射,且两边一旦错位,排查成本极高。
关联
- 前置:29.3 聚合与事务边界
- 并列:24.6 本地消息表、25.4 幂等设计与去重
- 下游:29.7 从模型到服务边界、53.5 契约测试
- 下游:32.4 事件溯源
- 下游:31.1 事件、命令、消息的区别