29.6 事件风暴工作坊

一句话

事件风暴是让一屋子人按时间轴把“已经发生的事”贴满一面墙的方法:2–4 小时里把业务、领域专家、开发的认知差摊开——它真正的产出不是流程图,而是上下文边界、聚合候选,以及一份比访谈准得多的“部门口径冲突清单”。

三种规模,别从最小的地方开始

级别 目标 人数 时长 产出 什么时候做
Big Picture 摸清全业务、找边界与冲突 8–15 2–4 小时 上下文候选、热点清单 项目启动、接手遗留系统
Process Level 打通一条端到端流程 5–8 3–4 小时 命令-事件链、策略、读模型 某条流程要重构
Design Level 单个聚合落到字段与不变量 3–5 1–2 天 聚合字段、事件 schema、验收用例 已经确定要做这块

⚠️ 最常见的失败是直接做 Design Level:大家对“订单到底该有哪些状态”还没共识,就开始讨论字段类型,结果墙上的模型只代表主持人的想象。

七种颜色,七种元素

颜色 元素 写法 例子
橙色 领域事件 过去式 订单已提交、支付已到账
蓝色 命令 祈使句 提交订单、取消订单
黄色 聚合 名词(单数) 订单、库存
紫色 策略 “每当 X 则 Y” 每当订单已支付 则 通知履约
绿色 读模型 谁要看什么 待发货列表
粉色 外部系统 系统名 支付网关、短信平台
红色 热点 / 冲突 一个问题 谁有权取消已支付订单?

排布规则:时间从左到右,同一时刻发生的事垂直对齐。垂直那一列往往就是一个事务、一个聚合的边界候选。

五步流程(Big Picture,约 2.5 小时)

步 做什么 时长 关键动作
1 混沌铺事件 15 分钟 每人独立写,不讨论、不排序;先求数量
2 排序与去重 40 分钟 按时间轴贴;同义不同名当场记进术语表(29.5 通用语言)
3 补命令与执行者 30 分钟 每个事件往左追“谁触发的”,补蓝贴;追不到的事件就是外部系统或漏了角色
4 圈聚合 30 分钟 哪些命令改同一批不变量 → 一个聚合(29.3 聚合与事务边界)
5 画边界 + 标热点 30 分钟 同一批人、同一批词、同一批数据 → 一个上下文;不确定的地方贴红贴

主持规则(硬规则,不遵守这场就白开)

规则 原因
业务方说话时间 ≥ 60% 技术方一开口就滑向“表怎么设计”,业务方立刻闭嘴
一人一支笔,同时写 避免“领导先写第一张”,后面全是附和
禁止 UI 级事件(“点了结算按钮”) 那是操作,不是业务事实;粒度错了聚合就圈不出来
每个橙贴必须过去式 强制“已发生”,暴露缺失的命令与角色
现场不解决争议,只贴红贴 一争论就停摆;红贴才是这场会最值钱的产出
主持人不下场建模 主持人一旦开始“我觉得应该这样”,全场就只剩一个模型

会后 48 小时:不整理等于作废

事件风暴的产出必须当场转成四种可交付物,否则一周后便利贴上的字谁都认不出来:

交付物 形态 交给谁
事件时间轴 一张表:事件 / 触发命令 / 执行者 / 聚合 / 上下文 全员,作为共同基线
上下文候选 + 映射 上下文清单 + 关系类型(29.2 上下文映射 (七种关系));从业务模型推到系统边界的方法见 2.4 从业务模型到系统边界 架构评审
聚合候选 聚合 → 不变量 → 命令清单 开发,直接进设计
术语表 diff 新增/废弃/改名的词(29.5 通用语言) 全员,进代码库

可直接抄的表头(贴进文档就能用):

| 时间点 | 领域事件 | 触发命令 | 执行者(角色/系统) | 聚合 | 上下文 | 备注/红贴 |
|---|---|---|---|---|---|---|
| T1 | 订单已提交 | 提交订单 | 买家 | 订单 | 交易 | 游客能不能下单? |

用数据验收这场工作坊

