《后端架构设计·架构基础与边界》3.3 聚合、事务边界与一致性边界

围绕 arch-lab 订单系统回答聚合该多大:给出 Order、InventoryReservation、Payment 的边界清单,解释库存为何不能塞进订单聚合,拆解总额、状态迁移、重复支付三条不变式,并用 internal/order 聚合、假端口补偿测试与真实 go test -race 输出验证跨聚合一致性的失败路径。

聚合边界不是“相关就放一起”

3.2 的事件风暴圈出了三个聚合:Order、InventoryReservation、Payment。接下来的问题不是“还有哪些对象相关”,而是“哪些对象必须在同一个事务里一起改”。聚合边界就是事务边界;把不必须一起改的东西塞进来,换来的不是更强的一致性,而是更长的锁、更大的失败半径和更难的扩缩容。

arch-lab 的订单聚合曾经有过一个诱人的版本:Order 里直接放 Stock 字段,下单时锁住订单,同时扣减库存。它在单机演示里工作得很好,因为只有一个进程、一个锁、没有真实并发。一旦库存按 SKU/仓分片、支付回调从外部进来,这个设计就会同时暴露锁粒度和失败语义两个问题。

arch-lab 的聚合清单

聚合标识内部对象事务内必须保证存储
Orderorder_idOrderItem、金额、状态、PaidPaymentID总额、状态迁移、重复支付orders、order_items
InventoryReservationreservation_idSKU、仓、数量、状态同一 SKU/仓不超卖inventory_reservations
Paymentpayment_id金额、状态、provider_ref同一幂等键只产生一笔支付payments
Customercustomer_id账号引用、默认地址客户资料自身一致由 identity 上下文拥有

Customer 不放进 Order 聚合,理由不是“它是一张独立的表”,而是三条:客户资料与订单的生命周期不同;一个客户对应大量订单,放进聚合会让每次下单都锁同一行;客户的隐私字段不应该被订单的读模型复制。订单只保存 CustomerID,需要展示时通过 identity 上下文的接口查询。

InventoryReservation 按 SKU/仓拆分,而不是按订单拆分。一个订单买三个 SKU,会产生三条预留记录;它们可以独立成功或失败,失败时由应用服务补偿已成功的部分。若把三条预留塞进一个“订单库存”聚合,锁的范围会从一个 SKU 扩大到整单,热门 SKU 的并发会直接退化。

Payment 独立成聚合,因为它有自己的幂等键、渠道回调和对账生命周期。订单只需要知道“有没有一笔成功支付”,不需要持有支付渠道的签名、重试次数和退款单。

为什么库存不能塞进 Order 聚合

第一,锁粒度。订单聚合被支付回调、取消请求、履约状态更新同时访问;如果它内部还持有库存,每次订单状态变更都要锁库存行。热门 SKU 的下单会被同一把锁串行化,吞吐量随并发上升而下降。

第二,失败语义。库存预留可能因为仓库拒绝、SKU 下架、数量不足而失败,这些失败不应该让订单聚合进入一个“半创建”状态。把库存放进 Order,New 就不得不做一次远程判断,构造函数的失败原因会混进订单的领域错误里。

第三,扩缩容。库存需要按 SKU/仓分片、独立扩容、独立降级;订单需要按订单号分片。两者的分片键不同,塞进一个聚合后无法独立扩展。模块化单体阶段看不出差别,等到库存要独立部署时,边界已经黏住了。

三条不变式

不变式范围保证方式测试
I1 订单总额 = 明细之和单聚合New 计算并保存 totalCents,items 不导出TestAggregateInvariants 改输入切片后总额不变
I2 状态迁移只经 TransitionTo单聚合status 不导出,TransitionTo 查合法边表非法边返回 INVALID_STATE 且状态/版本不变
I3 同一订单不能重复支付单聚合 + 跨聚合PaidPaymentID 本地守卫;Payment 侧用幂等键第二次 MarkPaid 返回 INVALID_STATE,不再调用支付端口

I1 和 I2 可以在一个聚合、一个事务里完成,不需要任何跨聚合协调。I3 的本地守卫只能保证“同一个 Order 实例不会被标记两次”,不能保证“支付网关不会收到两次扣款”。后者需要 Payment 聚合的幂等键和渠道侧的幂等支持;订单的 PaidPaymentID 是结果校验,不是分布式锁。

跨聚合的一致性可以最终一致,但必须满足两个条件:第一,每个聚合的本地事务先提交;第二,失败补偿有明确的触发者和幂等语义。arch-lab 的补偿触发者是 Checkout,补偿动作是释放已成功的预留;如果补偿本身失败,必须留下可重试的记录,而不是吞掉错误。

订单聚合该多大:四种候选边界

