☰
游戏后端压测CPU打满?从火焰图定位到Redis与Mongo治理实战
2026/10/7 12:55:44 网站建设 项目流程

先说结论:这轮游戏后端压测下来,开局就是 8 核 CPU 全部打满,接口 P99 从 15ms 飙到 900ms,我一度以为要回去重写整个业务代码。但顺着 CPU 一路摸排,最后发现所有救火点都落在 Redis 和 Mongo 上——真正的问题不是业务逻辑,而是我把 Redis 当成了不设限的存储,把 Mongo 当成了没有索引的表。这篇文章记录这次完整的压测优化实录,从第一现场的现象还原,到火焰图定位,再到 Redis/Mongo 两侧的治理实操,适合正在做游戏后端压测、或者线上 CPU 异常却不知道从哪下手的同学参考。

1. 第一轮压测:CPU 瞬间打满的现场还原

1.1 压测场景与工具准备:Jmeter 脚本怎么搭

先说项目背景。这是一套典型的游戏后端服务,主要接口包括登录、进入大厅、创建房间、匹配、对战结算,数据层依赖 Redis 做缓存和排行榜,Mongo 存战绩和房间流水。因为临近版本容量评估,我们需要做一轮压测摸底,压测工具选的是 JMeter,服务器是 8 核 16G 的云主机,Redis 和 Mongo 各自独立部署。

压测场景我选了登录-进入大厅-创建房间-匹配的混合链路,比例大致是 1:2:1。JMeter 脚本没有太多花活:一个线程组,500 线程,Ramp-Up 设 30 秒,循环跑 5 分钟,HTTP 请求从 CSV 里读账号密码,加了聚合报告和结果树。这里要提醒第一次做压测的同学,JMeter 脚本的写法不是重点,重点是你得盯住施压机本身。如果施压机的 CPU 先到 100%,压测报告就是假的。我当时给施压机也准备了监控,确认它在压测全程占用没有超过 60%。

另外提前把服务的 GC 日志、Redis 的 INFO 采样、Mongo 的 serverStatus 采样都打开了。后面排查问题时你会发现,这些平时不起眼的日志,关键时刻比任何性能分析工具都管用。

1.2 现象:CPU 100%、RT 毛刺、线程告警

压测跑了不到 2 分钟,监控面板就开始飘红。服务端 8 核 CPU 直接以 95% 到 100% 的状态横盘,load average 从 1 迅速涨到 11 左右,接口平均响应时间从稳定期的 15ms 跳到 400ms,P99 接近 900ms。这个表现基本上属于不可用状态了,如果这是线上真实流量,玩家那边的体感就是按钮点了半天没反应。

再看系统层的数据,Java 进程吃掉 780% 的 CPU,也就是 8 核里接近满的 7.8 核。这里有个排查细节:发现 CPU 100% 之后,不要只看整机数值,要拆一下 us 和 sy。我们当时 sys 占比不到 10%,绝大部分是 us,这说明 CPU 主要消耗在用户态的业务计算上,而不是频繁的系统调用、中断或者上下文切换。这个判断帮助我把优先级放在了业务调用链路上,而不是先去怀疑网络或者容器层。

业务侧同样有信号:核心线程池的活跃线程数打满,队列深度从 0 涨到几万,大量请求在线程池的队列里排队。这个现象不能简单归结为“并发太高”,因为在同样的 500 并发下,之前小流量验证时接口是正常的。所以问题一定隐藏在某个请求压境时才会触发的路径上。

1.3 我为什么不先看代码,而是先看中间件状态

按常理,CPU 打满应该先去翻业务代码。但我们没有一上来就啃代码,而是先看了 Redis、Mongo 两个中间件的关键指标。理由很简单:游戏后端这层服务逻辑本身不复杂,复杂的是依赖。压测过程中如果中间件出现大量重试、超时、长事务,业务线程会被拖住,CPU 会为了处理堆积的任务而忙转。先看中间件可以快速区分:到底是有人在白算,还是有人在等。

这一看还真有问题。Redis 实例的 CPU 使用率已经到了 75%,每秒命令数大约 5 万,其中 60% 都是重复读取同一个排行榜相关的 key。Mongo 那边更夸张,某个登录场景相关的集合查询次数不高,但平均耗时在 800ms 以上,几乎可以断定没走索引。这一个判断让我把精力从业务代码转移到中间件治理上,后面基本是沿着这条线走下来的。现在回头看,如果当时闷头去优化业务代码,大概率是白忙一场。

2. 定位 CPU 元凶:Redis 调用链路上的三处高开销

2.1 火焰图显示:序列化占了 32%

