ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java Netty 实现 MUD 多人在线游戏:并发、存档与数据一致性实战

2026/10/7 16:25:13 拓冰建站 浏览量
Java Netty 实现 MUD 多人在线游戏:并发、存档与数据一致性实战 简介这是吉林大学Java课程设计中获98分高分的MUD多人在线游戏模拟系统源码适合正在寻找课程设计、期末大作业或毕业设计参考方案的Java学习者。项目围绕多用户虚拟游戏场景完整实现了用户会话管理、实时数据交互、指令解析等关键机制代码注释详细模块化设计令各组件耦合度低便于理解和二次扩展。压缩包共40个文件其中Java源码与class编译文件对应业务实现与运行产物同时包含工程配置文件、说明文档及备份文件整体仅68KB轻量便捷可直接导入开发环境运行。目前已有44人学习下载。读者可从这套经过实际验证的方案中学习网络编程、多线程交互及面向对象设计的落地方式并借鉴清晰的项目划分用于后续功能维护对完成同类系统开发具有重要参考价值。1. 用 Java 重写一个 MUD才理解课程设计里的“多人在线”卡在哪一开始看到《Java MUD多人在线游戏系统开发实战源码吉大课程设计高分项目》这个标题很多人会以为 MUD 就是古早的纯文字聊天室拿 Java 写个 Socket 循环、攒一堆 if-else 命令就能交差。真做一遍就发现MUDMulti-User Dungeon虽然画面为零却是对 Java 网络编程、并发控制、数据结构设计最狠的试金石每个玩家一个连接、每个动作都要广播给同场景的人、战斗状态要能被其他玩家观察和干扰存档还得在不打断在线玩家的情况下完成。这套东西做完你基本就摸清了 Java 服务端开发里最值钱的那块——多线程共享状态下的数据一致性。这篇笔记面向两类人一是要做 Java 课程设计、想要高分且不怕动手的在校生二是想找一个小而完整的网络程序练手、验证自己对 NIO 和并发理解的从业者。我不会把源码逐行贴出来当说明书而是把系统拆成可复现的模块告诉你每个参数怎么定、每个坑长什么样。2. 先定骨架MUD 服务端的模块边界与并发模型选择2.1 为什么选 Netty 而不是 BIO连接数与阻塞的账要算清楚MUD 虽然在视觉上是文本交互但网络模型和大型网游一样属于 IO 密集型。一个课程设计如果限定 50 人同时在线用传统 BIOBlocking IO加线程池确实能撑住每个连接分配一个线程反正 50 个线程 JVM 扛得住。但你要考虑两个现实第一课程设计答辩时老师可能会拿 JMeter 或 telnet 脚本模拟上百个连接第二Java BIO 里一个线程被一个连接长期占用玩家挂机不输入就等于线程空转这个账越到后期越难看。我一般直接选 Netty原因不是炫技而是 Netty 能让你把网络层和业务层彻底解耦。Netty 的 NioEventLoopGroup 默认线程数为 CPU 核数乘 2对于 MUD 这种大量短消息交互的场景这种基于 epoll 的模型在连接数到达 200 以上时依然稳定。而且 Netty 自带的 LengthFieldBasedFrameDecoder 或 LineBasedFrameDecoder 能直接解决 TCP 粘包拆包问题省去自己拼缓冲区的痛苦。如果你做课程设计时老师明确要求“必须用原生 Java”那也至少要上 Selector 写 NIO不要在 ServerSocket 加线程池的路子上走到黑。理由很简单MUD 的命令是高频且零碎的一次“look”命令可能要广播给房间内 10 个玩家BIO 模型下广播遍历列表时线程切换成本高到不划算。2.2 三个核心模块拆解连接层、世界模型、游戏逻辑线程我在动手写代码前会先把服务端拆成三层这个划分方式是多年做后端养成的习惯搬来对付 MUD 正好合适。第一层叫传输层负责管连接。包括客户端的 accept 和 channel 的读写心跳包的收发还有断线时的资源清理。这一层只做字节和字符串的互转不碰游戏逻辑。Netty 在这一层给你提供了 ChannelHandler 抽象你只需要关心 channelRead 里拿到的是什么字符串以及 channelInactive 时该通知谁。第二层叫世界模型层它是 MUD 的核心数据世界。房间Room、玩家Player、怪物Monster、物品Item、出口Exit这五个类支撑起全部游戏场景。这一层需要精心设计数据结构房间之间用 Map方向, 房间ID 连接玩家身上用 Map装备槽位, 物品 表示当前穿戴。这层不关心网络只暴露线程安全的方法。第三层叫逻辑服务层负责处理命令解析、战斗计算、消息广播。一个玩家的输入“kill goblin”经过传输层到达这里逻辑层做语义解析调用世界模型里怪物的扣血方法然后把结果组装成字符串广播回去。这样拆的好处是调试时可以单独测某个层。曾有一次房间里的物品属性全乱了排查后发现是网络层把 JSON 字符串截断了如果没有分层隔离这种 bug 会浪费一整天。2.3 最小工程骨架的目录结构与启动类课程设计最忌讳一开始就把代码铺成一个包最后想要扩展一个功能要翻十万行。我建议用 Maven 管理目录如下mud-server/ ├── pom.xml ├── src/main/java/ │ ├── com/mud/ServerStartup.java │ ├── com/mud/network/ │ │ ├── GameServerInitializer.java │ │ ├── GameMessageHandler.java │ │ └── SessionManager.java │ ├── com/mud/world/ │ │ ├── Room.java │ │ ├── Exit.java │ │ ├── Player.java │ │ ├── Monster.java │ │ └── Item.java │ └── com/mud/logic/ │ ├── CommandParser.java │ ├── CombatService.java │ ├── BroadcastService.java │ └── GameWorld.java └── src/main/resources/ ├── world.json └── log4j2.xmlpom.xml 里只需要三个依赖netty-all、fastjson 或 Gson用于序列化存档、log4j2用来打日志。不要一上来引 Spring BootMUD 核心是一个长驻内存的游戏世界Spring Boot 的自动配置在这里帮不上忙还拖慢启动速度课程设计答辩时老师不会因为你用了 Spring 就加分但会因为你讲不清 Bean 生命周期而扣分。2.4 服务端启动与消息循环的代码骨架下面是最小可运行的启动类这个代码在 java 17 和 Netty 4.1.x 下编译运行没问题。package com.mud; import com.mud.network.GameServerInitializer; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelOption; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; public class ServerStartup { // 端口固定为 4000教室局域网环境下不容易和其他服务冲突 private static final int PORT 4000; public static void main(String[] args) throws InterruptedException { // bossGroup 负责接收新连接workerGroup 负责处理已建立的连接的 IO 事件 EventLoopGroup bossGroup new NioEventLoopGroup(1); // worker 线程数量设置为 CPU 核数 * 2对文本协议来说已经足够 EventLoopGroup workerGroup new NioEventLoopGroup(); try { ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new GameServerInitializer()) // 设置 TCP 连接的请求队列长度超过时内核会丢弃新连接 .option(ChannelOption.SO_BACKLOG, 128) // 开启 TCP keepalive让内核层帮我们探测死连接 .childOption(ChannelOption.SO_KEEPALIVE, true) .childOption(ChannelOption.TCP_NODELAY, true); ChannelFuture future bootstrap.bind(PORT).sync(); System.out.println(MUD server started on port PORT); // 主线程阻塞等待直到服务器关闭 future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }这段代码里三个参数值得你调一下SO_BACKLOG 设 128如果局域网内经常有客户端半连接卡住导致新玩家进不来调大到 256TCP_NODELAY 必须开 true否则小数据包会被 Nagle 算法攒着玩家敲命令后要等 40 毫秒才有反应体感极差workerGroup 线程数不用手动指定Netty 默认的 CPU 乘 2 在课程设计场景下刚好设太多反而因为上下文切换把响应时间拖慢。3. 房间、物品与命令解析把“可玩性”编码进数据结构3.1 房间(Room)为什么是 MUD 的地图单元MUD 的地图不是像素网格而是图结构。每个房间是一个节点每个方向是一条边。设计 Room 类时最核心的字段是 exits它是一个 MapString, Stringkey 是方向词north、south、east、west、up、downvalue 是目标房间的 ID。package com.mud.world; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class Room { private String id; // 房间唯一标识例如 room_1001 private String name; // 展示给玩家的名字例如 小镇广场 private String description; // 长描述玩家输入 look 时显示 private MapString, String exits new ConcurrentHashMap(); private ConcurrentHashMapString, Player players new ConcurrentHashMap(); private ConcurrentHashMapString, Monster monsters new ConcurrentHashMap(); private ConcurrentHashMapString, Item items new ConcurrentHashMap(); public String getExitsDescription() { return String.join(, , exits.keySet()); } public void addExit(String direction, String targetRoomId) { exits.put(direction, targetRoomId); } }这里用 ConcurrentHashMap 而不是 HashMap 是刻意的。想想这个场景玩家 A 从 room_1001 走到 room_1002同时玩家 B 在 room_1002 查看周围环境。如果 Room 内部用普通 HashMap遍历 players 时刚好赶上 A 的移除操作可能抛 ConcurrentModificationException。用 ConcurrentHashMap 虽然单次读写略慢但换来的是全局无锁遍历的安全。另一个细节是 exits 的 key 一定要做规范化玩家输入 “n” 或 “north” 都要映射到同一个 key。我见过有人直接拿用户输入当 map 的 key结果 “North” 和 “north” 走了两个不同的出口玩家直接卡在虚空里。3.2 玩家与 NPC 属性不要一个属性一个字段老手写游戏服务端都会把属性收敛成一个 MapString, Integer而不是定义十几个字段 hp、mp、attack、defense、speed、luck。原因有两个第一战斗技能可能临时加攻减防用字段要被一堆 setter 淹没第二存档和读档时把 Map 直接序列化为 JSON 最方便。package com.mud.world; import java.util.HashMap; import java.util.Map; public class Player { private String id; // 玩家ID private String name; // 玩家名 private String currentRoomId; // 当前所在房间 private MapString, Integer attrs new HashMap(); public Player(String id, String name) { this.id id; this.name name; this.attrs.put(hp, 100); this.attrs.put(maxHp, 100); this.attrs.put(mp, 50); this.attrs.put(maxMp, 50); this.attrs.put(attack, 15); this.attrs.put(defense, 5); this.attrs.put(level, 1); this.attrs.put(exp, 0); } public void changeHp(int delta) { int current attrs.get(hp); int newHp Math.max(0, Math.min(current delta, attrs.get(maxHp))); attrs.put(hp, newHp); } }changeHp 里用 Math.max 和 Math.min 钳制血量上下限这个写法比一堆 if 判断干净得多。强调一下只要 pvp 或 pve 涉及多人同时改变一个玩家属性changeHp 就必须加 synchronized 或者让调用方保证串行。这里先埋个伏笔第 4 章专门讲并发问题。物品设计同理用一个 ItemRecord 对象表示单个物品的 id、名称、类型武器/防具/消耗品、效果值放在玩家的背包 MapString, Integer 里key 是物品 idvalue 是数量。装备栏则是单独的数组或 Map记录当前穿戴的物品 id。3.3 命令解析器if-else 链在第十五条命令时失控MUD 玩家的所有操作都是命令字符串。最简单的做法是在一个方法里写 if (look.equals(cmd)) {...} else if (move.equals(cmd)) {...}。前十条命令这么写没问题到第十五条你就会发现命令前置参数比如 “get apple” 和 “get all”的处理逻辑纠缠在一起新加一个工具命令要小心翼翼避开前面的分支。我建议做两层解析第一层按空格把输入拆成 命令词 参数数组第二层用一个 MapString, CommandHandler 注册表把命令词映射到具体的 handler 对象。package com.mud.logic; import java.util.HashMap; import java.util.Map; import java.util.function.BiConsumer; public class CommandParser { // key 是命令词value 是处理方法 private MapString, BiConsumerPlayerSnapshot, String[] handlers new HashMap(); public CommandParser() { handlers.put(look, this::handleLook); handlers.put(move, this::handleMove); handlers.put(kill, this::handleKill); handlers.put(say, this::handleSay); handlers.put(get, this::handleGet); handlers.put(inventory, this::handleInventory); // 新增命令只需往这里加一行不需要改动其他逻辑 } public void execute(String playerId, String rawInput) { String trimmed rawInput.trim(); if (trimmed.isEmpty()) { return; } String[] parts trimmed.split(\\s, 2); String command parts[0].toLowerCase(); String[] args parts.length 1 ? parts[1].split(\\s) : new String[0]; BiConsumerPlayerSnapshot, String[] handler handlers.get(command); if (handler null) { // 未知命令给个友好提示而不是闷不吭声 System.out.println([unknown] playerId - rawInput); return; } handler.accept(new PlayerSnapshot(playerId), args); } }split(\s, 2) 这个正则是关键。第二个参数 2 表示只拆分成两段这样 “say hello world” 会被拆成 [say, [hello, world]]而不是 [say, hello, world]。不然你说一句话里带空格后面所有词都被错误地当成独立参数。每个 handler 接收 PlayerSnapshot 而不是 Player 对象本身是为了让命令处理层不直接持有可变领域对象的引用这个习惯从 Web 后端带过来在 MUD 里能减少很多并发边界问题。3.4 look 命令完整链路从 channelRead 到响应输出一个命令从网络到业务再回到网络中间经过的过程值得完整走一遍很多新手在这里把顺序搞反。下面给出 GameMessageHandler 里调用命令解析的代码package com.mud.network; import com.mud.logic.CommandParser; import com.mud.logic.GameWorld; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; public class GameMessageHandler extends SimpleChannelInboundHandlerString { private final CommandParser parser; private final GameWorld world; public GameMessageHandler(CommandParser parser, GameWorld world) { this.parser parser; this.world world; } Override public void channelRead0(ChannelHandlerContext ctx, String msg) { // 把 Netty 的 Channel 和玩家的业务ID关联起来 String playerId SessionManager.getPlayerId(ctx.channel()); if (playerId null) { // 尚未登录走登录逻辑 handleLogin(ctx, msg); return; } // 交给命令解析器由它决定调用哪个业务方法 parser.execute(playerId, msg); } private void handleLogin(ChannelHandlerContext ctx, String msg) { // 简单协议第一行是 login 用户名 String[] parts msg.split(\\s); if (parts.length 2 login.equals(parts[0])) { String name parts[1]; world.enterWorld(ctx, name); SessionManager.bind(ctx.channel(), name); } else { ctx.writeAndFlush(请先输入 login 你的名字\n); } } }外部玩家输入 “look”最终在 CommandParser 里调用 handleLook由 GameWorld 查询当前房间信息拼装成一个长字符串写回 Channel。网络层和世界模型之间的单向依赖到这里就清爽了网络层只认 String世界模型只认 Player 和 Room中间靠 CommandParser 做翻译。3.5 广播机制同样是“写回”为什么不能直接遍历 ChannelMUD 里的“广播”指的是把一个消息发送给同一房间内所有玩家。如果直接维护一个 Channel 列表逐个 writeAndFlush会有三个坑第一某些玩家可能已经断线但 SessionManager 还没清理写到一个已关闭的 Channel 上会抛异常第二广播时要按场景隔离不能把私聊消息发给全服第三频繁 writeAndFlush 会导致系统调用次数暴涨。最稳妥的广播方式是让 GameWorld 维护玩家 ID 到 Channel 的映射广播时先通过房间拿到玩家 ID 集合再查各自对应的 Channel 写出。package com.mud.logic; import io.netty.channel.Channel; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public class BroadcastService { private ConcurrentHashMapString, Channel onlineChannels new ConcurrentHashMap(); public void sendToPlayer(String playerId, String message) { Channel channel onlineChannels.get(playerId); if (channel ! null channel.isActive()) { channel.writeAndFlush(message \n); } } public void sendToRoom(String roomId, SetString playerIds, String message) { for (String pid : playerIds) { sendToPlayer(pid, message); } } public void register(String playerId, Channel channel) { onlineChannels.put(playerId, channel); channel.closeFuture().addListener(future - onlineChannels.remove(playerId)); } }注意 closeFuture 上的监听器这是 Netty 里最推荐的资源清理方式Channel 关闭后自动从在线表移除而不是依赖某个心跳定时器。我曾经偷懒不在 closeFuture 里移除结果玩家一断线再登录旧 Channel 还占着在线表新连接被挤下线排查了大半天。4. 并发与数据一致性多人同房间战斗时HashMap 会撒谎4.1 玩家线程模型每个玩家一个线程是灾难的起点有一种常见错误是给每个连接建一个独立线程处理该玩家的所有指令。这个方案在登录时看是正常的但只要两个玩家发生交互——比如 A 攻击 BA 的线程在改 B 的 hpB 的线程同时也在改自己的 hp——就会因为非原子操作导致血量写丢。看似加个 synchronized 就行但锁粒度一粗整个房间的玩家互相锁等待游戏体验变成幻灯片。Netty 的 EventLoop 模型比线程模型干净同一个 Channel 的所有 IO 事件都由同一个 EventLoop 线程处理这本身就保证了一个玩家命令的串行性。你不需要额外加锁来保护单个玩家的属性。跨玩家的操作怎么保护答案是把锁加到世界模型层而不是玩家对象层。我建议把 GameWorld 里所有“写”操作移动、战斗、拾取集中到一个方法里这个方法内部用一个全局 ReentrantLock 保证串行。MUD 不是高并发支付系统玩家每秒最多敲两次指令全局锁完全够用换来的是逻辑简单、不会死锁。4.2 ReentrantReadWriteLock 实现房间内多读单写如果全局锁让你觉得不够好看可以降级到房间级读写锁。同一个房间内广播读操作远多于战斗写操作用 ReentrantReadWriteLock 能提升并发度。package com.mud.world; import java.util.concurrent.locks.ReentrantReadWriteLock; public class Room { private final ReentrantReadWriteLock lock new ReentrantReadWriteLock(); // 玩家进入房间走写锁 public void addPlayer(Player p) { lock.writeLock().lock(); try { players.put(p.getId(), p); } finally { lock.writeLock().unlock(); } } // 查看房间内玩家列表走读锁 public String describePlayers() { lock.readLock().lock(); try { return players.keySet().toString(); } finally { lock.readLock().unlock(); } } }这里要严格遵守一个纪律拿到写锁后不要在里面调用可能再次获取锁的方法否则 ReentrantReadWriteLock 在写锁内部再拿读锁也是允许的但再拿写锁就要小心递归死锁。我建议写锁区块里只做 Map 操作和状态变更不发网络消息因为 writeAndFlush 是 IO 操作会持有锁等待内核缓冲区腾空把游戏世界锁死。4.3 数据持久化全量存档时怎么不让玩家卡顿MUD 的存档内容和普通 Web 应用的数据库不一样它本质上是一个内存世界快照。最简单的方案是每分钟把全服所有房间、玩家、物品序列化成 JSON 写到磁盘。但全量快照有个问题序列化一个大对象图可能耗时几十毫秒期间如果玩家在动就会存到一半的状态。我惯用的手法是 CopyOnWrite 思想存档触发时先把所有玩家和房间的引用快照到一个新列表然后只对列表里的持久化字段读值。更实用一点的做法是分片存档每 30 秒存玩家基础属性和房间地图战斗中的血量变化允许丢一次存档。package com.mud.logic; import com.alibaba.fastjson.JSON; import com.mud.world.Player; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.List; public class SaveService { private static final Path SAVE_DIR Path.of(./saves); public void savePlayers(ListPlayer onlinePlayers) { ListString lines new ArrayList(); for (Player p : onlinePlayers) { lines.add(JSON.toJSONString(p)); } try { Files.write(SAVE_DIR.resolve(players.json), lines, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } catch (Exception e) { System.err.println(存档失败 e.getMessage()); } } }注意这里把玩家列表传进来之前GameWorld 要先做一个快照而不是直接把实时列表传给 SaveService。快照本身很快因为它只复制引用。真正不确定的是 JSON 序列化耗时放在存档线程里执行不要占用 EventLoop 的处理时间。4.4 心跳超时与重复登录踢人逻辑的三个边界条件MUD 是长连接应用玩家可能挂着不动一整天TCP 层面连接还在但玩家其实已经离开。如果服务端不做心跳检测在线列表会堆积无数僵尸连接广播时浪费资源。Netty 提供 IdleStateHandler可以给每个 Channel 设置读空闲超时。package com.mud.network; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelPipeline; import io.netty.channel.socket.SocketChannel; import io.netty.handler.codec.LineBasedFrameDecoder; import io.netty.handler.codec.string.StringDecoder; import io.netty.handler.codec.string.StringEncoder; import io.netty.handler.timeout.IdleStateHandler; import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; public class GameServerInitializer extends ChannelInitializerSocketChannel { Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline ch.pipeline(); // 按行拆包客户端每行一条指令天然规避粘包 pipeline.addLast(new LineBasedFrameDecoder(1024)); pipeline.addLast(new StringDecoder(StandardCharsets.UTF_8)); pipeline.addLast(new StringEncoder(StandardCharsets.UTF_8)); // 5 分钟没收到客户端数据触发 userEventTriggered pipeline.addLast(new IdleStateHandler(300, 0, 0, TimeUnit.SECONDS)); pipeline.addLast(new GameMessageHandler()); } }IdleStateHandler 的构造函数是 readerIdleTime、writerIdleTime、allIdleTime。MUD 只检测读空闲所以第一个参数给 300后面两个给 0。触发超时后的处理逻辑一般在 channelInactive 或 userEventTriggered 里统一做掉。这个值设成 300 秒是基于课程设计场景学生做到一半去吃饭回来还想继续挂机300 秒内不会掉但真要离开一小时就自动踢出去。重复登录的边界条件藏在 SessionManager.bind 方法里。如果玩家 A 断开后马上重新登录但 GameMessageHandler 里旧 Channel 还没完全关闭此时 onlineChannels 里会出现新旧两个 key。处理方式是每次 bind 前先检查同名玩家是否已在线若在线则先强踢旧 Channel。package com.mud.network; import io.netty.channel.Channel; import java.util.concurrent.ConcurrentHashMap; public class SessionManager { private static final ConcurrentHashMapString, Channel SESSIONS new ConcurrentHashMap(); public static boolean bind(String playerId, Channel channel) { Channel old SESSIONS.put(playerId, channel); if (old ! null old.isActive()) { old.writeAndFlush(你的账号在其他地方登录你被挤下线。\n); old.close(); return false; } return true; } public static String getPlayerId(Channel channel) { for (var entry : SESSIONS.entrySet()) { if (entry.getValue().equals(channel)) { return entry.getKey(); } } return null; } }注意 getPlayerId 是遍历查找复杂度 O(n)在线人数少时没问题超过千人建议改成反向映射表。课程设计一般几十人这个写法反而简洁。5. 避坑与排查Netty 版本差异、telnet 编码、存档损坏5.1 现象玩家输入中文乱码命令全失效用 telnet 连上服务器后输入中文名字全部变成问号用这个名字登录的角色无法存档。原因telnet 默认按操作系统的本地编码发送字节Windows 下是 GBK而服务端 decoder 用 UTF-8 解析字节就对不上。另外 LineBasedFrameDecoder 只是按 \n 拆包它不感知字符边界。解决客户端连接后先发一条binary协商指令telnet 的二进制传输模式服务端不要做编码转换统一用 ISO-8859-1 接收原始字节再在命令解析层用 UTF-8 手动解码。这个坑在答辩演示时最容易暴露因为教室里 Windows 电脑大概率是 GBK 环境。5.2 现象服务端吞吐量正常但某个玩家操作延迟高达 5 秒玩家输入指令后其他玩家能看到广播结果但发出指令的本人界面卡住了。原因这个玩家的 Channel 在近 5 秒内有大量数据积压比如上一轮广播发了一个超长房间描述Netty 的高水位 watermark 机制触发后自动暂停对此 Channel 的写入。如果客户端没有及时读走数据TCP 窗口被占满服务端 writeAndFlush 阻塞在 IO 线程上。解决广播长文本时限制单次消息长度超过 2000 字符的自动截断或分页。另一个手段是检查客户端是否在 5 秒内读取了数据不要用 telnet 手动拖窗口、却要求服务端无限缓冲。5.3 现象存档文件读回时报 JSON 解析异常角色永久丢失服务器重启后玩家登录时发现角色数据读不出来报错定位到存档 JSON 的某个字段。原因存档是异步线程执行的玩家正在战斗时被快照Player 对象里的 attrs Map 在序列化过程中被并发修改Fastjson 抛出异常后代码只打了错误日志但没有回滚文件写成了半截。解决存档线程里改用对象拷贝而不是引用快照也就是先new HashMap(player.attrs)再序列化。更稳妥的方案是双文件轮转先写players.json.tmp写完后再原子替换为players.json这样即使写一半崩溃老存档还在。5.4 现象所有命令都执行了两次战斗伤害翻倍玩家输入 attack 后怪物掉血两次其他玩家看到了两行重复的战斗日志。原因ChannelPipeline 里重复添加了同一个 Handler 实例或者客户端在两次心跳重连后服务端没清理旧流水线导致一条 channelRead 被两个 handler 各消费一次。我实际踩过的是在 GameServerInitializer 里 new 了同一个 ChannelHandler 对象传给 addLast而不是每次连接都 new。解决自定义 Handler 每次连接都要 new 一个实例不要做单例。如果想省内存就在 initChannel 里传入共享的静态逻辑对象但不能共享带状态的 SessionManager。5.5 现象存档后房间内的怪物数量翻倍服务端运行一晚上第二天开机发现每个房间的怪物涨了三四倍玩家打怪杀不完。原因启动时加载存档逻辑判断条件写反服务器正常关闭时没有走“清空世界再读档”的流程导致旧世界的怪物对象没有被释放新读档的怪物又加进去了对象被重复引用。解决加载存档前先调用GameWorld.reset()把所有房间的 monsters 集合清空再对新集合填数据。这个和后勤的 log4j2 无关纯粹是初始化顺序问题。补一个原则服务端启动时的世界构建永远走“创建空地图 - 读取存档 - 填充玩家和怪物”的顺序不要走“保留内存态 - 增量更新”。6. 收尾从“能跑”到“像样”的验证与进阶技巧6.1 用 bash 脚本模拟 20 个并发机器人做冒烟测试课程设计答辩前至少要验证一下并发能力。不用 JMeter一个 bash 循环就能模拟 20 个客户端同时连接#!/bin/bash for i in $(seq 1 20); do (echo login bot_$i; sleep 2; echo look; sleep 1; echo move north; sleep 3) | timeout 10 nc localhost 4000 /tmp/bot_$i.log done wait echo All bots finished grep -c 房间 /tmp/bot_*.log | sort这个脚本跑完后检查每个日志文件是否都有“房间”描述的返回以及是否有人报连接拒绝。如果发现部分 bot 的 look 响应缺失说明 EventLoop 线程中有阻塞操作立刻把所有 IO 操作移出锁区域。6.2 三个能让你拿高分的亮点扩展第一个扩展是给 Room 增加天气和时间系统用一个全局 timer 每 30 秒广播一次当前房间描述变化这会让老师觉得你的世界是活的。第二个扩展是战斗日志的异步持久化把战斗记录存到 SQLite 而不是控制台这样答辩时能翻出记录展示服务器运行历史。第三个扩展是把世界地图做成一个有环的迷宫并在 README 里配上 ASCII 地图这比一万行代码更能说明你的设计能力。6.3 提交源码时一定要包含的东西课程设计一般要求交源码和文档。源码压成 zip 之前删掉 target 目录和 .idea 目录务必保留 resources 下的 world.json 和启动脚本。文档里贴一张时序图画“玩家输入命令 - 服务端解析 - 广播给同房间”的过程比贴代码截图高级得多。记得在 README 里写清楚 JDK 版本和 Netty 版本这两者不匹配会让人一直编译失败我之前写代码时用 Netty 4.1.100 配 JDK 8怎么调都报 module 错误最后才发现是版本问题。我做过多次这样的课程设计指导最大的心得是MUD 看似老古董但它把网络编程、并发、数据持久化这三座大山浓缩在一个可以运行的小项目里。你在配置 Netty 心脑血管的时踩过的坑在后面写任何 Java 后端服务时都会换成真金白银的经验。希望这篇笔记能帮你少走一些弯路把时间留在真正有意思的游戏设计上。本文还有配套的精品资源点击获取