Skip to content

10 玩法系统设计 ​

本章目标:掌握排行榜、活动、跨服、合服、滚服、战斗、背包、邮件、交易九类核心玩法系统的设计要点——每个系统从"它解决什么问题"出发,分析典型方案、引入的新问题与常见陷阱。

参考书籍:《游戏编程模式》(Robert Nystrom)的状态模式与组件模式、《百万在线》的大规模玩法系统实践、《游戏数据分析的艺术》(于洋等)的运营数据分析视角。

游戏玩法系统是玩家体验的核心骨架。如果说架构是游戏的"骨骼",那么玩法系统就是"肌肉"——它决定了玩家每一次点击、每一次战斗、每一次交易的具体感受。一个设计良好的玩法系统,不仅要能正确运行,还要能应对高并发、防止作弊、支持热更新,并且在不同游戏类型(塔防、挂机、MMO、链游、卡牌)之间保持一致的设计哲学。

核心原则:玩法系统的设计不是"怎么实现"的问题,而是"为什么这样设计"的问题。理解了"为什么",代码只是水到渠成的产物。

0. 玩法系统的三条共性约束 ​

九个系统看似各自独立,但它们共享同一组底层约束。写任何一个玩法系统之前,先确认这三条能满足:

约束含义违反后的典型事故
数据可结算玩法产出最终必须落到资产变更(货币/物品/数值),且变更可追溯活动奖励发放后无法对账,出现"幽灵发奖"
状态可恢复玩法进行中的状态(活动进度、副本层数、匹配等待)在进程重启后不丢或可重建服务器凌晨重启,全服活动进度清零
行为可审计争议发生时能回放"玩家当时做了什么、系统判了什么"玩家投诉排名被顶掉,运营无法给出证据

这三条约束直接对应前面章节的设施:货币流水表(第 2 章)、单写者与状态快照(第 5 章)、审计日志(第 2 章)。玩法系统设计的大部分工作,不是发明新机制,而是把玩法规则正确地安放到这些既有设施上。


1. 排行榜系统 ​

1.1 为什么需要排行榜 ​

排行榜是游戏社交性的基石。它解决了一个根本问题:如何让玩家产生持续追求的动力。当玩家看到自己排名从第 1000 名上升到第 500 名,这种进步感比任何数值奖励都更持久。

但排行榜的设计远不止"排序"这么简单。它需要回答几个关键问题:更新频率如何?同分怎么排名?赛季重置怎么处理?跨服数据如何汇总?

《百万在线》中特别指出:排行榜是玩家打开游戏后最先查看的界面之一,它的加载速度直接影响玩家的第一印象。一个卡顿的排行榜可能让玩家在 3 秒内关闭游戏。

1.2 技术选型 ​

类型数据特点更新频率可选方案适用场景
实时排行榜分数实时变化高(秒级)Redis Sorted Set竞技场、实时对战
延迟排行榜定时刷新低(分钟级)MySQL + Redis 缓存战力榜、成就榜
赛季排行榜赛季重置中分 key + 归档赛季竞技、天梯
跨服排行榜多服数据汇总中跨服服务 + Redis全服排名、公会战

Redis Sorted Set 是实时排行榜的常见首选,原因有三:O(log N) 的插入和查询复杂度、原生支持范围查询(ZREVRANGE)、内置去重(同一成员只保留一个分数)。

1.3 核心操作 ​

排行榜的核心操作只有三个:更新分数、查询排名、获取榜单及自身周边。理解了这三个操作,就掌握了排行榜大部分技术要点。

go
const rankKey = "rank:arena"

// 更新分数 - O(log N)。无变化时跳过,避免无效写放大
func (m *RankManager) UpdateScore(playerID uint64, score int64) error {
    old, err := m.redis.ZScore(m.ctx, rankKey, member(playerID)).Result()
    if err == nil && int64(old) == score {
        return nil // 分数未变,不写
    }
    return m.redis.ZAdd(m.ctx, rankKey, &redis.Z{
        Score:  float64(score),
        Member: member(playerID),
    }).Err()
}

// 查询自己的排名与周边玩家(榜上比我高 2 名、低 2 名)
func (m *RankManager) GetAround(playerID uint64) (rank int64, neighbors []uint64, err error) {
    r, err := m.redis.ZRevRank(m.ctx, rankKey, member(playerID)).Result()
    if err != nil {
        return 0, nil, err // 不在榜上
    }
    rank = r + 1 // Redis 排名从 0 开始
    start := max(r-2, 0)
    ids, err := m.redis.ZRevRange(m.ctx, rankKey, start, r+2).Result()
    if err != nil {
        return 0, nil, err
    }
    for _, id := range ids {
        n, _ := strconv.ParseUint(id, 10, 64)
        neighbors = append(neighbors, n)
    }
    return rank, neighbors, nil
}

"查自己的排名 + 周边"是玩家最频繁的查询模式——玩家关心的是"我和前面一名差多少",而不是"第一名是谁"。周边查询用 ZREVRANK 定位再 ZREVRANGE 取窗口,两次 O(log N) 操作即可,不需要把整个榜拉回来。

1.4 同分与唯一名次 ​

Sorted Set 内部按 (score, member 字典序) 排序,所以同分玩家的先后由 member 值决定——通常是玩家 ID,于是"ID 大的排前面"就成了隐含规则。这在结算发奖时会引起争议:同分凭什么他排我前面?

常见的处理方式是把"到达时间"编进分数里,实现同分先到先得:

go
const tsBits = 21 // 时间片位数

// 复合分数:高 32 位存真实分数,低 21 位存"取反后的分钟时间片"。
// 32 + 21 = 53,恰好落在 float64 尾数精度内,整型无损转换。
// 时间片取反后"越晚越小"——同分时先达到的玩家排前面。
// 适用前提:分数不超过 2^32(约 43 亿),超出时需要重新设计编码。
func compositeScore(score int64, reachedAt time.Time) float64 {
    minute := reachedAt.Unix() / 60 // 分钟精度,21 位约 4 年一循环
    ts := uint64(^minute) & ((1 << tsBits) - 1)
    return float64(uint64(score)<<tsBits | ts)
}

