工作坊不是画布,是一次有产出的会议
事件风暴最常见的失败,是把它开成“贴满一面墙的便签拍照会”:大家很兴奋,散会后没人知道哪条便签变成了代码。所以这次工作坊在开始前就定了三条规矩:只写已经发生的事实、每条便签只写一件事、上午不讨论技术方案。
参会角色与关注点如下。业务方不是来旁听的,他要在“什么算一笔成功订单”上做最终裁决;风控只在支付前介入,不参与库存讨论。
| 角色 | 人数 | 只回答的问题 |
|---|---|---|
| 业务 | 1 | 哪些事件对收入、履约承诺有影响 |
| 订单 | 2 | 订单状态允许怎么走、哪些规则必须原子 |
| 库存 | 1 | 预留粒度、释放时机、超卖边界 |
| 支付 | 1 | 成功/失败/超时分别意味着什么 |
| 风控 | 1 | 支付前要拦什么、拦截后订单去哪 |
时间盒:09:30–10:00 对齐订单全生命周期;10:00–11:30 事件倾倒;11:30–12:00 归类聚合;13:30–15:00 补命令与策略;15:00–16:00 收敛热点并分配 ADR。上午只允许问“然后发生了什么”,不允许问“这个接口怎么设计”。
四张表
命令表
命令是意图,可能被拒绝。命名用祈使句,每条命令必须声明它由哪个聚合处理、由谁触发。
| 命令 | 处理聚合 | 触发方 | 说明 |
|---|---|---|---|
| PlaceOrder | Order | HTTP | 客户下单,支持幂等键 |
| ReserveInventory | InventoryReservation | 策略 | 按 SKU/仓预留 |
| MarkInventoryReserved | Order | 策略 | 全部明细预留成功后推进状态 |
| ConfirmPayment | Payment | 支付网关 | 回调或主动查询确认 |
| MarkOrderPaid | Order | 策略 | 支付确认后推进订单 |
| CancelOrder | Order | HTTP/策略 | 客户取消或失败补偿 |
| ReleaseInventory | InventoryReservation | 策略 | 释放已预留库存 |
| RefundPayment | Payment | 策略 | 发起退款 |
| FulfillOrder | Order | HTTP | 仓库发货 |
| ExpireOrder | Order | 定时器 | 超时未支付关单 |
| RequestRefund | Order | HTTP | 客户申请退款 |
事件表
事件是已经发生的事实,统一用过去式。Observed 表示该事件只被投影、审计或外部系统消费,不再触发新命令。
| 事件 | 生产聚合 | 生产者命令 | Observed |
|---|---|---|---|
| OrderPlaced | Order | PlaceOrder | 否 |
| InventoryReserved | InventoryReservation | ReserveInventory | 否 |
| InventoryReservationFailed | InventoryReservation | ReserveInventory | 否 |
| PaymentConfirmed | Payment | ConfirmPayment | 否 |
| PaymentDeclined | Payment | ConfirmPayment | 否 |
| OrderPaid | Order | MarkOrderPaid | 是 |
| OrderCancelled | Order | CancelOrder | 否 |
| InventoryReleased | InventoryReservation | ReleaseInventory | 是 |
| PaymentRefunded | Payment | RefundPayment | 是 |
| OrderFulfilled | Order | FulfillOrder | 是 |
| OrderExpired | Order | ExpireOrder | 否 |
| RefundRequested | Order | RequestRefund | 否 |
| OrderInventoryReserved | Order | MarkInventoryReserved | 是 |
聚合表
聚合是一致性边界,不是“相关的表”。工作坊只圈出必须在一个事务里改完的最小集合。
| 聚合 | 标识 | 内部对象 | 负责的不变式 |
|---|---|---|---|
| Order | order_id | OrderItem、金额、状态 | 总额等于明细之和;状态只经合法边迁移;同一订单最多支付一次 |
| InventoryReservation | reservation_id | SKU、仓、数量、状态 | 同一 SKU/仓的预留不能超过可售量 |
| Payment | payment_id | 金额、状态、provider_ref | 同一幂等键只产生一笔支付 |
策略表
策略是“当某事件发生,如果满足条件,就发出某命令”。它是工作坊里最容易被忽略、却最能暴露边界的一张表。
| 策略 | 触发事件 | 发出命令 | 守卫条件 |
|---|---|---|---|
| P1 | OrderPlaced | ReserveInventory | order.status == CREATED |
| P2 | InventoryReserved | MarkInventoryReserved | 全部明细预留成功 |
| P3 | InventoryReservationFailed | CancelOrder | order.status == CREATED |
| P4 | PaymentConfirmed | MarkOrderPaid | order.status == INVENTORY_RESERVED |
| P5 | PaymentDeclined | CancelOrder、ReleaseInventory | 支付尝试已终态 |
| P6 | OrderCancelled | ReleaseInventory | 取消前状态为 INVENTORY_RESERVED |
| P7 | OrderCancelled | RefundPayment | 取消前状态为 PAID |
| P8 | OrderExpired | CancelOrder、ReleaseInventory | 预留 TTL 到期且未支付 |
| P9 | RefundRequested | RefundPayment | order.status == PAID |
一次完整链路走查
正常路径:PlaceOrder → OrderPlaced → P1 → ReserveInventory → InventoryReserved → P2 → MarkInventoryReserved → OrderInventoryReserved;客户支付后 ConfirmPayment → PaymentConfirmed → P4 → MarkOrderPaid → OrderPaid;仓库发货 FulfillOrder → OrderFulfilled。
失败路径有三条。预留失败:InventoryReservationFailed → P3 → CancelOrder。支付被拒:PaymentDeclined → P5 → CancelOrder + ReleaseInventory。超时未付:定时器发 ExpireOrder → OrderExpired → P8 → CancelOrder + ReleaseInventory。
走查时发现一个真问题:P5 同时发 CancelOrder 和 ReleaseInventory,但 CancelOrder 成功后 P6 又会发一次 ReleaseInventory。释放必须幂等,否则同一预留会被释放两次。这个问题不靠代码评审发现,靠把策略表逐条走一遍。
热点与未决问题
| 热点 | 工作坊结论 | 去向 |
|---|---|---|
| 已支付订单取消,先退款还是先释放库存 | 先退款,退款成功后再释放;退款失败保持预留并转人工 | ADR-003 |
| 支付回调超时,钱扣了但订单没收到成功 | 不在同步链路猜;按 provider_ref 主动查询并对账 | 卷二 |
| 预留 TTL 多长、由谁扫描 | 卷一只定义 ExpireOrder 命令,TTL 配置化 | 卷二 |
| 部分履约是否允许 | 卷一按整单履约;拆单需要新的 Shipment 聚合 | 卷二 |
| 风控拦截发生在下单前还是支付前 | 支付前,以 PaymentDeclined 的 reason 区分 | ADR-004 |
| 事件 schema 如何演进 | 卷一只做进程内事件,跨进程契约留到卷三 | 卷三 |
ADR-003 的核心取舍是:退款是外部不可逆操作,释放库存是可逆的。先退款可以把“钱没退但货已卖”的风险关在门外,代价是退款处理期间库存被占用。对 arch-lab 这种低客单价、低库存周转的场景,这个代价可以接受;如果库存周转是瓶颈,结论要重开。
把工作坊产出变成可执行模型
四张表如果只留在白板上,下一次迭代就会漂移。把它们编码成 internal/eventstorm 包,并提供 Validate:每条命令必须有聚合,每个事件必须有生产者且聚合一致,每条策略引用的命令/事件必须存在,每个事件要么被策略消费、要么显式标记为 Observed,策略触发的命令必须至少被一条策略发出。
// file: internal/eventstorm/model.go
// tier: L1
// verify: go test ./internal/eventstorm/
package eventstorm
import "fmt"
type Trigger string
const (
TriggerHTTP Trigger = "HTTP"
TriggerTimer Trigger = "TIMER"
TriggerGateway Trigger = "GATEWAY"
TriggerPolicy Trigger = "POLICY"
)
type Command struct {
Name string
Aggregate string
Triggers []Trigger
}
type Event struct {
Name string
Aggregate string
Producer string
Observed bool
}
type Policy struct {
Name string
On []string
Emits []string
Guard string
}
type Model struct {
Aggregates []string
Commands []Command
Events []Event
Policies []Policy
}
// Validate 把工作坊的四张表变成一组可执行断言。
func (m Model) Validate() []string {
var out []string
aggregates := make(map[string]bool, len(m.Aggregates))
commands := make(map[string]Command, len(m.Commands))
events := make(map[string]Event, len(m.Events))
consumed := make(map[string]bool)
emitted := make(map[string]bool)
for _, a := range m.Aggregates {
aggregates[a] = true
}
for _, c := range m.Commands {
if _, dup := commands[c.Name]; dup {
out = append(out, "duplicate command "+c.Name)
}
commands[c.Name] = c
if !aggregates[c.Aggregate] {
out = append(out, fmt.Sprintf("command %s has unknown aggregate %s", c.Name, c.Aggregate))
}
if len(c.Triggers) == 0 {
out = append(out, "command without trigger "+c.Name)
}
}
for _, e := range m.Events {
if _, dup := events[e.Name]; dup {
out = append(out, "duplicate event "+e.Name)
}
events[e.Name] = e
producer, ok := commands[e.Producer]
if !ok {
out = append(out, fmt.Sprintf("event %s has no producer command %s", e.Name, e.Producer))
continue
}
if producer.Aggregate != e.Aggregate {
out = append(out, fmt.Sprintf("event %s aggregate %s != producer aggregate %s", e.Name, e.Aggregate, producer.Aggregate))
}
}
for _, p := range m.Policies {
if len(p.On) == 0 || len(p.Emits) == 0 || p.Guard == "" {
out = append(out, "incomplete policy "+p.Name)
}
for _, name := range p.On {
if _, ok := events[name]; !ok {
out = append(out, fmt.Sprintf("policy %s references unknown event %s", p.Name, name))
}
consumed[name] = true
}
for _, name := range p.Emits {
if _, ok := commands[name]; !ok {
out = append(out, fmt.Sprintf("policy %s emits unknown command %s", p.Name, name))
}
emitted[name] = true
}
}
for _, e := range m.Events {
if !e.Observed && !consumed[e.Name] {
out = append(out, "orphan event "+e.Name)
}
}
for _, c := range m.Commands {
if hasPolicyTrigger(c.Triggers) && !emitted[c.Name] {
out = append(out, "unreachable policy command "+c.Name)
}
}
return out
}
func hasPolicyTrigger(triggers []Trigger) bool {
for _, tr := range triggers {
if tr == TriggerPolicy {
return true
}
}
return false
}
测试先构造工作坊的合法模型,再逐条变异。合法模型本身就是一份可执行的会议纪要:改动任何一条策略,都必须同时改测试。
// file: internal/eventstorm/model_test.go
// tier: L1
// verify: go test ./internal/eventstorm/ -v
package eventstorm
import (
"strings"
"testing"
)
func workshopModel() Model {
return Model{
Aggregates: []string{"Order", "InventoryReservation", "Payment"},
Commands: []Command{
{Name: "PlaceOrder", Aggregate: "Order", Triggers: []Trigger{TriggerHTTP}},
{Name: "ReserveInventory", Aggregate: "InventoryReservation", Triggers: []Trigger{TriggerPolicy}},
{Name: "MarkInventoryReserved", Aggregate: "Order", Triggers: []Trigger{TriggerPolicy}},
{Name: "ConfirmPayment", Aggregate: "Payment", Triggers: []Trigger{TriggerGateway}},
{Name: "MarkOrderPaid", Aggregate: "Order", Triggers: []Trigger{TriggerPolicy}},
{Name: "CancelOrder", Aggregate: "Order", Triggers: []Trigger{TriggerHTTP, TriggerPolicy}},
{Name: "ReleaseInventory", Aggregate: "InventoryReservation", Triggers: []Trigger{TriggerPolicy}},
{Name: "RefundPayment", Aggregate: "Payment", Triggers: []Trigger{TriggerPolicy}},
{Name: "FulfillOrder", Aggregate: "Order", Triggers: []Trigger{TriggerHTTP}},
{Name: "ExpireOrder", Aggregate: "Order", Triggers: []Trigger{TriggerTimer}},
{Name: "RequestRefund", Aggregate: "Order", Triggers: []Trigger{TriggerHTTP}},
},
Events: []Event{
{Name: "OrderPlaced", Aggregate: "Order", Producer: "PlaceOrder"},
{Name: "InventoryReserved", Aggregate: "InventoryReservation", Producer: "ReserveInventory"},
{Name: "InventoryReservationFailed", Aggregate: "InventoryReservation", Producer: "ReserveInventory"},
{Name: "PaymentConfirmed", Aggregate: "Payment", Producer: "ConfirmPayment"},
{Name: "PaymentDeclined", Aggregate: "Payment", Producer: "ConfirmPayment"},
{Name: "OrderPaid", Aggregate: "Order", Producer: "MarkOrderPaid", Observed: true},
{Name: "OrderCancelled", Aggregate: "Order", Producer: "CancelOrder"},
{Name: "InventoryReleased", Aggregate: "InventoryReservation", Producer: "ReleaseInventory", Observed: true},
{Name: "PaymentRefunded", Aggregate: "Payment", Producer: "RefundPayment", Observed: true},
{Name: "OrderFulfilled", Aggregate: "Order", Producer: "FulfillOrder", Observed: true},
{Name: "OrderExpired", Aggregate: "Order", Producer: "ExpireOrder"},
{Name: "RefundRequested", Aggregate: "Order", Producer: "RequestRefund"},
{Name: "OrderInventoryReserved", Aggregate: "Order", Producer: "MarkInventoryReserved", Observed: true},
},
Policies: []Policy{
{Name: "P1", On: []string{"OrderPlaced"}, Emits: []string{"ReserveInventory"}, Guard: "order.status == CREATED"},
{Name: "P2", On: []string{"InventoryReserved"}, Emits: []string{"MarkInventoryReserved"}, Guard: "all lines allocated"},
{Name: "P3", On: []string{"InventoryReservationFailed"}, Emits: []string{"CancelOrder"}, Guard: "order.status == CREATED"},
{Name: "P4", On: []string{"PaymentConfirmed"}, Emits: []string{"MarkOrderPaid"}, Guard: "order.status == INVENTORY_RESERVED"},
{Name: "P5", On: []string{"PaymentDeclined"}, Emits: []string{"CancelOrder", "ReleaseInventory"}, Guard: "payment attempt is terminal"},
{Name: "P6", On: []string{"OrderCancelled"}, Emits: []string{"ReleaseInventory"}, Guard: "order was INVENTORY_RESERVED"},
{Name: "P7", On: []string{"OrderCancelled"}, Emits: []string{"RefundPayment"}, Guard: "order was PAID"},
{Name: "P8", On: []string{"OrderExpired"}, Emits: []string{"CancelOrder", "ReleaseInventory"}, Guard: "reservation TTL reached"},
{Name: "P9", On: []string{"RefundRequested"}, Emits: []string{"RefundPayment"}, Guard: "order.status == PAID"},
},
}
}
func has(violations []string, fragment string) bool {
for _, v := range violations {
if strings.Contains(v, fragment) {
return true
}
}
return false
}
func TestWorkshopModelIsValid(t *testing.T) {
if violations := workshopModel().Validate(); len(violations) != 0 {
t.Fatalf("workshop model should be valid, got %v", violations)
}
}
func TestValidateTable(t *testing.T) {
tests := []struct {
name string
mutate func(*Model)
want string
}{
{"command without aggregate", func(m *Model) {
m.Commands = append(m.Commands, Command{Name: "ShipIt", Aggregate: "Shipment", Triggers: []Trigger{TriggerHTTP}})
}, "unknown aggregate Shipment"},
{"event without producer", func(m *Model) {
m.Events = append(m.Events, Event{Name: "GhostEvent", Aggregate: "Order", Producer: "GhostCommand"})
}, "no producer command GhostCommand"},
{"event aggregate mismatch", func(m *Model) { m.Events[0].Aggregate = "Payment" }, "aggregate Payment != producer aggregate Order"},
{"unknown event in policy", func(m *Model) {
m.Policies = append(m.Policies, Policy{Name: "P10", On: []string{"GhostEvent"}, Emits: []string{"CancelOrder"}, Guard: "never"})
}, "references unknown event GhostEvent"},
{"unknown command in policy", func(m *Model) {
m.Policies = append(m.Policies, Policy{Name: "P10", On: []string{"OrderPlaced"}, Emits: []string{"GhostCommand"}, Guard: "never"})
}, "emits unknown command GhostCommand"},
{"orphan event", func(m *Model) {
m.Events = append(m.Events, Event{Name: "OrderArchived", Aggregate: "Order", Producer: "PlaceOrder"})
}, "orphan event OrderArchived"},
{"unreachable policy command", func(m *Model) {
m.Commands = append(m.Commands, Command{Name: "RecalculateRisk", Aggregate: "Order", Triggers: []Trigger{TriggerPolicy}})
}, "unreachable policy command RecalculateRisk"},
{"policy without guard", func(m *Model) { m.Policies[0].Guard = "" }, "incomplete policy P1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := workshopModel()
tt.mutate(&m)
if violations := m.Validate(); !has(violations, tt.want) {
t.Fatalf("violations = %v, want %q", violations, tt.want)
}
})
}
}
在 darwin/arm64 + Go 1.26.0 上实际运行:
# 输出节选:其余 7 个子用例均为 PASS
=== RUN TestWorkshopModelIsValid
--- PASS: TestWorkshopModelIsValid (0.00s)
=== RUN TestValidateTable
=== RUN TestValidateTable/orphan_event
--- PASS: TestValidateTable (0.00s)
--- PASS: TestValidateTable/orphan_event (0.00s)
PASS
ok archlab/internal/eventstorm 0.457s
事件命名与策略的边界
事件必须是过去式,因为它描述已经发生的事实;命令必须是祈使句,因为它描述尚未发生的意图。PaymentConfirmed 和 ConfirmPayment 只差一个时态,但在代码里一个是输入、一个是输出,混用会让幂等设计失去落点。策略不是领域服务:它只回答“什么事件触发什么命令”,不写业务计算;守卫条件里的状态判断应该调用聚合的方法,而不是在策略里复制一份状态表,否则工作坊产出的表会变成第二套状态机。
与站内文章的分工
站内的 消息驱动架构
已经讲了事件通知、事件携带状态转移、消息队列选型与幂等消费。本节不重复这些模式,只记录一次工作坊如何从业务语言走到可执行的四张表,以及这些表如何被 internal/eventstorm 校验。
常见错误与边界
第一,把事件写成命令。ReserveInventory 是命令,InventoryReserved 是事件;把两者都叫“库存消息”,消费者就分不清该重试还是该补偿。第二,只写正常路径。工作坊的大部分价值来自 PaymentDeclined、InventoryReservationFailed、OrderExpired 这些不愉快的事件;没有它们,策略表只是主流程的注释。
第三,聚合按数据库表圈。Order 和 OrderItem 可以是一个聚合,但 Payment 不行;聚合边界看事务与不变式,不看外键。
第四,策略没有守卫条件。没有守卫的 P6 会在 CREATED 取消时也发 ReleaseInventory,把“没预留”当成“已预留”。
第五,把热点留成待办。每个热点必须明确去向:进 ADR、进卷二、还是当场否决;否则下次工作坊会重新讨论一遍。
小结
事件风暴的产出不是一面墙的便签,而是四张能互相校验的表:11 条命令、13 个事件、3 个聚合、9 条策略。走查暴露了重复释放的幂等要求;热点表把退款顺序送进 ADR-003,把支付超时、预留 TTL、部分履约和事件契约留到卷二、卷三。internal/eventstorm 把这些表变成可执行断言,表驱动测试保证命令有聚合、事件有生产者、策略引用有效、没有孤立事件。下一节讨论这些聚合到底该多大,以及哪些不变式必须在一个事务里完成。
阅读导航:上一节:3.1 子域、限界上下文与团队边界 · 下一节:3.3 聚合、事务边界与一致性边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。