候选锁范围失败语义扩缩容结论
Order 只含明细与状态单订单库存/支付失败可独立补偿订单号分片采用
Order 含库存预留订单 + SKU/仓库存失败污染订单构造分片键冲突否决
Order 含 Payment订单 + 支付单支付回调锁订单支付渠道无法独立降级否决
Order 含 Customer订单 + 客户客户资料变更锁订单客户与订单不同分片否决

“聚合越大,一致性越强”是错觉。聚合越大,事务持有锁的时间越长,失败时回滚的范围越大,能独立扩缩容的单元越少。正确的问题是:这条不变式是否必须在同一个事务里?如果答案是否,就应该用应用服务编排,而不是扩大聚合。

代码:聚合与协调

先看 Order 聚合。status、items、totalCents 都不导出,外部只能通过方法读写;TransitionTo 是唯一的状态写入口,MarkPaid 在调用它之前先检查重复支付。

// file: internal/order/order.go
// tier: L1
// verify: go test -race ./internal/order/
package order

import "time"

type Status string

const (
	StatusCreated           Status = "CREATED"
	StatusInventoryReserved Status = "INVENTORY_RESERVED"
	StatusPaid              Status = "PAID"
	StatusFulfilled         Status = "FULFILLED"
	StatusCancelled         Status = "CANCELLED"
	StatusRefunded          Status = "REFUNDED"
)

type Code string

const (
	CodeInvalidState    Code = "INVALID_STATE"
	CodeOutOfStock      Code = "OUT_OF_STOCK"
	CodePaymentFailed   Code = "PAYMENT_FAILED"
	CodeVersionConflict Code = "VERSION_CONFLICT"
)

type Error struct {
	Code Code
	Op   string
}

func (e *Error) Error() string { return string(e.Code) + ": " + e.Op }

type OrderItem struct {
	SKU            string
	Quantity       int
	UnitPriceCents int64
}

// items 与 totalCents 不导出:外部只能读,无法绕过总额不变式。
type Order struct {
	ID, CustomerID, Currency string
	status                   Status
	items                    []OrderItem
	totalCents               int64
	Version                  int64
	PaidPaymentID            string
	CreatedAt, UpdatedAt     time.Time
}

func New(id, customerID, currency string, items []OrderItem, now time.Time) (*Order, error) {
	if id == "" || customerID == "" || currency == "" || len(items) == 0 {
		return nil, &Error{Code: CodeInvalidState, Op: "order.New: missing field"}
	}
	o := &Order{ID: id, CustomerID: customerID, Currency: currency,
		status: StatusCreated, Version: 1, CreatedAt: now, UpdatedAt: now}
	var total int64
	for _, item := range items {
		if item.SKU == "" || item.Quantity <= 0 || item.UnitPriceCents < 0 {
			return nil, &Error{Code: CodeInvalidState, Op: "order.New: invalid " + item.SKU}
		}
		o.items = append(o.items, item)
		total += int64(item.Quantity) * item.UnitPriceCents
	}
	o.totalCents = total
	return o, nil
}

var allowed = map[Status][]Status{
	StatusCreated:           {StatusInventoryReserved, StatusCancelled},
	StatusInventoryReserved: {StatusPaid, StatusCancelled},
	StatusPaid:              {StatusFulfilled, StatusRefunded},
	StatusFulfilled:         {},
	StatusCancelled:         {},
	StatusRefunded:          {},
}

func (o *Order) TransitionTo(next Status, now time.Time) error {
	for _, candidate := range allowed[o.status] {
		if candidate == next {
			o.status = next
			o.Version++
			o.UpdatedAt = now
			return nil
		}
	}
	return &Error{Code: CodeInvalidState, Op: "order.Transition: " + string(o.status) + " -> " + string(next)}
}

func (o *Order) ensurePayable() error {
	if o.PaidPaymentID != "" {
		return &Error{Code: CodeInvalidState, Op: "order.Pay: duplicate " + o.PaidPaymentID}
	}
	if o.status != StatusInventoryReserved {
		return &Error{Code: CodeInvalidState, Op: "order.Pay: not reserved"}
	}
	return nil
}

func (o *Order) MarkPaid(paymentID string, now time.Time) error {
	if err := o.ensurePayable(); err != nil {
		return err
	}
	if paymentID == "" {
		return &Error{Code: CodePaymentFailed, Op: "order.Pay: empty payment id"}
	}
	o.PaidPaymentID = paymentID
	return o.TransitionTo(StatusPaid, now)
}

func (o *Order) Status() Status             { return o.status }
func (o *Order) TotalCents() int64          { return o.totalCents }
func (o *Order) Cancel(now time.Time) error { return o.TransitionTo(StatusCancelled, now) }

