首页 / 横向专题 / 排查与调试手册
排查与调试手册
拿到一个 bug 之后该按什么顺序做什么。 上一页(Bug 案例大全)记的是「查出来是什么」, 这一页记的是「怎么查」。
1. 开局五问
拿到报告先别看代码,先问清这五个问题。多数 bug 三步内就能锁定方向。
1
必现还是偶现?
必现 → 逻辑错 / 状态残留,代码里一定有确定性的分支走错。
偶现 → 时序、竞态、异步、网络快慢相关。让报告人说明机器配置和网络环境,慢机器更容易复现时序问题。
偶现 → 时序、竞态、异步、网络快慢相关。让报告人说明机器配置和网络环境,慢机器更容易复现时序问题。
2
有没有前置条件?
「冷启动第一次正常,之后必挂」= 状态残留,
去找前一次操作置了什么标志没复位。
这是最容易查的一类,别浪费——主动问「重启客户端还有吗」。
这是最容易查的一类,别浪费——主动问「重启客户端还有吗」。
3
所有场景都有还是只有某些?
部分场景有 → 多份实现(player/entity 双 executor)或配置差异(某副本没配某字段)。
先对比「好的场景」和「坏的场景」在配置上差什么,比读代码快。
先对比「好的场景」和「坏的场景」在配置上差什么,比读代码快。
4
有没有固定的时间特征?
「大约 13 秒」「正好 1 分钟」→ 直接去找那个数字对应的超时配置。
和帧率无关的整数时长,背后一定有定时器。
要求报告人量化,「有点慢」查不动,「稳定 13 秒」一步到位。
要求报告人量化,「有点慢」查不动,「稳定 13 秒」一步到位。
5
报出来的这个错,是症状还是根因?
「换装超时」的上游是「实体没了」;「角色不显示」的上游是「相机没切」。
凡是超时类、失败类报错,先确认操作对象存不存在、状态对不对,再查操作本身。
凡是超时类、失败类报错,先确认操作对象存不存在、状态对不对,再查操作本身。
2. 日志排查
2.1 日志在哪
| 场景 | 路径 | 特点 |
|---|---|---|
| 自己在编辑器里跑 | Saved/Logs/Client.log | 实时写入,UTC 时间戳,一个文件含多段 PIE |
| QA 给的「Client(N)」 | Downloads/ 下的快照 | 已经是打包好的日志文件 |
| 历史 PIE | Saved/Logs/Client-backup-*.log | 上一次运行的备份 |
Client.log 含多段 PIE,先切出你要的那段
一个文件里可能有五六次 PIE 的日志。直接 grep 会把几次运行的结果混在一起,
看到「同一个事件触发了 5 次」其实是 5 次 PIE 各一次。
先找最后一次
先找最后一次
LogWorld: Bringing World 或引擎启动标志,从那行往下看。
2.2 grep 套路
# 1. 先按 ELogAuthor 标记捞出某条链路打的所有日志
grep -n "ZZB" Client.log
# 2. 按功能中文前缀过滤(这就是为什么日志必须带前缀)
grep -n "传送" Client.log
grep -n "爬塔星级-" Client.log
# 3. 只看错误级
grep -nE "Error|Critical|Warning" Client.log
# 4. 看某个时间点前后的上下文(-B 前 / -A 后)
grep -n -B 20 -A 20 "传送完成" Client.log
# 5. 统计某事件触发次数——判断「有没有重复执行」
grep -c "OnEndClientLoading" Client.log
# 6. 提取时间戳做时序对比
grep -nE "(遮罩|相机|解锁)" Client.log | head -50
# 7. 场景加载链:前缀是固定格式 [Loading|当前→目标|资源名|阶段|已完成/总数]
# 卡在第几步一眼可见,最后一条就是卡住的那一步
grep -n "\[Loading|" Client.log | tail -30
# 8. 「这次为什么走了非无缝」:判定链把 13 个字段打在同一行
grep -n "无缝加载判定" Client.log
最有用的一招:把两条时间线摆在一起
很多 bug 的本质是两个独立流程的时序错配(遮罩关了但相机没恢复、
实体清理跑在实体创建之后)。
用一个
用一个
grep -E "(A关键字|B关键字)" 把两条链的日志按时间穿插打出来,
错配一眼可见。这比分别看两遍有效得多。
2.3 加日志的三条规矩
① 统一中文前缀
'传送-'、'爬塔星级-'。
QA 复测时只会 grep 一个关键字,没前缀等于没日志。② 聚合值必附明细
打「已得 5 星」没用。要打
2010=mask5/2星,2011=mask7/3星,一行定位到具体对象。③ 标 ELogAuthor
日志签名的第二段。一个文件挂多条链路时,
靠它把日志按来源分流。
// 排查期的标准日志:入口 + 关键分支 + 出口,各一条
Log.Warn(ELogModule.Teleport, ELogAuthor.ZZB, '传送-开始同步瞬移',
['reason', reason], ['目标点', `${x},${y},${z}`], ['isStreaming', this.IsStreamingLevel]);
要打的三样东西
① 入参(尤其是决定分支的那个字段)
② 当前的关键状态标志(
③ 走了哪个分支——if/else 各打一条,能看出「为什么没进那个分支」
② 当前的关键状态标志(
IsStreamingLevel 这类)——
状态残留类 bug 全靠这个③ 走了哪个分支——if/else 各打一条,能看出「为什么没进那个分支」
排查完记得降级
调试期挂
Log.Warn 是为了方便过滤,但稳定后要降到 Debug。
常规流程日志占着 Warn 通道,真正的告警会被淹没。
(爬塔星级链路目前就欠这一步。)
3. 编译与产物验证
1
npm run lint-fix
多文件改动时先跑。prettier 格式问题本地 compile 不报但自动测试会拦,
最常见的是多行函数签名的换行方式。
2
npm run compile
tsc 不算编译。自定义编译器还会跑顶层声明检查、PascalCase 命名检查等项目规则。
3
看到「0 错误」也未必真 emit 了
全项目任意文件有诊断错误,就会在 emit 阶段之前直接退出。
你改的文件没问题,但别人的文件有错 → 磁盘上的 .js 压根没更新 →
运行起来还是老代码,怎么调都不对。
4
卡住时强制 emit + grep 产物
compile-skip-diagnostic 跳过诊断强制输出,
然后直接 grep 生成的 .js 确认你的改动真的落盘了。
# 验证产物是否包含你的改动
grep -rn "你新加的函数名" JavaScript/ # 或对应的输出目录
编辑器代码不走 npm compile
改了 scheme 之后
Src/UniverseEditor/** 的改动要走 gulp 构建,输出到 JavaScript_Raw。改了 scheme 之后
npm run compile 通过不代表编辑器里能看到——
两条流水线完全独立。「改了没生效」时先确认你跑的是不是对的那条流水线。
4. 按症状分类的排查路径
4.1 「角色/物体不显示」
1
先问:相机视角对不对?
这一问能砍掉一半排查面。
东西可能好好地站在那里,只是相机没切过去(#12)。
让报告人用自由视角或 GM 转一圈看看。
2
Actor 是否 Valid
实体被清理了?(#22)
3
显隐引用计数是否配平
VisibilityLock 有人漏 pop?(#14)
查有没有
ForceShowActor 逃生口。4
材质状态
溶解材质残留导致「看不见」而不是「不存在」(#15)
5
位置是否合理
被传到世界原点 / 地下 / 极远处,也表现为「不见了」
4.2 「卡住不动 / 流程走不下去」
| 检查项 | 怎么查 |
|---|---|
| 状态标志残留 | 冷启动后第一次正不正常?正常就是残留(#1) |
| 命令队列卡死 | 队列里某个 Cmd 在等永远不来的条件 → 后面全饿死(#5)。 先 grep [Loading| 看最后一条是哪个阶段,再对着
19 节点时间轴定位;
场景加载 executor 全部没有超时兜底,卡住就是永久卡住 |
| 回调被覆盖/吞掉 | 搜 SetXxxCallback 有几处调用(#4) |
| Once 被提前消费 | 注册到目标时刻之间,事件还会不会被别的原因触发(#2) |
| 协议没发/没收 | grep 发送点和接收点的日志,区分「没发」「发了没收」「收了没处理」 |
| 提前 return 跳过收尾 | 数函数里 return 的个数 vs 收尾动作出现次数(#6) |
4.3 「配了没生效」
1
表导出了吗
导表工具是
ConfigTool.Gui.exe(不是 Excel 宏)。
去 ClientJson 目录确认文件时间戳和字段。2
编辑器 scheme 和运行时校验 scheme 是同一个吗
报「非法字段」时第一顺位查这个,别当脏数据(#17)
3
主控树上放对应节点了吗
很多功能「配数据」+「放节点」缺一不可(#18)
4
跑的是哪条编译流水线
编辑器改动要 gulp,不是 npm compile
5
资产路径对得上吗
加载失败多为静默失败,不报错只是没效果
4.4 「表现异常 / 穿帮」
| 症状 | 先查 |
|---|---|
| loading 期间画面不对 | 中转关卡(loadscene)里的全局 Actor——后处理、雾、方向光(#8) |
| 某一瞬间穿模/裁剪 | 两条时间线错配:遮罩何时关 vs 被遮的东西何时恢复(#9) |
| 画面装不下 / 有黑边 | 相机距离只算了单轴 FOV(#11) |
| Sequence 首帧穿帮 | 需要 Play() 后立刻 Pause() 预热绑定,定格在首帧 |
4.5 「数据不对 / 残留」
| 症状 | 先查 |
|---|---|
| 换关卡后还是上一局数据 | 该在 OnLeaveLevel 清的写进了 OnClear |
| 第一次对、之后全错 | 缓存复用分支静默丢弃了新参数(#21) |
| UI 元素翻倍 | 异步创建被重入(#16) |
| 名字/头像和对象对不上 | 两个数组共用下标(#10) |
| 数值算出来是 NaN | protobuf int64 没过 MathUtils.LongToNumber |
| 部分对象状态没恢复 | 两次全量扫描之间集合变了,需要自维护集合(#13) |
4.6 「穿地 / 地形消失 / 某块场景没出来」
| 症状 | 先查 |
|---|---|
| 解锁移动瞬间穿地下坠 | WP 说流送完成但碰撞体还没 build—— 三个叠加原因,修法是 3×3 贴地预检 |
| 站着不动约一分钟后掉下去 | 等流送的命令永久没返回 → 流送灯交接从未执行 → 落点那盏灯关掉后没人持 cell (#5、流送假死) |
| 远处看着有地面走过去没有 | HLOD 低模代理,不代表真实碰撞体在场 |
| 某块场景 / 子关卡没出来 | 先 WhereAmI 看客户端算出的 DataLayer 状态,
再看两套 LayerTasks 求差集结果;
DataLayer 本身不走协议,别先怀疑服务器 |
| 上一张图的场景块残留 | DataLayer 重置漏了—— 两个重置函数管的范围不同 |
工具清单见 流送 debug 手段:
DebugPrintAllDataLayerStatus / DebugDump /
GM DebugSetDataLayerState / WhereAmI。
5. 工具与手段
5.1 二分定位
改动定位法
「昨天还好的,今天挂了」→ 不要读代码,去看提交记录。
按时间二分回退,几步就能定位到具体那次改动。
比起从症状反推根因,这个方法快一个数量级, 只要问题是回归型的就优先用它。
比起从症状反推根因,这个方法快一个数量级, 只要问题是回归型的就优先用它。
分支二分法
一个函数走不到你期望的分支,不要盯着条件看。
在每个 if/else 里各打一条日志,跑一遍就知道在哪拐弯了。
「猜条件为什么不成立」很慢,「看它实际走了哪」很快。
「猜条件为什么不成立」很慢,「看它实际走了哪」很快。
5.2 GM 命令
传送、给道具、跳关、切副本都有对应 GM。 排查时用 GM 直达问题现场,不要每次都从头玩一遍—— 复现成本降下来,排查速度会成倍提升。
5.3 断点
puerts 支持 VSCode 挂 inspector 断点。适合确定性、必现的逻辑问题。
但对时序类问题基本没用——断下来的那一刻时序就变了。
时序问题只能靠日志时间戳。
5.4 对比法
有一个「正常的」参照物就赢了一半
「A 副本正常 B 副本挂」「本地正常他人挂」「第一次正常第二次挂」——
凡是能找到对照组,就去 diff 两边的差异,
比从零推理根因快得多。
diff 的对象可以是:配置数据、走的代码路径、日志序列、资产路径。
diff 的对象可以是:配置数据、走的代码路径、日志序列、资产路径。
6. 提交前自查清单
| 项 | 确认 |
|---|---|
| 格式 | 多文件改动过一遍 npm run lint-fix |
| 编译 | npm run compile 通过,且确认真 emit 了 |
| 行尾 | 没有把 CRLF 文件改成 LF |
| 注释 | 新抽的常量有中文 JSDoc;注释不超过 2 行 |
| 日志 | 带中文功能前缀 + ELogAuthor;调试用的 Warn 该降级的降了 |
| 状态标志 | 置 true 的每条退出路径都复位了(含失败/超时/打断) |
| 注册配对 | OnAddEvents 里每一条在 OnRemoveEvents 里都有对应 |
| 定时器 | 销毁时清了;建之前判了 Has |
| 双实现 | 搜过注册点,确认没有第二处需要同步改 |
| 边界 | 只改了需求范围内的路径,没顺手动无关入口 |
| P4 | 没有 p4 lock;发现已锁立即 unlock |
范围纪律
用户说「只改 X,Y 不动」时,开工前先列出 in-scope / out-of-scope 路径清单,
每次 edit 前对照一遍。
「顺手把相邻的入口也改了」是最容易引入回归的行为—— 即使那个改动看起来更正确,也应该先说明再动。
「顺手把相邻的入口也改了」是最容易引入回归的行为—— 即使那个改动看起来更正确,也应该先说明再动。