1. 压测现场的真实崩溃:不是代码写得烂,是资源调度失衡
那天凌晨两点,我们刚把新上线的跨服战副本逻辑合入预发环境,照例跑一轮 JMeter 压测——目标 3000 并发用户,模拟玩家集中入场、技能连招、战报广播三个高负载阶段。结果还没撑过第 47 秒,监控面板就炸了:CPU 使用率曲线像被钉在 99% 的钢板上纹丝不动,Redis 连接池耗尽告警频闪,MongoDB 的queue指标飙升到 127,慢查询日志里塞满了find和updateOne的 800ms+ 记录。更诡异的是,应用日志里几乎没报错,GC 日志也平静如常,整个系统像一台被卡死的精密钟表,所有齿轮都在转,但指针一动不动。
这不是第一次遇到 CPU 打满却查不出热点的问题。过去三年我带过的 7 个游戏后端项目里,有 5 次压测失败的根因都不是业务逻辑缺陷,而是资源争用链路中的隐性瓶颈被并发放大。比如这次,表面看是 CPU 满载,但top -H一查线程 CPU 占比,发现真正吃资源的不是游戏逻辑线程,而是 Redis 客户端的 Netty EventLoop 线程和 MongoDB Java Driver 的连接池管理线程;jstack抓取的线程堆栈里,大量线程卡在sun.nio.ch.EPollArrayWrapper.epollWait和com.mongodb.internal.connection.DefaultConnectionPool.getOrCreateConnection上——这说明问题不在“算得多”,而在“等得久”。
提示:游戏后端压测中 CPU 打满 ≠ 代码效率低。当 CPU 利用率持续高于 90%,首先要怀疑的是 I/O 等待、锁竞争或连接池耗尽导致的线程阻塞,而非盲目优化算法。真正的瓶颈往往藏在“等待”背后,而不是“计算”本身。
你可能会说:“加机器不就完了?”但现实是,我们试过把 Redis 实例从 4 核升到 16 核,CPU 使用率反而从 99% 涨到 100%,因为更多核只是让更多线程排队等同一个网络连接;MongoDB 从单节点扩到三节点副本集,queue指标只降了 5%,因为写请求依然卡在主节点的 WiredTiger 缓存刷盘队列里。这就像往堵死的高速路口加车道——车流没疏通,只会让堵车范围更大。
所以这次我们决定不做“扩容式急救”,而是做一次全链路资源治理:从 Redis 的序列化协议选型、连接复用策略,到 MongoDB 的索引覆盖度、写关注级别,再到 JVM 层面对 Netty 和 Mongo Driver 的线程模型调优。核心思路很朴素:让每个组件只做它最擅长的事,且不给上下游制造等待。比如 Redis 不该承担复杂聚合计算,MongoDB 不该被当作缓存层滥用,而 JVM 的线程池不该为数据库连接等待而无限扩容。
这个过程没有银弹,但每一步都有明确的数据反馈。我们用redis-cli --latency测出单次命令平均延迟从 8.2ms 降到 0.9ms,用mongostat观察到qrw(读写队列)从 127 降到 0,用arthas的thread -n 5看到高 CPU 线程从 12 个减少到 2 个。最终,同一套 JMeter 脚本,在不增加任何服务器资源的前提下,支撑并发从 3000 提升到 8500,TPS 从 1200 稳定在 4100,P99 响应时间从 1280ms 压到 210ms。这不是性能翻倍,而是把原本被浪费在等待上的 70% 资源释放出来,重新分配给真实业务计算。
如果你正在为游戏后端压测卡在某个数值上反复挣扎,这篇文章就是为你写的。它不讲抽象理论,只记录我们踩过的每一个坑、验证过的每一组参数、以及为什么必须这样改——因为游戏服务器的稳定性,从来不是靠堆资源堆出来的,而是靠对每个字节、每次连接、每毫秒等待的极致较真。
2. Redis 治理:从“万能缓存”到“精准管道”的认知重构
游戏后端里,Redis 常被当成“万能胶水”:登录态存它、排行榜存它、背包快照存它、甚至聊天消息都往里塞。这种用法在小规模时很爽,但压测时立刻暴露本质——Redis 不是内存数据库,它是基于单线程事件循环的内存键值管道。它的高性能来自极简的命令处理模型,代价是任何阻塞操作都会让整条流水线停摆。而我们之前的设计,恰恰在多个环节把它变成了“阻塞源”。
2.1 序列化协议:JSON 不是万能解药,ProtoBuf 才是游戏场景的刚需
压测初期,我们用的是 Jackson + JSON 序列化。一个简单的玩家状态对象(含 12 个字段、3 个嵌套 List),序列化后字符串长度达 1842 字节,反序列化耗时平均 1.2ms。更致命的是,JSON 是文本协议,Redis 服务端无法理解其结构,所有GET操作都返回完整字符串,客户端必须全量解析才能取一个字段——比如只查玩家金币数,却要反序列化整个背包+装备+任务状态。
我们做了三组对比实验:
- JSON(Jackson):序列化 1.2ms,反序列化 1.3ms,网络传输 1842B
- Kryo(默认配置):序列化 0.4ms,反序列化 0.5ms,传输 921B
- Protobuf(v3,预编译 schema):序列化 0.18ms,反序列化 0.22ms,传输 317B
关键差异在于 Protobuf 的二进制紧凑性和字段按需解析能力。我们定义了.proto文件:
message PlayerState { int64 uid = 1; string name = 2; int32 gold = 3; repeated Item items = 4; map<string, int32> buffs = 5; }然后用playerState.toBuilder().setGold(9999).build().toByteArray()直接生成二进制,Redis 中存SET player:1001 <binary>。需要查金币时,不再GET全量再解析,而是用GETRANGE player:1001 24 27(gold 字段在二进制中的偏移区间)直接截取 4 字节,再ByteBuffer.wrap(bytes).getInt()解析——整个过程耗时 0.03ms,比 JSON 方案快 40 倍。
注意:Protobuf 的优势在“已知结构+高频访问”。如果业务要求 Redis 存储完全动态的 JSON(如玩家自定义配置),那 JSON 仍是合理选择,但必须接受其性能代价。游戏后端中 80% 的缓存数据结构是稳定的,强行用 JSON 是用通用性换性能。
2.2 连接模型:JedisPool 的“伪连接池”陷阱与 Lettuce 的原生异步真相
我们最初用 JedisPool,配置maxTotal=200。压测时发现,即使并发只有 500,Jedis 就频繁抛JedisConnectionException: Could not get a resource from the pool。jstack显示所有线程卡在JedisFactory.makeObject()的synchronized块里——JedisPool 的连接获取是全局锁的,200 个连接在高并发下成了串行瓶颈。
换成 Lettuce 后,问题迎刃而解。Lettuce 的设计哲学完全不同:它基于 Netty 构建单连接多路复用(Multiplexing),一个RedisClient实例内部维护一个 Netty Channel,所有命令通过该 Channel 异步发送,响应通过 Promise 回调。我们实测:
- JedisPool(200 连接):500 并发下平均获取连接耗时 12ms,超时率 18%
- Lettuce(1 连接):500 并发下命令发送耗时稳定在 0.05ms,无超时
但这不意味着可以无脑切 Lettuce。它的坑在于事务和 Pipeline 的语义差异。Jedis 的multi()是真正的 Redis 事务,而 Lettuce 的multi()只是客户端命令缓冲,若中间某条命令失败,后续命令仍会执行。我们为此重写了所有涉及原子操作的逻辑,用RedisStringCommands.set(key, value, SetOption.upsert(), Expiration.seconds(300))替代multi/exec,用 Lua 脚本封装复杂逻辑(如“扣金币+发通知+更新排行榜”),确保原子性落在服务端。
2.3 数据结构误用:SortedSet 当排行榜,Hash 当状态,别再用 String 存一切
压测时发现ZREVRANGE leaderboard 0 99命令耗时高达 15ms,远超预期。redis-cli --bigkeys一查,排行榜 SortedSet 里存了 230 万个成员,而ZCARD显示实际活跃玩家仅 1.2 万。原来业务代码每分钟都ZADD一次全服玩家,不管是否在线——这是典型的“用空间换时间”思维失控。
解决方案分三层:
- 数据清洗:用
ZREMRANGEBYSCORE leaderboard -inf (1672531200(昨天零点时间戳)定期清理过期分数 - 结构优化:将全服排行榜拆分为“实时榜”(内存 SortedSet,只存最近 1 小时活跃玩家)和“历史榜”(MongoDB 归档,按天分区)
- 访问降级:前端请求
/leaderboard时,先查 Redis 实时榜(命中率 92%),未命中则查 MongoDB 并回填 Redis,设置EXPIRE30 秒防雪崩
另一个典型误用是用 String 存玩家状态。比如SET player:1001 '{"gold":100,"level":5}',每次更新都要GET全量 → 修改 JSON →SET全量。改成 Hash 后:
HSET player:1001 gold 100 level 5(原子更新)HGET player:1001 gold(精准读取)HINCRBY player:1001 gold 10(原子增减)
网络传输从 32 字节 JSON 降到 18 字节二进制(Hash key+field+value),命令执行耗时从 0.8ms 降到 0.15ms。更重要的是,避免了 JSON 解析的 GC 压力——压测时 Young GC 频率从 12 次/秒降到 3 次/秒。
3. MongoDB 治理:从“文档仓库”到“精准索引引擎”的硬核调优
MongoDB 在游戏后端常被当作“高级 JSON 存储”,开发时图省事,所有查询都走{}全表扫描。压测时db.currentOp({secs_running: {$gt: 1}})一查,慢查询全是find和updateOne,explain("executionStats")显示executionTimeMillisEstimate动辄 600ms 以上,nReturned却只有 1——这意味着为了找 1 条记录,MongoDB 扫了 60 万条文档。
3.1 索引诊断:不是“加索引就完事”,而是“让每条查询都命中最优索引”
我们用db.setProfilingLevel(2, {slowms: 10})开启慢查询日志,收集 1 小时压测数据,用db.system.profile.aggregate([{$match: {millis: {$gt: 10}}}, {$group: {_id: "$query", count: {$sum: 1}}}, {$sort: {count: -1}}])统计高频慢查询。排前三的是:
{"uid": 1001, "type": "equip"}→ 查询玩家装备{"sceneId": 101, "x": {$gte: 100, $lte: 200}, "y": {$gte: 150, $lte: 250}}→ 场景内玩家坐标查询{"logTime": {$gte: ISODate("2024-01-01"), $lt: ISODate("2024-01-02")}, "action": "login"}→ 登录日志查询
针对第一条,我们创建复合索引:
db.player_items.createIndex({"uid": 1, "type": 1}, {background: true})注意background: true是必须的,否则建索引会锁表。重建后,explain显示executionTimeMillisEstimate从 580ms 降到 3ms,totalDocsExamined从 592312 降到 1。
第二条坐标查询更棘手。x和y单独建索引无效,因为$gte/$lte在复合索引中要求前导字段必须是等值查询。我们改用地理空间索引:
db.players.createIndex({"location": "2dsphere"}, {background: true}) // 查询时用 db.players.find({ "location": { "$near": { "$geometry": {"type": "Point", "coordinates": [150, 200]}, "$maxDistance": 100 } } })$near比$gte/$lte快 17 倍,且支持球面距离计算(为后续跨服地图做准备)。
第三条时间范围查询,我们采用时间分片索引:
// 按天分片,每天一个集合 db.logs_20240101.createIndex({"logTime": 1, "action": 1}) db.logs_20240102.createIndex({"logTime": 1, "action": 1})应用层根据logTime自动路由到对应集合,避免单集合过大。explain显示executionTimeMillisEstimate从 420ms 降到 8ms。
3.2 写操作治理:WiredTiger 缓存与写关注的平衡术
压测时mongostat显示qrw(读写队列)长期 > 100,flushes(WiredTiger 缓存刷盘次数)每秒 3-5 次,hard page faults(硬缺页)飙升。根本原因是写请求太多,WiredTiger 缓存来不及刷盘,新写入被阻塞在内存队列里。
我们调整了两个关键参数:
- WiredTiger 缓存大小:默认是
min(50% RAM, 1GB),但我们把 32GB 机器的缓存设为24G(storage.wiredTiger.engineConfig.cacheSizeGB: 24)。实测page faults从 1200/s 降到 80/s,flushes从 5/s 降到 0.3/s。 - 写关注(Write Concern):默认
w:1(主节点写入即返回),但我们对非关键日志(如玩家移动轨迹)改为w:0(fire-and-forget),对关键操作(如充值、装备合成)保持w:2(主+一个副本确认)。w:0的写入耗时从 8ms 降到 1.2ms,qrw降低 65%。
提示:
w:0不等于“不持久化”。WiredTiger 的 journal 日志默认开启,即使进程崩溃,journal 也能恢复未刷盘数据。w:0只是跳过服务端确认,由客户端承担“可能丢失”的风险——游戏里玩家移动坐标丢几帧,远不如充值失败后果严重。
3.3 连接池与驱动:MongoClient 的线程安全与连接复用真相
我们曾以为MongoClient是线程安全的,所以全局单例。但压测时发现com.mongodb.internal.connection.DefaultConnectionPool.getOrCreateConnection方法成为热点。MongoClient确实线程安全,但它的连接池管理器(DefaultConnectionPool)在高并发下存在锁竞争。
解决方案是显式配置连接池:
MongoClientSettings settings = MongoClientSettings.builder() .applyToConnectionPoolSettings(builder -> builder.maxConnectionLifeTime(0, TimeUnit.MILLISECONDS) // 连接永不过期 .maxConnectionIdleTime(0, TimeUnit.MILLISECONDS) // 连接永不空闲 .maxWaitTime(100, TimeUnit.MILLISECONDS) // 获取连接超时 100ms .maxConnectionCount(200) // 最大连接数 200 .minConnectionCount(50)) // 最小连接数 50 .build(); MongoClient mongoClient = MongoClients.create(settings);关键参数解读:
maxConnectionLifeTime=0:避免连接因老化被回收重建(重建连接耗时 20-50ms)maxConnectionIdleTime=0:防止连接空闲时被服务端断开(MongoDB 默认 60 分钟断开空闲连接)maxWaitTime=100ms:超过 100ms 没拿到连接就报错,避免线程无限等待maxConnectionCount=200:根据mongostat的conn值动态调整(压测时conn稳定在 180 左右)
调优后,getOrCreateConnection耗时从 15ms 降到 0.3ms,qrw从 127 降到 0。
4. JVM 与网络层协同:Netty、Mongo Driver 与 GC 的三角调优
压测时 CPU 打满,jstat -gc却显示 Young GC 频率正常(2-3 次/秒),Full GC 几乎为 0。jstack抓取的线程堆栈里,大量线程处于RUNNABLE状态,但top -H显示它们 CPU 占用极低——这说明线程在忙等(busy-wait),而非真正在计算。
4.1 Netty EventLoop 线程绑定:从“共享池”到“专属通道”的硬隔离
我们用 Lettuce 连接 Redis,但没指定EventLoopGroup,导致所有 Redis 命令共用 Netty 默认的MultithreadEventLoopGroup(CPU 核数 * 2 个线程)。压测时,这些线程既要处理 Redis 响应,又要处理 MongoDB Driver 的 Netty 通信,还要响应 HTTP 请求——资源争用严重。
解决方案是为不同组件分配专属 EventLoopGroup:
// Redis 专用 EventLoopGroup EventLoopGroup redisGroup = new NioEventLoopGroup(4, new DefaultThreadFactory("redis-eventloop")); RedisClient redisClient = RedisClient.create(RedisURI.create("redis://127.0.0.1:6379")); redisClient.setOptions(ClientOptions.builder() .socketOptions(SocketOptions.builder().connectTimeout(Duration.ofSeconds(3)).build()) .timeoutOptions(TimeoutOptions.builder().fixedTimeout(Duration.ofSeconds(2)).build()) .build()); StatefulRedisConnection<String, String> redisConnection = redisClient.connect( new Utf8StringCodec(), redisGroup); // MongoDB 专用 EventLoopGroup EventLoopGroup mongoGroup = new NioEventLoopGroup(2, new DefaultThreadFactory("mongo-eventloop")); MongoClient mongoClient = MongoClients.create( MongoClientSettings.builder() .applyToClusterSettings(builder -> builder.hosts(Arrays.asList(new ServerAddress("127.0.0.1:27017")))) .applyToSocketSettings(builder -> builder.connectTimeout(3, TimeUnit.SECONDS) .readTimeout(5, TimeUnit.SECONDS)) .applyToTransportSettings(builder -> builder.nettyEventLoopGroup(mongoGroup)) // 关键!绑定专属 EventLoopGroup .build());效果立竿见影:Redis EventLoop 线程 CPU 占用从 95% 降到 32%,MongoDB EventLoop 线程从 88% 降到 25%,HTTP 处理线程(Tomcat)CPU 占用从 45% 升到 68%——资源被正确分配给了真正需要计算的业务逻辑。
4.2 MongoDB Driver 的心跳与连接保活:避免“假死连接”拖垮线程池
压测运行 30 分钟后,mongostat显示conn(当前连接数)从 180 慢慢涨到 220,qrw重新爬升。netstat -an | grep :27017 | wc -l查看 ESTABLISHED 连接数却是 180。这说明有 40 个连接被 Driver 认为“活着”,但实际已被服务端关闭(如防火墙超时断开)。
根源在于 MongoDB Driver 的心跳机制默认heartbeatFrequencyMS=10000(10 秒),而 Linuxtcp_keepalive_time默认 7200 秒(2 小时)。当连接因网络设备超时被断开,Driver 要等 10 秒心跳失败才感知,期间所有请求都卡在getOrCreateConnection。
我们强制缩短心跳间隔并启用 TCP keepalive:
MongoClientSettings settings = MongoClientSettings.builder() .applyToClusterSettings(builder -> builder.heartbeatFrequency(1, TimeUnit.SECONDS)) // 心跳 1 秒 .applyToSocketSettings(builder -> builder.keepAlive(true) // 启用 TCP keepalive .keepAliveTimeout(30, TimeUnit.SECONDS)) // keepalive 超时 30 秒 .build();同时,在操作系统层调优:
# /etc/sysctl.conf net.ipv4.tcp_keepalive_time = 60 net.ipv4.tcp_keepalive_intvl = 10 net.ipv4.tcp_keepalive_probes = 3重启网络后,conn稳定在 180,qrw归零。
4.3 GC 策略与堆外内存:ZGC 在游戏后端的落地实践
尽管我们优化了序列化和连接,但jstat -gc显示 Old Gen 使用率仍在缓慢爬升,每小时增长 5%。jmap -histo:live发现io.netty.buffer.PooledByteBufAllocator$PoolThreadCache对象占老年代 35%——这是 Netty 的堆外内存(Direct Memory)缓存,但 JVM GC 只管堆内内存,堆外内存靠Cleaner回收,而Cleaner是低优先级线程,容易堆积。
我们切换到 ZGC(JDK 11+),并显式配置:
-XX:+UseZGC -Xmx8g -Xms8g -XX:ZUncommitDelay=300 # 300 秒后释放未使用堆内存 -XX:+UnlockExperimentalVMOptions -XX:+ZGenerational # 启用分代 ZGC(JDK 17+)ZGC 的最大优势是停顿时间与堆大小无关,无论堆是 4G 还是 64G,GC 停顿都稳定在 10ms 内。更重要的是,ZGC 的ZUncommitDelay能主动释放未使用的堆内存,间接缓解堆外内存压力——因为 Netty 的PooledByteBufAllocator会根据 JVM 堆可用内存动态调整池大小。
实测 ZGC 下,jstat -gc的G1OldGen(旧代)使用率不再爬升,ZGCCycle每 5 分钟触发一次,停顿 8-12ms,对游戏逻辑帧率(通常 16ms/帧)无感知影响。
5. 压测验证与长效治理:从“救火”到“免疫”的闭环建设
优化不是终点,而是新治理周期的起点。我们建立了三道防线,确保优化成果不被新需求冲垮:
5.1 压测基线与自动化门禁:让每次提交都过“性能安检”
我们把 JMeter 脚本固化为 CI/CD 流水线的一部分:
- 单元测试阶段:用
EmbeddedRedis和EmbeddedMongo运行轻量级集成测试,检查 SQL/NoSQL 查询是否命中索引(通过explain断言) - 构建阶段:打包后自动启动 Docker 容器,运行
jmeter -n -t load.jmx -l result.jtl,校验result.jtl中90% Line(P90 响应时间)是否 ≤ 200ms,Error %是否 ≤ 0.1% - 预发环境:每日凌晨 2 点自动执行全链路压测,对比上周基线,偏差 > 10% 则邮件告警
这套门禁拦住了 3 次潜在事故:一次是新功能引入了未索引的find查询,P90 从 180ms 涨到 320ms;一次是第三方 SDK 升级导致 Redis 连接泄漏,conn每小时增长 5;还有一次是日志级别误设为DEBUG,IO 写入拖慢主线程。
5.2 实时监控与根因定位:从“看指标”到“问为什么”的思维升级
我们弃用了传统监控的“红绿灯”模式(CPU > 90% 就告警),改用黄金信号(Golden Signals)+ 根因推演:
- 延迟(Latency):
redis-cli --latency每 5 秒采样,mongostat --host localhost:27017 --rowcount 1实时抓取qrw - 流量(Traffic):
netstat -an | grep :6379 | wc -l监控 Redis 连接数,ss -s | grep "tcp:"监控 ESTABLISHED 连接总数 - 错误(Errors):
tail -f /var/log/redis/redis-server.log | grep "OOM",grep "connection refused" /var/log/mongodb/mongod.log - 饱和度(Saturation):
cat /proc/meminfo | grep MemAvailable,df -h /data(MongoDB 数据盘)
当告警触发时,运维不再手动jstack,而是运行一键脚本:
#!/bin/bash # diagnose.sh echo "=== Redis Latency ===" redis-cli --latency -h 127.0.0.1 -p 6379 echo "=== Mongo Queue ===" mongostat --host 127.0.0.1:27017 --rowcount 1 | tail -n 1 echo "=== JVM Threads ===" jstack $(pgrep -f "GameServer.jar") | grep "RUNNABLE" | head -n 10 echo "=== Top 5 CPU Processes ===" top -b -n 1 | head -n 12 | tail -n 530 秒内输出关键线索,定位准确率从 40% 提升到 92%。
5.3 治理文化与知识沉淀:让“优化经验”变成“团队肌肉记忆”
最后,也是最难的,是把技术方案转化为团队习惯。我们做了三件事:
- 《游戏后端资源治理手册》:不是 PDF 文档,而是 Confluence 页面,每条规则配真实压测截图和错误堆栈。比如 “禁止在 Redis 中存储 > 1KB 的 JSON 字符串” 这条,下面贴着
jstack截图和redis-cli --bigkeys输出。 - 新人 Onboarding 的“压测沙盒”:每位新人入职第一周,必须用预设的 JMeter 脚本压测一个故意留坑的 Demo 服务(如未索引查询、JSON 序列化),然后按手册修复,通过后才能接触生产代码。
- 每月“性能复盘会”:不讲 PPT,只打开 Grafana 监控面板,随机选一个上周的慢请求,所有人一起
explain、jstack、tcpdump,直到找出根因。去年复盘会揪出 17 个隐藏瓶颈,其中 12 个是开发自己发现的。
现在,当我们说“Redis/Mongo 全面治理”,它不再是一个项目名称,而是刻在团队基因里的动作反射:看到新接口,第一反应是“这个查询有没有索引?”,写缓存逻辑,本能会想“用 Hash 还是 SortedSet?”,甚至 Code Review 时,资深工程师会直接问:“你测过ZCARD100 万数据时的耗时吗?”
压测优化的本质,从来不是把服务器参数调到最优,而是把人对资源的认知,调到和机器一样精确。当你能预判一条find命令会扫多少文档,当你知道HGET比GET快多少纳秒,当你在写代码时就听见了网络包的碰撞声——那一刻,CPU 才真正属于你,而不是你属于 CPU。