« June 2016 | Main | August 2016 »

July 13, 2016

在 skynet 中如何实现多 actor 协作的事务

今天在 qq 群中,有个同学问,在 skynet 中,如果多个 actor 需要协作时,能否有事务来保证一系列操作的过程中,状态不会被破坏。

他举了个例子:

如果有一段业务逻辑是:

local a = skynet.call(A, ...)
local b = skynet.call(B, ...)
if a and b then
  dosomething()
end

如何保证 a and b 这个条件有效呢?也就是说,在第一行从 A 处获取状态后,还要向 B 查询一个状态;如果希望两者查询完毕,一直到 dosomething() 结束前,A 和 B 的状态都不要改变。期间,如果有请求发到了 A 或者 B ,最后都暂时挂起。

后来,又有同学补充了一个更实际一点的案例:

如果我需要买点东西,先从银行查询余额,然后从商店获取一个物品,然后去银行扣掉钱,再把物品加到背包里。这个过程会涉及多个 actor ,整个过程又需要是一个事务,不要在执行这个事务过程中,有任何涉及的 actor 状态改变。

固然,我们肯定可以从设计上找到一个更好的方法处理上面这类交易事件,而不需要涉及复杂的多个 actor 之间的交互。但这也是一个典型的案例说明本文一开始想表达的需求。

如果一系列的操作只涉及一个 actor ,那么在这个 actor 中使用 skynet.queue 就可以避免在处理消息过程中的外部干扰了。但若涉及多个 actor 就会麻烦一些。

我简单思考了一下,似乎还是可以用 skynet.queue 这个模块来解决这个问题。简单做了个实现,供参考。

这里是 github 上的仓库地址

这个事务的模拟有很多限制,其中之一是,任何时候系统中只能有一个事务在处理。(这个限制应该可以去掉,但加上这个限制,可以让这个 example 代码更简单一些)

如果你的 actor 要针对事务处理来响应消息,那么就应该使用 transaction.dispatch 来处理消息,而不是直接用 skynet.dispatch 分发。transaction.dispatch 会回传一个 transaction 对象,如果需要对外请求,应该使用 transaction:call 而不能再使用 skynet.call 。

transaction:call 会把事务的 session 号传递给对方,而每个能处理事务的 actor 每次收到请求后,都会根据 session 号把这个请求加到对应的队列中(由 skynet.queue 管理)。

而每个执行队列的最前面,都会向 transactiond 这个中心服务请求锁。如果当前没有事务在运行,那么回直接返回,从而拿到执行权;如果当前有其它事务在运行,则这个请求会被挂起,继而让整个运行队列都挂起。


这个 example 还有一些缺陷,如果要实际使用需要进一步完善。

比如 transaction 的创建和销毁都是显式的 api 调用(transaction.create 和 transaction:release),如果漏掉了 release 调用,或是中间过程有 error 没有捕获,或是还没等到 release 环节,服务自己退出了,跳过了 release ,都会导致整个事务锁不会被解开。

改进方法是用 pcall 封装事务的 create/release 过程,并在 transactiond 中增加一个监视创建者退出的方法。这个这里就不展开了。

July 11, 2016

Skynet 1.0.0 发布

经历了 5 个 RC 版后,skynet 终于迎来了第一个正式发布版 1.0.0 。

目前这个版本基于 2012 年 7 月开始编写的代码,几乎从一开始就以开源模式发展。我们可以看到 2012 年 8 月 1 日在 github 上的第一次提交,到上周一正式版前的最后一次修改,已经过去了 4 年。

1403 次提交;

59 个代码贡献者;

3962 个 star ;

1850 个 fork ;

579 个邮件列表订阅者;

1700 个 qq 群成员。

我曾经在 2015 年 3 月发布第一个 1.0 alpha 版的时,计划在 2-3 月发布第一个正式版 ,没想到拖了整整一年。

甚至在 2015 年底决定冻结新特性开发,进入 RC 流程 后,还把计划从农历新年拖到了夏天。

终于打上 1.0.0 的 tag 我还是挺开心的。

