一张票据,三种失败
《裂隙战场》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 <--------------+
两条规则:
consumed与voided是终态,任何反向转移都返回错误,而不是“尽力修复”。- 只有未消费票据能作废并释放冻结资产;已消费票据的资产已经转成跨服副本,只能走结算链路。
有效期由活动配置给出,不接受客户端传入。示例值为 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 资产归属与跨服结算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。