先发货、后付款的那条订单
库存服务有 8 个消费 worker,订单事件按 order_id 进了 4 个分区。某天一条 order.paid 因为数据库慢查询卡了 2 秒,同一个分区里的 order.fulfilled 被另一个 worker 先取走并写入投影,状态变成 FULFILLED;慢查询结束后,order.paid 才把状态写回 PAID。仓库已经按 FULFILLED 出了货,订单页却显示待发货;如果下游还有退款逻辑,状态回退会触发一次不该发生的退款。
直觉是“生产端按顺序发送,消费端自然按顺序收到”。这个直觉只在单分区、单消费者、无重试时成立。broker 的顺序保证通常只有一条:同一分区内,消息按写入顺序追加。消费者一旦在分区内并发处理,或者失败重试把某条消息挪到后面,顺序就不再有保证。跨分区更没有全局顺序,order-1 的支付和 order-2 的发货谁先谁后没有业务意义。
站内 消息队列深入 讨论过分区、位点与重平衡的机制;本节不重复 broker 内部结构,只处理订单投影在乱序、重放和死信下的收敛路径。
顺序的三个层次
把“顺序”拆成三层,才能判断哪些必须保、哪些可以放弃:
| 层次 | 保证内容 | 典型丢失场景 | 处理方式 |
|---|---|---|---|
| 分区内顺序 | 同一分区按追加顺序被看到 | 分区内并发消费、失败重试插队 | 单分区单在途,或按订单键串行 |
| 跨分区顺序 | 不同分区之间没有顺序 | 重平衡、多分区并行 | 不依赖;需要时引入全局序列器 |
| 业务因果 | 付款依赖预占、发货依赖付款 | 乱序到达、重放 | 版本号 + 状态机拒绝越级 |
第一层是 broker 能给的,第二层是 broker 明确不给的,第三层是应用必须自己维护的。订单系统真正需要的是第三层:不管消息以什么顺序到达,投影都不能进入非法状态。把第三层误当成第一层,就会写出“消费者按到达顺序覆盖状态”的代码,一旦并发或重试就回退。
分区键选 order_id 能让同一订单的事件落在同一分区,这是成本最低的因果保护。但同一分区仍然可能被多个 worker 并发处理,所以消费者要么限制每个分区同时只有一条在途消息,要么按 order_id 再做一次串行化。前者吞吐低但实现简单,后者需要一个键级队列,复杂度取决于并发度。
版本号把乱序变成可重试的冲突
每条订单事件带一个 Version,表示应用后投影应达到的版本:预占是 1,支付是 2,发货是 3。投影只接受 current+1:
- 版本小于等于当前值:重复事件,直接确认,不产生副作用。
- 版本等于当前值加一:正常推进。
- 版本大于当前值加一:前面缺事件,返回
ErrConflict,让调用方重试。 - 事件类型与版本不匹配、或类型未知:永远无法应用,返回
ErrPoison。
这套规则把“顺序”从安全属性变成了活性属性。安全性由版本条件保证:无论消息怎么乱,投影都不会从 CREATED 直接跳到 FULFILLED,也不会把 PAID 覆盖回 INVENTORY_RESERVED。活性则可能被破坏:如果版本 1 的事件永久丢失,版本 2 会一直冲突,重试到预算耗尽后进入死信。此时需要重放或对账把缺口补上,而不是让消费者无限重试。
重试预算必须有限。无限重试会让一条缺前驱的事件占住分区,后面的消息全部排队;重试次数太少又会把可恢复的乱序误判为死信。常见配置是 3 到 5 次,带指数退避,并把“冲突次数”作为独立指标;如果某个分区持续冲突,说明上游丢事件或分区键错了,应该告警而不是继续加预算。
死信不是垃圾桶
死信队列保存的是“当前无法处理、但以后可能可以处理”的事件。它至少要记录原始事件、投递次数和失败原因,否则重放时既不知道重放什么,也不知道当时为什么失败。
失败要分类,不同类别的处置完全不同:
| 失败类别 | 判据 | 处置 | 能否自动重放 |
|---|---|---|---|
| 暂时性依赖失败 | 数据库超时、下游 5xx | 退避重试 | 能,恢复后重放 |
| 版本缺口 | ErrConflict 且前驱可补 | 重试到预算,再进死信 | 能,先补前驱 |
| 毒消息 | 未知类型、版本与类型矛盾 | 立即进死信并告警 | 不能,需修代码或丢弃 |
| 业务拒绝 | 状态机不允许的迁移 | 进死信并人工判定 | 视业务规则而定 |
重放要按分区位点进行,而不是“把死信全部再发一遍”。位点之前的消息已经处理过,重放只会制造重复;位点之后的消息可能包含着真正的缺口。重放时仍然走同一套版本规则,所以重复事件会被忽略,乱序事件会再次冲突,行为与首次消费一致。这就是为什么版本号比“处理时间”更适合做重放判据:时间戳会变,版本不会。
重放解决不了两类差异:事件从未产生(生产端在提交后崩溃且没有 outbox),以及毒消息背后的代码缺陷尚未修复。这两类差异只能由对账任务从权威数据源修复。对账读取订单库的真实状态,与投影逐条比较版本和状态,发现落后就写入修复记录。修复记录本身也要幂等:同一个订单重复对账不应产生多条修复或重复副作用。
顺序应该是“先修代码、再重放死信、最后对账”。先对账会把本可由重放恢复的事件直接覆盖掉,丢失死信里的证据;只重放不对账,则永远修不了从未产生的事件。两者是接力,不是二选一。
可运行实现
下面的实现把投影、分区、消费分类和对账放在一个包里。Apply 用版本条件拒绝越级和重复;Consumer.Handle 把冲突、毒消息和重试预算分开;Reconcile 从权威状态修复投影。
// file: internal/platform/ordering/ordering.go
// tier: L2
// verify: go test ./internal/platform/ordering/ -count=1
package ordering
import (
"errors"
"fmt"
"hash/fnv"
"sync"
)
var (
// ErrConflict means the event arrived before the version it depends on.
ErrConflict = errors.New("event version conflict")
// ErrPoison means the event can never be applied as written.
ErrPoison = errors.New("poison event")
)
// Status is the order projection state.
type Status string
// Order projection states, matching the arch-lab state machine.
const (
StatusCreated Status = "CREATED"
StatusReserved Status = "INVENTORY_RESERVED"
StatusPaid Status = "PAID"
StatusFulfilled Status = "FULFILLED"
)
// Event is one order event. Version is the projection version it produces.
type Event struct {
ID string
OrderID string
Version int64
Type string
}
// OrderState is the projection row for one order.
type OrderState struct {
Version int64
Status Status
}
func eventTarget(e Event) (Status, int64, error) {
switch e.Type {
case "reserve":
return StatusReserved, 1, nil
case "pay":
return StatusPaid, 2, nil
case "fulfill":
return StatusFulfilled, 3, nil
default:
return "", 0, fmt.Errorf("ordering: event %s has type %q: %w", e.ID, e.Type, ErrPoison)
}
}
// Projection applies order events in version order.
type Projection struct {
mu sync.Mutex
orders map[string]OrderState
applied int
}
// NewProjection returns an empty projection.
func NewProjection() *Projection {
return &Projection{orders: make(map[string]OrderState)}
}
// Apply advances the order to e.Version. It reports false for duplicates and
// returns ErrConflict when an earlier version is missing.
func (p *Projection) Apply(e Event) (bool, error) {
status, expected, err := eventTarget(e)
if err != nil {
return false, err
}
if e.Version != expected {
return false, fmt.Errorf("ordering: %s expects v%d, got v%d: %w", e.Type, expected, e.Version, ErrPoison)
}
p.mu.Lock()
defer p.mu.Unlock()
cur := p.orders[e.OrderID]
if e.Version <= cur.Version {
return false, nil
}
if e.Version != cur.Version+1 {
return false, fmt.Errorf("ordering: order %s at v%d, got v%d: %w", e.OrderID, cur.Version, e.Version, ErrConflict)
}
p.orders[e.OrderID] = OrderState{Version: e.Version, Status: status}
p.applied++
return true, nil
}
// Get returns the projection row for one order.
func (p *Projection) Get(orderID string) (OrderState, bool) {
p.mu.Lock()
defer p.mu.Unlock()
st, ok := p.orders[orderID]
return st, ok
}
// AppliedCount reports how many version advances were applied.
func (p *Projection) AppliedCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.applied
}
// Partition maps an order key to a stable partition.
func Partition(orderID string, partitions int) int {
if partitions <= 0 {
return 0
}
h := fnv.New32a()
_, _ = h.Write([]byte(orderID))
return int(h.Sum32() % uint32(partitions))
}
// Outcome is what the dispatcher should do with a delivery.
type Outcome string
// Dispatch outcomes.
const (
OutcomeApplied Outcome = "applied"
OutcomeDuplicate Outcome = "duplicate"
OutcomeRetry Outcome = "retry"
OutcomeDead Outcome = "dead"
)
// DLQEntry is one dead-lettered event.
type DLQEntry struct {
Event Event
Attempts int
Reason string
}
// Consumer applies events for one partition and classifies failures.
type Consumer struct {
proj *Projection
MaxAttempts int
dlq []DLQEntry
}
// NewConsumer returns a consumer with a retry budget per event.
func NewConsumer(p *Projection, maxAttempts int) *Consumer {
if maxAttempts <= 0 {
maxAttempts = 3
}
return &Consumer{proj: p, MaxAttempts: maxAttempts}
}
// Handle classifies one delivery. attempt is the 1-based broker delivery
// count; the caller redelivers when the outcome is OutcomeRetry.
func (c *Consumer) Handle(e Event, attempt int) Outcome {
applied, err := c.proj.Apply(e)
switch {
case err == nil && applied:
return OutcomeApplied
case err == nil:
return OutcomeDuplicate
case errors.Is(err, ErrPoison):
c.dead(e, attempt, err)
return OutcomeDead
case errors.Is(err, ErrConflict):
if attempt >= c.MaxAttempts {
c.dead(e, attempt, err)
return OutcomeDead
}
return OutcomeRetry
default:
c.dead(e, attempt, err)
return OutcomeDead
}
}
func (c *Consumer) dead(e Event, attempt int, err error) {
c.dlq = append(c.dlq, DLQEntry{Event: e, Attempts: attempt, Reason: err.Error()})
}
// DLQ returns a copy of the dead-letter queue.
func (c *Consumer) DLQ() []DLQEntry {
return append([]DLQEntry(nil), c.dlq...)
}
// Source is the authoritative order store used by reconciliation.
type Source interface {
Status(orderID string) (OrderState, bool)
}
// MapSource is an in-memory Source for tests and drills.
type MapSource map[string]OrderState
// Status implements Source.
func (m MapSource) Status(orderID string) (OrderState, bool) {
st, ok := m[orderID]
return st, ok
}
// Fix records one reconciliation repair.
type Fix struct {
OrderID string
From OrderState
To OrderState
}
// Reconcile aligns the projection with the source of truth. It repairs
// differences that replay cannot: events that were dead-lettered or never
// produced.
func Reconcile(p *Projection, src Source, orderIDs []string) []Fix {
p.mu.Lock()
defer p.mu.Unlock()
var fixes []Fix
for _, id := range orderIDs {
want, ok := src.Status(id)
if !ok {
continue
}
got := p.orders[id]
if got == want {
continue
}
p.orders[id] = want
fixes = append(fixes, Fix{OrderID: id, From: got, To: want})
}
return fixes
}
Apply 先校验类型与版本的对应关系,再进入锁;类型错误是毒消息,不占用重试预算。版本缺口返回 ErrConflict,由 Handle 判断是否还有预算。Reconcile 不做业务计算,只以权威状态覆盖投影,因此重复执行是幂等的。重放器本身没有出现在这段代码里,它只需要“切片 + 偏移”两个变量:每次取 [offset, offset+n),处理成功后把偏移推进到 offset+n,与 relay 位点的推进方式相同。
本机实测:乱序、死信、重放与对账
测试直接把事件按错误顺序投给消费者,并记录每次分类结果。命令与原始输出:
$ go test ./internal/platform/ordering/ -v -count=1
=== RUN TestPartitionAffinity
ordering_test.go:32: partition-affinity: orders=8 partitions=4 used=4 stable=yes
--- PASS: TestPartitionAffinity (0.00s)
=== RUN TestOutOfOrderRejectedThenConverges
ordering_test.go:60: out-of-order: attempts=3 applied=2 final=v2/PAID dlq=0
--- PASS: TestOutOfOrderRejectedThenConverges (0.00s)
=== RUN TestPoisonGoesStraightToDLQ
ordering_test.go:85: poison: events=2 dead=2 attempts=1/1 applied=0
--- PASS: TestPoisonGoesStraightToDLQ (0.00s)
=== RUN TestConflictExhaustsRetryBudget
ordering_test.go:109: retry-budget: max=3 attempts=3 outcome=dead applied=0
--- PASS: TestConflictExhaustsRetryBudget (0.00s)
=== RUN TestReconcileRepairsDeadLettered
ordering_test.go:135: reconcile: before=v1/INVENTORY_RESERVED after=v2/PAID fixes=1 dlq=1
--- PASS: TestReconcileRepairsDeadLettered (0.00s)
=== RUN TestReplayRebuildsProjection
ordering_test.go:207: replay: events=5 rebuilt_applied=5 duplicates=2 offset=5
--- PASS: TestReplayRebuildsProjection (0.00s)
=== RUN TestNextBatchBounds
ordering_test.go:223: seek-bounds: out_of_range=1 zero_size=1 drained=1
--- PASS: TestNextBatchBounds (0.00s)
PASS
ok archlab/internal/platform/ordering 0.231s
读数分五组。分区测试把 8 个订单映射到 4 个分区,4 个分区都被用到,同一订单重复计算 100 次结果不变;它验证的是亲和性,不是负载均衡。
乱序测试先投版本 2 的支付,返回 retry;再投版本 1 的预占,applied;最后重投支付,applied。总共 3 次投递、2 次状态推进,最终 v2/PAID,死信为 0。关键点是版本 2 第一次没有写入任何状态,而不是“先写再被覆盖”。
毒消息测试投了两条:版本与类型矛盾的支付、未知类型 ship,都在第一次投递直接进死信,尝试次数都是 1,投影没有推进。重试预算测试只投一条版本 2、前驱永远不来的支付,第 3 次投递后进死信,避免无限重试。
对账测试先应用预占(v1),把版本 2 的支付写成毒消息进死信;权威源给出 v2/PAID,Reconcile 产生 1 条修复,投影变成 v2/PAID,死信仍然保留 1 条。这体现“对账补状态、死信留证据”,两者不能互相替代。
重放测试用 5 条事件重建投影:先读 2 条,回到偏移 0 再分批读完,重建后的状态与增量消费完全一致,5 次推进、2 次重复被忽略,最终偏移为 5。越界和零长度批次不会移动偏移。这些数字是单进程内存投影上的行为验证,不是 broker 重放吞吐。
上线判据
| 检查项 | 通过条件 | 不通过的动作 |
|---|---|---|
| 分区键 | 同一订单的事件总是同一分区 | 改键前先评估迁移与乱序 |
| 分区内并发 | 每分区单在途,或按订单键串行 | 降并发或加键级队列 |
| 版本来源 | 版本由状态机生成,不用时间戳 | 重做事件 schema,禁止回填时间戳 |
| 死信内容 | 保留 payload、尝试次数、错误原因 | 先补字段,再允许上线 |
| 重放顺序 | 先修缺陷、再重放、最后对账 | 调整运维手册并演练 |
| 对账幂等 | 重复执行不产生重复修复或副作用 | 加修复记录唯一键 |
常见错误与边界
第一,分区内开并发却不做键级串行。broker 只保证追加顺序,多个 worker 同时取同一分区,处理完成顺序由数据库延迟决定;状态回退往往在压测时才会出现。
第二,把跨分区当全局有序。依赖 order-1 的支付先于 order-2 的预占没有业务依据;如果确实需要全局顺序,就要引入单一序列器或共识,代价远高于按订单分区。
第三,版本缺口无限重试。缺前驱的事件会占住分区,后面的事件全部排队;必须设置重试预算,并把持续冲突转成告警和死信。
第四,重放不幂等。重放时重新执行扣款、发短信,而不是走版本判重;正确做法是复用同一套 Apply,让重复事件自然变成 no-op。
第五,死信只存消息 ID。没有 payload 就无法重放,没有失败原因就无法分类;死信表应该能独立支撑一次事后分析。
第六,先对账再重放。对账直接覆盖投影,会掩盖本可恢复的事件,也让死信失去意义;顺序必须是修复、重放、对账。
小结
消息顺序不是 broker 的单方面承诺,而是“分区亲和 + 分区内串行 + 版本条件应用”三者合起来的结果。版本号让乱序变成可重试的冲突,让重复变成可忽略的 no-op,也让重放有了确定的判据;死信保存无法立即处理的事件和原因,重放补上缺口,对账修复从未产生或无法重放的状态。本机测试验证了 8 个订单在 4 个分区上的亲和性、乱序三次投递后收敛到 PAID、毒消息一次进死信、缺口三次后进死信、重放 5 条事件后状态一致,以及对账补上被死信吞掉的 v2/PAID。这条闭环只保证最终收敛,不保证实时;下一章把“幂等键”单独拿出来,讨论作用域、生命周期与存储选型。
阅读导航:上一节:消费端幂等、去重表与恰好一次的真实边界 · 下一节:幂等键设计:作用域、生命周期与存储 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。