《后端架构设计·架构基础与边界》7.2 事件、命令与消息的语义差异

用订单域的 OrderPlaced、ReserveInventory 和传输信封说明事件、命令、消息三者的语义差异,给出 Kind 校验、单处理者命令与多订阅者事件的 Go 实现,以及七组表驱动与路由测试的真实输出,并列出把事件当命令、把命令当广播的四类误用后果。

三个词不是同义词

“发个消息”“发个事件”“发个命令”在日常讨论里经常混着说,落到代码里却变成同一个 Publish(topic, payload)。站内 消息驱动架构 已经讲过事件驱动的整体结构,本节只收紧一个更小的边界:这三个词描述的到底是三种东西,还是同一种东西的三个名字。

先给结论。事件是已经发生的事实,时态是过去,发布者不关心谁消费、也不接受业务拒绝;命令是希望发生的意图,时态是祈使,必须有一个明确的接收者,并且可以被拒绝;消息不是前两者的同级概念,它是承载事件或命令的传输信封,负责 ID、类型、时间、关联 ID 和负载。把消息当成第三种语义,就会得到“发了消息但不知道对方该不该拒绝”的代码。

订单域里的三种东西

在 arch-lab 的下单流程里,三种东西可以同时出现:

  • OrderPlaced 是事件:订单已经创建,事实无法被拒绝。它可以有投影、通知、积分三个订阅者,也可以暂时一个都没有。
  • ReserveInventory 是命令:编排器要求库存服务预留指定 SKU,库存服务可以返回 OUT_OF_STOCK。这个拒绝必须回到编排器,否则订单会停在错误状态。
  • Envelope 是消息:它把上面两者序列化后送进 Kafka、NATS 或进程内总线,自身不解释业务含义,只保证“这是什么类型、属于哪次请求”。
维度事件 Event命令 Command消息 Message
语义已发生的事实希望发生的意图传输信封
时态过去祈使无
接收者0 到 N 个恰好 1 个由路由决定
业务拒绝不存在可以拒绝只反映投递结果
重试责任各消费者幂等发布者决定传输层重投
arch-lab 例子OrderPlacedReserveInventoryEnvelope

这张表最重要的两行是“接收者”和“业务拒绝”。事件的发布者不应该知道订阅者是谁,所以新增一个订阅者不需要改发布方;命令的发布者必须知道唯一接收者,否则“库存不足”这个拒绝没有地方返回。很多“事件驱动”事故,本质是把这两行合并了。

误用之后会发生什么

第一种误用是把事件当命令。订单服务发布 OrderPlaced,支付消费者订阅后直接扣款。发布方以为自己在广播事实,实际上触发了一个有外部副作用的动作;新增一个消费者就可能多扣一次款,而且发布方无法知道扣款失败。事件里如果还塞了 please_charge: true 这类字段,等于用事实的壳包装命令。

第二种误用是把命令当广播。ReserveInventory 被两个消费者订阅,各自扣一次库存;或者用 fire-and-forget 发布,库存不足的拒绝被吞掉,编排器只能等超时。命令的语义要求“恰好一个处理者”和“结果回到发布者”,这两个约束必须在类型或总线层强制,而不是靠文档。

第三种误用是消息不带 Kind。消费者拿到一个 JSON,不知道应该校验成事件还是命令,也不知道失败时该返回 4xx、重试还是记录后忽略。路由键只说明“去哪”,不说明“该做什么”。

第四种误用是事件里写指令。OrderPlaced 的负载里带 next_action: "reserve_inventory",消费者被迫按发布者的时间表行动,订阅者之间产生隐式顺序依赖。事件应该描述已经发生的事实,编排决策属于命令发布者。

把语义写进类型:Envelope 与总线

先定义信封。Kind 只有 event 和 command 两个合法值;命令额外要求 CorrelationID,因为没有关联 ID 就无法把拒绝、重试和审计串回同一次业务请求。Validate 在进入总线前拦截非法信封,避免错误在消费者侧才暴露。

// file: internal/messaging/envelope.go
// tier: L1
// verify: go test -race -v ./internal/messaging/
package messaging

import (
	"errors"
	"fmt"
	"time"
)

type Kind string

const (
	KindEvent   Kind = "event"
	KindCommand Kind = "command"
)

var (
	ErrInvalidEnvelope = errors.New("invalid envelope")
	ErrNoHandler       = errors.New("no handler")
	ErrHandlerExists   = errors.New("handler already registered")
)

