🔥博客主页: 【小扳_-CSDN博客】
❤感谢大家点赞👍收藏⭐评论✍

前三篇讲的是"玩家怎么进出跨服"。这一篇讲中间那段:他真的在打的时候,战斗服在干什么。

这是整个系列里唯一需要写实时循环的一篇,也是代码量最大的一篇。

开始之前先澄清一个矛盾,因为很多人卡在这里:

第二篇说"战斗服要尽量无状态",可战斗明明需要把 100 个单位的血量、位置、CD 全放在内存里——这不就是有状态吗?

这个矛盾不解决,后面所有的设计都会拧巴。

文章目录

1.0 先把"无状态"这个词说准

1.1 三类状态,只有一类绝对不能放

2.0 为什么必须是固定 tick,不能"来包就算"

2.1 两种驱动模型的对比

2.2 tick 频率怎么选

3.0 tick 循环的实现

3.1 逻辑时间驱动,不是系统时间驱动

3.2 追赶与雪崩保护

3.3 一个房间一个任务,不是一人一线程

4.0 无状态的落地:四条硬规则

4.1 数据从快照来,不从库来

4.2 产出只出结算单,不写库

4.3 战斗内能喝药吗:消耗品怎么处理

4.4 随机数必须可复现

5.0 状态同步:快照、增量、序列号

5.1 为什么不能只发增量

5.2 快照与增量的频率配比

5.3 丢包恢复:靠序列号,不靠重传

6.0 输入:为什么会"卡在原地"

7.0 容量与房间生命周期

7.1 一个 tick 的时间预算

7.2 房间从创建到销毁

8.0 战斗服挂了怎么办

9.0 怎么测:机器人 + 单房间压测

10.0 总结


1.0 先把"无状态"这个词说准

1.1 三类状态,只有一类绝对不能放

"战斗服无状态"这个说法其实不准确。准确的说法是:战斗服不持有不可重建的状态。

把战斗服里的状态分三类看:

类型

例子

丢了会怎样

该放哪

瞬时状态

当前帧的坐标、朝向、动画进度

无所谓,下一帧就算出来

战斗服内存 ✅

会话状态

这场战斗有哪些玩家、谁是队长、已进行多少 tick

可以重建(从路由表 + 会话表)

战斗服内存 + 定期上报 ✅

持久状态

玩家背包、等级、装备、货币

不可恢复,直接是数据事故

绝不允许出现在战斗服 ❌

所以"无状态"的真实含义是:

战斗服是一个可丢弃的计算容器。 它可以随便重启、随便扩容、随便被 kill -9,因为里面没有任何一样东西是"只有它才知道"的。

这个定义一确立,很多设计问题自动有答案了:

  • 要不要在战斗服缓存玩家背包?不要,那是持久状态。
  • 战斗服能不能自己算掉落?能,但结果必须变成结算单,不能直接入包。
  • 战斗服挂了会不会丢数据?不会丢持久数据,最多这一场作废。

关键推论:既然战斗服可丢弃,那么它挂掉的正确处置不是"恢复它",而是"作废这一场 + 补偿玩家"。这个结论会在第 8 节用到。


2.0 为什么必须是固定 tick,不能"来包就算"

2.1 两种驱动模型的对比

一个很自然的想法:客户端发来"我往右走",服务器收到就更新位置,然后广播给别人。事件驱动,简单直接。

这个做法在实时战斗里会出三类问题:

问题

具体表现

根因

逻辑不可复现

同一个 bug 复现不了,日志里看不出来

每帧间隔不同,浮点累加结果不同

物理不稳定

高速移动的角色穿透墙体

单帧位移过大,碰撞检测漏检

体验不一致

高帧率玩家移动比低帧率玩家快

位移按"每帧多少"算,帧率不同速度不同

第三条是最致命的。如果位移写成"每帧移动 5 像素",60 帧的玩家每秒移动 300 像素,30 帧的玩家每秒只移动 150 像素。同一个游戏,两种速度。

固定 tick 解决的正是这件事:把"帧"从可变的时间单位,变成固定的逻辑步长。

20Hz = 每 tick 50ms,1 秒固定 20 步
不管这一秒内收到多少包、服务器忙不忙,逻辑永远走 20 步