func realScore(composite float64) int64 {
    return int64(composite) >> tsBits // 值转换还原真实分数
}

需要注意 float64 的精度上限:float64 的尾数只有 53 位,64 位整数直接转 float64 会丢失低位——所以时间片压缩到 21 位(分钟粒度)来凑足预算。如果分数需要超过 2^32,位预算不够,就得改用 Lua 脚本在 Redis 内部处理,或放弃 Sorted Set 改用数据库方案。

赛博竞技类游戏还有一种更简单的规则:允许并列名次。同分玩家取相同名次(如并列第 5),发奖按并列处理。这把技术问题转成了策划规则问题——很多产品实际选择了这条路,因为它对玩家更友好。

1.5 赛季与重置 ​

赛季排行榜的关键决策是旧数据去哪里。直接 DEL 掉排行榜 key 会带来两个问题:结算瞬间玩家查不到自己的历史成绩;出了争议无法回溯。

常见的做法是按赛季分 key + 归档:

  • 进行中的赛季用 rank:arena:s3(s3 为赛季号),赛季切换时新数据自然写入 s4,旧 key 保留一段时间
  • 结算程序在切换前后各快照一次 Top N,连同奖励发放流水写入归档表
  • 旧 key 过期后,历史查询走归档表

结算本身需要幂等:结算程序崩溃重跑时,不能给玩家重复发奖。和第 2 章的货币流水一样,用"赛季号 + 玩家 ID"作为发奖的幂等键。

1.6 跨服排行榜 ​

跨服排行榜是排行榜系统中最复杂的部分。核心挑战在于:如何在分布式环境下高效汇总多服数据。

常见的错误做法是"全量同步"——把所有服务器的数据同步到一个中心 Redis。这在小规模时可行,但当服务器数量超过 10 个、每个服有 10 万玩家时,中心 Redis 会成为瓶颈,而且任何一个服的写入波动都会影响全局。

合理的做法是分层聚合:每服维护本地排行榜,跨服服务只存储各服上报的 Top N(如每服前 100 名)。查询全局排名时,先在聚合榜中筛选;玩家的全局名次由"聚合榜中排在我前面的玩家数 + 我所在服中排在我前面的玩家数"推算。只有 Top N 之外的玩家查全局排名时才提示"未进入展示范围"——绝大多数玩家并不需要精确的全局名次。

陷阱:跨服排行榜的更新延迟通常在 5-30 秒。如果策划要求"实时跨服排名",需要和他们沟通这个技术限制,而不是硬扛性能开销。

1.7 防刷 ​

排行榜是刷分重灾区,因为排名直接关联奖励。常见防线:

  • 来源校验:分数只能由战斗结算等特定逻辑产生,不开放通用"加分"接口
  • 增量上限:单次结算的分数变化设上限(一场比赛最多涨多少分),异常大额变化进审计队列
  • 对局有效性:PVP 分数结算要求对手满足最低条件(等级差、场次),防"小号喂分"
  • 只升不降的榜(如最高战力榜):天然抗刷但仍需防"异常跳变"

2. 活动系统 ​

2.1 活动系统的本质 ​

活动系统是游戏运营的核心工具。它的本质是时间驱动的状态机——在特定时间窗口内,改变游戏规则、发放奖励、引导玩家行为。

一个活动从策划提出到玩家参与,经历四个阶段:预告期(制造期待)、进行期(引导行为)、结算期(发放奖励)、回顾期(复盘优化)。每个阶段都有不同的技术需求:预告期需要推送通知,进行期需要实时统计,结算期需要批量计算,回顾期需要数据分析。

2.2 活动状态机 ​

活动的状态流转看似简单,但实际运营中充满了边界情况:活动时间修改、临时下线、奖励追加、数据回滚。这些"异常"才是活动系统设计的真正挑战。

text
未开始 ──→ 进行中 ──→ 已结束 ──→ 已归档
   │          │          │
   │          ├──→ 暂停中(紧急修复,恢复后继续计时或延长)
   │          └──→ 回滚中(数据异常,回退进度重新计算)
   └──────────────→ 延期中(运营需求,平移时间窗口)

状态机要写成纯函数式判定:给定(当前时间,活动配置,玩家数据)→ 确定状态。避免把状态存成可变字段——运营改时间后,存下来的旧状态和新配置可能矛盾。每次判定都重算,状态只是推导结果。

2.3 配置结构与热更新 ​

活动系统最重要的技术能力是热更新——不停服修改活动配置。这在运营中极其常见:策划发现某个活动奖励太慷慨,需要紧急调整概率;或者某个活动参与率太低,需要追加奖励。

热更新的核心是配置与逻辑分离:活动逻辑固定在代码中,活动参数(时间、奖励、概率)存储在配置中心。修改参数时,只需要更新配置,不需要重新部署。

go
// 活动配置 - 由运营平台下发,可热更
type ActivityConfig struct {
    ID        int32
    Type      string    // "login_reward" / "rank_race" / "limited_shop"
    StartAt   int64     // 服务器时间戳
    EndAt     int64
    Rewards   []Reward  // 奖励内容
    Condition string    // 参与条件表达式,如 "level>=20"
    Version   int32     // 配置版本号,变更时递增
}

// 活动管理器 - 按周期扫描状态转换
func (m *ActivityManager) checkActivities(now time.Time) {
    for _, cfg := range m.configs.Snapshot() { // 读配置快照,避免遍历时热更
        switch {
        case now.Unix() < cfg.StartAt:
            // 未开始:预告期内可推送提醒
        case now.Unix() <= cfg.EndAt:
            m.onActivityActive(cfg)
        default:
            m.settle(cfg) // 触发结算(幂等)
        }
    }
}

配置下发后需要版本号与生效确认:服务端加载新配置后向配置中心回报版本,运营平台看到全部服务器确认才算发布成功。否则"配置中心已更新、部分服务器还在跑旧配置"的灰度不一致,会造成各服活动时间不同步。

2.4 时间基准与开服活动 ​

