记一次战斗服务器 CPU 打满 100% 且“无法恢复“的排查

记一次战斗服务器 CPU 打满 100% 且"无法恢复"的排查(arthas + iotop + gdb 全链路)

这是一次游戏服务器压测期间的真实排查记录:现象是 CPU 被打满后停止压测也不恢复,最后用 arthas、vmstat/iostat/iotop、gdb 一路追到一个"意料之外"的根因。文末整理了这类问题的通用排查套路,希望对你有帮助。

一、背景

项目是一个带战斗校验的玩法:客户端与服务器共用同一套战斗逻辑(服务器侧通过 JNI 调用一个基于 xLua 封装的本地校验库),战斗结束后服务器重放校验一遍,保证战斗结果可信。

压测阶段发现战斗服务器(后文称"战斗服")的 CPU 表现异常,于是有了这次排查。

二、现象:先把问题"说清楚"

先看现象,这里其实有三个关键信息:

  1. 压测条件很轻:不到 10 个压测机器人,请求间隔 3 秒。
  2. CPU 占用是"线性打满":1 个机器人就把单个核吃到 100%,2 个机器人占 2 个核,机器人数量超过 CPU 核数后整机打满。
  3. 两个反常点 :
    • 停止机器人后,CPU 依然 100%,不释放;
    • 较高概率下 JVM 直接宕机,机器上生成了 core 文件。

排查技巧 #1:拿到问题先别急着动手,把现象写清楚,尤其是反常点 。"停止压测 CPU 不恢复"说明进程并不是"忙完了在喘气",而是还有活没干完------这一条在后面直接帮我们锁定了根因。

三、排查过程

3.1 第一步:定位热点线程(arthas)

CPU 高,第一步永远是先看是谁在吃 CPU。登录机器,用 arthas attach 上进程:

bash 复制代码
# 启动 arthas
java -jar arthas-boot.jar

# 先看整体:哪些线程 CPU 高
dashboard

# 查看线程 CPU 占用排行
thread -n 3

结果:占用 CPU 最高的是负责处理战斗校验消息的工作线程,堆栈显示它停在 Lua 本地库的调用里。

复制代码
$ thread -n 3
"BattleCheck-Worker-4" Id=57 cpuUsage=98.7% deltaTime=98ms time=120300ms RUNNABLE
    at com.xxx.battle.NativeChecker.check(NativeChecker.java:-1)
    at com.xxx.battle.BattleVerifyTask.run(BattleVerifyTask.java:88)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:1149)
"BattleCheck-Worker-2" Id=55 cpuUsage=97.6% deltaTime=97ms time=119800ms RUNNABLE
    at com.xxx.battle.NativeChecker.check(NativeChecker.java:-1)
    at com.xxx.battle.BattleVerifyTask.run(BattleVerifyTask.java:88)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:1149)
"BattleCheck-Worker-1" Id=54 cpuUsage=97.1% deltaTime=97ms time=118400ms RUNNABLE
    at com.xxx.battle.NativeChecker.check(NativeChecker.java:-1)
    at com.xxx.battle.BattleVerifyTask.run(BattleVerifyTask.java:88)

(线程名与包名已脱敏)可以看到前几名全是校验工作线程,堆栈的栈顶都停在 native 方法上。只用一个线程名还不能下结论,但堆栈给了我们第一个怀疑对象。于是做一个对照组实验:

把 Lua 校验逻辑关掉,同样的压力再跑一遍 → 一切正常。

问题范围就此锁定:出在本地校验库的逻辑上。

3.2 第二步:最小化复现

把压力降到最小:只跑 1 个机器人、3 秒请求间隔。

bash 复制代码
top
复制代码
top - 15:32:41 up 45 days,  3 users,  load average: 1.02, 0.65, 0.28
Tasks: 183 total,   1 running, 182 sleeping,   0 stopped,   0 zombie
%Cpu(s): 12.6 us,  0.8 sy,  0.0 ni, 86.4 id,  0.2 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :  15872.0 total,   6231.4 free,   4520.8 used,   5120.8 buff/cache

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
  12345 game      20   0  9681040 286432  12088 S  99.7   1.8  12:35.68 java

单核被打满:8 核机器上整体 us 只有 12.6%,但 java 进程单个 CPU 已经 99.7%(top 的进程 CPU 按单核算)。单个机器人也能把一个核吃到 100%。这说明:

  • 问题与并发竞争、多线程调度无关,单线程执行一次校验本身就有问题;
  • 也解释了"几个机器人占几个核"------每个校验任务都独占一个核在跑。