为了看清 CPU 到底烧在哪个函数上,我用 Arthas 的 profiler 命令抓了一个 30 秒的火焰图。操作不复杂:Arthas 启动后执行profiler start,压测打到一半执行profiler stop --format html,最后把 HTML 拉下来看。如果你还没用过这个工具,我强烈建议下次压测前先跑熟它。火焰图对“CPU 时间去哪了”这类问题,是效率最高的答案。

结果很清晰。应用内热点排名:第一是 Redis 相关的 JSON 序列化和反序列化,占了 CPU 样本的 32%;第二是 JVM GC 和日志输出,占 20% 左右;第三才是接口业务逻辑本身,只占 10%。这个比例让我挺意外的——业务代码没写几行,序列化反而成了最大的 CPU 消耗点。

具体到栈上,出现最多的是 Jackson 的writeValueAsString和readValue。我们用 Redis 缓存用户背包信息,每个 key 是一个 String,value 是整个背包对象的 JSON。一个背包可能有 200 个装备,序列化出来的字符串有 10KB 以上。登录时要把十几个背包缓存都读出来,每个都做一次 JSON 解析,高峰期一个请求要处理接近 200KB 的字符串。CPU 不打满才怪。

2.2 线程堆栈里的 Lettuce 超时与重试风暴

除了火焰图,jstack 线程转储也能看到相似结论。我们当时用的 Redis 客户端是 Lettuce,压测中大量线程卡在 Lettuce 的异步命令回调上,日志里铺天盖地都是redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException这类报错。

这里有个很多人忽略的机制:Lettuce 是异步非阻塞客户端,默认命令超时时间非常长,连接数也不设上限。当 Redis 服务端被大量请求打满时,命令排队时间越来越长,客户端等不到响应就开始超时;超时后业务方如果写了重试逻辑,就会再发一轮命令,把 Redis 彻底压死。更严重的是,连接数会随着线程数膨胀,每个线程都尝试建立连接,最后 Redis 的 connection 数从 200 涨到 2000。大量的 TCP 连接建立和维护,本身也在消耗 CPU 和内存。所以那一刻 CPU 打满,不只是序列化的锅,还有连接风暴和重试风暴的叠加。

2.3 根本原因:Redis 命令设计太重,缓存变成了存储

当我把 Redis 端的 key 列表拉出来看的时候,几十个大 key 赫然在列。比如排行榜 key 是一个 sorted set,里面有 10 万成员,每次都用 zrange 全量拉回,然后应用层再做排序和过滤;比如用户背包 key 用 String 存整个 JSON,读一次要传 10KB;比如同一个用户上下线要往 Redis 写很多次,都是无脑的读写。

说白了,我们把 Redis 当成一个不设上限的存储器在用了,而不是缓存。缓存本应只存热数据、只存小对象、只承担快速读取,结果我们让它承载了全量数据、大对象和复杂计算。负载一高,Redis 的 CPU 先被打满,接着客户端开始超时,然后应用线程被拖死,最终表现为应用服务器 CPU 100%。

另一个隐藏问题是过期时间设置得太随意。很多 key 的过期时间都落在同一个整点,比如凌晨 0 点,或者用户第一次登录后的 24 小时整。游戏晚高峰的时候集中过期,过期后所有流量同时回源,Redis 和底层 DB 一起被打,CPU 直接冲高。这些问题单独看都不致命,合在一起就成了一场性能事故。

3. Redis 治理实战:连接池、序列化、Key 瘦身与热 key 拆分

3.1 连接池与超时参数:不要照抄默认值

第一刀先切连接与超时。我们把 Lettuce 的配置从默认值改成硬性约束:连接池最大连接数从无限改为 200,maxIdle 设为 50,minIdle 保持 10,命令超时时间从默认的 60 秒改为 1 秒,同时显式关闭无限重试。改完之后最明显的感觉是,线程不再被长时间的等待拖死了。以前一个命令如果 Redis 端忙,业务线程可能傻等几十秒,现在最多 1 秒就失败,虽然失败仍然存在,但至少不会像雪崩一样把整个线程池堆满。

同时我在应用侧加了一个统一的 Redis 访问入口,里面做了一个简单的降级逻辑:当某个 key 的访问耗时连续超过 200ms,或者连续失败达到 10 次,就返回缓存的穿透默认值,并把该 key 的访问临时降级为读库。这样即使 Redis 抖动,业务也不会跟着立刻雪崩。这个降级的阈值需要根据业务调整,游戏场景对实时性要求高,但对非核心数据的偶尔降级是可以接受的。