活动时间有两个基准问题:

  • 统一用服务器时间。客户端本地时间不可信(玩家可改手机时间),活动判定一律以服务器时间为准,客户端只做展示。下发活动信息时把服务器当前时间一起带上,客户端据此校准倒计时显示。
  • 开服活动用开服时间而非自然时间。"开服 7 天签到"的"第 1 天"应该从本服开放时刻起算。这类活动的配置需要支持"相对锚点"(开服时间 + N 小时),而不仅是绝对时间戳。

跨时区运营(如欧美服)还要决定活动是否按当地时间开启。常见做法是按运营时区的当地时间配置(美服晚上 8 点开的活动,配置为美服时区),配置中心下发时换算成 UTC 存储,服务端运行时统一用 UTC 判定。

2.5 玩家活动数据与结算幂等 ​

活动是全局的,但玩家的参与进度是每人的。玩家维度活动数据的设计要点:

go
// 玩家活动数据 - 记录参与进度与领奖状态
type PlayerActivity struct {
    PlayerID   uint64
    ActivityID int32
    Progress   map[string]int64 // 任务进度,如 "kill_count": 120
    Claimed    map[int32]bool   // 已领取的奖励档位(幂等标记)
    Version    int32            // 乐观锁版本
}

// 领取奖励 - 幂等:重复请求返回同一结果而不是报错
func (s *ActivityService) Claim(p *PlayerActivity, cfg *ActivityConfig, tier int32) (*Reward, error) {
    if p.Claimed[tier] {
        return nil, ErrAlreadyClaimed
    }
    if !s.conditionMet(p, cfg, tier) {
        return nil, ErrConditionNotMet
    }
    p.Claimed[tier] = true // 先置位再发奖,配合流水表保证可追溯
    reward := cfg.Rewards[tier]
    return &reward, s.grantWithLog(p.PlayerID, reward, "activity:"+fmt.Sprint(cfg.ID))
}

两个细节值得强调:

  • 先标记后发奖(或放入同一事务)。反过来"先发奖后标记"在发奖成功、标记失败时会重复发放;配合第 2 章的货币流水(RefID 幂等键),即使两次执行也只会入账一次。
  • 活动结束后的补发。玩家在结算时刻不在线、或结算后发现部分玩家漏发,运营需要补发能力。补发走邮件系统(见第 8 节),同样带幂等键。

2.6 不同游戏类型的活动差异 ​

游戏类型活动频率活动复杂度技术重点
卡牌手游高(每周 1-2 个)中抽卡概率、限时卡池
MMO中(每月 2-3 个)高跨服活动、大规模战斗
挂机游戏低(每月 1 个)低离线收益、定时重置
塔防游戏中(每两周 1 个)中限时关卡、排行榜竞争
链游低(每月 1 个)高链上确权、代币经济

陷阱:链游的活动设计需要注意"链上确认时间"——以太坊的区块确认约需 12 秒,如果活动涉及链上操作,玩家体验会明显延迟。常见做法是将高频操作放在链下,只在关键节点(如奖励确权)上链。


3. 跨服系统 ​

3.1 为什么需要跨服 ​

跨服系统解决的核心问题是玩家基数与匹配质量的矛盾。单服玩家数量有限,导致匹配等待时间长、对局质量低。跨服系统将多个服务器的玩家汇聚在一起,大幅提升匹配效率。

但跨服也带来了巨大的技术挑战:数据一致性、网络延迟、状态归属。《百万在线》中指出,跨服系统的设计本质上是在"玩家体验"和"技术复杂度"之间寻找平衡点。

3.2 数据归属:快照进,结算回 ​

跨服系统最重要的架构决策是数据归属:跨服玩法期间,玩家的数据归谁管?

常见的可靠模式是"快照进、结算回":

  1. 快照进:玩家进入跨服玩法时,本服把参赛数据(阵容、战力、装备)打包成只读快照发给跨服服。跨服期间本服的数值变化不影响快照。
  2. 跨服服自治:对局、积分、临时状态全部在跨服服内存与存储中完成,跨服服不回查本服——这避免了跨服实时依赖,也让跨服服可以独立扩缩容。
  3. 结算回:玩法结束后,跨服服把结果(名次、奖励)通过可靠消息队列发回各本服,由本服执行发奖。跨服服不直接修改本服数据。

这样设计后,故障域是清晰的:跨服服宕机只影响跨服玩法本身,本服数据永远不会因为跨服故障而处于中间态。奖励发放复用本服既有的幂等发放逻辑,跨服消息重复投递也不会重复入账。

快照的时效性需要与策划对齐:跨服玩法期间玩家换了装备,快照不变——这通常是合理的(比赛报名后换装不影响已提交的参赛阵容),但需要产品明确确认。

3.3 跨服匹配 ​

跨服匹配的关键是匹配池设计——如何高效地从数万玩家中找到水平相近的对手。

核心思想是动态扩展评分范围:玩家等待时间越长,匹配范围越大。这保证了"等待时间"和"匹配质量"之间的平衡。

go
// 匹配队列 - 动态扩展评分范围
func (q *MatchQueue) TryMatch() []*MatchRequest {
    q.mu.Lock()
    defer q.mu.Unlock()

    now := time.Now()
    for i := 0; i < len(q.players); i++ {
        p1 := q.players[i]
        // 范围 = 初始范围 + 等待时间(秒) × 扩展速率
        range1 := q.ratingRange + now.Sub(p1.QueueTime).Seconds()*q.expandRate

        for j := i + 1; j < len(q.players); j++ {
            p2 := q.players[j]
            effectiveRange := math.Min(range1,
                q.ratingRange+now.Sub(p2.QueueTime).Seconds()*q.expandRate)

            if math.Abs(p1.Rating-p2.Rating) <= effectiveRange &&
                p1.GameMode == p2.GameMode && p1.Region == p2.Region {
                return []*MatchRequest{p1, p2} // 匹配成功
            }
        }
    }
    return nil
}

O(N²) 的两两比较在万人池里会成为瓶颈。规模上来后需要按分段分桶(rating 区间建桶,先同桶匹配,超时后放宽到相邻桶),或引入真正的撮合服务。分段分桶还带来一个额外好处:匹配逻辑天然按 region/mode 隔离,多 zone 部署时互不干扰。

