一、通信模式决定失败责任
同一句“下单成功”,在同步调用和异步事件里含义不同。同步返回成功,表示下游已经处理完;异步返回成功,只表示事件已经交给 Broker,玩家余额可能还没变。通信模式选错,后面所有重试、补偿、告警都会跟着错位。
游戏服务端常见的争论是“要不要上消息队列”。问题不是队列本身,而是调用方是否必须立刻拿到结果,以及失败出现时由谁负责把状态修好。下面先把四种形态拆开,再讨论怎么选。
二、四种通信形态
同步请求-响应。 支付扣款、进入战斗前校验角色归属都属于这一类。调用方阻塞等待下游返回;超时只说明结果未知,不能直接判定失败。接口契约通常由服务方维护,调用方需要设置独立于上游的超时预算。
单向事件。 登录成功广播、成就解锁、日志投递属于这一类。生产者发布后不关心谁消费,也不等待结果。失败处理落在消费端:重试、幂等、死信和对账,缺一不可。生产端只能保证“事件已写出”,不能保证“业务已生效”。
请求-响应 over 消息。 跨进程查询背包、跨服查询角色常用这种写法。请求和回复各是一条消息,用 correlation ID 关联。传输是异步的,语义仍是同步的:调用方必须维护 pending 表、超时和迟到的回复清理,否则内存里会堆积永远等不到的回调。
编排与协同。 编排(orchestration)由中心服务按步骤调用各服务,失败时由中心决定重试、跳过还是补偿。协同(choreography)没有中心,各服务监听事件自行推进。编排的流程集中、日志完整,但中心容易变厚;协同耦合低,流程却散落在多个消费者里,排障必须靠 trace 串起来。
三、失败语义对照
| 形态 | 耦合 | 延迟 | 可观测性 | 事务性 | 失败责任 |
|---|---|---|---|---|---|
| 同步请求-响应 | 调用方依赖下游接口 | 一次网络往返 | 调用链清晰 | 下游提交后才算成功 | 调用方处理超时与降级 |
| 单向事件 | 只依赖事件契约 | 生产端低,消费端异步 | 需要消息轨迹 | 最终一致 | 消费方重试与幂等 |
| 消息上的请求-响应 | 依赖请求与回复两个主题 | 两次消息跳转 | 需要 correlation ID | 调用方仍需处理未知结果 | 双方共同排障 |
| 编排 | 中心知道所有步骤 | 取决于最长步骤 | 中心日志完整 | 中心负责补偿 | 编排器 |
| 协同 | 各服务只认事件 | 链路长短不一 | 分散,需要 trace 串联 | 各服务自行保证 | 没有唯一负责人 |
表里最容易被忽略的是“失败责任”一列。同步调用超时后,调用方如果直接重试,责任就落在自己身上;异步事件消费失败后,如果只记录日志不告警,责任同样在自己身上。通信模式只是把责任放到某个位置,不会让责任消失。
四、选择判据
- 调用方是否需要立即结果:需要就选同步或消息上的请求-响应;不需要才选单向事件。
- 下游是否可独立演进:事件契约稳定时异步更合适;接口频繁变化时同步调用更直接。
- 失败能否人工兜底:不能人工兜底的关键路径要同步确认;可对账、可补单的流程适合异步。
- 延迟预算是否明确:同步链路的总超时必须小于上游超时;异步要给出业务可接受的延迟上限。
- 重复执行是否安全:下游没有幂等能力时,先补幂等键,再谈异步。
一个实用口径是“钱和资格同步,通知和统计异步”。充值、发货、进入副本资格需要立刻确定;邮件提醒、战绩统计、运营埋点可以晚几秒。把这条线划清楚,比抽象地争论同步还是异步更有用。
五、失败场景分析
用异步掩盖同步依赖。 充值到账后客户端要立刻刷新余额,服务端却只发布事件就返回成功。玩家看不到钻石,重复点击支付,最终可能重复扣款。正确做法是同步确认入账,或者明确返回“处理中”并禁止同一订单再次下单。
把超时当失败直接重试。 下游已经扣款,只是响应在网络上丢了。调用方重试后同一订单被发放两次。修复不能只加重试次数,而要用订单号做幂等,并把超时订单记入待对账表,由对账任务确认后再决定补发还是收尾。
事件重复。 Broker 通常至少投递一次,网络抖动、消费者重启都会带来重复。只按消息 ID 去重挡不住“同一订单换了事件号重发”,业务幂等键必须落在订单、玩家道具这类聚合上。
事件里塞大对象。 背包快照、战斗回放整段塞进事件,Broker 存储和网络成本上升,消费者还要处理多个版本的结构。事件只放聚合 ID、必要变更和版本号,大对象让消费者按 ID 回查。
没有补偿路径。 消费失败进死信后无人处理,玩家永远少一件道具。死信必须可查询、可重放,并且有告警;只把错误写进日志不算补偿。
六、Go 实现:同一笔订单的两条路径
pattern.go 把同一笔充值订单写成两条路径。同步路径 SyncService.Purchase 带超时,超时后把订单记入 UncertainLog 并返回 degraded;异步路径 AsyncService.Purchase 只发布事件,Handle 负责消费。两条路径共用 Wallet,它按订单号幂等,重复事件不会二次加钻。
// file: ch11/pattern/pattern.go
// tier: L1
// verify: go test ./ch11/pattern/
// Package pattern 对照同一笔充值订单的同步调用与异步事件两种实现。
package pattern
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
var (
ErrEmptyOrderID = errors.New("pattern: empty order id")
ErrEmptyProduct = errors.New("pattern: empty product id")
ErrNoPlayer = errors.New("pattern: missing player")
ErrBadAmount = errors.New("pattern: non-positive diamonds or amount")
)
// Order 是一笔充值订单。OrderID 由支付侧生成,同时作为业务幂等键。
type Order struct {
OrderID string
PlayerID uint64
ProductID string
Diamonds int64
AmountFen int64
}
func (o Order) Validate() error {
switch {
case o.OrderID == "":
return ErrEmptyOrderID
case o.PlayerID == 0:
return ErrNoPlayer
case o.ProductID == "":
return ErrEmptyProduct
case o.Diamonds <= 0 || o.AmountFen <= 0:
return ErrBadAmount
}
return nil
}
// Receipt 是一次发放的回执。Applied 为 false 表示这笔订单此前已经发过。
type Receipt struct {
OrderID string
PlayerID uint64
Diamonds int64
Balance int64
Applied bool
}
// Wallet 用订单号做幂等,重复 Apply 不会增加余额。
type Wallet struct {
mu sync.Mutex
balances map[uint64]int64
receipts map[string]Receipt
}
func NewWallet() *Wallet {
return &Wallet{balances: make(map[uint64]int64), receipts: make(map[string]Receipt)}
}
func (w *Wallet) Balance(playerID uint64) int64 {
w.mu.Lock()
defer w.mu.Unlock()
return w.balances[playerID]
}
// Apply 首次入账返回 Applied=true,重复订单返回历史回执与 Applied=false。
func (w *Wallet) Apply(o Order) (Receipt, error) {
if err := o.Validate(); err != nil {
return Receipt{}, err
}
w.mu.Lock()
defer w.mu.Unlock()
if r, ok := w.receipts[o.OrderID]; ok {
r.Applied = false
return r, nil
}
w.balances[o.PlayerID] += o.Diamonds
r := Receipt{OrderID: o.OrderID, PlayerID: o.PlayerID, Diamonds: o.Diamonds, Balance: w.balances[o.PlayerID], Applied: true}
w.receipts[o.OrderID] = r
return r, nil
}
// Backend 是支付/发货下游的最小接口,Status 用于超时后的对账。
type Backend interface {
Grant(ctx context.Context, o Order) (Receipt, error)
Status(ctx context.Context, orderID string) (Receipt, bool, error)
}
type SyncStatus string
const (
SyncApplied SyncStatus = "applied"
SyncDegraded SyncStatus = "degraded"
SyncRejected SyncStatus = "rejected"
)
type SyncResult struct {
Status SyncStatus
Receipt Receipt
Err error
}
// UncertainLog 保存“已经发出、但没拿到确定结果”的订单。
type UncertainLog struct {
mu sync.Mutex
orders map[string]Order
}
func NewUncertainLog() *UncertainLog {
return &UncertainLog{orders: make(map[string]Order)}
}
func (u *UncertainLog) Add(o Order) {
u.mu.Lock()
defer u.mu.Unlock()
u.orders[o.OrderID] = o
}
func (u *UncertainLog) Remove(orderID string) {
u.mu.Lock()
defer u.mu.Unlock()
delete(u.orders, orderID)
}
func (u *UncertainLog) Pending() []Order {
u.mu.Lock()
defer u.mu.Unlock()
out := make([]Order, 0, len(u.orders))
for _, o := range u.orders {
out = append(out, o)
}
return out
}
// SyncService 走同步请求-响应:调用方等待下游结果,超时进入待对账状态。
type SyncService struct {
backend Backend
timeout time.Duration
uncertain *UncertainLog
}
func NewSyncService(b Backend, timeout time.Duration, log *UncertainLog) *SyncService {
if log == nil {
log = NewUncertainLog()
}
return &SyncService{backend: b, timeout: timeout, uncertain: log}
}
// Purchase 发起同步发放。超时不等于失败,只表示结果未知。
func (s *SyncService) Purchase(ctx context.Context, o Order) SyncResult {
if err := o.Validate(); err != nil {
return SyncResult{Status: SyncRejected, Err: err}
}
ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
r, err := s.backend.Grant(ctx, o)
if err == nil {
s.uncertain.Remove(o.OrderID)
return SyncResult{Status: SyncApplied, Receipt: r}
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
s.uncertain.Add(o)
return SyncResult{Status: SyncDegraded, Err: err}
}
return SyncResult{Status: SyncRejected, Err: err}
}
// Event 是异步路径上的领域事件。EventID 用于传输层去重,OrderID 用于业务幂等。
type Event struct {
EventID string
Type string
Order Order
At time.Time
}
// Sink 是事件出口,生产环境通常由消息队列实现。
type Sink interface {
Publish(Event) error
}
type AsyncStats struct {
Published int
Applied int
Duplicates int
Rejected int
}
// AsyncService 先发布事件再异步入账,调用方拿到的是“已受理”而不是“已到账”。
type AsyncService struct {
wallet *Wallet
sink Sink
mu sync.Mutex
seen map[string]struct{}
stats AsyncStats
}
func NewAsyncService(w *Wallet, sink Sink) *AsyncService {
return &AsyncService{wallet: w, sink: sink, seen: make(map[string]struct{})}
}
// Purchase 校验订单并发布事件;本地余额此时不会变化。
func (a *AsyncService) Purchase(ctx context.Context, o Order) (Event, error) {
if err := ctx.Err(); err != nil {
return Event{}, err
}
if err := o.Validate(); err != nil {
return Event{}, err
}
ev := Event{
EventID: fmt.Sprintf("evt-%s-%d", o.OrderID, time.Now().UnixNano()),
Type: "order.paid",
Order: o,
At: time.Now(),
}
if err := a.sink.Publish(ev); err != nil {
return Event{}, err
}
a.mu.Lock()
a.stats.Published++
a.mu.Unlock()
return ev, nil
}
// Handle 消费事件。事件号去重挡重复投递,账本订单号幂等挡语义重复。
func (a *AsyncService) Handle(ctx context.Context, ev Event) error {
if err := ctx.Err(); err != nil {
return err
}
a.mu.Lock()
defer a.mu.Unlock()
if _, ok := a.seen[ev.EventID]; ok {
a.stats.Duplicates++
return nil
}
r, err := a.wallet.Apply(ev.Order)
if err != nil {
a.stats.Rejected++
return err
}
a.seen[ev.EventID] = struct{}{}
if r.Applied {
a.stats.Applied++
} else {
a.stats.Duplicates++
}
return nil
}
func (a *AsyncService) Stats() AsyncStats {
a.mu.Lock()
defer a.mu.Unlock()
return a.stats
}
代码里有两个刻意的设计。第一,同步超时没有被当成失败,SyncDegraded 与 SyncRejected 分开,调用方可以决定“查一下再补”而不是盲目重试。第二,异步消费同时做事件号去重和订单号幂等:前者挡住同一条消息重投,后者挡住语义重复。两层都保留,是因为它们防的不是同一种问题。
七、测试覆盖
| 测试 | 断言 |
|---|---|
| TestSyncPurchaseApplied | 正常同步入账,余额与回执一致 |
| TestSyncTimeoutDegradesWithoutAssumingFailure | 超时返回 degraded,订单进入待对账,余额不变 |
| TestSyncInvalidOrderRejected | 非法订单不触达下游 |
| TestAsyncEventualConsistency | 发布后余额不变,消费后到账 |
| TestAsyncDuplicateEventsAppliedOnce | 同消息重投与同订单新事件号都只入账一次 |
| TestAsyncPublishFailureLeavesBalanceUntouched | 发布失败不产生本地余额 |
测试不依赖真实 Broker:超时用 10 毫秒的上下文触发,重复投递直接调用 Handle 模拟,发布失败用返回错误的假 Sink 模拟。这样失败路径可以稳定复现,不会因为网络快慢偶发通过。
八、运行验证
cd .local/checks/gs-practice-lab && gofmt -l ch11/pattern && go vet ./ch11/pattern/ && go test ./ch11/pattern/ -count=1
ok gspractice/ch11/pattern 0.272s
gofmt -l 没有输出,说明格式化一致;go vet 与测试均通过。示例中的耗时是本次运行值,换机器会变化。
小结
同步与异步不是性能选项,而是失败语义的选择。同步调用要区分“确定失败”和“结果未知”,超时订单必须进入对账;异步事件要接受最终一致,并用业务键做幂等。钱和资格走同步确认,通知和统计走异步,是游戏服务端比较稳妥的默认分工。
常见错误与边界
- 异步发布成功就向客户端返回“已到账”,实际消费可能还没开始。
- 同步超时后直接重试,没有订单号幂等,造成重复发货。
- 只按消息 ID 去重,同一订单换事件号后仍然重复入账。
- 把超时、下游 5xx、业务拒绝都归为同一种失败,降级策略无法区分。
- 事件里塞完整快照,消费者被大对象和版本兼容拖住。
- 待对账表只写不读,超时订单永远悬空。
- 同步链路层层叠加超时,最外层先超时,内层还在继续执行。
- 关键路径没有人工补单入口,死信积压后只能改库。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。