// Envelope 是传输层唯一认识的类型:Kind 决定语义,Name 决定路由,
// Payload 只承载领域序列化结果,不参与路由。
type Envelope struct {
	ID            string
	Kind          Kind
	Name          string
	OccurredAt    time.Time
	CorrelationID string
	Payload       []byte
}

func (e Envelope) Validate() error {
	switch {
	case e.ID == "":
		return fmt.Errorf("%w: missing id", ErrInvalidEnvelope)
	case e.Kind != KindEvent && e.Kind != KindCommand:
		return fmt.Errorf("%w: unknown kind %q", ErrInvalidEnvelope, e.Kind)
	case e.Name == "":
		return fmt.Errorf("%w: missing name", ErrInvalidEnvelope)
	case e.OccurredAt.IsZero():
		return fmt.Errorf("%w: missing occurred_at", ErrInvalidEnvelope)
	case e.Kind == KindCommand && e.CorrelationID == "":
		return fmt.Errorf("%w: command requires correlation_id", ErrInvalidEnvelope)
	}
	return nil
}

func NewEvent(id, name string, at time.Time, payload []byte) (Envelope, error) {
	e := Envelope{ID: id, Kind: KindEvent, Name: name, OccurredAt: at, Payload: payload}
	return e, e.Validate()
}

func NewCommand(id, name, correlationID string, at time.Time, payload []byte) (Envelope, error) {
	e := Envelope{
		ID: id, Kind: KindCommand, Name: name, CorrelationID: correlationID,
		OccurredAt: at, Payload: payload,
	}
	return e, e.Validate()
}

总线把两种语义拆成两套注册接口。Subscribe 用于事件,允许同名多注册,也允许零订阅;RegisterCommand 用于命令,同名第二次注册直接返回 ErrHandlerExists。Publish 根据 Kind 分流:事件遍历所有订阅者并用 errors.Join 收集错误,某个订阅者失败不影响其他订阅者;命令只找一个处理者,找不到返回 ErrNoHandler,处理者的业务错误原样回到发布者。

// file: internal/messaging/bus.go
// tier: L1
// verify: go test -race -v ./internal/messaging/
package messaging

import (
	"context"
	"errors"
	"fmt"
	"sync"
)

type Handler func(context.Context, Envelope) error

type Publisher interface {
	Publish(ctx context.Context, e Envelope) error
}

type Subscriber interface {
	Subscribe(name string, h Handler)
}

type Bus struct {
	mu       sync.RWMutex
	events   map[string][]Handler
	commands map[string]Handler
}

func NewBus() *Bus {
	return &Bus{events: make(map[string][]Handler), commands: make(map[string]Handler)}
}

// Subscribe 只用于事件:同一事件可以有零个、一个或多个消费者。
func (b *Bus) Subscribe(name string, h Handler) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.events[name] = append(b.events[name], h)
}

// RegisterCommand 只用于命令:同一命令名只允许一个处理者。
func (b *Bus) RegisterCommand(name string, h Handler) error {
	b.mu.Lock()
	defer b.mu.Unlock()
	if _, ok := b.commands[name]; ok {
		return fmt.Errorf("%w: %s", ErrHandlerExists, name)
	}
	b.commands[name] = h
	return nil
}

func (b *Bus) Publish(ctx context.Context, e Envelope) error {
	if err := e.Validate(); err != nil {
		return err
	}
	b.mu.RLock()
	switch e.Kind {
	case KindEvent:
		handlers := append([]Handler(nil), b.events[e.Name]...)
		b.mu.RUnlock()
		// 事件是已经发生的事实:发布者不等待业务结果,某个订阅者失败
		// 也不阻止其他订阅者收到它。
		return fanOut(ctx, handlers, e)
	case KindCommand:
		h, ok := b.commands[e.Name]
		b.mu.RUnlock()
		if !ok {
			return fmt.Errorf("%w: %s", ErrNoHandler, e.Name)
		}
		// 命令有明确的接收者:拒绝和失败必须回到发布者。
		return h(ctx, e)
	}
	b.mu.RUnlock()
	return fmt.Errorf("%w: unknown kind %q", ErrInvalidEnvelope, e.Kind)
}

func fanOut(ctx context.Context, handlers []Handler, e Envelope) error {
	var errs []error
	for _, h := range handlers {
		if err := h(ctx, e); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...)
}

