• 游戏服务器开发指南(七):资源流通保证先减后增的顺序


    大家好!我是长三月,一位在游戏行业工作多年的老程序员,专注于分享服务器开发相关的文章。

    本文是通用程序设计主题下的第二篇。这个主题主要探讨如何编写高效、健壮、易读的游戏业务代码,每篇从一个小点切入。本次讨论的原则是:资源流通应保证先减后增的逻辑顺序,这样编写的代码通常具有更好的健壮性。

    从数值的角度来看,游戏本质上是就是不同资源的持续流通。资源的流通包括两种:一种是资源转换,即消耗A资源生成B资源,例如购买商品就是花钱获得物品;另一种是资源转移,即从A所有者转移到B所有者手中,例如为公会捐钱,钱从个人转移到了公会下面。

    无论是哪种资源流通,我们都有两种处理顺序:先增后减,或者先减后增。粗略看上去这两者没有什么不同,但实际在容错性上有显著差别。

    让我们回顾在前一篇《条件判断永远放在状态变更前》中讲到的购买商品例子,假设我们将其逻辑修改为先增后减:

    public Msg buy(int playerId, int commodityId, int num) {
    	if (!isCommodityIdValid(commodityId)) {	// 判断商品id是否存在
    		return newFailMsg("错误的商品id");
    	}
    	if (!isCommodityEnough(commodityId)) {	// 判断商店中商品数量是否足够
    		return newFailMsg("这种商品剩余数量已不足");
    	}
    	int costMoney = getCommodityCost(commodityId, num);
    	if (!isMoneyEnough(playerId, costMoney)) {	// 判断玩家金币是否足够
    		return newFailMsg("您的金币不足");
    	}
    	deliverCommodity(playerId, commodity, num);	// 发送商品给玩家
    	consumeMoney(playerId, costMoney);	// 消耗金币 
    	return newSuccMsg(playerId, commodityId, num);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    那么当代码执行consumeMoney出错而中断逻辑时,会造成非常糟糕的后果:玩家每调用一次购买接口,都会无偿获得商品,而不用消耗任何钱!这就是最让人头大的刷资源情况。如果刷资源的人数众多,而且刷到的资源又被转换成了其他资源不好追溯,那么不得已只能使用最后的保留手段——回档。这对玩家体验无疑是巨大的伤害。

    再让我们考虑改回先减后增的顺序。如果在deliverCommodity这一步报错,那么会造成玩家花了钱却没能获得商品。这样的影响相对可控,因为玩家尝试过几次之后就会发现问题而主动停下来,即使没有发现,等钱花完也会被迫停下来。更重要的是,数据在事后是可恢复的,只需要检查报错日志和接口日志,就能计算出玩家错误扣除的金币数额,再给玩家补上,而不像刷资源那样只能使用回档的方式来暴力解决。所以,先减后增优于先增后减的第一个理由是,前者在代码出错时造成的影响更小更可控

    很多游戏服务器使用Actor模型来开发,这样可以避免加锁和线程同步的烦恼。不过,Actor模型也引入了其他并发模型下所没有的一些陷阱,常见的例如,Actor异步通信下接口执行不再保证原子性,而可能分步乱序执行。在这种情况下,先减后增和先增后减两种逻辑顺序也会带来不同的结果。

    让我们看一个为公会捐钱的例子。玩家加入公会后,可以为公会捐钱,让公会变得更强。这个过程是钱从玩家手中转移到公会下面,用Skynet来写就是:

    function command.endow(player, endow_num)
        if player.money < endow_num then   -- 如果玩家持有的金币数不够,直接返回
            return
        end
        local success = skynet.call(union, "lua", "endow",  endow_num);	-- 向公会agent请求捐款
        if success then
            player.money = player.money - endow_num
        end
    end
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    以上代码是按照先增后减的顺序,即先向公会agent请求,让公会得到这笔钱,然后如果捐款成功,那么再扣除自己的钱。

    这样写的问题在于,Skynet在处理阻塞操作时,会暂停当前协程的执行,转而执行别的协程逻辑,直到阻塞操作返回才从暂停上下文重新开始执行。如果在执行别的协程逻辑时,恰好遇到了一个扣玩家钱的操作,如购买商品,那么可能导致后面再实际扣捐款钱时已经不够,钱扣成负的。

    解决办法是调整逻辑顺序,改为先减后增的顺序,即先行扣除要捐的钱,再向公会agent请求捐款,如果捐款失败,那么再把钱返还给玩家。具体代码如下:

    function command.endow(player, endow_num)
        if player.money < endow_num then   -- 如果玩家持有的金币数不够,直接返回
            return
        end
        player.money = player.money - endow_num	-- 先扣钱
        local success = skynet.call(union, "lua", "endow",  endow_num);	-- 向公会agent请求捐款
        if not success then	-- 如果捐款失败,再把钱加回来
            player.money = player.money + endow_num
        end
    end
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    这里不难得出先减后增更优的第二个理由:在Actor模型下,先减后增有助于避免异步通信下接口乱序执行带来的逻辑错误

  • 相关阅读:
    基于EasyExcel锁定指定列导出数据到excel
    MySQL数据类型
    汽车车系查询易语言代码
    什么是文件格式的幻数
    CodeTON Round 2 A - D
    DOM事件流+阻止冒泡事件+dom包含
    熟悉又陌生的package.json
    【入门篇】UML-FlowChat流程图
    基于kubernetes的分布式限流
    前后端跨域解决方案——jsonp,core,代理服务器
  • 原文地址:https://blog.csdn.net/needmorecode/article/details/130879565