游戏后端篇-深入了解跨服战斗服:固定 tick 怎么跑、无状态怎么做到、快照同步怎么设计(附完整实现)
🔥博客主页: 【小扳_-CSDN博客】
❤感谢大家点赞👍收藏⭐评论✍


前三篇讲的是"玩家怎么进出跨服"。这一篇讲中间那段:他真的在打的时候,战斗服在干什么。
这是整个系列里唯一需要写实时循环的一篇,也是代码量最大的一篇。
开始之前先澄清一个矛盾,因为很多人卡在这里:
第二篇说"战斗服要尽量无状态",可战斗明明需要把 100 个单位的血量、位置、CD 全放在内存里——这不就是有状态吗?
这个矛盾不解决,后面所有的设计都会拧巴。
文章目录
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 瓶) | ✅ | 这是本场战斗的临时状态,属于"可丢弃"那一类 |
| 把新的背包直接写进 | ❌ | 破坏第一原则,而且战斗服不该有库连接 |
| 把"消耗了 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。
"预算打满"= "随时会崩"。
按这个预算估算容量,你需要回答三个问题(具体数字取决于你的技能系统复杂度,这里只给算法不给结论):
- 单房间单 tick 的逻辑耗时是多少?(实测,不是估)
- 单核能并发跑几个房间?= 单核可用时间 / (单房间 tick 耗时 × 20)
- 需要多少核 = 目标房间数 / 单核房间数 × 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 总结
七句话:
- "无状态"的准确含义是"没有不可重建的状态"。 战斗服可以有内存里的战场,但不能有背包和等级。它是可丢弃的计算容器。
- 固定 tick 解决的是"同一份逻辑、不同速度"的问题。 20Hz 是大多数战场的平衡点,别一上来就 60Hz。
- tick 号必须由真实时间推算,不能靠调度连续性。 否则误差累积,跑一小时就差出几秒。追赶要设上限,宁可丢时间不可积压。
- 房间内单线程串行,外部只投递命令。 用非线程安全的容器,让设计错误暴露而不是被掩盖。
- "不写库"不等于"不能改内存"。 战斗内可以吃药、可以扣减,只是把扣减记进结算单延后落库——这也是崩溃时药水能一起回来的原因。
- 快照是基准,增量是变化,序列号决定要不要重新要快照。 实时同步不需要精确补发历史,因为状态可以覆盖。
- 输入无新包时保持上一次,不要置空。 这一个判断决定了玩家会不会"卡在原地"。
下一篇写结算幂等与容灾补偿。前面埋的坑(MQ 重投、回收任务误判、崩溃后的补偿)会在那里一次性收口,也会给出"账对不上"的排查方法论。再下一篇收尾,讲跨服里唯一不能"结果回源"的东西:共享状态(跨服拍卖行、跨服排行榜)。
如果这篇对你有帮助,点个赞👍收藏⭐。代码里的 TICK_RATE 和 MAX_CATCH_UP 建议按你的玩法实测再定。

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



所有评论(0)