对比一下:

项

事件驱动

固定 tick

逻辑步长

不确定(取决于包到达间隔)

固定 50ms

可复现性

差

强(同种子 + 同输入 = 同结果)

物理稳定性

差

好(单步位移可控)

玩家体验

与实际帧率耦合

与帧率解耦

带宽利用

包到即算,延迟低

需要缓冲,引入一个 tick 的延迟

代价是引入最多一个 tick(50ms)的输入延迟。对于 100 人同场的战场,这个代价值得——你要的是所有人看到同一个世界,不是某一个人快 50 毫秒。

2.2 tick 频率怎么选

频率

单 tick 时长

逻辑预算(≤30%)

适合

代价

10Hz

100ms

30ms

SLG、回合制、卡牌

动作类手感明显发木

20Hz

50ms

15ms

大多数 MMO 战场、MOBA

平衡点

30Hz

33ms

10ms

动作性强的战斗

CPU 消耗约 1.5 倍

60Hz

16ms

5ms

强实时对抗(射击、格斗)

带宽和 CPU 都翻倍

那张"逻辑预算 30%"是什么意思?见第 7.1 节。这里先记住一个判据:

逻辑耗时必须稳定低于单 tick 时长的 30%。 超过 50% 就一定会开始掉 tick,超过 100% 就会雪崩。

新手最容易犯的错是把频率定得太高。 60Hz 听起来更"专业",但双倍 CPU 换来的是玩家几乎感知不到的手感提升——因为客户端有插值,人眼分辨不出 16ms 和 50ms 的服务器步长差异。

从 20Hz 起步,压测跑不动再往上加。


3.0 tick 循环的实现

3.1 逻辑时间驱动,不是系统时间驱动

这是本节最核心的一句话,因为它决定了你的战斗会不会"随时间漂移"。

先说错误写法——用 scheduleAtFixedRate 直接绑 doTick():

// ❌ 看着对,实际上会漂
scheduler.scheduleAtFixedRate(this::doTick, 0, 50, TimeUnit.MILLISECONDS);

问题在于 scheduleAtFixedRate 的"固定"依赖上一次执行的结束时刻。如果第 N 次 doTick 花了 80ms(超过 50ms 周期),后续所有 tick 都会被顺延,而且误差会累积——跑一小时后,逻辑时间和真实时间可能差出好几秒。

正确做法:让 tick 号由真实时间推算出来,而不是靠调度的连续性。

 

这样即使中间卡了 200ms,expectedTick 也会准确反映出"应该已经走到第几 tick",偏差不会累积。

3.2 追赶与雪崩保护

有了 expectedTick,循环长这样:

第三个分支很关键,叫雪崩保护。

场景:服务器被其他任务抢占 CPU,卡了 3 秒。这 3 秒对应 60 个 tick。如果不加限制地追赶,就要连续跑 60 次 doTick——而每次都要广播,广播又要占用 CPU,于是更追不上,越追越堵,最终整个房间卡死。

正确做法是直接放弃这 60 个 tick,把逻辑时间对齐到当前时刻。

代价是这 3 秒内玩家看到的画面会"跳"一下。但比起房间卡死,跳一下是可接受的。

这是一个明确的取舍:宁可丢时间,不可积压。

3.3 一个房间一个任务,不是一人一线程

先明确反例:不要给每个玩家开线程。

100 人一个房间,就是 100 个线程加 100 把锁,还要保证它们对同一份世界状态的操作是原子的。这条路走不通。

正确模型:一个房间一个执行单元,房间内所有逻辑单线程串行。

@Slf4j
public class BattleRoom implements Runnable {

    private static final int TICK_RATE = 20;
    private static final long TICK_NANOS = 1_000_000_000L / TICK_RATE;   // 50ms
    /** 最多一次追 5 个 tick,超过就放弃追赶 */
    private static final int MAX_CATCH_UP = 5;

    private final String sessionId;
    private final long seed;
    private final BattleMap map;
    /** 玩家集合:读写都在本房间线程内,不需要并发容器 */
    private final Map<Long, BattlePlayer> players = new HashMap<>();
    /** 待广播的增量,tick 结束时统一发送 */
    private final List<FrameDelta> outbox = new ArrayList<>();

