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