《游戏服务端编程实践》11.4 微服务间通信模式(同步/异步)

对照同步请求-响应、单向事件、消息请求-响应与编排/协同四种通信形态的失败语义,用 Go 实现同一笔充值订单的同步调用与异步事件两条路径,并测试同步超时降级、异步最终一致与重复事件幂等,给出选型判据和常见错误清单,帮助读者判断何时该等待结果、何时该接受最终一致。

一、通信模式决定失败责任

同一句“下单成功”,在同步调用和异步事件里含义不同。同步返回成功,表示下游已经处理完;异步返回成功,只表示事件已经交给 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、业务拒绝都归为同一种失败,降级策略无法区分。
  • 事件里塞完整快照,消费者被大对象和版本兼容拖住。
  • 待对账表只写不读,超时订单永远悬空。
  • 同步链路层层叠加超时,最外层先超时,内层还在继续执行。
  • 关键路径没有人工补单入口,死信积压后只能改库。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist