《游戏服务端架构·规模化、全球化与成本》7.1 跨服匹配与报名

《裂隙战场》跨区排位首周出现票据挂起、扩容失败与重复报名三起事故。本节把跨服报名拆成只读快照、冻结额度与有期限票据,给出赛区划分的硬软约束、票据四态机、失败补偿边界,并用可运行的 Go 夹具演示签发、锁定、消费、作废与过期扫描,解释扩容失败为何要快速失败而不是重试。

一张票据,三种失败

《裂隙战场》S3 赛季把跨区排位开放给了六个服。玩家在 s-a 报名,被分到 z-east 的跨服房间;报名时主服冻结了 300 荣誉点作为入场凭证。上线首周遇到三次不同的问题:玩家报名后退出客户端,票据在队列里挂了两小时;一场比赛凑齐十人后实例扩容失败,十张票据全部卡在“已锁定”;还有玩家连点报名按钮,同一账号进了两个队列。

三个问题指向同一个东西:报名不是一个接口调用,而是一张有状态、有有效期、能作废的票据。这一节讨论票据里放什么、赛区怎么选、失败后怎么收场。

报名带什么过去

跨服不是把角色复制一份。复制会产生两个可写副本,回流时无法判断谁是新数据。北辰的规则是:主服始终拥有角色与资产,跨服只拿到一份带期限的只读快照,外加一张可冻结额度的票据。

数据跨服侧形态可写回流方式
角色等级、段位快照否不回传,段位由结算单单独计算
装备与外观快照否不回传,跨服内修改仅本场有效
荣誉点等资产冻结额度否结算后按结果解冻或扣除
临时货币跨服本地账本是活动结束清零,不回流
战斗结果结算单是由结算链路写回主服

这张表的关键列是“可写”。只要跨服侧能写主服资产,就必须有双向同步和冲突合并;把可写面缩到“临时货币 + 结算单”,跨服就退化成一次有状态的计算,而不是第二个主服。

票据是授权的载体。它记录 player_id、主服、目标赛区、模式、冻结额度、签发时间与过期时间。跨服实例只认票据,不直接读主服库;票据过期,实例拒绝入场。这样“玩家掉线后票据还在”不会变成“幽灵角色占着房间”。

赛区怎么划,队列怎么排

赛区划分有三个可选维度,北辰三款产品各取所需:

产品主维度次维度排队目标(示例值)取舍
《裂隙战场》延迟段位P95 小于 90 秒延迟优先,段位相近的池子更小
《群岛纪元》活动分组服务器归属P95 小于 5 分钟宁可等,也要同组对抗
《泡泡工坊》延迟无P95 小于 30 秒休闲局不设段位门槛

延迟是硬约束,段位是软约束。硬约束先过滤,软约束再排序;反过来会得到一个“段位完美但 200ms”的队列,玩家用脚投票。

公平性与排队时间是一对直接冲突。把段位区间放宽 5 级,队列会快,但碾压局变多;收窄 5 级,对局质量上升,高段位玩家在凌晨可能排不到人。北辰的做法是分时段放宽:高峰保持窄区间,低峰按固定阶梯放宽,并在客户端显示“预计等待”。放宽规则写进配置,不写进代码,运营可以按天调整。

赛区路由本身很短:过滤不可达赛区、按延迟与 ID 稳定排序、取前若干作为候选。稳定排序很重要——同样输入在重试时得到同样顺序,玩家不会因为重试看到完全不同的赛区列表。

票据状态机

票据只有四个状态,转移关系固定:

             Lock()                Consume()
  issued ------------> locked ------------> consumed
    |                    |
    | Cancel()/          | Cancel()/
    | Expire()/          | FailAllocate()
    | FailAllocate()     |
    v                    v
  voided <--------------+

两条规则:

  1. consumed 与 voided 是终态,任何反向转移都返回错误,而不是“尽力修复”。
  2. 只有未消费票据能作废并释放冻结资产;已消费票据的资产已经转成跨服副本,只能走结算链路。

有效期由活动配置给出,不接受客户端传入。示例值为 15 分钟:足够覆盖排队与进场,又不会让掉线玩家长期占位。定时任务扫描到期票据,作废并按票据 ID 排序返回退款记录;重复扫描不会重复退款,因为终态票据不在扫描集合里。

失败路径与补偿

失败触发点票据动作玩家可见兜底
报名后掉线客户端断开保留至过期重连可继续排队过期自动作废退款
比赛未开人数不足/取消立即作废提示并退回冻结资产补偿活动积分(示例值 10)
扩容失败实例分配失败立即作废提示“赛场繁忙”退款 + 优先队列券
重复报名连点/多端拒绝第二张提示已有报名客户端幂等按钮
时钟偏差跨区机器时间不同以票据服务时间为准无感所有节点 NTP,过期判断单点

扩容失败最容易被写成“重试三次再说”。但玩家已经等了很久,重试只会把不确定性延长。北辰的选择是快速失败:分配失败即作废退款,同时发一张优先队列券,下一次报名排到队首。补偿要能解释,不能只说“系统错误”。

可运行片段:票据、状态机与过期扫描

internal/crossmatch 把上面的规则落成代码。crossmatch.go 负责票据签发、状态转移、作废与过期扫描;同包的 route.go 提供赛区过滤与稳定排序,文章不展开。

// file: internal/crossmatch/crossmatch.go
// tier: L1
// verify: go test ./internal/crossmatch/
package crossmatch

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

// State 是报名票据的状态。票据只能沿 Issued -> Locked -> Consumed 前进,
// 任何未消费的票据都可以作废并释放冻结资产。
type State string

const (
	Issued   State = "issued"
	Locked   State = "locked"
	Consumed State = "consumed"
	Voided   State = "voided"
)

var (
	ErrDuplicateSignup   = errors.New("crossmatch: player already has an active ticket")
	ErrTicketNotFound    = errors.New("crossmatch: ticket not found")
	ErrInvalidTransition = errors.New("crossmatch: invalid state transition")
	ErrInvalidSignup     = errors.New("crossmatch: invalid signup")
)

// Ticket 是玩家带进跨服实例的凭据。Frozen 表示主服已冻结的资产数量,
// 跨服侧只看到副本,不能直接改写主服账本。
type Ticket struct {
	ID        string
	PlayerID  string
	HomeServ  string
	ZoneID    string
	Mode      string
	State     State
	Frozen    int64
	IssuedAt  time.Time
	ExpiresAt time.Time
}

// Compensation 记录一次因作废而需要退回主服的冻结资产。
type Compensation struct {
	TicketID string
	PlayerID string
	Amount   int64
	Reason   string
}

// SignupRequest 是报名入口的完整输入。TTL 由活动配置给出,不接受客户端指定。
type SignupRequest struct {
	PlayerID string
	HomeServ string
	ZoneID   string
	Mode     string
	Frozen   int64
	TTL      time.Duration
}

// Registry 是单进程内的报名簿;生产环境换成带唯一约束的存储,判据不变。
type Registry struct {
	tickets map[string]*Ticket
	active  map[string]string
	seq     int
	now     func() time.Time
}

// NewRegistry 注入时钟,便于测试过期与作废。
func NewRegistry(now func() time.Time) *Registry {
	if now == nil {
		now = time.Now
	}
	return &Registry{
		tickets: make(map[string]*Ticket),
		active:  make(map[string]string),
		now:     now,
	}
}

// Signup 校验后签发票据并冻结资产;同一 player+mode 只允许一张有效票据。
func (r *Registry) Signup(req SignupRequest) (*Ticket, error) {
	switch {
	case req.PlayerID == "" || req.HomeServ == "" || req.ZoneID == "" || req.Mode == "":
		return nil, fmt.Errorf("%w: missing required field", ErrInvalidSignup)
	case req.Frozen < 0:
		return nil, fmt.Errorf("%w: negative frozen amount", ErrInvalidSignup)
	case req.TTL <= 0:
		return nil, fmt.Errorf("%w: ttl must be positive", ErrInvalidSignup)
	}
	key := req.PlayerID + "|" + req.Mode
	if _, ok := r.active[key]; ok {
		return nil, ErrDuplicateSignup
	}
	now := r.now()
	r.seq++
	t := &Ticket{
		ID:        fmt.Sprintf("t-%04d", r.seq),
		PlayerID:  req.PlayerID,
		HomeServ:  req.HomeServ,
		ZoneID:    req.ZoneID,
		Mode:      req.Mode,
		State:     Issued,
		Frozen:    req.Frozen,
		IssuedAt:  now,
		ExpiresAt: now.Add(req.TTL),
	}
	r.tickets[t.ID] = t
	r.active[key] = t.ID
	return t, nil
}

// transition 是唯一的状态修改入口,避免业务代码绕过状态机。
func (r *Registry) transition(t *Ticket, to State) error {
	if !CanTransition(t.State, to) {
		return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, t.State, to)
	}
	t.State = to
	return nil
}