希望正在使用 skynet 的项目,能够尽早同步到这个版本。如果旧版本已经用在生产环境的项目升级风险过大,也建议下一个新项目直接使用 1.0.0 版。

只有围绕同一个稳定版做开发,才能发挥开源项目的最大威力:更多的眼睛关注下,让 bug 无处遁形。

July 06, 2016

一元购庄家如何作弊

今天见同事贴了张照片,是网易大楼前有人拉横幅声讨“一元购” 这个产品的。

我之前没有听说过,上网搜了一下,发现最近搞这个的满多。大概就是说,每个玩家出一块钱买一个很贵的东西。然后系统把这些人凑在一起抽签,抽到谁谁就拿走那个东西。

比如一部手机卖 5000 块,有 5000 个人想买,一个人出一块钱,最后也只有一个人拿到货,其他人都损失了一块钱。

这不就是卖彩票么?

当然凑不到 5000 个人也没关系,系统(庄家)可以买走大部分票,这并不影响个人参与者中奖概率。当然,前提是庄家没有作弊,抽签是公平的。

那么庄家有没有办法作弊呢?

我们来看看网易“一元夺宝”的规则

摘录如下:

  1. 商品的最后一个号码分配完毕后,将公示截止该时间点本站全部商品的最后50个参与时间;

  2. 将这50个时间的数值进行求和(得出数值A)(每个时间按时、分、秒、毫秒的顺序组合,如20:15:25.362则为201525362);

  3. 为保证公平公正公开,系统还会等待一小段时间,取最近下一期中国福利彩票“老时时彩”的揭晓结果(一个五位数值B);

  4. (数值A+数值B)除以该商品总需人次得到的余数 + 原始数 10000001,得到最终幸运号码,拥有该幸运号码者,直接获得该商品。

其实能保证公平的关键在 3 ,引入了一个第三方看起来比较公正的随机数。除此之外的所有参数都是可以庄家作假的。


我想讨论一下,如果这个第三方随机数是公正的前提下,一元购的庄家还有没有机会作弊。

首先,这个外部随机数是个 5 位数,如果参与人数并非固定,那么经过对人数取模后,每个号码的分布并不是完全一致的,虽然偏差很小,但是有一定的作弊空间。所以、规则上若想回避,参与人数不可以大于 5 位数,且最后能除尽 100000 。直观上说,每组人数最好是 100 , 1000, 5000 这样的。

其次,我觉得最大的规则漏洞是,同时进行的多组抽签都依赖了这个单一随机数,这使得庄家很容易钻空子。比如,如果每组人数是 100 的话,庄家只要同时开 100 组。如果第三方随机数是多少,他都有办法在其中一组中作弊成功。通俗点讲,庄家在不同组买全了所有号码,且这个购买是无需支付差价成本的(差价指每注的实际价值和标价间的差距,比如 100 个人去抽实际价值 80 块的东西,每注的实际价值只有 8 毛,差价就是 2 毛)。它要做的只是操作其它变量看起来符合结果就够了。

以上讨论仅仅只是说明庄家有没有作弊的空间,并非说网易或是别家经营这个业务的庄家真的有作弊行为。我相信这个玩法的盈利点和卖彩票差不多,根本不必靠作弊就可以赚钱。只需要把 3000 块成本的商品在平台上标 5000 就自然可以赚大头了。

本质上就是赌博,我是很不喜欢的。


如果不考虑用户的操作成本和理解成本,有没有可能指定一个更好更公平的规则,尽量回避庄家作弊呢?

我想最简单的做法是不依赖第三方随机数,而让参与人变成随机数提供者。

比如:如果有 100 个人参加抽签,那么先给 100 人发号。然后每个人自己产生一个随机数(自己想的还是自己用工具 app 产生的无所谓),并给一个足够长的随机串做盐。

大家用一个公开 hash 算法计算 hash(随机数 , 盐) 得到一个 hash 串,提交给组织者(庄家)。以上过程可以由 app 完成,为了提高 app 可信度,允许用户自己在 app 外计算,只提交 hash 串;同时给信任 app 的人提供更方便的自动计算功能。

等所有人提交完后,公示每个人的提交串,然后进入开奖流程。