3.4 跨服公会战的设计要点 ​

公会战是跨服系统中最复杂的玩法。它需要解决几个核心问题:战场分配(哪台服务器承载战斗)、状态同步(如何保证所有玩家看到一致的战场)、奖励结算(如何跨服发放奖励)。

  • 战场分配:优先选择参战公会人数较多的服务器作为战场,减少跨服数据传输。如果双方人数接近,选择负载最低的服务器。
  • 状态同步:采用"服务端权威 + 客户端预测"模式。服务端计算所有战斗逻辑,客户端做本地预测和表现。同步模型的完整取舍见知识库"同步、战斗与实时交互"一章。
  • 奖励结算:走第 3.2 节的"结算回"链路,战场服只产出结果,发奖落回本服。

陷阱:跨服公会战的最大坑是"战场服务器宕机"——如果承载战斗的服务器挂了,所有参战玩家都会掉线。解决方案是战场状态定期落快照(房间级快照 + 输入日志),故障时在备用节点恢复;无法恢复的对局按规则判负或重赛,并把补偿走邮件发放。


4. 合服系统 ​

4.1 业务背景 ​

合服是游戏生命周期中的必然事件。当服务器活跃人数低于阈值时,游戏体验会急剧下降——匹配等待时间变长、世界频道冷清、公会战凑不齐人。合服将多个低活跃服务器合并,恢复游戏活力。

但合服是技术上最危险的操作之一。数据迁移、冲突处理、回滚方案——任何一步出错都可能导致大量玩家数据丢失。

4.2 四个阶段 ​

合服不是简单的"把数据搬过去",而是一个需要精心策划的工程:

  1. 准备期(1-2 周):数据备份、冲突检测、补偿计算、合服脚本演练
  2. 执行期(4-8 小时):停服公告、停止写入、数据迁移、冲突处理
  3. 验证期(24 小时):数据完整性检查、功能验证、监控告警值守
  4. 稳定期(1 周):观察玩家反馈、处理异常工单、发放合服补偿

4.3 ID 撞号:合服最大的隐藏坑 ​

玩家重名、公会重名是显性冲突,处理规则简单(后缀服务器编号,如"玩家A·3服")。真正危险的是隐性 ID 冲突:两个服的玩家 ID 都是各自从 1 开始自增的,合并后必然撞号。

如果底层数据用的是全局自增 ID 而没有服务器维度,合服时就需要全量 ID 重映射:

go
// 合服 ID 重映射:3 服玩家 ID 统一加偏移
// 映射表必须持久化——合服后几个月内,玩家客服、日志回溯都需要查旧 ID
type IDMapping struct {
    OldServerID int32
    OldPlayerID uint64
    NewPlayerID uint64
}

func buildMigration(srcServer int32, offset uint64) func(uint64) uint64 {
    return func(oldID uint64) uint64 {
        return oldID + offset // offset = 目标服当前最大 ID + 安全余量
    }
}

重映射的难点不在映射本身,而在引用完整性:邮件的收件人、好友列表、公会成员、排行榜成员、订单记录……所有引用 playerID 的地方都要同步改写。漏改一处就是"邮件发给不存在的人"这类线上事故。

两条经验:

  • 设计期预防优于合服期补救。如果项目还在早期,在每个玩家 ID 中编码服务器号(如高 16 位存 serverID),或所有表都带 serverID 列,合服就退化为"逻辑合并"而无需改写 ID。
  • 映射表永久保留(至少保留到游戏停运)。它合服后不参与业务,但日志分析、客服工单、数据修复都依赖它把旧 ID 翻译成新 ID。

其他数据冲突的处理规则:

text
冲突处理规则:
├── 重名玩家     → 后缀服务器编号(如 "玩家A·3服"),免费送改名卡
├── 公会名冲突   → 后缀服务器编号
├── 排行榜       → 合并后重新计算排名
├── 邮件         → 合并到收件人名下,按时间排序
├── 好友/黑名单  → 双向合并;对方未合入时挂"跨服好友"占位
└── 公会战记录   → 历史数据归档,新赛季重新开始

4.4 干跑与回滚 ​

陷阱:合服最大的风险是"数据丢失不可逆"。合服前做完整备份,并且合服脚本要支持干跑模式——先模拟合服过程,输出"将被改写/合并/删除"的报告,人工确认后再真正执行。

执行期的安全网:

  • 先停写再迁移。停服后确认所有写入落盘(MySQL 无活跃连接、Redis 持久化完成),再开始迁移。边写边迁是数据不一致的根源。
  • 行数校验。每个表记录"源 A 行数 + 源 B 行数 = 目标行数"的核对报告,任何不等都中止开服。
  • 一键回滚脚本。迁移失败时回滚到备份,包括数据库和 Redis 快照。回滚脚本和迁移脚本同等重要,且需要真的演练过一次。

5. 滚服系统 ​

5.1 滚服的本质 ​

滚服(开新服 + 合老服)是手游运营的核心策略。它的本质是用新服务器吸引新玩家,用合服保持老服活力。

开新服的触发条件通常包括:现有服务器在线人数饱和、排行榜竞争过于激烈、运营活动需要新服配合。合服的触发条件则是:服务器活跃率低于阈值、在线人数持续下降。

5.2 开服的工程化 ​

"开新服"在工程上需要做到一键开服:选服流程(建库、灌基础数据、配置活动锚点、注册到网关/列表服)自动化,10 分钟内完成。开服是高峰事件——新服开放瞬间的登录洪峰往往超过平时数倍,容量规划按开服峰值而不是日均峰值。

开服时机的常见策略:

  • 工作日晚上 8 点:玩家有充足时间体验新服
  • 周五晚上:周末是游戏高峰期,开服效果最好
  • 避开重大节日:春节等节日期间玩家已有固定游戏习惯

陷阱:开服过频会导致"滚服疲劳"——玩家觉得每个服都是"短命服",不愿意投入时间。建议新服开放间隔不低于 3 天,让每个服有足够的时间积累核心玩家。


6. 战斗系统 ​

6.1 核心挑战 ​

