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”,不是就说明中间漏了事件,应报警而不是硬处理。若版本号与事件版本各搞一套,就需要额外维护一份映射,且两边一旦错位,排查成本极高。

关联