跨聚合的顺序与补偿放在应用服务里。PlaceAndReserve 逐条预留,任何一条失败就逆序释放已成功的预留并把订单置为取消;Pay 在调用支付端口之前先做本地守卫,失败时释放全部预留。

// file: internal/order/checkout.go
// tier: L1
// verify: go test -race ./internal/order/
package order

import (
	"context"
	"time"
)

type InventoryPort interface {
	Reserve(ctx context.Context, orderID, sku string, qty int) (reservationID string, err error)
	Release(ctx context.Context, reservationID string) error
}

type PaymentPort interface {
	Charge(ctx context.Context, orderID string, amountCents int64) (paymentID string, err error)
	Refund(ctx context.Context, paymentID string) error
}

// Checkout 是应用服务:跨聚合的顺序与补偿放在这里,Order 聚合不持有库存。
type Checkout struct {
	Inventory InventoryPort
	Payments  PaymentPort
}

func (c *Checkout) PlaceAndReserve(ctx context.Context, o *Order, now time.Time) ([]string, error) {
	var reservations []string
	for _, item := range o.items {
		id, err := c.Inventory.Reserve(ctx, o.ID, item.SKU, item.Quantity)
		if err != nil {
			c.releaseAll(ctx, reservations)
			_ = o.Cancel(now)
			return nil, &Error{Code: CodeOutOfStock, Op: "reserve " + item.SKU + ": " + err.Error()}
		}
		reservations = append(reservations, id)
	}
	if err := o.TransitionTo(StatusInventoryReserved, now); err != nil {
		c.releaseAll(ctx, reservations)
		return nil, err
	}
	return reservations, nil
}

func (c *Checkout) Pay(ctx context.Context, o *Order, reservations []string, now time.Time) error {
	if err := o.ensurePayable(); err != nil {
		return err
	}
	paymentID, err := c.Payments.Charge(ctx, o.ID, o.totalCents)
	if err != nil {
		// 只有明确拒绝才补偿;超时歧义交给卷二的对账,不在这里猜。
		c.releaseAll(ctx, reservations)
		_ = o.Cancel(now)
		return &Error{Code: CodePaymentFailed, Op: "charge: " + err.Error()}
	}
	return o.MarkPaid(paymentID, now)
}

func (c *Checkout) releaseAll(ctx context.Context, ids []string) {
	for i := len(ids) - 1; i >= 0; i-- {
		_ = c.Inventory.Release(ctx, ids[i])
	}
}

测试用假库存端口制造“第二个 SKU 失败”,断言第一个预留被释放、订单被取消、错误码是 OUT_OF_STOCK。同一条测试路径也验证了 I3:重复调用 MarkPaid 返回 INVALID_STATE,且版本号和 PaidPaymentID 不变。

// file: internal/order/aggregate_test.go
// tier: L1
// verify: go test -race ./internal/order/ -v
package order

import (
	"context"
	"errors"
	"fmt"
	"testing"
	"time"
)

var testNow = time.Unix(1_700_000_000, 0)

func newTestOrder(t *testing.T) *Order {
	o, err := New("ord-1", "cus-1", "CNY", []OrderItem{
		{SKU: "sku-a", Quantity: 2, UnitPriceCents: 1999},
		{SKU: "sku-b", Quantity: 1, UnitPriceCents: 500},
	}, testNow)
	if err != nil {
		t.Fatal(err)
	}
	return o
}

func requireCode(t *testing.T, err error, want Code) {
	var domainErr *Error
	if !errors.As(err, &domainErr) || domainErr.Code != want {
		t.Fatalf("error = %v, want code %s", err, want)
	}
}

func TestAggregateInvariants(t *testing.T) {
	items := []OrderItem{
		{SKU: "sku-a", Quantity: 2, UnitPriceCents: 1999},
		{SKU: "sku-b", Quantity: 1, UnitPriceCents: 500},
	}
	o, err := New("ord-1", "cus-1", "CNY", items, testNow)
	if err != nil {
		t.Fatal(err)
	}
	if o.TotalCents() != 4498 {
		t.Fatalf("total = %d, want 4498", o.TotalCents())
	}
	items[0].Quantity = 100
	if o.TotalCents() != 4498 {
		t.Fatal("New aliased caller slice")
	}
	requireCode(t, o.TransitionTo(StatusPaid, testNow), CodeInvalidState)
	if o.Status() != StatusCreated {
		t.Fatalf("illegal transition mutated status to %s", o.Status())
	}
	if err := o.TransitionTo(StatusInventoryReserved, testNow); err != nil {
		t.Fatal(err)
	}
	if err := o.MarkPaid("pay-1", testNow); err != nil {
		t.Fatal(err)
	}
	version := o.Version
	requireCode(t, o.MarkPaid("pay-2", testNow), CodeInvalidState)
	if o.Version != version || o.PaidPaymentID != "pay-1" {
		t.Fatalf("duplicate payment mutated order: version=%d paid=%s", o.Version, o.PaidPaymentID)
	}
}