战斗系统是游戏玩法的核心,也是技术上最复杂的部分。它需要解决几个根本问题:实时性(毫秒级响应)、公平性(防作弊)、可扩展性(支持新玩法)。

《游戏编程模式》中提出的"组件模式"很适合战斗系统设计:将战斗逻辑拆分为独立的组件(移动、攻击、技能、状态),通过组合实现不同的战斗玩法。帧同步、预测回滚、确定性这些实时同步的深入话题,知识库"同步、战斗与实时交互"一章已完整覆盖,本节聚焦玩法系统的架构视角。

6.2 PVE vs PVP 的设计差异 ​

维度PVEPVP
逻辑执行位置服务端可选需要服务端权威
防作弊要求低极高
网络延迟容忍度高(可接受 200ms+)低(建议 <100ms)
战斗回放需求低高(争议仲裁)
数值平衡压力低(可以调整怪物)高(玩家对比敏感)

6.3 架构选择 ​

对于不同类型的游戏,战斗系统的架构差异很大:

  • 卡牌/回合制:请求-响应模式,服务端计算所有逻辑,客户端播放表现
  • MOBA/FPS:帧同步或状态同步,需要 UDP 长连接,毫秒级同步
  • MMO:AOI + 状态同步,按区域管理广播,大规模状态广播
  • 挂机:离线计算为主,登录时或定时任务处理战斗结果

陷阱:回合制游戏最容易犯的错误是"客户端权威"——让客户端计算战斗结果上报。这几乎等于把作弊工具送给玩家。所有影响资产和排名的战斗,结算逻辑都应在服务端执行。

6.4 战斗校验分级 ​

"服务端权威"不等于"每次战斗都完整重算"。完整重算的成本可能不可接受(一场 MMO 团本几分钟的模拟),工程上的常见做法是按风险分级校验:

级别适用场景校验方式
全量重算竞技场、天梯等排名相关 PVP服务端用双方快照完整模拟一遍,结果以重算为准
结果校验普通副本客户端上报结果,服务端校验合法性(时长下限、伤害上限、产出上限)
抽查校验低风险 PVE按比例抽样重放,异常率超阈值升级为全量校验

伤害上限、战斗时长下限这类"合法性边界"来自数值表:服务端知道玩家属性上限和技能倍率上限,任何超出理论上限的上报都可直接拒绝。这层校验实现成本低、拦截效果好,是性价比最高的反作弊手段。

战斗录像(输入日志或状态快照序列)是争议仲裁的最后依据,PVP 玩法建议至少保留一个赛季。

6.5 离线与挂机结算 ​

挂机类游戏的"战斗"大多不在对局内发生:离线收益按"上次结算时间 × 产出速率"计算,上线时一次性补发。这类结算的要点是封顶与分段:离线收益通常封顶(如最多累计 12 小时),超过部分引导玩家用道具找回——这既是数值设计也是服务器防止补偿洪峰的手段。

计算离线收益时使用整数分钟或小时为粒度,避免秒级浮点累加带来的不一致;补发走与在线收益相同的资产管线(流水 + 幂等),离线结算和在线结算共用一套产出规则,避免两套逻辑数值漂移。


7. 背包系统 ​

7.1 格子模型与列表模型 ​

背包的存储模型有两种:

  • 格子模型:固定 N 个格子,每格放一组物品。前端展示直观,但扩容、整理(合并碎片)逻辑繁琐,格子索引还可能被客户端引用造成兼容负担。
  • 列表模型:背包是物品的集合,容量只是数量上限。扩展灵活,前端按需排序展示。

手游普遍采用列表模型 + 客户端虚拟排序。服务端不关心"物品在第几格",只关心"有哪些、各多少"——排序是纯表现逻辑。

7.2 物品实例与堆叠 ​

核心设计决策是普通物品按 (itemID, count) 堆叠存储,有独立属性的物品(装备)每件一个实例:

go
type StackItem struct { // 可堆叠:材料、消耗品
    ItemID int32
    Count  int64
}

type EquipItem struct { // 不可堆叠:每件装备属性不同
    InstanceID uint64 // 全局唯一实例 ID
    ItemID     int32
    Enhance    int16   // 强化等级
    Affixes    []int32 // 词缀
    Bound      bool    // 绑定状态
}

堆叠规则需要仔细定义:同 itemID 可叠加,但有属性差异的实例不可叠加;绑定与非绑定同类物品是否共格,不同产品选择不同——关键是规则写进配置而不是散落在代码里。装备的 InstanceID 是它后续参与强化、交易、邮件附件、日志追踪的锚点:一件装备从产出到销毁,所有记录都以 InstanceID 串联。

7.3 操作原子性 ​

背包操作(添加、删除、使用)需要保证原子性——要么全部成功,要么全部失败。这在高并发场景下尤其重要:两个请求同时扣减同一个物品,可能导致物品数量变为负数。

复合操作(如"合成需要扣 3 种材料")的正确姿势是两阶段提交:先预检所有扣减是否满足,预检通过后一次性执行:

go
// 扣减材料 - 先全部校验,再统一扣减
func (m *InventoryManager) Consume(inv *Inventory, costs []StackItem) error {
    // 阶段一:预检(只读)
    for _, c := range costs {
        if inv.CountOf(c.ItemID) < c.Count {
            return ErrItemNotEnough
        }
    }
    // 阶段二:执行(inv 已在单写者模型下独占,期间无并发修改)
    for _, c := range costs {
        inv.Remove(c.ItemID, c.Count)
    }
    return m.save(inv)
}

这套代码能工作的前提是第 5 章的单写者模型:同一玩家的背包在同一时刻只有一个执行流在修改。若做不到单写者,就需要数据库乐观锁或分布式锁兜底,两阶段预检在并发下仍然不可靠。

陷阱:背包系统的另一个常见问题是"物品丢失"——玩家使用物品时服务器宕机,物品被扣但效果未生效。解决方案是事务日志:先记录使用意图,确认效果生效后再确认扣减;或把"扣材料"与"发产出"放进同一个数据库事务(若同库)。

变更流水同样适用于物品:重要物品(高稀有度装备、限定道具)的获得与消耗记录流水(谁、何时、从哪来、到哪去),盗号追溯与运营补偿都依赖它。