3.3 第三步:换个角度------CPU 高不一定是"在计算"

到这里最容易掉进的坑是:认定这是"校验计算太重",然后一头扎进算法优化。

先别急。回头看现象里的反常点:机器人停了 CPU 为什么不释放? 如果只是"计算慢",计算完就该闲下来。除非------它根本不是在算,或者在算之外还在干别的。

CPU 利用率里 user/sys/iowait 的构成可以告诉我们答案。先看整体 IO 情况:

bash 复制代码
vmstat 1
复制代码
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 2  0      0 6379432 122484 5242880    0    0    12 68432  9821 15320 11  2 85  2  0
 1  0      0 6372356 122484 5242880    0    0     0 91136  9973 16204 12  3 83  2  0
 1  0      0 6370512 122484 5242880    0    0     0 87296  9891 15877 12  3 83  2  0

输出里 bo(每秒写出的磁盘块数)一栏高达 7~9 万,说明进程在高频写磁盘。再用 iostat 确认具体是哪块盘、什么速率:

bash 复制代码
iostat -x 1
复制代码
Device         r/s     w/s     rkB/s     wkB/s   rrqm/s  wrqm/s r_await w_await aqu-sz rareq-sz wareq-sz  %util
vda            0.00 6231.00      0.00  75842.00     0.00   12.00    0.00    1.86   11.32     0.00    12.17  98.40
vda1           0.00 6231.00      0.00  75842.00     0.00   12.00    0.00    1.86   11.32     0.00    12.17  98.40

确认了:磁盘写入速率异常高,%util 已接近 100%。接下来用 iotop 定位是哪个进程在写:

bash 复制代码
iotop -o   # 只显示有 IO 活动的进程
复制代码
Total DISK READ :       0.00 B/s | Total DISK WRITE :      74.55 M/s
Actual DISK READ:       0.00 B/s | Actual DISK WRITE:      72.31 M/s
   TID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN     IO    COMMAND
 12345 be/4 game       0.00 B/s   71.83 M/s   0.00 %  85.20 %  java -jar battle-server.jar
 12346 be/4 game       0.00 B/s    2.41 M/s   0.00 %   3.10 %  java -jar battle-server.jar

结果:就是战斗服进程自己在不停地写磁盘,写入速率约 70+ MB/s。

3.4 第四步:找到"真凶"------一个疯狂增长的日志文件

进程在写什么?顺着找进程打开的、大小在持续增长的文件,很快锁定目标:Lua 库自己的 log 文件。

复制代码
$ ls -lh /home/game/server/log/lua.log
-rw-r--r-- 1 game game 1.3G Jun  3 17:01 lua.log

$ ls -lh /home/game/server/log/lua.log        # 约 10 秒后再看一次
-rw-r--r-- 1 game game 2.1G Jun  3 17:02 lua.log

观察发现两个事实:

  1. 这个 log 文件在持续高速增长;
  2. 机器人已经停了,日志还在涨。

第二条正是"CPU 不释放"的答案:本地库里有逻辑在循环写日志,只要进程活着它就一直写------写文件是系统调用,系统调用开销表现成 CPU 占用,所以看起来像"计算打满",停了压力也不下来。

验证很简单:关闭 Lua 库的日志开关,再跑同样的压测 → CPU 恢复正常。

第一个问题(CPU 打满且不释放)定位完毕,根因是本地库的日志逻辑,修复需要库的维护方处理。

排查技巧 #2:CPU 100% 先分清是 user 还是 sys/iowait。"看起来在计算"和"真的在计算"是两回事,本例最初的误导就在于堆栈看起来像计算密集。当现象和推理矛盾时(停了不恢复),优先怀疑你的假设,而不是现象。

3.5 第五步:收口一个,再查下一个------宕机与"真·计算重"

第一个问题定位后,还剩两个遗留问题,继续逐个处理。

问题 A:关了日志之后,人数一多 CPU 还是高,且有概率宕机。

先处理宕机。机器上已经生成了 core 文件,直接用 gdb 分析:

bash 复制代码
gdb <java可执行文件> <core文件>
(gdb) bt        # 查看崩溃时的调用堆栈
复制代码
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x00007f8b2c1d3e55 in luaV_execute () from /home/game/server/lib/libbattlecheck.so
#1  0x00007f8b2c1c9a12 in luaD_call () from /home/game/server/lib/libbattlecheck.so
#2  0x00007f8b2c1b7f34 in luaD_pcall () from /home/game/server/lib/libbattlecheck.so
#3  0x00007f8b2c1a5c21 in battle_check_run () from /home/game/server/lib/libbattlecheck.so
#4  0x00007f8b2c194d88 in Java_com_xxx_battle_NativeChecker_check () from /home/game/server/lib/libbattlecheck.so
#5  0x00007f8b0402a318 in ?? ()

