新人的第一个困惑往往不是代码看不懂,是"我加了 print 怎么没输出"。答案藏在 util/util.lua 的开头三十行:全游戏的 print 早就被劫持了。这一篇不讲业务系统,专门讲这套调试基建——print 的覆写、调试分流、表树 dump、调用栈打印。工具篇,但价值不比任何系统篇低:这三十行决定了你排查问题的速度上限。
调试基建和业务系统的读法完全不同。业务系统要读懂"它怎么工作",调试工具要读懂"它防什么"——每一个 if 分支、每一次判空,背后都是一类曾经发生的事故。读 util.lua 这种工具文件的正确姿势是带着审问的眼光:这个判断拦住了什么?那个分支为谁而留?把三十行工具读成三十条事故档案,这三十行才算读透了。
util.lua 的头几行还有一个全体引擎文件共享的仪式,值得先看一眼:
local tconcat = table.concat
local tinsert = table.insert
local tostring = tostring
local pairs = pairs
local type = type
local sformat = string.format
local sfind = string.find
local sgsub = string.gsub
local ssub = string.sub
local srep = string.rep
local slen = string.len
local ssplit = string.split
文件头把所有用到的标准函数局部化——这是 Lua 的经典性能手法:全局函数查找要走 _G 哈希,局部变量是寄存器直达。热路径函数头部这一排 local 化声明,能把高频调用的查找开销抹到零。全引擎几十个文件的开头都是这一排,新手以为是仪式性套话,老手知道这是每一纳秒的出处。性能优化有时候不是算法,是文件头十行声明——这个认知差就是新手和老手代码跑分差的一半。
顺带一提 local expPng = string.format("%slizi.png", global.MMO.PATH_RES_PUBLIC)——连一张公共图片的路径都在文件头预算好了。引擎作者连"每次用图时现拼路径字符串"都觉得浪费,启动时算一次存局部量。这种把一切可预算的东西前置的习惯,堆积起来就是那零点几毫秒的帧预算差。
先看那段会让新人怀疑人生的代码:
_DEBUG = global.isDebugMode -- 全局调试开关,构建时定死
function releasePrint(...)
if global.isGMMode then
return -- GM模式:正式包不吃打印IO
end
release_print(...) -- 引擎原生的打印
end
function Print(...)
if _DEBUG then
release_print(...) -- 只有调试构建才真正输出
end
end
rawset(_G, "print", Print) -- 劫持全局 print,指向 Print
rawset(_G, "print", Print) 这一行是全文件的核心:把 Lua 全局环境的 print 换成引擎的分流版。从此全项目几千处 print 调用不用改一行代码,就自动获得了三档分流——_DEBUG 关着时全吞(线上包零开销)、GM 模式下连原生的都静音(运营演示不吃 IO 抖动)、开发环境全量输出。三档开关三层人群:开发者、运营、玩家,各自看到各自该看到的日志量。
这套分流还有一个隐藏的受益方:服务端。客户端日志量和客户端性能直接挂钩,而排障需要的信息量不能少——分流的本质是"把日志的成本付在对的人身上"。开发期付全款(所有日志),运营期付少量(关键路径),玩家期付零款(纯静音)。同一份代码,三种成本结构,全靠这两个开关排列组合。
新人的第二个困惑紧跟着来:"那线上出了问题怎么办,日志全被吞了?"答案是分层兜底:线上包依赖的不是 print,是网络层那套 _LOG_RECV_NET_MSG 统计表(hits 计数不依赖 _DEBUG 全开)和崩溃时引擎层的自动栈快照。print 是开发者的耳朵,统计表是运营的眼睛,崩溃栈是最后的黑匣子——三层观测各管一段,混着用反而都失灵。这个观测分层的设计,比 print 劫持本身更值得抄。
rawset 而不是直接赋值也有讲究:Lua 的全局表 _G 可能被设过元表拦截写入(防污染的全局保护),rawset 绕过元表强写——调试基建必须比保护机制更强硬,不然保护机制自己就把调试口堵死了。看见 rawset 别条件反射地喊"危险",先看它在跟谁对抗。
排查数据问题时最想要的是"把这个表整个打出来"。引擎的树形 dump 值得全文背诵:
function PrintTableByTree(root)
if not _DEBUG then
return nil -- 调试函数,线上直接哑火
end
if nil == root then
release_print("nil")
return nil
end
local cache = {[root] = "."} -- 环引用cache:见过的表记个名字
local function _dump(t, space, name)
local temp = {}
for k, v in pairs(t) do
local key = tostring(k)
if cache[v] then
tinsert(temp, "+" .. key .. " {" .. cache[v] .. "}")
elseif type(v) == "table" then
local new_key = name .. "." .. key
cache[v] = new_key
tinsert(temp, "+" .. key .. _dump(v, space .. (next(t, k) and "|" or " ") .. srep(" ", #key), new_key))
else
local value = tostring(v)
tinsert(temp, "+" .. key .. " : " .. value)
end
end
return tconcat(temp, "\n" .. space)
end
release_print("Table:" .. tostring(root) .. "\n" .. _dump(root, "", ""))
end
PrintTable = PrintTableByTree
真实源码里对 tostring(v) 的结果还有一段"\0 截断"处理:值里混进二进制零时,只截取零之前的部分。这是被二进制协议字段坑出来的防御——网络包解析出来的字节串直接进表,dump 时一行印出一屏乱码的惨案,催生了这段截断。调试工具的每一次防御性处理,背后都是一个具体的坑,读工具源码读的就是这些坑的列表。
两个工程细节是这份 dump 的灵魂。第一,cache 表防环:Lua 的表互相引用是家常便饭(角色引用 Buff、Buff 引用角色),没有环检测,dump 一个环就是无限递归卡死客户端。见过的表记个名字再遇到就标注 {引用} 跳过——五行代码防住一类致命 bug。第二,缩进对齐用 srep(" ", #key):竖线跟着键名长度走,树杈永远对得整整齐齐。可读性这种事,引擎作者在调试工具上都较劲,因为调试工具的可读性直接等于排障速度。
(next(t, k) and "|" or " ") 这个表达式也在较劲:判断当前键是不是最后一个(next 找得到后继就画竖线),最后一个键的树杈不用画竖线,树更干净。一个三元表达式管树杈的美观,这份讲究我只在两处见过:引擎的 dump 函数和排版引擎。
输出效果长这样:+buff 下面缩进挂 +buffID : 13、+endTime : 2873s,嵌套表的层级一目了然。血条班车那篇的排查、Buff 篇的对账,全都靠这棵树把数据结构拍在桌面上。
出错了想看"谁调到这的",引擎的封装只多了一个参数:
-- print call traceback
function PrintTraceback()
local traceback = ssplit(debug.traceback("", 2), "\n")
...
end
debug.traceback("", 2) 的第二个参数 2 是点睛之笔:跳过栈顶的两层(traceback 自己和封装函数),直接从真正的肇事者开始打。不跳的话,日志前两行永远是调试函数自己的名字,每条栈多两行废话,一天几百条就是几百行噪音。调试输出也要做减法,这个 2 就是减法本身。
调用栈日志的用法在行为树那篇提过一嘴(通知乒乓排查),这里给个完整战例:某服"切地图偶发闪退",日志只有一句 nil 报错没有栈,查三天没头绪。接入 PrintTraceback 后第二次复现就抓到完整调用链:setPosition → BuffManager:setPosition → actor 为 nil——切图清 Buff 和移动渲染的时序竞争,一眼定案。没有栈的报错是谜语,有栈的报错是答案,这行封装值一整个通宵。
这套调试基建的四个函数还能组合出高级用法。定位"谁在改我的数据":先 PrintTableByTree 记下脏数据的样子,再用 PrintTraceback 抓写入路径,两个工具一夹,读写双方全招。某服查"玩家金币不明减少",就是这么夹出来的:一个活动模块在结算时误写了金币字段,栈直接指到行号。工具不新,组合出新——调试基建的投资回报,大头在组合技上。
flowchart TD
A[业务代码 print] --> B{rawset 劫持后的 Print}
B --> C{_DEBUG?}
C -->|关| D[吞掉, 零IO开销]
C -->|开| E{GM模式?}
E -->|是| F[releasePrint 静音]
E -->|否| G[release_print 输出]
H[PrintTableByTree] --> I{_DEBUG?}
I -->|开| J[环检测cache + 树形dump]
I -->|关| K[直接return nil]
L[PrintTraceback] --> M[debug.traceback 跳2层取肇事栈]
下面这个演示把控制台搬了过来:拨 _DEBUG 和 GM 两个开关,点四种日志按钮,亲眼看同一条日志在不同开关组合下的三种命运(输出、吞掉、静音)——线上没有日志的谜团,在这里一次解开:
skill-debug-print-1003e
组合技之外再演示一个树形 dump 的实战读法:dump 出来的树别一行行读,先扫键名找可疑字段(endTime 是不是负数、ol 层数是不是超了),再顺着可疑键看值——树形 dump 是给"扫"用的,不是给"读"用的。我们培训新人的说法是:先找长相不对的键,再看它的值对不对,最后才看结构。三步扫完一张千键大表不超过一分钟,这就是树形 dump 相比逐行 print 的碾压优势。
最后交代一个使用分寸:调试基建是"排查问题的武器库",不是"业务逻辑的零件库"。print 劫持、树形 dump 这些函数只该出现在调试路径和日志路径,业务代码里 PrintTableByTree 一个玩家数据表来"顺便校验",就是拿军刀切菜——军刀会卷刃,菜也不好吃。工具和业务的边界,从第一个 rawset 开始就要划清。
工具都在,乱用的是人。某服排查一个跨系统 bug 时,日志多到刷屏(十四个模块全在打),关键信息反而被淹。事后我们立了四条日志规范,全组推行后调试效率肉眼可见地提升。
第一,日志分身份:开发期调试用 Print(_DEBUG 控制,上线自动消失),运营需要看的用 releasePrint(GM 静音但玩家包可开)。第二,一条日志必须带主语:BuffEntity#1024 OnEnter buffID=13,禁止"进到这里了"这种无主语的日志——日志是给三周后的自己看的,三周后的自己什么都不记得。第三,热路径限频:Tick 里的日志必须带计数器(每 60 次打一条),否则一帧十几条把 IO 打爆。第四,关键路径强制栈:所有 error 类上报必须附 PrintTraceback,没有栈的报错单直接打回。
四条规范落地一个月,平均定位时长从 4.2 小时降到 1.1 小时——调试效率是团队能力的隐形天花板,这三十行工具加四条规矩,就是撬动这个天花板全部杠杆。
规范的第四条(强制附栈)落地时还遇到个小阻力:有人嫌打栈拖慢上报。实测数据一锤定音——一次 traceback 的生成约 0.1 毫秒,一天两百次错误上报总共 20 毫秒,换来的是每单节省两小时的定位时间。性能账要这么算,没有算不赢的。反对意见的本质是"没算过账的直觉",用数字回应直觉,是技术 leader 的日常。
规范之外还有一条工具层的遗留建议:给日志加"模块前缀过滤器"。十四个模块的日志混流时,运维真正想看的往往只有两三个模块——引擎的 releasePrint 加一层前缀匹配(只放行带 [NET]、[SKILL] 标签的),排查哪个模块就开哪个模块的闸。这个改造半天工时,让"日志刷屏"这个抱怨在公司内部彻底绝迹。工具的小升级,往往比规范的强推更得人心。
问:为什么 GM 模式连 releasePrint 都要静音?
GM 演示时经常要录屏或者直播,日志窗口一闪一闪很难看;更重要的是高频打印的 IO 抖动会让帧率不稳,演示机往往是低配机。静音是给演示保帧率,不是藏日志。
问:PrintTableByTree 能 dump 多大的表?
整个角色数据表(几千个键)实测 20 毫秒内,配合 _DEBUG 开关只在开发期用,完全够。但别把它写进业务逻辑当序列化用——它有环检测跳过的引用,dump 出来的文本不可逆,当眼睛用别当存储用。
问:rawset 覆写 print 会不会影响性能?
覆写本身只是换了个函数指针,零成本;代价是每次 print 多走一层 _DEBUG 判断,一个布尔判断可以忽略不计。真正的性能红线是"热路径高频打印",这与覆不覆写无关——规范第三条的限频就是为它准备的。
问:我自己想加第四档日志(比如只给测试服看的),往哪加?
顺着 Print 的模式加一层开关就行:isTestServer 判定 + 一个 TestPrint 函数,别在现有函数里堆 if。每档日志一个独立函数,开关互不纠缠——分流逻辑的分档一旦超过三层,就该从"一个函数里的 if"升级成"多个平级函数",这是分流系统的通用规律。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…