8. 邮件系统 ​

8.1 多重角色 ​

游戏内邮件系统承担着多重角色:系统奖励发放、玩家间通信、运营通知、补偿发放。它是游戏运营中最常用的工具之一。

设计邮件系统时需要考虑几个关键问题:附件领取的原子性(防止重复领取)、批量邮件的性能(全服补偿可能涉及百万玩家)、过期清理策略(防止邮件无限堆积)。

8.2 存储模型:个人邮件与全服邮件分开 ​

把"发给一百万人的公告"和"发给一个人的补偿"存成同一种数据,是邮件系统最常见的性能事故根源。常见的做法是两类邮件分开建模:

  • 个人邮件:一条记录对应一个收件人,带附件与领取状态。数量可控,走玩家数据存储。
  • 全服邮件:只有一份模板记录(标题、内容、附件、有效期),不预生成百万条收件记录。玩家登录时对比"我的上次邮件检查时间"与"全服邮件发布时间",动态看到这封邮件;领取时把 (邮件 ID, 玩家 ID) 写入领取表,作为已领取凭证。

全服邮件的领取表把 O(N) 的写入(发邮件时)推迟成了 O(1)(每个玩家领一次),百万玩家的全服邮件从"发送耗时几十分钟"变成"发布即生效"。

需要全服邮件支持"只发给满足条件的玩家"(如 30 天未登录召回)时,常见做法是在邮件模板上带筛选条件,登录时判定——或者用离线任务预生成目标玩家的个人邮件。前者省存储但延迟生效(登录才判定),后者即时但写放大,按运营场景选择。

8.3 附件领取的幂等与溢出 ​

go
// 领取附件 - 领取状态与发放写入同一事务
func (m *MailService) ClaimAttachment(playerID uint64, mailID int64) (*Reward, error) {
    mail, err := m.load(mailID, playerID)
    if err != nil {
        return nil, err
    }
    if mail.AttachmentClaimed {
        return nil, ErrAlreadyClaimed // 幂等:重复领取返回明确错误
    }
    if err := m.grantWithLog(playerID, mail.Attachment,
        "mail:"+fmt.Sprint(mailID)); err != nil {
        return nil, err
    }
    mail.AttachmentClaimed = true
    return &mail.Attachment, m.saveClaimState(mail)
}

两个边界情况:

  • 附件溢出:邮件自带来附件,玩家邮箱已满时领取附件放不进背包。常见做法是邮件保留"待提取"状态并提示清理背包,或支持附件直接转化为"临时背包/找回系统"。绝不能因为背包满就丢弃附件。
  • 过期清理:未领取附件的邮件到期后,常见规则是"到期自动补发到找回系统"而不是删除。删除带附件的邮件是最容易引发客诉的操作,清理逻辑必须先检查附件状态。

陷阱:网络抖动会导致客户端重复发送领取请求,服务端需要把"重复领取"识别为幂等命中(返回成功或明确的"已领取"),而不是当作新请求处理一半。


9. 交易系统 ​

9.1 核心风险 ​

交易系统是游戏经济的命脉,也是作弊和外挂的重灾区。核心风险包括:刷金(利用 BUG 无限获取货币)、洗钱(通过交易转移非法获取的物品)、RMT(现实货币交易)。

《游戏数据分析的艺术》中特别指出:交易系统的安全设计不是"堵漏洞"的问题,而是"建立规则"的问题——好的规则设计比事后修补有效得多。

9.2 安全机制分层 ​

交易安全需要多层防护:

  1. 价格区间限制:物品交易价格不能偏离系统参考价太远,防止异常定价洗钱
  2. 频率限制:单位时间内交易次数有上限(令牌桶的思路见第 4 章)
  3. 同人限制:同一实名/设备/支付账号之间不能交易,防小号转移
  4. 手续费:交易税收回收游戏币,抑制经济膨胀(与第 2 章的产出回收配平呼应)
  5. 新获得物限制:刚获得的物品(尤其是充值获得)一段时间内不可交易,给风控留出追回窗口
  6. 异常检测:离线分析识别异常交易模式(固定价格高频对倒、新号快速转移),命中后冻结待审

交易一旦成立就是资产转移,成交流水(买卖双方、物品实例、价格、手续费、时间)必须落库并可审计——它是经济风控(第 2 章的流水与对账)在交易场景的数据基础。

9.3 寄售行的架构归属 ​

拍卖行/寄售行比点对点交易更复杂,需要处理:挂单、出价竞争、倒计时、流拍处理、税费计算。架构上有一个关键选择:寄售行放哪里?

  • 放在本服:实现简单,但市场被服务器切割,单服经济体小、物价波动大。
  • 独立交易服务(全服/跨服共用):市场统一、价格发现能力强;但成为全服关键路径,需要独立扩容与故障隔离。大 DAU 手游普遍选择独立交易服务,理由和跨服系统一样——把高频撮合从游戏主逻辑中剥离,故障域分离。

寄售行的核心并发点是同一物品的并发出价/同一物品被同时下架。独立服务内用单写者队列串行处理每个拍卖标的的操作,成交时在同一事务内完成"扣买家货币、转物品、记账、通知双方",任何一步失败整体回滚。

陷阱:链游的交易系统需要注意"链上确认延迟"——以太坊的交易确认需要 12 秒以上。如果交易涉及 NFT 转移,玩家需要等待链上确认,体验会明显下降。常见做法是"乐观确认":先在链下完成交易并可用,定期批量上链确权。


10. 代码示例:赛季结算与全服邮件的最小骨架 ​

第 0 节的三条共性约束——数据可结算、状态可恢复、行为可审计——听起来抽象,落到代码上就是「幂等键 + 凭证表」两个模式。这一节把它们装进本章最容易出事故的两个场景:赛季结算发奖(1.5 节)与全服邮件领取(8.2、8.3 节)。示例是本章设计决策的最小 Go 演示实现(非摘自真实项目),小节内代码块合并后可通过 go vet 与 go build 编译,并受全库代码块回归测试约束。

赛季结算:崩溃重跑不会重复发奖 ​