    private final long startNanos = System.nanoTime();
    private long tick = 0;
    private volatile RoomState state = RoomState.RUNNING;

    @Override
    public void run() {
        while (state == RoomState.RUNNING) {
            long expected = (System.nanoTime() - startNanos) / TICK_NANOS;
            long catchUp = expected - tick;

            if (catchUp <= 0) {
                // 还没到下一个 tick 边界,让出 CPU
                LockSupport.parkNanos(TICK_NANOS / 2);
                continue;
            }
            if (catchUp > MAX_CATCH_UP) {
                // 雪崩保护:放弃追赶,直接对齐
                log.warn("房间落后过多,丢弃 {} 个 tick session={}", catchUp, sessionId);
                tick = expected;
                continue;
            }

            // 正常路径:逐个 tick 推进
            for (long i = 0; i < catchUp && state == RoomState.RUNNING; i++) {
                long begin = System.nanoTime();
                try {
                    doTick(++tick);
                } catch (Exception e) {
                    // 单个 tick 异常不能让房间崩掉
                    log.error("tick 执行异常 session={} tick={}", sessionId, tick, e);
                }
                long cost = (System.nanoTime() - begin) / 1_000_000;
                if (cost > TICK_NANOS / 1_000_000 / 2) {
                    log.warn("tick 耗时 {}ms 超过预算一半 session={}", cost, sessionId);
                }
            }
            flush();
        }
        log.info("房间循环结束 session={} tick={}", sessionId, tick);
    }

    private void doTick(long t) {
        // ① 消费输入
        for (BattlePlayer p : players.values()) {
            p.consumeInput(t);
        }
        // ② 推进逻辑:移动 → 技能 → 伤害 → 状态刷新
        for (BattlePlayer p : players.values()) {
            p.move(t);
        }
        skillSystem.update(players, t);
        damageSystem.update(players, t);
        buffSystem.update(players, t);
        // ③ 判定胜负
        winCondition.check(this);
        // ④ 生成增量
        outbox.add(buildDelta(t));
    }

    private void flush() {
        if (outbox.isEmpty()) {
            return;
        }
        for (FrameDelta d : outbox) {
            broadcaster.broadcast(sessionId, d);
        }
        outbox.clear();
    }

    public void stop(RoomState reason) {
        this.state = reason;
    }
}

三个设计点要解释:

① 玩家集合用 HashMap 而不是 ConcurrentHashMap。

因为所有读写都在房间线程内,加并发容器只增加开销,还会掩盖"跨线程访问"的设计错误。用非线程安全的容器,反而是一种保护——真有跨线程访问,它会立刻抛异常,而不是静默地产生脏数据。

对外提供的操作(比如玩家掉线)走"投递命令"的方式,而不是直接改状态:

/** 外部线程调用,只投递命令,不直接改状态 */
public void post(RoomCommand cmd) {
    commandQueue.offer(cmd);   // LinkedBlockingQueue
}

然后在 doTick 开头统一消费 commandQueue。这样房间内的世界永远是单线程访问的。

② 广播放在所有 tick 之后,不是每个 tick 之后。

这是一个容易被忽略的性能点。追赶 5 个 tick 时,如果每个 tick 都广播一次,玩家会瞬间收到 5 个包,其中 4 个很快被下一个覆盖——纯浪费带宽。

正确做法是把这几帧的增量合并,只广播最后一个。中间帧的数据本来就会被后面的覆盖,没必要发。

③ 异常必须捕获在 tick 内。

如果 doTick 抛异常一路冒泡到 run(),整个房间循环就退出了,100 个玩家瞬间掉线。

捕获在 tick 级别,坏一帧不影响下一帧。同时要打点告警——频繁抛异常的 tick 说明有逻辑 bug,不能就这么吞了。


4.0 无状态的落地:四条硬规则

前面定义了"无状态 = 不持有不可重建的状态"。下面四条规则是它的可执行版本,违反任何一条,战斗服就变成了有状态服务。

4.1 数据从快照来,不从库来

战斗服不允许访问玩家数据库。

这条规则听起来极端,但它挡住了一整类问题:一旦战斗服能查库,就会有人写"这里顺便查一下玩家等级",然后那个查询会在战斗服扩容时变成 100 个连接、在跨服时变成跨库查询,最后在某次压测里炸掉。