便利贴贴完不等于模型成立。把墙上的内容录成结构化清单,跑一遍体检:

package main

import "fmt"

type Kind string

const (
	Cmd      Kind = "command"
	Evt      Kind = "event"
	Policy   Kind = "policy"
	Read     Kind = "readmodel"
	External Kind = "external"
	Hot      Kind = "hotspot"
)

type Note struct {
	Context   string
	Kind      Kind
	Aggregate string // 命令/事件归属的聚合;策略、读模型、热点为空
	Text      string
}

var wall = []Note{
	// 交易 trade
	{"trade", Cmd, "Order", "提交订单"},
	{"trade", Evt, "Order", "订单已提交"},
	{"trade", Cmd, "Order", "支付订单"},
	{"trade", Evt, "Order", "支付已到账"},
	{"trade", Policy, "", "支付已到账则通知履约"},
	{"trade", Cmd, "Order", "取消订单"},
	{"trade", Evt, "Order", "订单已取消"},
	{"trade", Cmd, "Order", "确认收货"},
	{"trade", Evt, "Order", "订单已完成"},
	{"trade", Cmd, "Refund", "申请退款"},
	{"trade", Evt, "Refund", "退款已受理"},
	{"trade", Read, "", "我的订单列表"},
	{"trade", Hot, "", "订单状态到底有几种?"},

	// 库存 inventory
	{"inventory", Cmd, "Stock", "预占库存"},
	{"inventory", Evt, "Stock", "库存已预占"},
	{"inventory", Cmd, "Stock", "释放库存"},
	{"inventory", Evt, "Stock", "库存已释放"},
	{"inventory", Cmd, "Stock", "扣减库存"},
	{"inventory", Evt, "Stock", "库存已扣减"},
	{"inventory", Evt, "", "库存已同步"},

	// 履约 fulfillment
	{"fulfillment", Cmd, "Shipment", "创建发货单"},
	{"fulfillment", Evt, "Shipment", "发货单已创建"},
	{"fulfillment", Evt, "Shipment", "包裹已出库"},
	{"fulfillment", Evt, "Shipment", "包裹已揽收"},
	{"fulfillment", Evt, "Shipment", "包裹已到达分拣中心"},
	{"fulfillment", Evt, "Shipment", "包裹已发出派送"},
	{"fulfillment", Evt, "Shipment", "包裹已签收"},
	{"fulfillment", Evt, "Shipment", "签收异常已登记"},
	{"fulfillment", Evt, "Shipment", "退货已入库"},
	{"fulfillment", Evt, "Shipment", "换货已重发"},
	{"fulfillment", Read, "", "待发货列表"},

	// 营销 marketing
	{"marketing", Cmd, "Campaign", "创建活动"},
	{"marketing", Evt, "Campaign", "活动已上线"},
	{"marketing", Cmd, "Coupon", "发放优惠券"},
	{"marketing", Evt, "Coupon", "优惠券已发放"},
	{"marketing", Cmd, "Campaign", "结束活动"},
	{"marketing", Evt, "Campaign", "活动已结束"},
	{"marketing", Hot, "", "活动价和会员价哪个优先?"},
	{"marketing", Hot, "", "券能不能叠加?"},
}