// Lock 在匹配成功、实例已分配后调用;票据仍可因进场失败而作废。
func (r *Registry) Lock(ticketID string) error {
	t, ok := r.tickets[ticketID]
	if !ok {
		return ErrTicketNotFound
	}
	return r.transition(t, Locked)
}

// Consume 在玩家成功进入跨服实例后调用,冻结资产转为跨服副本。
func (r *Registry) Consume(ticketID string) error {
	t, ok := r.tickets[ticketID]
	if !ok {
		return ErrTicketNotFound
	}
	if err := r.transition(t, Consumed); err != nil {
		return err
	}
	delete(r.active, t.PlayerID+"|"+t.Mode)
	return nil
}

// Cancel 处理玩家主动取消;未消费票据作废并生成资产退回记录。
func (r *Registry) Cancel(ticketID, reason string) (Compensation, error) {
	return r.void(ticketID, reason)
}

// FailAllocate 处理实例扩容失败:票据作废,冻结资产原路退回。
func (r *Registry) FailAllocate(ticketID string) (Compensation, error) {
	return r.void(ticketID, "allocate_failed")
}

func (r *Registry) void(ticketID, reason string) (Compensation, error) {
	t, ok := r.tickets[ticketID]
	if !ok {
		return Compensation{}, ErrTicketNotFound
	}
	if err := r.transition(t, Voided); err != nil {
		return Compensation{}, err
	}
	delete(r.active, t.PlayerID+"|"+t.Mode)
	return Compensation{TicketID: t.ID, PlayerID: t.PlayerID, Amount: t.Frozen, Reason: reason}, nil
}

// SweepExpired 作废所有到期未消费票据,按票据 ID 排序返回退回记录。
// 生产环境由定时任务驱动,重跑时已作废票据不会重复退回。
func (r *Registry) SweepExpired() []Compensation {
	now := r.now()
	var expired []*Ticket
	for _, t := range r.tickets {
		if t.State != Issued && t.State != Locked {
			continue
		}
		if !now.Before(t.ExpiresAt) {
			expired = append(expired, t)
		}
	}
	sort.Slice(expired, func(i, j int) bool { return expired[i].ID < expired[j].ID })
	out := make([]Compensation, 0, len(expired))
	for _, t := range expired {
		c, err := r.void(t.ID, "expired")
		if err == nil {
			out = append(out, c)
		}
	}
	return out
}

// CanTransition 是票据状态机的唯一真值表;消费与作废都是终态。
func CanTransition(from, to State) bool {
	switch from {
	case Issued:
		return to == Locked || to == Voided
	case Locked:
		return to == Consumed || to == Voided
	default:
		return false
	}
}

Signup 先做参数校验,再用 player|mode 作为唯一键拒绝重复报名。transition 是唯一的状态写入口,业务代码无法绕过真值表。void 同时做三件事:推进到 voided、从活跃索引删除、生成退款记录;三者在一个函数里,避免“状态改了但资产没退”。

本机验证命令与结果:

cd .local/checks/gsarch-lab
gofmt -l internal/crossmatch
go vet ./internal/crossmatch/
go test -count=1 ./internal/crossmatch/
ok  	gsarchlab/internal/crossmatch

gofmt -l 无输出,go vet 退出码 0,测试退出码 0。该包有 6 个表驱动测试,覆盖赛区准入、路由排序、报名校验、状态真值表、取消/扩容失败补偿与过期扫描。

小结

报名票据要回答三个问题:带什么过去、什么时候失效、失败后怎么退。北辰的答案是只带只读快照与冻结额度,票据有明确 TTL,任何未消费票据都能作废退款。赛区选择先硬约束后软约束,公平性通过分时段放宽调节,而不是牺牲延迟。状态机只有四个状态,终态不可逆;扩容失败快速失败并补偿,不把玩家留在重试里。

常见错误有四个:把角色整份复制到跨服并允许双写;票据有效期由客户端决定;重复报名靠数据库唯一键兜底却没有友好提示;扩容失败无限重试。前两个会制造资产冲突,后两个会把技术故障拖成舆情。

下一节讨论票据消费之后的事:跨服结果如何写回主服,带回的奖励受什么限制,丢包与重复回传如何收敛。

阅读导航:上一节:6.3 对账、补偿与定时任务 · 下一节:7.2 资产归属与跨服结算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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