正确做法:进入战斗时,本服把一份只读快照传进来。

public class BattleSnapshot {
    private final long uid;
    private final int zoneId;
    private final int level;
    /** 只带战斗需要的属性,不带背包、邮件、任务 */
    private final Map<String, Integer> attrs;
    private final List<SkillSnapshot> skills;
    private final List<EquipSnapshot> equips;
}

关于快照有一个很重要的判断标准:

快照里只放"这一场战斗会读到的字段",不为"以后可能用到"留位置。

因为快照越大,进入跨服的耗时越长、内存占用越高,而且快照里多一个字段,就多一个"跨服期间改了但没同步"的潜在 bug。

4.2 产出只出结算单,不写库

战斗服产出的唯一东西是一张结算单:

public class SettlementBill {
    private String sessionId;
    private long seed;                  // 随机种子,用于事后复现
    private long totalTicks;            // 总 tick 数,用于核对时长
    private List<PlayerResult> results; // 每个玩家的胜负、伤害、掉落
    private List<Consumption> consumptions; // 本场实际消耗的道具(见 4.3)
    private long finishedAt;
}

然后走 MQ 回本服,由本服写库(幂等,见第二篇)。

这条规则带来的最大好处是:战斗服可以完全没有数据库依赖。

没有数据库连接,就意味着启动快、扩容快、挂掉不污染连接池、压测时不会把主库打死。

4.3 战斗内能喝药吗:消耗品怎么处理

这一节回答一个几乎必然会有人问的问题:

按上面的规则,战斗服不写库,那玩家在战斗里还能喝药、用卷轴吗?是不是只能带一份定死的属性进去,背包全程只读?

能喝药。 这里混淆了两个不同的东西:持久状态和运行状态。

动作

战斗服能不能做

为什么

读快照里的道具数量

✅

快照本来就是只读输入

战斗内扣减(内存里 10 瓶变 7 瓶)

✅

这是本场战斗的临时状态,属于"可丢弃"那一类

把新的背包直接写进 db_zone_1

❌

破坏第一原则,而且战斗服不该有库连接

把"消耗了 3 瓶"写进结算单

✅

唯一正确的出口

完整链路:

进战斗:本服打包快照(含可携带道具与数量)→ 战斗服
战斗中:喝药 → 只改内存计数 → 记一条消耗明细
结 束:结算单 = { 胜负 + 掉落 + 消耗清单 } → MQ → 本服
落 库:本服幂等消费,一次性扣减、一次性发奖

代码上,一个道具槽只需要两个数:

public class ItemSlot {
    private final int itemId;
    private final int carryIn;     // 进入时的数量,只读
    private int remain;            // 战斗中剩余,只在本房间线程内变

    public boolean consume(int n) {
        if (remain < n) {
            return false;
        }
        remain -= n;
        return true;
    }
}

结算单里相应多一组字段:

public class Consumption {
    private long uid;
    private int itemId;
    private int count;       // 本场实际消耗数量
}

扣减没有消失,只是被延后到了结算。 原来"实时扣",现在"记完账一起扣"。

为什么延后是安全的? 因为此时租约已经冻结了本服对这个玩家的写权(第二篇)。战斗期间,他的背包不可能被别处改动——不能交易、不能邮寄、不能卖店、不能上架。

这也是为什么很多游戏在跨服期间会弹"跨服中,无法交易"的提示。那不是运营限制,是架构必需。

反过来想:如果本服还能并发改背包,结算时就会覆盖掉那些改动——这正是第一篇那个"药水喝了但效果没生效"的事故。

三类东西要分开处理

类型

例子

怎么带进去

战斗中能否变

战斗消耗品

血瓶、buff 卷轴

带数量进快照

内存扣减,记消耗明细

属性与装备

攻击力、武器、宝石

只带算好的最终值,不带原始数据

基准值固定;buff / debuff 临时加减

战斗外生效的道具

经验加成卡、双倍掉落

进入前已生效,带算好的倍率

战斗中不变

第二行正好回答"是不是带一份定死的战斗力进去":是。带进去的是进入瞬间算完的最终属性,不是"等级 + 装备 + 宝石"这套可以重算的原始数据。