3.2 序列化从 JSON 换成二进制之后

接下来处理序列化。我们的方案比较保守:把 Redis 里 String 类型的 JSON,直接换成 byte[],序列化框架改用 Protobuf。有人可能觉得改动成本高,但实际上收益非常大。Protobuf 序列化后的背包对象体积只有 JSON 的四分之一左右,反序列化的 CPU 消耗也比 Jackson 低一个量级。我们的压测数据很直观:切换后单独压背包接口,原本 P99 大概 15ms,直接降到 4ms,服务端 CPU 占用从 85% 降到 40%。

这里有几个实操注意点。第一,二进制序列化之后,Redis 可视化客户端看到的是乱码,排查问题不方便,所以我们在 key 上保留了可读前缀,value 用二进制,两边兼顾。第二,一定要做双读兼容,灰度时先读老数据、写新数据,确认稳定后再切读,并且给数据加上版本号,保证回滚时能快速退回去。很多同学一上来就全量切换,结果线上兼容性问题直接爆雷。

3.3 数据结构选型:Hash 替代 String,Set 的合理使用

大 value 问题不能只靠序列化。背包对象里有几百个独立物品,用 String 存整个对象,哪怕序列化压缩了,仍然要整体写入和读取。我们改成了 Hash,每个物品的 id 是 field,物品数据是 value,读取时用 HGET 只拉需要的 field,而不是 HGETALL。对比效果很明显:原来读整个背包要 256ms,改后读单个物品只要 0.8ms,读十个物品也就 2ms。Hash 还天然适合做局部更新,改一个物品不需要把整个对象重新写一遍。

这次梳理 key 类型的时候,我把redis 数据类型又重新过了一遍。排行榜用 ZSet,计数用 INCR,会话用 String 带过期时间,缓存对象能用 Hash 就不用 String;id 列表用 Set 做并集交集,但要注意大 Set 的存量数据要分批裁剪,别一次性全删。很多人对数据类型的理解停留在面试题层面,实际治理的时候才会发现,数据结构选错了,后面多少优化都白搭。

3.4 缓存治理:大 key、热 key、过期时间错峰

Redis 的全面治理必须包括缓存治理。我们写了一个扫 key 的脚本,把所有超过 5000 个 field 的 Hash 和超过 5KB 的 String 全部扫出来,逐一拆分或者压缩。热 key 方面,排行榜这个 10 万成员的大 key 拆成了分钟级的小分片,key 上加时间后缀,按 5 分钟一片,查询时合并最近两个分片。这一步做完,Redis 的每秒命令数从 5 万降到了 1.2 万,实例 CPU 从 75% 降到 20%。

过期时间错峰也做了。做法是在基础 TTL 上加一个随机值,比如原本 60 秒过期,实际按 60 秒到 120 秒之间随机,让所有 key 不要在同一秒集体失效。同时给可能被击穿的重缓存加了互斥锁,只放一个请求回源,其他请求先拿默认值。这个策略可以避免缓存雪崩时回源流量直接把 DB 打死。热搜里总有人问 redis 缓存治理,很多人的第一反应是加内存、加集群,但真正的治理恰恰是先解决 key 层面的事。

3.5 顺带解决了 Redis 分布式锁的坑

缓存治理里我们顺手处理了一个分布式锁的问题。之前我们用 SETNX 自己实现了一个“简单锁”,压测时发现锁经常提前过期,导致两个线程同时进入关键代码段,产生了重复匹配和重复结算的问题。那时候我们给锁设置了固定 10 秒过期,但业务执行时间可能超过 10 秒,锁先释放了,另一个线程拿着新锁进入,旧的还在跑,两边的操作就冲突了。

后来直接换成了 Redisson,它对锁有看门狗续期机制,默认锁 30 秒,每 10 秒自动续期,业务没执行完锁不会提前过期。Redisson 底层用的是 Lua 脚本保证原子性,不需要我们自己担心释放逻辑。这个改动看似跟 CPU 优化无关,但压测中分布式锁的异常重试,也是线程堆积和 CPU 攀升的帮凶之一。如果你想深入了解 Redis 分布式锁,面试常问的 Redlock 其实在业务场景里用得并不多,大部分场景用 Redisson 就够了,别自己造轮子。

4. 第二轮压测:CPU 降了,但 Mongo 的慢查询暴露了

4.1 压测再次扬起:应用 CPU 稳了,Mongo CPU 又满

Redis 治理完成后再跑同一套压测,应用服务器 CPU 从 100% 降到 60% 以下,RT 也能稳住。但问题不会消失,只会转移。在压测进行到 3 分钟左右,MongoDB 实例的 CPU 开始向上冲,很快就到了 90% 以上,Mongo 的连接数也异常飙升。