go
type Reward struct {
    Coin  int64
    Item  int32
    Count int64
}

type RankedPlayer struct {
    UID  uint64
    Rank int
}

// prizeFor:名次 → 奖励。真实实现读数值配置表(可热更,第 17 章),
// 演示版用倒数公式表达「越靠前越厚」即可
func prizeFor(rank int) Reward {
    return Reward{Coin: int64(10000 / rank)}
}

var ErrDuplicateRef = errors.New("reward already granted")

// SettleSeason:赛季结算。幂等键 = 赛季号 + 玩家 ID——
// 结算程序崩溃重跑,重复调用不会重复发奖(1.5 节的纪律)
func SettleSeason(season int32, top []RankedPlayer, grant func(uid uint64, r Reward, refID string) error) error {
    for _, p := range top {
        refID := fmt.Sprintf("season:%d:player:%d", season, p.UID)
        err := grant(p.UID, prizeFor(p.Rank), refID)
        switch {
        case err == nil:
            // 本格发奖成功
        case errors.Is(err, ErrDuplicateRef):
            continue // 幂等命中:这格奖已发过,跳过而不是报错
        default:
            return err // 真实失败:中断并重跑,已发的格子被幂等键挡住
        }
    }
    return nil
}

SettleSeason 敢于整批重跑的底气全部来自幂等键:season + 玩家 ID 拼进 refID,底下是第 2 章 6.3 节的流水先行账务——同一格奖第二次 grant 会命中 ErrDuplicateRef,跳过即可。注意失败处理的不对称:幂等命中是继续,真实失败是中断重跑——中断后重跑时已发过的格子被幂等键挡住,没发的格子从头补,结算程序因此可以做成「无状态 + 随时重入」,这是 1.5 节「结算本身需要幂等」的完整含义。Top N 快照在切换前后各存一份(1.5 节的归档要求),争议发生时证据链齐全。

全服邮件:一份模板 + 登录时判定 + 领取凭证表 ​

go
// GlobalMail:全服邮件只有一份模板记录,不预生成百万条收件记录(8.2 节)
type GlobalMail struct {
    ID        int64
    Title     string
    Reward    Reward // 附件:沿用上一块的 Reward,入账走流水管线
    PublishAt time.Time
    ExpireAt  time.Time
}

// claimKey:领取凭证 (邮件ID, 玩家ID),生产落库时加唯一约束
type claimKey struct {
    MailID int64
    UID    uint64
}

var (
    ErrNoMail         = errors.New("mail not found")
    ErrMailExpired    = errors.New("mail expired")
    ErrAlreadyClaimed = errors.New("attachment already claimed")
)

type MailBoard struct {
    mails   []GlobalMail
    claimed map[claimKey]struct{}
}

// VisibleTo:登录时对比「我的上次检查时间」与「发布时间」,
// 动态可见、零收件记录——O(N) 写入推迟成 O(1) 的全部秘密
func (b *MailBoard) VisibleTo(lastCheck time.Time) []GlobalMail {
    var out []GlobalMail
    for _, m := range b.mails {
        if m.PublishAt.After(lastCheck) {
            out = append(out, m)
        }
    }
    return out
}

// Claim:领取附件。返回的奖励由调用方走第 2 章的流水管线入账,
// 邮件系统自己绝不碰余额
func (b *MailBoard) Claim(uid uint64, mailID int64, now time.Time) (Reward, error) {
    var mail *GlobalMail
    for i := range b.mails {
        if b.mails[i].ID == mailID {
            mail = &b.mails[i]
            break
        }
    }
    if mail == nil {
        return Reward{}, ErrNoMail
    }
    if now.After(mail.ExpireAt) {
        return Reward{}, ErrMailExpired // 过期走找回系统,绝不静默删除(8.3 节)
    }
    key := claimKey{MailID: mailID, UID: uid}
    if _, ok := b.claimed[key]; ok {
        return Reward{}, ErrAlreadyClaimed // 网络重试在这里被识别,绝不当新请求处理
    }
    b.claimed[key] = struct{}{}
    return mail.Reward, nil
}

VisibleTo 只做一次时间比较,百万玩家的「收件」是零记录的——8.2 节把 O(N) 写入推迟成 O(1) 的核心就在这个函数签名里:它需要调用方带上 lastCheck,也就是玩家维度的「上次邮件检查时间」。Claim 的三个检查各有分工:过期检查守住「清理逻辑必须先看附件状态」的红线(过期走找回系统而不是删除,8.3 节);凭证表查重把网络重试识别为幂等命中(8.3 节的陷阱行);领取动作只写一条 (邮件ID, 玩家ID) 凭证——它同时是审计证据,客诉「我没领到」时查这张表。附件入账仍走流水管线,邮件系统自己不碰余额。

两个场景共用同一套底层设施:幂等键来自货币流水(第 2 章),凭证表来自数据库唯一约束(第 8 章),状态恢复靠的是「结算可重跑 + 邮件模板持久化」。玩法系统的大部分工作不是发明新机制,而是把规则正确安放到这些既有设施上——第 0 节那句话,到这里应该看得见了。


11. 不同游戏类型的系统优先级 ​

不同游戏类型对玩法系统的需求差异很大,理解这些差异有助于合理分配开发资源。

系统塔防挂机MMO链游卡牌
排行榜★★★★★★★★★★★★★
活动系统★★★★★★★★★★★★★
跨服系统★★★★★★★★
合服系统★★★★★★★★★
战斗系统★★★★★★★★★★★
背包系统★★★★★★★★★★★★★★
交易系统★★★★★★★★★★★
邮件系统★★★★★★★★★★★★

12. 设计决策指南 ​

何时选择 Redis Sorted Set 做排行榜

  • 实时性要求高(秒级更新)
  • 只需要 Top N、排名和周边查询
  • 数据量在百万级以内
  • 不需要复杂查询(如"查询某分数段的所有玩家")

何时选择 MySQL 做排行榜

  • 需要复杂查询(按条件筛选、多维度排序)
  • 数据量超过千万级
  • 更新频率低(每天刷新一次)
  • 需要历史数据归档