因为重算意味着战斗服要懂宝石合成、套装加成、天赋树——那就等于把半个业务系统搬进战斗服,彻底违背 4.1。

后扣还是预扣

方案

做法

优点

缺点

后扣

战斗内只记账,结算时统一扣

精确,只扣实际用的

玩家中途看主背包数量不变,可能困惑

预扣

进战斗先扣上限(如 10 瓶),结算退未用的

玩家看得见减少,逻辑更简单

需要退款逻辑,预扣上限要策划定

多数项目用后扣,然后在战斗 UI 里单独显示"本场可用:10 瓶",不去动主背包的显示。既不困惑,也不用写退款。

顺带一个好处

因为扣减从来没有落到库里,所以战斗服崩溃、结算单丢失时,本服按进入前快照回滚——药水也一并回来了。

如果当时是实时扣库,就会变成"药扣了、战斗作废、奖励没有",还得专门写一套退款逻辑。

"产出只走结算单"这一条规则,同时买到了两份保险:战斗服可以完全没有数据库依赖,以及崩溃时不会出现"单向损失"。

最后提醒一句:有些竞技游戏禁止战斗内吃药,那是玩法决定(防氪金碾压、控制战斗节奏),不是技术做不到。这两件事要分开,别让架构限制替策划背锅。

4.4 随机数必须可复现

这条规则最容易被当成"过度设计",但它是最便宜的事故排查工具。

先看反例:

// ❌ 不可复现:出问题无法重放
if (Math.random() < 0.1) {
    dropItem(player);
}

再看正确写法:

// 种子在房间创建时确定,随结算单一并回传
public class BattleRandom {
    private final Random rng;

    public BattleRandom(String sessionId, long roomSeed) {
        // 同一 sessionId + 同一 roomSeed → 同一序列
        this.rng = new Random(sessionId.hashCode() * 31L + roomSeed);
    }

    public boolean roll(double probability) {
        return rng.nextDouble() < probability;
    }
}

为什么值得这么做?因为跨服最常见的一类工单是:

「我明明赢了,为什么结算是失败?」「我打出了暴击,为什么没有掉落?」

有种子你就可以在测试环境完整重放这一场战斗,看着它一步步跑完,直接定位问题。没有种子,你只能猜。

成本是几行代码,收益是"能复现"——这在分布式系统里非常奢侈。

要注意:种子必须在同一个 tick 序列里调用相同次数的随机数。所以随机数只能由战斗逻辑调用,不能被日志、埋点、A/B 分支提前消耗——否则序列就错位了。


5.0 状态同步:快照、增量、序列号

5.1 为什么不能只发增量

增量同步看起来很省带宽:每 tick 只发"变了的东西"。但玩家一掉线,增量链就断了——他不知道自己在断线期间错过了什么。

所以必须有全量快照作为"基准点",增量作为"基准点之后的变化"。

连接建立 / 重连  → 发全量快照(带 tick 号 T0)
之后每 tick      → 发增量(T0+1, T0+2, ...)

这就是第三篇 6.4 节说的"重连时下发快照"的完整含义——快照不是补充手段,是同步机制的基准。

5.2 快照与增量的频率配比

消息类型

内容

频率

大小量级

全量快照

视野内所有单位的完整状态

进入/重连时;之后每 5-10 秒一次校准

大

增量

本 tick 变化过的字段

每 tick(20 次/秒)

小

为什么要定期再发一次全量快照?因为增量会累积误差(丢包、客户端插值偏差)。定期校准能防止"越跑越偏"。

这个频率不需要很密——5 到 10 秒一次足够。太密就退化成"一直在发全量",带宽优势没了。

5.3 丢包恢复:靠序列号,不靠重传

增量消息带一个单调递增的序列号:

public class FrameDelta {
    private String sessionId;
    private long tick;
    private long seq;                 // 本房间内单调递增
    private List<UnitDelta> changes;
}

客户端收到后检查 seq 是否连续:

  • 连续 → 正常应用
  • 不连续 → 直接请求一次全量快照,不要求服务器重传丢失的那几帧