堆栈明确指向 Lua 库的执行逻辑------宕机同样是本地库的问题,交给库的维护方跟进。

排查技巧 #3:涉及 JNI / 本地库的 Java 进程,JVM 层的工具(arthas、jstack)只能看到"进入 native 就断了",core dump + gdb 是穿透 native 层的唯一手段 。平时确保服务器开启了 core dump(ulimit -c unlimited),关键时候才有的查。

问题 B:关了日志后 CPU 仍然偏高。

这次是真的"计算重"了。统计单场战斗的校验耗时(服务端校验日志):

复制代码
[INFO] battle verify cost: stage=1  rounds=8   cost=201ms
[INFO] battle verify cost: stage=2  rounds=11  cost=268ms
[INFO] battle verify cost: stage=5  rounds=19  cost=415ms
[INFO] battle verify cost: stage=8  rounds=27  cost=573ms
[INFO] battle verify cost: stage=1  rounds=8   cost=191ms    <- 换号重打第 1 关

数据说话:第一关校验耗时约 200ms,关卡越深、回合数越多,耗时越长。按这个量级,多机器人并发时打满 CPU 是必然的------这不是 bug,是校验逻辑本身的性能问题,优化方案同样需要库的维护方评估。

四、复盘:这次排查的通用套路

整个过程串起来,就是一张"现象 → 工具 → 结论"的路线图:

步骤 现象/问题 用的工具 得到的结论
1 谁在吃 CPU? arthas(dashboard / thread) 工作线程,堆栈停在 Lua 本地库
2 是并发问题吗? 最小化复现(1 个机器人 + top) 不是,单次执行本身就有问题
3 真的是在计算吗? vmstat → iostat → iotop 进程在疯狂写磁盘
4 在写什么? 观察文件大小增长 Lua 日志文件;停了压力还在写 → CPU 不释放的根因
5 为什么宕机? gdb 分析 core dump 崩在本地库执行逻辑
6 为什么还是慢? 校验耗时统计 单次校验本身太重,属性能问题

几条可复用的经验:

  1. 把现象写清楚,抓住反常点。"停了不恢复"是本次排查最重要的线索;
  2. 对照组实验是最快的定位手段:关闭怀疑点 → 正常,范围立刻缩小;
  3. CPU 高先分类:user / sys / iowait 各自意味着完全不同的方向;
  4. 一次只收口一个问题:定位完日志问题再去查宕机,多线并进容易乱;
  5. 跨语言调用是排查盲区:JNI 边界上,准备好 core dump 和 gdb。

五、写在最后

这次排查最后虽然是"甩锅"给了本地库的维护方,但把问题准确定位、把证据(堆栈、IO 数据、core 分析、耗时数据)整理清楚交出去,本身就是服务端开发的核心能力------否则两边只能互相猜。

如果你也遇到过"看起来是 CPU 计算密集、最后根因却在别处"的案例,欢迎在评论区聊聊。


相关推荐
超级大福宝44 分钟前
VS Code Remote-SSH 因 RemoteForward 端口冲突导致连接失败的排查与解决
运维·服务器·windows·vscode·ssh
starzy19901 小时前
Flink ResourceManager启动流程源码深度剖析:从ClusterEntrypoint到Slot分配的完整链路
java·大数据·flink
悠仁さん1 小时前
【C++】auto
java·前端·c++
郝学胜-神的一滴1 小时前
C++ Templates 07:编译报错和调试手段与实战技巧
开发语言·c++·后端·软件工程·visual studio
fengkai45451 小时前
十一、MySQL 第 4-7 章
运维·数据库·mysql
荣合技术服务1 小时前
服务器数据恢复服务商——荣合科算技术服务
运维·服务器·数据恢复·荣合科算
Sayai1 小时前
ClickHouse WITH FILL 实战:监控大屏时间序列补零,附多粒度指标表与物化视图级联设计
大数据·运维·数据仓库·clickhouse·物化视图
九章-悲回风1 小时前
Linux - Docker及常用镜像部署
linux·运维·docker
害人终害己1 小时前
Redisson 连接 Redis 失败 报错解决方案
java·开发语言