打开 Mongo 的 serverStatus,发现 connections 已经到一两千,qps 不高,但每条查询平均耗时非常长。这一轮现象和 Redis 很像:客户端没崩,服务端数据库计算慢,拖住了业务线程,应用 CPU 又跟着反弹。所以压测优化从来不是一个一次性的过程,每做完一层优化,重新压测,都会暴露下一层的问题。这也是为什么我建议每轮压测都要保留监控快照,否则你根本分不清是进步了还是退步了。

4.2 Mongo 慢日志分析:全表扫描与未命中索引

定位 Mongo 问题,最直接的入口是慢查询日志。我们开启了 profiling,db.setProfilingLevel(2, 100),把所有超过 100ms 的操作抓出来。结果非常典型:按用户 ID 查最近 30 天战绩列表时,没有索引,导致全表扫描;按房间 ID 查询对战详情,查询条件里包含大量不存在的字段,查出来几千条记录,再在应用层做内存聚合。慢日志里的 planSummary 写得很清楚,就是 COLLSCAN,全表扫。

用 explain 去验证,exectionStats 显示 docsExamined 是几万,而 returned 只有几十条,这个比例基本可以宣告索引设计不合格。给字段建索引的优先级应该遵循:等值查询字段大于排序字段,再大于范围过滤字段。我们为com_id + match_time建了复合索引,为user_id + create_time建了另一个复合索引,慢查询基本消失了。索引不是越多越好,但缺了该有的索引,对 CPU 来说是灾难级的浪费。

4.3 驱动层面的连接数与等待

Mongo 的问题不止索引。游戏后端用的 Mongo 客户端默认连接池大小是 100,压测时每个请求都向连接池获取连接,池满了之后请求开始排队,Mongo 端线程数暴涨,CPU 也有一部分花在上下文切换上。我们把连接池调整到 50,同时调整了等待队列的大小,不允许客户端无限等待,让请求可以快速失败而不是把线程堵死在池上。

另外,查询时设置了readPreference=secondaryPreferred,把一些不要求实时的统计查询分流到从节点,主节点 CPU 明显下降。对战绩查询这类可以容忍几百毫秒延迟的场景,走从节点是完全没问题的。这个调整要注意主从延迟的监控,如果延迟超过业务容忍阈值,就要考虑把一部分请求拉回主节点。

5. Mongo 治理:索引、查询重写、批量写入三管齐下

5.1 复合索引设计:匹配查询加排序

Mongo 索引设计最容易忽略的是排序字段。我们一开始给 user_id 建了单字段索引,但按时间排序的查询仍然走内存排序,代价很高。后来按“等值字段在前,排序字段在后”的顺序建复合索引,比如{ user_id: 1, create_time: -1 },索引就能同时支持过滤和排序,Mongo 不再出现 SORT 阶段。

这里的经验是,单字段索引宁缺毋滥,复合索引一定要先想清楚查询模式。建索引本身就是一种空间换时间,索引太多也会拖慢写入。我们是先通过慢日志把高频慢查询都列出来,再统一设计索引,而不是遇到一个慢查询就随手加一个索引。最后 Mongo 里的索引数量不仅没增加,反而删掉了好几个没用的单字段索引。

5.2 查询重写:让 explain 和 hint 配合

有了索引不等于查询会自动走。我们还要重写慢查询。一个典型的例子是某个查询本来是把大集合里的所有字段都查出来,再在应用层过滤;我们改成查询条件里显式加上状态字段,projection 只返回必要字段,并且顺手用 hint 绑定索引,防止 Mongo 因为统计信息变化而选错执行计划。

用 explain 看到的 winningPlan 从 COLLSCAN 变成了 IXSCAN,docsExamined 从 2 万降到了 200,这一步的收益是最直观的。另外,分页查询曾经用 skip 加 limit,到了深分页阶段慢到无法直视。我们改成基于排序字段的游标查询方式之后,性能稳定了很多。如果产品要求一定要页码跳转,可以只允许跳前几页,后面的页用游标代替,这是游戏对战记录这类列表最合适的方案。

5.3 写入优化:bulkWrite 替代逐条 insert

游戏后端在生成对局结果和上传行为日志时,以前是一个文档一条 insert,一次对局几十条记录要来回几十次,请求耗时和 CPU 都很伤。后来改成 Mongo bulkWrite,一次批量写入几十条,平均写入耗时从 900ms 降到 120ms。注意控制每个 batch 的大小,我们压测下来每个 batch 控制在 500 条以内,太大了反而容易触发驱动或者服务端的性能拐点。