开奖流程要求每个参与者提交原始随机数和随机盐,服务器和之前的 hash 串比对。任何一个人错误提交或不提交,都会导致这局游戏流掉。系统没收资本,扣除个人信誉值,并把其它投注退还给遵守规则的参与者。

如果所有人都提交,把数字加起来,得到中奖人的号码。

在这套规则里,任何人不参与开奖流程都会导致无法开奖,所以是相对公平的。如果有人恶意不开奖,或庄家做假(发现自己开了后会导致真正玩家中奖而不开),用户就得依靠查阅同组人的信誉值甚至历史来决定自己是否要跟他们玩了。

系统(庄家)可以帮助用户撮合信誉度高的用户在同一组里。

这套规则的一个风险是,庄家对玩家的信誉值作假;比如给一个机器人很高的信誉,这点需要玩家警惕,也比较容易被发现。庄家作弊风险很大。

另一个风险是,庄家如果拥有很强的计算力和很长的开奖时间,可能暴力解出玩家提交的随机数。这点需要通过加长盐和加大随机数的取值范围来缓解。

July 01, 2016

如何只基于请求回应模式实现 MMO 级别的场景服务

在上一篇 blog 里,我谈到游戏服务器其实只需要使用 req/resp 模式就够了。有同学表示不太理解,认为服务器主动推送,或者说 pub/sub 的消息模式必不可少。

在聊天中我解释了很多,是时候记录一下了。

从本质上来说,如果你只是想把一系列消息发送到客户端,req/resp 请求回应模式和 pub/sub 发布订阅模式并没有什么不同。你可以把 req/resp 理解成一次性的,只订阅一条(或有限条)消息;如果需要维持这类消息的推送,需要客户端在收到消息后再次发起订阅。

这和 timer 定时器系统一样,订阅一个定期触发的定时器这种功能并非必要。如果你需要一个每分钟触发一次的定时器,完全可以在触发一次定时操作后,立刻发起下一次定时任务请求。它在功能上是等价的。

潜在的实时性影响是:如果规定必须在收到回应消息后,才允许发起下一次请求;那么一个事件发生后推送到客户端的延迟可能增加了最多 2 倍。即,如果你刚刚回应了一条消息,这个时候又发生了新的事件;客户端需要在收到前一个事件后再提起新请求,然后你才可以把后发生的事件通过新请求的回应包发过去。

降低这个实时性影响的方法也很简单,协议上允许在客户端发起请求后未收到回应之前再次发起请求即可。对应同类消息许多少并发的请求,要根据这类消息的实时性要求有多高来决定。无限提高允许的并发量没有太大意义,毕竟物理上的延迟不可能完全消除。

从客户端来看,MMO 这样的游戏,客户端仅仅是一个呈现设备,它不断的接收到服务器发过来的游戏世界的状态变化,而做出表现。客户端主要靠回应包来分发业务逻辑,使用请求回应模式,无非是根据回应包里的 session 号匹配之前请求包(通常包的类型在请求包中,不必放在回应包数据里),将两者(请求/回应)合并起来调用消息分发函数而已。

既然效果一样,请求回应模式有更大的数据冗余(一些看起来没有意义的请求包,在订阅模式中,你可能只需要订阅一次)那么请求回应模式带来的好处是什么呢?

  1. 它天然可以解决过载问题。如果服务器不考虑客户端的带宽/硬件处理能力而推送数据,客户端很可能无法及时处理,而无谓的浪费了双方的带宽。而请求/回应模式中,如果我还没处理完,就不会要更多,很自然的回避了问题。

  2. 有选择性的发送消息这件事,对服务器来说,要么是一个时间复杂度为 O(n2) 的操作:你需要对每个用户遍历对比其他用户来决定这条消息对方是否关心;要么,会把数据结构的复杂度提高一个数量级:你需要把对象进行各种分类、索引,以提高检索的效率。而如果把选择我关心的消息的权利放在客户端,那么时间复杂度就只有 O(n) 了。客户端总能更了解自己的情况。我是 iphone6s ,所以我可以关心我周围 50 个人的变化;你用个两年前的红米,屏蔽了周围所有人,服务器就不要把我旁边的人跳了个舞的事件发给我了。

  3. 消息优先级更好处理。我正在战斗,那些在世界聊天频道对撕的消息晚点给我,不要耽误了我的对手正在搓火球这种我更关心的事件。

  4. 无论是客户端还是服务器,只要想清楚,业务逻辑可以写得更清晰。怎么做呢,下面展开讲。