何时引入跨服系统

  • 单服在线人数持续低于匹配阈值
  • 匹配等待时间超过 30 秒
  • 赛季排行榜需要全服竞争
  • 运营有明确的跨服玩法需求

何时选择独立交易服务

  • 全服统一市场的产品诉求明确
  • 交易频率已经影响主逻辑性能
  • 需要专业的风控与审计管线

合服的充分准备

  • 全量备份 + 干跑报告人工确认
  • ID 映射方案确定(预防性设计或全量重映射)
  • 回滚脚本演练过
  • 补偿方案与客服话术就绪

13. 实战案例:一次「合服后 23 笔充值未发货」的完整排查 ​

案例为教学示例(非摘自真实项目),数字经过取整简化。第 12 讲的案例走「数据口径」,本案例走「数据一致性」:它把 4.3 的 ID 映射与 4.4 的停写纪律串成一次真实事故——迁移校验全过,库是一致的,账却是错的。

背景与现象:3 服、7 服并入 1 服,停服窗口 14:10–18:30。开服当晚 19 点起客服涌入 23 单「充值不到账」工单,高度相似:全部是停服公告(14:00)发出之后的充值,全部来自 3 服与 7 服。而运维侧一片平静——迁移行数校验全过、监控无一告警(死信队列当时不在告警清单里)。

第一步:对账,先证明「账真的错了」(呼应第 08 讲幂等与对账) ​

渠道侧当日扣款成功 1060 笔(3 服 620 + 7 服 440),游戏侧发货成功 1037 笔,差异 23 笔——与工单数完全吻合,差异率约 2.2%。对账按「渠道扣款 ↔ 订单落库 ↔ 邮件发货」三方对齐,而不是只看订单表行数:23 笔订单都在订单表里,状态是「发货失败,重试耗尽」。行数校验是防「丢行」的,这里一行都没丢。

第二步:找共同点(维度拆分定位交集) ​

23 笔差异订单的交集不是玩家画像、不是金额、不是支付渠道,而是支付时间:全部落在 14:05–14:10。日常同时段均值约 8 笔,停服公告后的「最后冲刺」把它抬到约 3 倍。

第三步:根因——停写纪律漏了「在途异步」 ​

时间线还原:

text
14:00       停服公告;运营触发合服前置检查
14:05       前置检查冻结邮件库写入(为快照做准备)
14:05–14:10 冲刺充值 23 笔:订单落库成功,发货任务投递消息队列
14:10       停写:发货 worker 的重试全部被冻结的邮件库拒绝,按退避重试
14:30       数据迁移完成:订单/玩家表 ID 重映射(库内一致,行数校验通过)
14:40       重试依次耗尽,23 条发货消息进死信队列(无告警)
18:30       开服;人工重放死信 → 仍然全部失败

开服后重放仍失败才是要害:死信消息体里的 playerID 是重映射前的旧 ID,而消费端按 4.3 的设计「映射表合服后不参与业务」——映射表只留给客服与日志用,发货 worker 没有翻译逻辑。旧 ID 在合并后的库里已不存在,插入邮件必然失败。

三层根因,一层比一层本质:

  1. 直接原因:消息体里的旧 ID 在重映射后失效
  2. 机制原因:停写检查单按「我们的服务」枚举写入方(游戏服、网关、发货 worker),没有按「数据生产者」枚举——消息队列里已投递未消费的存量消息也是一种「在途写入」,停写停不掉它
  3. 设计原因:4.4 的行数校验只能抓「丢行」,抓不了「写错路径」——库内一致性与「库和在途消息的一致性」是两件事

第四步:处置与复盘 ​

止血(当晚):用映射表把 23 条死信的旧 ID 批量翻译成新 ID 后重放,23/23 补发成功,客服按工单逐单核销。

回填清单(呼应第 14 讲复盘回填纪律):

回填项归属完成标准
发货消费端接入 ID 映射表(带合服批次号,仅历史批次生效)发货服务死信重放用例通过
停写检查单新增「在途消息清零」:消费 lag 归零才允许开始迁移运维检查单评审通过
三方对账差异 > 0 即告警(此前是 T+1 人工报表)数据/运维告警演练一次
合服窗口结束后 1 小时内死信清零运维写入合服 runbook

案例的三个教训 ​

  • 「停写」的定义是所有数据生产者,不只是自己的服务。渠道重试、定时任务、消息队列里的存量消息都是写入源——检查单按生产者枚举,停写才有完备性可言
  • 设计假设要标注失效条件。「映射表合服后不参与业务」成立的前提是没有跨重映射时点的在途引用;假设被打破时的表现(发货失败)与假设本身相距很远,排障时极难联想到
  • 行数校验防「丢」,对账防「错」。迁移校验(行数、主键唯一)与业务对账(订单↔发货↔到账)是两道独立的闸,合服两个都不能少——23 笔订单行数校验全过,账还是错了

本章小结 ​

问题简答
排行榜同分怎么办?复合分数编入时间戳实现先到先得,或允许并列名次(规则化),结算前与策划对齐
赛季重置旧数据去哪?按赛季分 key,结算快照入归档表,发奖用"赛季+玩家"幂等键
跨服玩法的数据归属?快照进(本服打包只读快照)、跨服服自治、结算回(可靠消息回本服发奖),故障域隔离
合服最大的隐藏坑?玩家 ID 撞号。预防:ID 编码服务器号;补救:全量重映射 + 映射表永久保留
行数校验全过,合服为什么还会丢账?行数校验防「丢行」,防不了在途消息——停写按「数据生产者」清零(消费 lag 归零),消费端预置 ID 映射翻译(见 13 节案例)
战斗结算怎么防作弊又不拖垮性能?分级校验:排名相关全量重算、普通副本边界校验(伤害上限/时长下限)、低风险抽查
背包两阶段提交为什么可靠?预检只读、执行独占——前提是单写者模型保证同一玩家数据无并发修改
全服邮件为什么不能生成百万条记录?模板一份 + 登录时判定 + 领取表记录凭证,把 O(N) 写入推迟为 O(1)
交易系统的第一道防线?规则而非修补:价格区间、频率限制、同人限制、手续费、新获得物限制、异常检测

游戏知识体系