为什么不做重传?因为战斗是实时的,重传的第 37 帧到你手上时,第 50 帧已经在路上了。你补上了 37,但 38-49 还是缺的,最后还是要一套补发机制。

用"请求一次快照"替代"重传 N 帧",实现简单、恢复快、代码少一半。

这是实时同步里一个非常实用的取舍:状态是可以覆盖的,所以永远不需要精确补发历史。

(反过来,帧同步就不能这么干——帧同步的每一帧输入都会影响后续所有计算,缺一帧必须补,这就是它更难的原因。)


6.0 输入:为什么会"卡在原地"

玩家反馈:"我一直在按前进,但角色站着不动。"

排查发现:客户端 20Hz 发包,服务器 20Hz 消费,看起来匹配。但网络抖动导致某一秒只到了 12 个包,服务器那一秒的 8 个 tick 没有输入可消费,角色就停了。

根因:服务器把"这一 tick 没有新输入"当成了"玩家没有操作"。

正确做法是输入保持(input holding):

public class InputBuffer {
    /** 环形缓冲,容量固定,防止恶意刷包打爆内存 */
    private static final int CAPACITY = 8;
    private final InputFrame[] buf = new InputFrame[CAPACITY];
    private int head = 0, tail = 0;
    /** 上一次消费过的输入,用于保持 */
    private InputFrame lastConsumed;

    public void offer(InputFrame f) {
        int next = (head + 1) % CAPACITY;
        if (next == tail) {
            // 缓冲满:丢弃最旧的包,而不是阻塞或扩容
            tail = (tail + 1) % CAPACITY;
            log.debug("输入缓冲溢出,丢弃最旧包");
        }
        buf[head] = f;
        head = next;
    }

    /** 每 tick 取一个;没有新输入就沿用上一次 */
    public InputFrame pollOrHold() {
        if (tail == head) {
            return lastConsumed;      // 关键:保持,而不是置空
        }
        lastConsumed = buf[tail];
        tail = (tail + 1) % CAPACITY;
        return lastConsumed;
    }
}

三个细节:

① 缓冲容量固定为 8,满了丢最旧的。

如果不设上限,客户端被恶意改造后疯狂发输入包,服务器内存会被撑爆。丢最旧不丢最新,因为最新输入最接近玩家当前意图。

② 没有新输入时沿用上一次,而不是置空。

这一条直接决定了玩家会不会"卡在原地"。玩家按住前进键时,客户端每秒发 20 个"前进",但只要漏了几个,角色就会一顿一顿。保持上一次输入,移动就平滑了。

③ 为什么缓冲区只有 8 个(约 400ms)?

因为输入缓冲本质是"用延迟换平滑"。缓冲越深,抗抖动越强,但玩家的操作延迟越大。400ms 已经是可接受的上限,再深玩家会觉得"按键有粘滞感"。


7.0 容量与房间生命周期

7.1 一个 tick 的时间预算

20Hz 意味着每 tick 有 50ms。这个 50ms 不是全部能给逻辑用:

用途

预算占比

20Hz 下的耗时

逻辑计算(移动、技能、伤害、buff)

≤ 30%

15ms

增量构建与序列化

≤ 20%

10ms

广播与网络 IO

≤ 30%

15ms

余量(GC、调度抖动、突发)

≥ 20%

10ms

为什么要留 20% 余量? 因为 JVM 有 GC,操作系统有调度,网络有突发。一个跑满 100% 预算的房间,在遇到一次 Young GC(几十毫秒)时必然掉 tick。

"预算打满"= "随时会崩"。

按这个预算估算容量,你需要回答三个问题(具体数字取决于你的技能系统复杂度,这里只给算法不给结论):

  1. 单房间单 tick 的逻辑耗时是多少?(实测,不是估)
  1. 单核能并发跑几个房间?= 单核可用时间 / (单房间 tick 耗时 × 20)
  1. 需要多少核 = 目标房间数 / 单核房间数 × 1.3(留 30% 冗余)

注意第 2 步的"实测":这个数只能压测出来,任何人给你一个"单核能跑 200 个房间"的数字都不该信,因为它完全取决于你的战斗逻辑复杂度。

7.2 房间从创建到销毁

两个容易被忽略的清理逻辑:

① 空房间要能自己死。