在 MMO 的场景服务中,只使用请求回应模式该怎么做?

首先把场景中的玩家、NPC 等都抽象成一样的对象。同时将场景打上合适大小网格(比如一格 100 米为边长,这取决于你允许的玩家视野),将每个格子也看成一个对象。

每个对象都带有一个(或多个)事件队列。

对于格子对象来说,事件只记录有别的对象来到这个格子,以及一个对象离开格子。

玩家对象的事件队列则记录这个对象自己做了什么动作,收到了什么伤害,增加或减少了什么 buf 等等。

你的场景应该是以某个心跳周期(通常不会超过 10Hz)来工作的,也就是游戏服务器上,所有对象的变化都是离散的。

所有的攻击、移动、技能都会以事件的方式,加上时间戳放到所属对象的事件队列中。

客户端的查询协议中的来说只有两种:

  1. 查询一个对象的当前状态以及该对象最新事件的时间戳。

  2. 查询一个对象从某个时间戳开始,以后发生的所有事件列表。

如果客户端还不了解一个对象时,它发起查询 1 ;如果已经了解了这个对象,发起查询 2 。或者根据需要发起查询 1 (例如曾经查询过,但很久都不关心了)

在查询没有回应前,限制再次提起查询的次数。

这样总能保持对某个对象最新状态的了解。

当一个玩家进入场景,他要做的:

  1. 查询进入点。

  2. 根据进入点坐标,查询所在格和周围格的发起查询。

  3. 根据周围格的查询,获知了周围存在的对象。然后分别对这些对象做进一步查询。作为 MMO 可能存在大量的对象,通常筛选一部分就够了。

  4. 一旦感兴趣的对象走远,就不再监视它;这点不必依赖格子对象的状态变化。所以格子的状态反馈只需要有新增对象即可,而不必有对象离开的消息。所谓监视一个对象,指在每次查询回应后,再次提起下一次查询。有些对象,比如自己,需要响应更及时。应该同时向服务器发起多个查询请求,这样,服务器一旦判定你被攻击或被某些事件改变,总能有可用的回应 session 供发送回应包。同理,如果你在和人/ NPC 战斗,那么对手也是重点关注对象。而周围人跑来跑去、做了个动作,可能你就不那么关心了。这个可以由客户端来控制哪些该保持追踪、哪些暂时不必理会。

  5. 最后关注自己(在场景中那个对象)。这样如果有人攻击你,你就能知道这个事件的发生。在攻击发生后,还可以立刻去关注打你的这个人(如果之前没有关注的话)。


可以做,且容易的做的优化有:

  1. 事件合并。当用户请求某个时间戳之后的所有事件时,有些事件是可以合并的,比如多次移动可以合并成一个;格子里一个对象进进出出,可以合并掉,并只回应新增事件。

  2. 回应包缓存。把事件列表序列化为网络包有一定的开销。有些信息肯定会有很多玩家来索要,你可以只做一次序列化工作,把数据缓存起来,等多个人来请求时,直接回应给他,省去了将信息反复序列化的流程。

  3. 多查询合并。查询多个格子的时候,可以合并在同一个请求中,这样,对象在临近格子间的移动消息可能就被合并掉了。

从协议层,可以允许一次提起对很多对象的查询请求,它们的查询 session 一定是连续的,所以不必逐个编码而只需要给个区间就够了;同时也不妨碍单独回应。在上面的查询约定中,如果发现时间戳之后到当下,对象事件列表中若没有事件,服务器是不回应的。

例如,如果你来到一个无人区,对所在格做了一次查询,除了你自己外,没有其它对象;你若再次查询,结果不会有变化,所以服务器会挂起你的请求,直到附近有新对象出现。


由于请求和回应是一一对应的,所以不用担心比单纯的数据推送(订阅模式)多出太多不必要的流量。