事务性写入可以考虑 Mongo 的事务能力,但事务时间一定要控制在一个很小的窗口里。不要把所有操作都包进事务,日志和流水写入用默认的写关注就够了,突出的是吞吐;真正需要保证一致性的对战结算,才用事务。这个取舍想清楚,CPU 和延迟都能省下来。

5.4 离线清理与副本集部署的注意点

Mongo 治理到这个时候,CPU 已经从 90% 降到了 25% 左右,但要想长稳运行,还得做一次历史和冷数据的拆分。我们对战绩流水表加了 TTL 索引,按创建时间自动清理超过 180 天的数据,避免数据无限膨胀导致索引和文档扫描成本越来越高。加了 TTL 之后,Mongo 的磁盘空间和 CPU 都更平稳了。

副本集部署方面,我们滚动升级了新版本,同时保持监控打开。副本集的每个节点都会占用一部分 CPU 做同步和心跳,压测的时候要确认从节点不会因为同步压力变成下一个瓶颈。这里还要注意备份和慢查询日志的保存时间要提前定义,不然日志文件会悄悄把磁盘塞满,影响整个集群的稳定性。

6. 验收与后续:监控补齐、压测基准、复盘清单

6.1 回压结果:RT 下降多少、CPU 曲线

最终我们跑了一个小时的长稳压测:应用服务器 CPU 稳定在 35% 到 45%,Redis 实例 CPU 15% 到 20%,Mongo 实例 CPU 20% 到 25%,接口 P99 从 900ms 降到 120ms。这是同一套 JMeter 脚本、500 并发、同样的业务链路对比出来的数据。优化前后对比,可以看下面这个表格:

指标优化前优化后
应用服务器 CPU95%-100%35%-45%
Redis 实例 CPU75%15%-20%
Mongo 实例 CPU90%+20%-25%
接口平均 RT400ms40ms
接口 P99900ms120ms
Redis QPS5万1.2万

长稳压测跑完以后,我们又尝试把并发拉高到 800,P95 仍然能保持在 300ms 以内。这个容量数据比之前拍脑袋预估的“大概能抗住多少并发”可靠得多。有了这个基准,后续每一次发布或者中间件变更,只要在预发环境用同一套脚本重新压一遍,就能快速判断有没有性能回退。

6.2 监控和告警:CPU、慢查询、连接数、命令耗时

这次优化让我意识到之前监控太粗了。之前只有 CPU 和内存的告警,但这些指标只能告诉我们“系统出问题了”,不能告诉我们“是哪个环节导致问题”。所以这次我们把监控补齐了,加入中间件指标:Redis 命令耗时 P99、Lettuce 连接池等待数、Mongo 慢查询数量、Mongo 连接池使用率、Mongo 主从延迟。全部接入 Prometheus 和 Grafana,配好告警规则。

比如 Redis 命令 P99 连续 5 分钟超过 50ms 就报警;Mongo 慢查询数量过去 5 分钟超过 10 条就报警;连接池使用率超过 80% 就报警。有了这些指标,下次压测或者线上抖动时,我可以直接告诉问题是哪个中间件,而不是靠猜。如果你想用 Docker 搭一套 Redis 环境,热搜里提到的docker 安装 redis 主从,可以顺手把主从也部署起来,这样缓存层的压测环境才更接近生产。

6.3 一份可复用的压测治理复盘清单

最后给大家整理一份可以直接照用的清单,也是这次优化之后的内部沉淀:

  • 压测前先确认施压机本身不会先扛不住,否则报告失真;
  • 压测时应用、中间件、硬件资源三块监控必须同时看,不能只看应用;
  • 发现 CPU 高,先抓火焰图和线程堆栈,别急着改代码;
  • Redis 治理顺序:连接池与超时配置、序列化、数据结构、大 key 与热 key、过期时间错峰;
  • Mongo 治理顺序:慢日志 explain、复合索引、查询重写、批量写入;
  • 每个优化点都要单独压测验证,不要几件事一起改,否则你根本不知道是哪一步见效;
  • 保留优化前后的压测报告,方便后续做容量评估和发布对比。

这次优化之后,我最大的体会是:性能问题很少只有一个源头。CPU 打满只是表象,真正的原因是 Redis 用得太重、Mongo 没建索引、连接池和超时配置又放大了故障。压测的意义不只是看系统能扛多少并发,而是帮你把平时没人注意的隐患一个个暴露出来。如果你也在做游戏后端压测,希望这篇实录能让你少走一点弯路,先看中间件,再谈优化代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询