如果所有玩家都掉线了,房间还在跑 tick,就是纯浪费 CPU。判定条件:连续 N 个 tick 无在线玩家 → 转 SETTLING 并提前结束。

② SETTLING 必须保证结算单发出去了才能 DESTROYED。

顺序不能反。先销毁房间再发结算单,如果发消息那一刻进程挂了,这场战斗的产出就永久丢失,而且没有任何痕迹。先入队,再销毁。


8.0 战斗服挂了怎么办

这是"无状态"设计真正兑现价值的地方。

房间崩溃后,正确的处置顺序是:

① 网关检测到该节点失联 → 停止向它转发
② 该节点上的所有房间标记 ABORTED
③ 每个房间对应玩家 → 通知本服 recover(第二篇的入口)
④ 本服回滚到进入前快照 + 发补偿邮件
⑤ 租约到期由回收任务兜底(万一通知也丢了)

为什么不做战斗服热迁移?

因为成本和收益完全不匹配:

方案

要做到什么

收益

热迁移

状态序列化 + 目标节点反序列化 + 连接切换 + 时钟对齐

玩家无感,一场战斗不中断

直接作废

通知 + 回滚 + 补偿

一场战斗作废,玩家拿补偿

热迁移需要解决的问题(状态一致性、迁移窗口内的输入、时钟对齐)比整个战斗逻辑都复杂。而它救回来的只是"一场 15 分钟的战斗"。

第一次做跨服,别碰热迁移。 把作废 + 补偿做扎实,成本低一个数量级,玩家体验的差距没有想象中大——毕竟服务器崩溃本身是低频事件。


9.0 怎么测:机器人 + 单房间压测

战斗服的测试不能用"手动点两下"的方式做,因为你要验证的是20Hz 下连续跑 15 分钟不抖。

测试

怎么做

看什么

单房间稳定性

100 个机器人满员跑 15 分钟

tick 耗时 P99 是否 < 15ms;有无掉 tick 告警

随机性复现

用固定种子跑两遍,比对结算单

两遍结果完全一致(验证 4.4)

断线恢复

机器人每 60 秒随机断线重连

重连后能追上快照,不掉队

雪崩保护

人为让某 tick 阻塞 3 秒

不追 60 帧,日志有"丢弃 N 个 tick",房间存活

空房间回收

全部机器人退出

房间在 N tick 内自动销毁,无残留

第二条"随机性复现"是最容易被跳过、也最有价值的一条。它验证的不只是掉落,而是整个战斗逻辑的确定性——如果两遍结果不一致,说明有地方引用了外部时间或未初始化的状态。

这条测试跑通,你就拥有了"线上问题可以本地重放"的能力。


10.0 总结

七句话:

  1. "无状态"的准确含义是"没有不可重建的状态"。 战斗服可以有内存里的战场,但不能有背包和等级。它是可丢弃的计算容器。
  1. 固定 tick 解决的是"同一份逻辑、不同速度"的问题。 20Hz 是大多数战场的平衡点,别一上来就 60Hz。
  1. tick 号必须由真实时间推算,不能靠调度连续性。 否则误差累积,跑一小时就差出几秒。追赶要设上限,宁可丢时间不可积压。
  1. 房间内单线程串行,外部只投递命令。 用非线程安全的容器,让设计错误暴露而不是被掩盖。
  1. "不写库"不等于"不能改内存"。 战斗内可以吃药、可以扣减,只是把扣减记进结算单延后落库——这也是崩溃时药水能一起回来的原因。
  1. 快照是基准,增量是变化,序列号决定要不要重新要快照。 实时同步不需要精确补发历史,因为状态可以覆盖。
  1. 输入无新包时保持上一次,不要置空。 这一个判断决定了玩家会不会"卡在原地"。

下一篇写结算幂等与容灾补偿。前面埋的坑(MQ 重投、回收任务误判、崩溃后的补偿)会在那里一次性收口,也会给出"账对不上"的排查方法论。再下一篇收尾,讲跨服里唯一不能"结果回源"的东西:共享状态(跨服拍卖行、跨服排行榜)。

如果这篇对你有帮助,点个赞👍收藏⭐。代码里的 TICK_RATE 和 MAX_CATCH_UP 建议按你的玩法实测再定。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