type fakeInventory struct {
	failSKU  string
	seq      int
	released []string
}

func (f *fakeInventory) Reserve(_ context.Context, _, sku string, _ int) (string, error) {
	if sku == f.failSKU {
		return "", errors.New("warehouse rejected")
	}
	f.seq++
	return fmt.Sprintf("res-%d", f.seq), nil
}

func (f *fakeInventory) Release(_ context.Context, id string) error {
	f.released = append(f.released, id)
	return nil
}

func TestPlaceAndReserveCompensatesPartialFailure(t *testing.T) {
	inventory := &fakeInventory{failSKU: "sku-b"}
	checkout := &Checkout{Inventory: inventory}
	o := newTestOrder(t)

	_, err := checkout.PlaceAndReserve(context.Background(), o, testNow)
	requireCode(t, err, CodeOutOfStock)
	if o.Status() != StatusCancelled || len(inventory.released) != 1 || inventory.released[0] != "res-1" {
		t.Fatalf("status=%s released=%v, want CANCELLED/[res-1]", o.Status(), inventory.released)
	}
}

在 darwin/arm64 + Go 1.26.0 上实际运行:

=== RUN   TestAggregateInvariants
--- PASS: TestAggregateInvariants (0.00s)
=== RUN   TestPlaceAndReserveCompensatesPartialFailure
--- PASS: TestPlaceAndReserveCompensatesPartialFailure (0.00s)
PASS
ok  	archlab/internal/order	1.575s

完整仓库里还有一条并发用例:两个 goroutine 各自加载同一个订单的两个副本,同时 MarkPaid,再用 version 做条件更新。实测结果是一个成功、一个返回 VERSION_CONFLICT;这里省略代码,是因为它验证的是仓储的乐观锁,而不是聚合本身。仓储层把“影响行数为 0”映射成 VERSION_CONFLICT 的那一步,要到后续把仓储换成 PostgreSQL 条件更新时才真正闭环。

事务边界与一致性边界

事务边界回答“哪些写入必须原子”,一致性边界回答“多久之后必须一致”。Order 的 I1、I2 在单事务内;库存预留、支付扣款、通知投递都在 Order 之外,允许最终一致。最终一致不是“随便什么时候一致”,每条跨聚合链路都要定义:谁发起、失败怎么补偿、补偿失败怎么记录、读模型如何暴露中间状态。订单详情页在支付回调到达前可能显示 INVENTORY_RESERVED,这是合法的中间状态,不是数据错误;读模型应该展示“处理中”,而不是把中间状态藏起来,否则客服无法判断订单卡在哪一步。

与站内文章的分工

站内的 分布式数据一致性 已经系统讲了 CAP/BASE、2PC、TCC、Saga 和 Seata。本节不重复这些协议,只回答 arch-lab 的一个具体问题:订单聚合该多大、哪三条不变式必须在单事务内、哪些跨聚合链路可以最终一致,以及补偿代码应该放在哪里。

常见错误与边界

第一,把聚合当成“聚合根 + 所有关联表”。关联表可以很多,但只有必须同事务修改的对象才属于聚合。第二,用数据库外键推导聚合边界。外键说明引用关系,不说明一致性要求;order_items 有外键不等于 payments 也要进 Order。

第三,在聚合里调用远程服务。构造函数里查库存、TransitionTo 里发消息,都会让聚合变成不可测试的编排器;远程调用属于应用服务。

第四,把 Version 当成分布式锁。它只能做单库条件更新;跨服务的并发要靠幂等键、唯一约束或状态机守卫。

第五,补偿失败就吞掉。releaseAll 里的 _ = 是 L1 的简化;生产实现必须把失败写入重试表,否则库存会永久泄漏。

小结

聚合边界是事务边界,不是“相关对象”的集合。arch-lab 采用 Order、InventoryReservation、Payment 三个聚合,Customer 留在 identity 上下文;库存不塞进 Order,是因为锁粒度、失败语义和分片键都不同。I1 总额、I2 状态迁移在单聚合内保证,I3 重复支付需要聚合守卫加 Payment 幂等键共同保证。Checkout 承担跨聚合的顺序与补偿,假端口测试验证了部分失败时的逆序释放。下一章从这些边界出发,讨论模块化单体如何组织依赖、如何用编译期检查防止边界退化。

阅读导航:上一节:3.2 订单域事件风暴工作坊:产出聚合、事件、边界 · 下一节:4.1 模块化单体:先拿到边界再谈拆分 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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