这次记录一个非常典型的服务端逻辑坑:玩家申请帮会城市时,明明身上钱和交子都足够,却在点击申请后直接报错,日志里还能看到一长串 skynet.call failed 和 human_item_logic:del_available_item == 扣除道具异常。
这类问题特别容易被误判成背包脏数据、物品表配置错误,甚至是 skynet.queue 死锁。但真正把调用链走完以后会发现,问题并不在底层框架,而是在业务判断和实际扣费逻辑没有保持一致。
如果你也碰到了“申请城市时报错、断言、调用失败”这一类问题,这篇教程可以直接照着排查和修。
一、问题现象
线上或本地测试时,申请城市会出现类似下面的报错:
./lualib/human_item_logic.lua:121: human_item_logic:del_available_item == 扣除道具异常
./services/scene/scenecore.lua:11041: in function 'scene.scenecore.cost_human_occupy_city'
./services/msgagent.lua:2203: in local 'f'
./framework/lualib/skynet.lua:720: call failed
从玩家视角看,表现一般是这几种:
- 点击申请城市后没有正常完成
- 客户端可能弹错或者没有正确提示
- 服务端日志出现
call failed
- 上层看起来像
msgagent 或 skynet 崩了
但这里有一个关键点:
- 真正的原始异常不是
msgagent
- 也不是
queue.lua
- 而是城市申请扣费时,道具扣除分支直接断言了
二、先看申请城市的调用链
这次问题链路非常清晰,核心入口在:
/home/ubuntu/Game2/services/msgagent.lua
/home/ubuntu/Game2/services/scene/scenecore.lua
/home/ubuntu/Game2/lualib/human_item_logic.lua
申请城市时,msgagent.lua 里的逻辑大致如下:
local result = skynet.call(my_scene, "lua", "check_human_occupy_city", my_obj_id)
if not result then
return
end
result = skynet.call(".Guildmanager", "lua", "city_apply", my_guid, city_apply)
if result then
skynet.call(my_scene, "lua", "cost_human_occupy_city", my_obj_id)
end
这说明整条链路分成了三步:
- 先检查玩家是否满足申请条件
- 再执行帮会城市申请
- 申请成功后再扣除资源
所以我们要重点核对的是:
- 检查条件是不是允许通过
- 实际扣费是不是和检查条件一致
三、根因分析
1. 检查函数允许“令牌或钱”二选一
scenecore.lua 中原始的 check_human_occupy_city 逻辑是这样的:
function scenecore:check_human_occupy_city(who)
local human = self:get_obj_by_id(who)
if not human then return end
local money = human:get_money()
local jiaozi = human:get_jiaozi()
local item_index = 30008013
local item_count = human_item_logic:calc_bag_item_count(human, item_index)
local result = (item_count > 0) or ((money + jiaozi) >= (1000 * 10000))
if not result then
human:notify_tips("创建帮会领地失败,需要1000交子或者建城令牌")
end
return result
end
这段代码表达得很明确:
- 只要玩家有
30008013 建城令牌,就能通过
- 或者玩家没有令牌,但
money + jiaozi >= 1000 * 10000,一样能通过
也就是说,设计意图本来就是:
2. 实际扣费却写成了“先强行扣令牌”
问题就出在 cost_human_occupy_city。
原始代码是:
function scenecore:cost_human_occupy_city(who)
local human = self:get_obj_by_id(who)
if not human then return end
local item_index = 30008013
local logparam = {}
local count = 1
local result = human_item_logic:del_available_item(logparam, human, item_index, count)
if result then
return true
else
human:cost_money_with_priority(1000 * 10000, "创建帮会领地")
end
human:notify_tips("创建帮会领地成功!")
end
表面上它像是在说:
但实际上,这段逻辑根本不会按作者预期进入 else。
3. del_available_item() 删不到时会直接断言
human_item_logic.lua 里的 del_available_item() 有一段关键逻辑:
if curdel < 1 then
human:notify_tips("扣除道具异常。")
assert(false,"human_item_logic:del_available_item == 扣除道具异常")
end
也就是说:
- 如果玩家没有建城令牌
- 或者虽然检查统计到了,但实际删除时没有删成功
- 这里不会返回
false
- 而是直接
assert
一旦断言抛出,cost_human_occupy_city() 后面的 else 就根本不会执行。
所以这次报错的真实原因就是:
- 玩家没有建城令牌
- 但是钱和交子足够
check_human_occupy_city() 判定通过
cost_human_occupy_city() 还是先去删令牌
del_available_item() 发现删不到,直接断言
- 上层
msgagent 收到的就变成了 call failed
这就是一个非常标准的“检查条件和执行条件不一致”问题。
四、为什么日志看起来像框架报错
很多人第一眼看到:
skynet.lua:720: call failed
skynet/queue.lua:20
skynet.dispatch_message
就会开始怀疑:
- 框架消息队列有问题
- scene 服务卡死
- 消息串线
但实际上这些只是上层调用失败后的连带栈。
真正要盯住的是最底层第一条业务断言:
human_item_logic:del_available_item == 扣除道具异常
只要抓到这条,就可以直接把排查范围缩到:
- 哪个功能在扣道具
- 它扣的是不是正确的道具
- 它有没有先判断玩家是否真的有这个道具
五、最终修复思路
这次修复不需要大改,也不需要动客户端,核心原则就两条:
- 当前有建城令牌时,才走扣令牌分支
- 当前没有建城令牌时,才走扣钱分支
同时再补一层保护:
- 因为
del_available_item() 内部会 assert
- 所以调用时最好用
pcall
- 避免一次扣费失败直接把整个
skynet.call 打崩
六、修复后的推荐写法
修改文件:
/home/ubuntu/Game2/services/scene/scenecore.lua
将 cost_human_occupy_city() 改成下面这样:
***付费内容***
七、这个修复为什么有效
改完以后,逻辑就顺了:
情况 1:玩家有建城令牌
item_count >= 1
- 进入扣令牌分支
- 扣除成功后直接提示成功
情况 2:玩家没有建城令牌,但钱和交子足够
item_count == 0
- 不再去碰
del_available_item()
- 直接走
cost_money_with_priority()
- 不会再触发“扣除道具异常”
情况 3:玩家既没令牌,钱也不够
- 这种情况本来就会被
check_human_occupy_city() 拦住
- 用户直接收到“需要1000交子或者建城令牌”的提示
情况 4:令牌分支内部异常
- 这时不会把整个 scene 调用直接炸掉
- 会记录 warning 日志
- 并给玩家一个明确失败提示
八、实战排查时最容易忽略的点
这次问题里,有三个细节特别值得记住。
1. 不要只看“检查通过了”
很多业务代码里都会先写一个 check_xxx(),然后再写一个 cost_xxx()。
如果两边没有共用同一套判定逻辑,就很容易出现:
所以遇到这类问题时,一定要把“检查”和“执行”两个函数配套看。
2. 不要把 assert 当成普通失败返回
这次原代码最大的问题之一,就是作者把 del_available_item() 当成了“删不到就返回 false”的函数来用。
但实际上它是:
这两种函数的调用姿势完全不同。
如果你把“会断言的函数”当成“会返回 false 的函数”来写分支,后面的兜底逻辑基本等于不存在。
3. 上层 call failed 往往只是结果,不是根因
只要日志里还有更底层的业务断言,就先看业务断言。
像这次:
msgagent.lua:2203 只是调用 cost_human_occupy_city()
- 真正出错的是
human_item_logic:del_available_item()
这个顺序一定不要看反。
九、建议顺手检查的一个业务风险
虽然这次修复已经解决了“申请城市时报扣除道具异常”的主问题,但当前链路里还有一个值得注意的点:
msgagent.lua 是先调用 .Guildmanager.city_apply
- 申请成功以后,才回 scene 扣资源
这意味着如果出现下面这种情况:
那就可能出现:
这不影响本次主修复是否生效,但如果你准备做长期稳定版,建议后续再补一个更彻底的方案,例如:
- 先扣费,再申请
- 或者申请失败回滚
- 或者把申请和扣费合并成同一条可控事务链
十、修复后的测试方法
可以按下面三组数据直接验证。
测试一:只有令牌,没有足够钱
- 背包放 1 个
30008013
- 金钱和交子故意设得不足
- 申请城市应成功
- 令牌应被扣掉
测试二:没有令牌,但钱和交子足够
- 删除
30008013
- 保证
money + jiaozi >= 1000 * 10000
- 申请城市应成功
- 不应再出现“扣除道具异常”
测试三:既没令牌,钱也不够
创建帮会领地失败,需要1000交子或者建城令牌
测试四:观察日志
确认申请城市过程中不再出现:
human_item_logic:del_available_item == 扣除道具异常
skynet.lua:720: call failed
十一、结语
这次问题本质上不是“背包扣道具坏了”,也不是“框架不稳定”,而是一个很常见的业务层失配:
这种问题在老项目里非常常见,尤其容易藏在:
- 道具扣除
- 货币扣除
- 次数消耗
- 活动报名
- 副本进入条件
所以以后看到类似“明明条件够了,执行时却断言”的报错,优先怀疑:
- 检查和执行是不是两套条件
- 下层函数到底是返回
false 还是直接 assert
只要把这两件事对齐,很多看起来像“大问题”的报错,实际上都能很快落地修掉。
剩余 12% 内容需要支付 18.00
金币 后可完整阅读
支持付费阅读,激励作者创作更好的作品。