这里有一个容易忽略的取舍:事件发布返回 error 并不代表“事件被拒绝”,它只表示“至少一个订阅者投递失败”。调用方可以记录、告警或重投,但不能因此回滚已经发生的事实。命令返回的 error 则是业务结果,调用方必须处理。同一个 Publish 签名承载两种错误语义,所以类型里的 Kind 不是装饰,而是调用方解释返回值的唯一依据。

表驱动测试与真实输出

测试先覆盖信封校验的五种输入,再分别验证事件扇出、订阅者失败隔离、零订阅、命令单处理者、命令无处理者和命令拒绝回传。所有计数器用 atomic,因为测试要在 -race 下跑。

// file: internal/messaging/bus_test.go
// tier: L1
// verify: go test -race -v ./internal/messaging/
package messaging

import (
	"context"
	"errors"
	"sync/atomic"
	"testing"
	"time"
)

var (
	ErrOutOfStock = errors.New("out of stock")
	ErrProjection = errors.New("projection failed")
)

func at() time.Time { return time.Date(2026, 10, 9, 0, 0, 0, 0, time.UTC) }

func TestEnvelopeValidation(t *testing.T) {
	cases := []struct {
		name string
		env  Envelope
		want error
	}{
		{"event", Envelope{ID: "e-1", Kind: KindEvent, Name: "OrderPlaced", OccurredAt: at()}, nil},
		{"command", Envelope{ID: "c-1", Kind: KindCommand, Name: "ReserveInventory", CorrelationID: "req-1", OccurredAt: at()}, nil},
		{"missing id", Envelope{Kind: KindEvent, Name: "OrderPlaced", OccurredAt: at()}, ErrInvalidEnvelope},
		{"unknown kind", Envelope{ID: "x", Kind: "query", Name: "GetOrder", OccurredAt: at()}, ErrInvalidEnvelope},
		{"command without correlation", Envelope{ID: "c-2", Kind: KindCommand, Name: "ReserveInventory", OccurredAt: at()}, ErrInvalidEnvelope},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := tc.env.Validate()
			if tc.want == nil && err != nil {
				t.Fatalf("err = %v", err)
			}
			if tc.want != nil && !errors.Is(err, tc.want) {
				t.Fatalf("err = %v, want %v", err, tc.want)
			}
		})
	}
}

func TestEventFansOutToEverySubscriber(t *testing.T) {
	bus := NewBus()
	var calls atomic.Int32
	for i := 0; i < 2; i++ {
		bus.Subscribe("OrderPlaced", func(context.Context, Envelope) error {
			calls.Add(1)
			return nil
		})
	}
	ev, err := NewEvent("e-1", "OrderPlaced", at(), []byte(`{"order_id":"o-1"}`))
	if err != nil {
		t.Fatal(err)
	}
	if err := bus.Publish(context.Background(), ev); err != nil {
		t.Fatal(err)
	}
	if calls.Load() != 2 {
		t.Fatalf("calls = %d, want 2", calls.Load())
	}
}

func TestEventContinuesAfterSubscriberError(t *testing.T) {
	bus := NewBus()
	var second atomic.Bool
	bus.Subscribe("OrderPlaced", func(context.Context, Envelope) error { return ErrProjection })
	bus.Subscribe("OrderPlaced", func(context.Context, Envelope) error {
		second.Store(true)
		return nil
	})
	ev, _ := NewEvent("e-1", "OrderPlaced", at(), nil)
	if err := bus.Publish(context.Background(), ev); !errors.Is(err, ErrProjection) {
		t.Fatalf("err = %v, want ErrProjection", err)
	}
	if !second.Load() {
		t.Fatal("second subscriber was skipped")
	}
}

func TestEventWithNoSubscriberIsAccepted(t *testing.T) {
	ev, _ := NewEvent("e-1", "OrderPlaced", at(), nil)
	if err := NewBus().Publish(context.Background(), ev); err != nil {
		t.Fatalf("event with no subscriber: %v", err)
	}
}