func main() {
	type stat struct {
		evt, cmd, policy, read, hot, noAgg int
		aggs                              map[string]bool
	}
	stats := map[string]*stat{}
	var order []string
	for _, n := range wall {
		s, ok := stats[n.Context]
		if !ok {
			s = &stat{aggs: map[string]bool{}}
			stats[n.Context] = s
			order = append(order, n.Context)
		}
		switch n.Kind {
		case Evt:
			s.evt++
			if n.Aggregate == "" {
				s.noAgg++
			}
		case Cmd:
			s.cmd++
		case Policy:
			s.policy++
		case Read:
			s.read++
		case Hot:
			s.hot++
		}
		if n.Aggregate != "" {
			s.aggs[n.Aggregate] = true
		}
	}

	fmt.Println("=== 事件风暴便利贴体检 ===")
	fmt.Printf("%-12s %-4s %-4s %-4s %-6s %-6s %-6s %-4s %-9s %s\n",
		"上下文", "事件", "命令", "策略", "读模型", "聚合", "无聚合", "热点", "命令/事件", "判定")
	for _, c := range order {
		s := stats[c]
		ratio := float64(s.cmd) / float64(max(s.evt, 1))
		total := s.evt + s.cmd + s.policy + s.read + s.hot
		verdict := "OK"
		switch {
		case s.noAgg > 0:
			verdict = fmt.Sprintf("⚠ %d 个事件无聚合归属(多半是集成事件或 UI 操作)", s.noAgg)
		case ratio < 0.6:
			verdict = fmt.Sprintf("⚠ 命令/事件比 %.2f 过低:只记了结果,没记谁触发", ratio)
		case ratio > 1.5:
			verdict = fmt.Sprintf("⚠ 命令/事件比 %.2f 过高:状态变化没建成领域事件", ratio)
		case float64(s.hot)*100/float64(total) > 15:
			verdict = fmt.Sprintf("⚠ 热点密度 %.0f%%:先解决认知冲突再定边界", float64(s.hot)*100/float64(total))
		case s.evt > 12:
			verdict = fmt.Sprintf("⚠ %d 个事件:这个上下文可能还能再切", s.evt)
		}
		fmt.Printf("%-12s %-4d %-4d %-4d %-6d %-6d %-6d %-4d %-9.2f %s\n",
			c, s.evt, s.cmd, s.policy, s.read, len(s.aggs), s.noAgg, s.hot, ratio, verdict)
	}

	evts, aggs := 0, 0
	for _, s := range stats {
		evts += s.evt
		aggs += len(s.aggs)
	}
	fmt.Printf("\n共 %d 张便利贴,%d 个上下文,%d 个聚合,平均每个聚合 %.1f 个事件\n",
		len(wall), len(stats), aggs, float64(evts)/float64(aggs))
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

本机实测(macOS,Apple M1,Go 1.27):

=== 事件风暴便利贴体检 ===
上下文          事件   命令   策略   读模型    聚合     无聚合    热点   命令/事件     判定
trade        5    5    1    1      2      0      1    1.00      OK
inventory    4    3    0    0      1      1      0    0.75      ⚠ 1 个事件无聚合归属(多半是集成事件或 UI 操作)
fulfillment  9    1    0    1      1      0      0    0.11      ⚠ 命令/事件比 0.11 过低:只记了结果,没记谁触发
marketing    3    3    0    0      2      0      2    1.00      ⚠ 热点密度 25%:先解决认知冲突再定边界

共 39 张便利贴,4 个上下文,6 个聚合,平均每个聚合 3.5 个事件

三条告警,每条都对应一个具体动作;trade 是作为对照的正常形状:

判定 病历 动作
无聚合归属的事件 inventory 有 1 个事件没有聚合:库存已同步——它不是领域事件,是外部系统的集成事件,或者干脆是定时任务 从领域事件墙上摘下来,归到“外部系统”区;真正的领域事件必须有一个聚合在它身上发生了状态变化
命令/事件比过低 fulfillment 有 9 个事件、只有 1 个命令(比值 0.11) 补命令:每个事件往左追“谁触发的”。追不到说明漏了角色(快递员、仓库系统、客服),或者事件其实是别的上下文发来的
热点密度过高 marketing 8 张便利贴里 2 张红贴(25%) 先别定边界。两个红贴(活动价 vs 会员价、券能不能叠加)说明定价规则本身没共识,这时候切出来的上下文一定是错的
OK(对照) trade:5 事件 / 5 命令 / 2 聚合 / 1 红贴 命令与事件一一对应,红贴只有一个——这是可以进入下一步(画边界)的上下文形状

🔑 这张体检表的价值在于:它把“我觉得这个边界不对”变成了“这个上下文有 1 个事件没有聚合归属、另一个上下文的命令/事件比只有 0.11”。 前者无法推进会议,后者能直接派活。

什么时候别开这个工作坊

情况 为什么不适用 替代做法
找不到真正的领域专家(只有产品经理) 贴出来的是产品经理的想象,不是业务事实 先做用户访谈与现场观察(1.2 用户故事与用例)
范围是“整个公司” 200 张以上便利贴必然收敛不了 先用 2.2 业务流程建模 (BPMN)(待补)把端到端流程画清楚,再按价值链切成 3–5 个业务域,一个域一场
纯远程且无主持人 并行度归零,退化成听一个人讲 必须有主持人控场 + 在线白板分区,或改做小范围 Design Level
已经决定了技术方案,只需要人签字 会变成“说服会”,红贴不敢贴 直接写 ADR(58.1 ADR 实践),别浪费两天

反模式

反模式 后果
只有产品经理 + 架构师参加 拿到的是“二手业务”,关键角色(客服、仓库、财务)全部缺失
一上来就讨论表结构、微服务拆分 滑进 Design Level,Big Picture 的产出(边界与冲突)永远拿不到
事件写成 UI 操作(“点击结算”) 粒度错,聚合圈不出来,边界也跟着错
200 个事件不划分范围 墙上无法收敛,最后按“谁嗓门大”定边界
会后不整理、不落文档 一周后便利贴作废,投入的 8 人 × 4 小时 = 32 人时归零
用在线白板但没分区、没颜色约定 大家各贴各的,时间轴不存在,事件无法排序
把红贴当“待办”直接分派 红贴是认知冲突,不是任务;先对齐定义,再谈实现

自检

Q1 一场 Big Picture 贴完,营销上下文有 2 张红贴(“活动价和会员价哪个优先”“券能不能叠加”)。主持人该当场组织讨论解决吗?

▸ 答案:不该当场解决,但也不能只记下来就完事。当场解决会让会议停在一个点上(8 个人等 2 个人吵),而且往往解决不了——这类冲突通常需要查合同、看财务口径、问法务。正确动作是:① 红贴保留在墙上,标注“需要谁拍板”;② 会后 48 小时内由主持人组织一个小范围(业务负责人 + 财务 + 产品)专门对齐,产出写进术语表与规则;③ 在对齐之前,这个上下文不进入服务拆分,因为定价规则的边界就是上下文的边界。

Q2 体检表显示履约上下文“命令/事件比 0.11”。这说明什么?为什么这不是“履约很简单”的好消息?

▸ 答案:说明墙上只有结果、没有触发者。9 个事件(已出库、已揽收、已到达、已派送、已签收……)只对应 1 个命令(创建发货单),意味着这些状态变化的触发源没有建模:快递公司的回调、仓库的扫描枪、客服的手工操作,全都被当成了“自然发生”。这会直接导致两个后果:① 聚合的边界画不出来(不知道谁改状态);② 集成设计缺失(这些事件其实大多来自外部系统,属于集成事件,需要 ACL 或回调接口)。补命令的过程,本质是在找“状态变化的驱动力”。

Q3 为什么事件风暴要求“业务方说话时间 ≥ 60%”,而不是“业务方与技术方各一半”?

▸ 答案:因为这场会的唯一稀缺资源是业务事实,技术方掌握的信息(表结构、接口、框架)都可以会后补。技术方一旦主导,讨论会迅速滑向“这个字段用不用 JSON”“要不要分库”,而这些问题的答案依赖边界,边界又依赖业务事实——顺序反了就会用技术假设倒推业务模型。60% 不是精确比例,它的作用是给主持人一个可执行的干预信号:技术方连续讲超过几分钟,就该打断。

Q4 事件风暴的产出里,为什么“上下文候选”比“聚合候选”更容易出错,需要更谨慎地评审?

▸ 答案:聚合候选可以由不变量当场验证(“这两条规则必须同一事务成立吗?”),上下文候选验证不了——它取决于团队、语言、变更节奏这些会变的东西。同一堵墙,今天按“交易/履约/库存”切,半年后团队调整就可能要重切。所以上下文候选必须带上三个附加信息才有评审价值:谁维护、术语差异在哪、变更频率差多少;只写一个名字的上下文清单,评审时无从判断。

关联