func TestCommandAllowsExactlyOneHandler(t *testing.T) {
	bus := NewBus()
	h := func(context.Context, Envelope) error { return nil }
	if err := bus.RegisterCommand("ReserveInventory", h); err != nil {
		t.Fatal(err)
	}
	if err := bus.RegisterCommand("ReserveInventory", h); !errors.Is(err, ErrHandlerExists) {
		t.Fatalf("second register err = %v, want ErrHandlerExists", err)
	}
	cmd, _ := NewCommand("c-1", "ReserveInventory", "req-1", at(), nil)
	if err := bus.Publish(context.Background(), cmd); err != nil {
		t.Fatalf("publish command: %v", err)
	}
}

func TestCommandWithoutHandlerIsRejected(t *testing.T) {
	cmd, _ := NewCommand("c-1", "ReserveInventory", "req-1", at(), nil)
	if err := NewBus().Publish(context.Background(), cmd); !errors.Is(err, ErrNoHandler) {
		t.Fatalf("err = %v, want ErrNoHandler", err)
	}
}

func TestCommandRejectionReachesPublisher(t *testing.T) {
	bus := NewBus()
	_ = bus.RegisterCommand("ReserveInventory", func(context.Context, Envelope) error {
		return ErrOutOfStock
	})
	cmd, _ := NewCommand("c-1", "ReserveInventory", "req-1", at(), nil)
	if err := bus.Publish(context.Background(), cmd); !errors.Is(err, ErrOutOfStock) {
		t.Fatalf("err = %v, want ErrOutOfStock", err)
	}
}

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),在独立模块 .local/checks/ch07-be-arch-base-07-2-event-command-message 中执行。gofmt -l . 无输出,go vet ./... 通过。

$ go test -race -v ./internal/messaging/
--- PASS: TestEnvelopeValidation (0.00s)
    --- PASS: TestEnvelopeValidation/event (0.00s)
    --- PASS: TestEnvelopeValidation/command (0.00s)
    --- PASS: TestEnvelopeValidation/missing_id (0.00s)
    --- PASS: TestEnvelopeValidation/unknown_kind (0.00s)
    --- PASS: TestEnvelopeValidation/command_without_correlation (0.00s)
--- PASS: TestEventFansOutToEverySubscriber (0.00s)
--- PASS: TestEventContinuesAfterSubscriberError (0.00s)
--- PASS: TestEventWithNoSubscriberIsAccepted (0.00s)
--- PASS: TestCommandAllowsExactlyOneHandler (0.00s)
--- PASS: TestCommandWithoutHandlerIsRejected (0.00s)
--- PASS: TestCommandRejectionReachesPublisher (0.00s)
PASS
ok  	archlab/internal/messaging	1.332s

与已有事件驱动文章的分工

站内 消息驱动架构 回答的是“什么时候该用事件驱动、事件如何落库、如何避免双写”,属于架构风格与一致性层面。本节回答更前置的问题:“这条消息到底该被当成事实还是意图”。前者决定系统形状,后者决定单个接口的契约;如果语义层没定清,事件驱动的基础设施越完整,误用传播得越快。消息可靠投递与幂等的细节放在 7.3 和卷二展开,这里不重复。

常见错误与边界

第一,用 topic 名称代替语义。order-events 里既放 OrderPlaced 又放 CancelOrder,消费者只能靠 payload 猜。正确做法是让 Kind 成为信封的必填字段,topic 只负责粗粒度隔离。

第二,认为命令必须同步返回。命令可以有异步执行路径,但“接受/拒绝”的确认必须回到发布者;如果连接受与否都不返回,它就已经退化成事件,不该叫命令。

第三,把事件消费者的失败当成发布失败。事件已经发生,发布方无法撤销;消费者失败应由重试、死信和补偿处理,不能反向阻塞事实的产生。

第四,为了“解耦”把所有命令都改成事件。命令的拒绝能力是业务需要的,库存不足、支付失败、状态非法都必须有明确回传路径;把这些改成事件,只会把失败检测推迟到对账。

小结

事件、命令、消息的差异不在传输介质,而在契约:事件是零到多个消费者共享的既成事实,命令是恰好一个接收者且可拒绝的意图,消息是承载两者的信封。arch-lab 用 Kind 校验、Subscribe 与 RegisterCommand 两套注册接口、以及“事件聚合错误、命令原样回传”的发布逻辑,把这三条契约写成了可执行代码。下一节继续往契约内部走:当同一条命令或同一个事件被重复投递时,幂等责任究竟该由 API、领域还是消息层承担。

阅读导航:上一节:7.1 同步调用链的隐性耦合 · 下一节:7.3 幂等责任归属:API、领域、消息层的契约 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查