彻底解决多国语言

最近,我在抄王者荣耀玩。而多国语言自然是一个避不开的问题。

在这次的设计中,UI系统我采用了FairyGui

FairyGUI通过“字符串表”和“分支”功能提供了多国语言解决方案。

FairyGUI把用到的所有文字导出到一个xml文件中,然后为每个外国语言翻译一个相应的xml文件(字符串表),只要在运行时加载相应的xml文件,就可以将所有UI上的文字自动切换到相应的语言。

当然多国语言不止有文字,还有图片等资源。FairyGUI可以为每个外国语言设置一个分支(假设所有外国语言都需要使用不同的图片),每个分支上可以使用不同的图片、布局等,只要执行 UIPackage.branch = "en";,打开UI时就会显示对应分支的UI。这样就实现了非文字类多国语言的方案。

这案相当惊艳,对我有不小的启发。


虽然FairyGUI已经有了相当完美的多国语言方案,但是游戏不仅仅只有UI,一些非UI部分同样会有多国语言的需求,比如不同国家玩家看到的英雄的Tips, 一些3D素材等。

因此,业务逻辑还需要一个自己的多国语言模块,这部分我6年前就实现过

但是,FairyGUI的多国语言方案让我意识到一个事实,多国语言应该是一种和业务逻辑无关的需求。因此理论上,业务程序员不需要关心多国语言的存在,仅策划和美术关心就够了。

之前的设计中,所有多国语言的显示,都需要业务程序员干预,比如写一条local str = multi_lan.get(key)

如果显示多国语言的UI设计有变动,业务程序员就要相应的增加或删减multi_lan.get语句。

这违反了Open-Close原则,同时增加了业务程序的心智负担。

我希望这次可以更近一步,业务程序员完全不需要干预

我重新思考了一下整个游戏的运行流程后发现,屏幕上的所有显示内容其实都是来源于配置。游戏逻辑代码中不需要也不应该去写死每一个字符串(文字或资源路径)。

这样只要我们从配置入手,就可以在底层彻底解决多国语言的问题。

虽然,新的设计要比之前复杂很多。但是,大部分开销和复杂度都在离线(打表)逻辑,运行时代价不高。

按以往的经验,程序需要的配置表往往不是策划直接操作的第一格式。

策划大都是先用Excel来配置相关数据,之后再使用程序提供的配置生成工具,将Excel文件转换为程序使用的配置表格式。

这次多国语言的主角就是这个配置生成工具


由于Excel的特性, 一般在使用Excel文件作配置表时,都会使用关系型数据库的思路来操作。即先设计表结构,再填充表内容。

比如一个Hero表的Excel文件格式可能是下面这样:

HeroID HeroName HeroTips HeroSpeed
int(a) string(c) string(c) int(s)
1000 刘备 刘皇叔是大哥 100
1001 关羽 关云长是二哥 50
1002 张飞 张翼德是小弟 50

第一行用于标识每一列数据的字段名。

第二行用于标识每一个字段的类型(int, string), 以及是被客户端(c、a), 还是服务端(s、a)使用。

第三行开始(包括)就是真正的配置数据。

按照之前的多国语言思路,整个流程大致是这样的。

先将上述Hero表拆分为Hero表和Language表。 Hero表用来配置Hero相关信息的,Language表用于配置多国语言信息。

Hero表:

HeroID HeroName HeroTips HeroSpeed
int(a) string(c) string(c) int(s)
1000 HeroName_1000 HeroTips_1000 100
1001 HeroName_1001 HeroTips_1001 50
1002 HeroName_1002 HeroTips_1002 50

Language表:

Key Value
string(c) string(c)
HeroName_1000 刘备
HeroName_1001 关羽
HeroName_1002 张飞
HeroTips_1000 刘皇叔是大哥
HeroTips_1001 关云长是二哥
HeroTips_1002 张翼德是小弟

然后在需要显示HeroName和HeroTips的地方(即使HeroTips是资源路径同样适用)使用如下代码:

local id = 1000
local name = Language[Hero[id].HeroName]
local tips = Language[Hero[id].HeroTips]

上述工作流有不少弊端:

  1. 程序不满足Open-Close原则,业务程序员需要关心哪些字段是需要做多国语言处理的,一旦UI改了表现设计,可能需要程序修改代码。相比之下,改了UI表现的FairyGUI却不需要修改业务逻辑代码。

  2. 对策划不友好,策划需要手工维护Hero表和Language表之间的同步,人类极不擅长这类工作。这使用两表之间同步极易出错,而且不易发现(只有在运行时用到配错的那一行数据时,才能发现错误)。就算是使用自动化测试都不太可能覆盖表中100%的数据。

  3. 由于人类极不擅长Hero和Language表之间的同步,导致策划在修改Language表时,往往只增加不删除,这会导致Language越来越大。以我的经验来讲,这种情况是存在的,尤其是对于半路接手的策划。

  4. 由于所有跟文字相关的内容都被移到Language表中,这导致Hero表的可读性下降,往往打开一个Hero.xls之后,你找不到“刘备”是哪个。策划们的通常做法是再加一个字段, 这个字段即不用于客户端也不用于服务端, 仅用增加可读性。但是,增加了一个字段的同时,又增加了维护数据之间同步的工作量,出错的概率更大了。


新的多国语言设计中,我为Excel文件引入了lan类型。

lan类型不仅表明这个字段是一个字符串类型,还表明这个字段对于每个外国语言都有一个不同的值。

我们的Hero表最终配置如下:

HeroID HeroName HeroTips HeroSpeed
int(a) lan(c) lan(c) int(s)
1000 刘备 刘皇叔是大哥 100
1001 关羽 关云长是二哥 50
1002 张飞 张翼德是小弟 50

是的,策划的工作仅仅是把需要进行多国语言处理的字段由string改成lan即可(当然根据需要可以扩展为lan[](c)等列表类型)。

配置生成工具会有一个选项叫做导出多国语言文件,用于导出所有表中类型为lan的字段。

配置生成工具总是会在输出文件的末尾追加新添加的文字。而已经翻译过的文字保持不变。

导出的语言文件Lan.xlsx如下:

CN
刘备
关羽
张飞
刘皇叔是大哥
关云长是二哥
张翼德是小弟

在Lan.xlsx中添加要支持的语言及翻译内容,例如添加英语支持:(注:配置生成工具不会也不应该修改已经翻译过的文字, 如果某一列已经删除,可以将这一列移动第二个Sheet中去,以做备份)

CN EN
刘备 Liu Bei
关羽 Guan Yu
张飞 Zhang Fei
刘皇叔是大哥 Uncle Liu is the eldest brother
关云长是二哥 Guan Yunchang is the second brother
张翼德是小弟 Zhang Yide is the younger brother

再使用配置生成工具中的导出配置文件功能, 生成客户端和服务端需要使用的配置即可(这里可以使用增量导出功能, 参考Makefile的做法)。

至于配置生成工具到底如何工作,采用不同的配置文件格式有不同的做法。

以Lua为例,我们导出的配置文件如下:

--hero.lua
local lan = require LAN .. ".hero"
local M =  {
[1000] = {
    HeroID = 1000,
    HeroName = lan.HeroName_1000,
    HeroTips = lan.HeroTips_1000,
    HeroSpeed = 100,
},
[1001] = {
    HeroID = 1001,
    HeroName = lan.HeroName_1001,
    HeroTips = lan.HeroTips_1001,
    HeroSpeed = 50,
},
[1002] = {
    HeroID = 1002,
    HeroName = lan.HeroName_1002,
    HeroTips = lan.HeroTips_1002,
    HeroSpeed = 50,
},
}
return M
--cn/hero.lua
local M = {
HeroName_1000 = '刘备',
HeroName_1001 = '关羽',
HeroName_1002 = '张飞',
HeroTips_1000 = '刘皇叔是大哥',
HeroTips_1001 = '关云长是二哥',
HeroTips_1002 = '张翼德是小弟',
}
return M
--en/hero.lua
local M = {
HeroName_1000 = 'Liu Bei',
HeroName_1001 = 'Guan Yu',
HeroName_1002 = 'Zhang Fei',
HeroTips_1000 = 'Uncle Liu is the eldest brother',
HeroTips_1001 = 'Guan Yunchang is the second brother',
HeroTips_1002 = 'Zhang Yide is the younger brother',
}
return M

有同学说,你这个和策划配出来的XML格式并没有什么不同啊,优势在哪里。

以写代码而论,本质上你写的高级语言和汇编并没有什么不同。为什么你要写高级语言呢,因为写的效率高,出错概率小。

有了这个思路,再次对比上面新旧两种多国语言方案的优劣:

新的多国语言方案,策划只需要做两件事就能保证一定正确:1. 配置正确的lan类型。 2. 给出正确的翻译文本

旧的多国语言方案, 同步Language和Hero表有负担,一旦同步错误不容易发现,没有特殊手段清理Language表中废弃的行,Hero.xls表失去可读性等各种缺点。

谈谈数据库的选型

在开发游戏服务器程序的过程中,好像大家都默认使用Mysql, 如果有性能问题,大不了再加个Memcached, 或者干脆使用Redis来做数据库。

但这么做是否真的对所有模式的游戏服务器都合适呢, 对于某些游戏模式,是不是有更好的选择?

这是我最近在看《MySql是怎样运行的》,突然想到的问题。

我挑了三款存储模式完全不同的数据库, 来对比一下它们的特点。


Mysql: 一款关系型数据库。

由于有RedoLog,UndoLog的存在, 支持事务数据落地比较可靠

存储引擎InnoDB采用B+Tree作为存储结构, 而由于B+Tree的性质以及RedoLog,BinLog,UndoLog等机制的存在,导致Mysql的写入性能远低于查询性能。

因此Mysql适用于查询压力大,但是写入压力小的场景。虽然这些复杂的机制拖慢了写入速度,但是MySql可以提供各种复杂的查询

即使如此,由于Mysql的数据结构是严格和磁盘对应的,相比Memcached和Redis等,将数据以内存数据结构的方式完全存储在内存的程序来讲,Mysql的查询性能还是要差不少。

这也是为什么在一些读流量大的地方,有时候会加Memcached或Redis作为前端,以防止大流量将Mysql冲垮(还可以使用从机做读写分离)。


Redis: 一款读写性能都很卓越的NoSql内存数据库。

本质上Redis就是一个带持久化功能的内存缓存,所有的数据以最适合内存访问的方式存储,因此查询极快写入极快不支持事务仅支持键-值查询

其持久化方式分为RDB和AOF两种方式:

RDB是通过定时将进程内存中的数据集快照写入磁盘文件,这种持久化方式性能较高, 但安全性较低。默认配置下,可能会丢失最近60s的数据,由于RDB每次都是重新写入全量数据集,随着持久化频率间隔的降低,会显著增加CPU和IO开销。

AOF是性能和可靠性的另一种折衷, 每一条修改命令都会尽可能快的(不是立即,Redis会在Sleep前才会尝试)写入到文件系统缓存。至于什么时机通知操作系统将文件系统的脏页刷新到磁盘上, Redis最高可以配置为每次写入到操作系统文件系统缓存时,都执行刷新操作,默认为每秒通知操作系统刷新。

AOF文件的大小会随着数据修改次数的增加而逐渐变大,当大到一定程度后,Redis会Fork一个进程对AOF文件进行重写,以达到减少AOF文件尺寸的目的。AOF的重写时机同样可以进行配置。

不管是AOF还RDF方案, 都有一个不可避免的缺点, 每次生成RDB文件或重写AOF文件时, 都会将内存中全量的数据写入文件, 在数据量很大的情况下, 会产生CPU峰值


LevelDB: 一款写性能卓越的NoSql数据库。

LevelDB底层采用LSM数据结构来存储数据, 所以写入极快查询较慢, 不支持事务仅支持键-值查询(还支持键的遍历)

与Redis相反,LevelDB将所有数据都存储在硬盘上,仅在自身内存中缓存热数据

由于LSM数据结构的特殊性,LevelDB还需要WAL(Write Ahead Log)来保证数据的可靠性。

WAL的作用和MySql的RedoLog的作用几乎一样,都是用于在意外Crash时,恢复还没有写入磁盘的数据。

在LSM数据结构中, 所有数据都是存储在SSTable中, 而SSTable是只读的。

这意味着随着数据增删改次数的增加,SSTable会变的越来越大。这时LevelDB的后台线程会在合适的时机,合并SSTable,以达到减少SSTable文件的目的。

LevelDB在合并数据时,是以SSTable文件为单位进行的, 而每个SSTable文件的大小一般为2M。这保证了,即使在数据库存有超大规模数据时,其合并过程依然是可控的


总的来讲,MySql适合查询场景复杂, 而且查询多于写入的场景。Redis适合单进程数据量不大,并且对查询和写入都要求极高的场景。而LevelDB则适合于写多,读少的场景。

不管是Redis的内存限制,还是RDB生成/AOF的重写机制,都限制了其单进程能处理的数据量要远低于Mysql和LevelDB。同时,Redis的查询和写入性能也是这三者之间最出色的。


在我们游戏中,玩家数据是需要长驻内存的,即使一个玩家下线,别的玩家还是可以影响他的所有数据(包括货币和英雄)。

这意味着,我们必须在开服期间,就要从数据库加载所有游戏数据到游戏进程。之后只需要操作进程内数据即可。

在不考虑数据安全的情况下,甚至我们都不需要数据库。

只需要在停服时,像Redis写入RDB一样,将每个系统的数据按照约定的格式写入到不同的文件,下次开服再加载回来。

使用数据库的理由是,它可以让我们按需写入数据,可以提高数据的安全性。

那么,我们对它的需求就只剩一点了:“写入要快,持久化要安全”。

从安全性上来讲,Mysql, LevelDB, Redis的AOF都满足要求。

就写入速度而言,显然MySql落选了,因为他是为查询设计的数据库系统。

就现在需求而言,而Redis和LevelDB在CPU和内存足够的情况下,其实差别不大,甚至Redis要优于LevelDB。

如果我们想再节约一点,就会发现LevelDB在内存和CPU峰值方面优于Redis, 同时他的写入性能要差于Redis, 因为LevelDB有WAL和SSTable两次写入。

ps. 这种取舍其实很像数据结构的优化,一份平均每写10次只查一次的数据,显然没有必要每次写入之后都对数据排一下序,选择时关键是还是要看清数据库的定位以及自己的需求。

再谈Lua热更新(终)

在写这篇文章之前, 我特意在标题前加了个"终"字。因为我相信,这就是生产环境中热更新的最终出路。

大约在4年前,我实现过一版热更新

但是这个版本并不理想。在一些简单场景下工作良好,场景稍一复杂,就显得捉襟见肘。

比如下面代码, 我的热更新实现就无法很好的进行更新。

--M1.lua
local M1 = {}
local case = {
[1] = function() --[[xx]] end
[2] = function() --[[xx]] end
}
function M1.foo(x)
    case[x]()
end
return M1
--M2.lua
local foo = require "M1.foo"
local M2 = {}
function M2.foo(x)
    foo(x)
end
return M

它即不能很好的更新M1.foo函数,因为M2直接持有了M1.foo函数的引用。也不能很好的更新M1中的case引用的函数, 因为case中的函数不是任何一个函数的直接上值。

当然,我们可以加入一些强制规范来解决问题。比如:不允许一个模块持有其他模块的函数引用,不允许一个函数只被引用而不被函数引用,所有上值全部入表,等等。

不允许一个模块持有其他模块的函数这个问题不大,毕竟可以增加代码可读性。但是,限制总是有或多或少增加实现者的负担,也会限制实现者的创造性。

热更新作为一种非常规修复Bug的方案,我不希望他对实现者做过多的干扰。

在这之后我一度认为进程级滚动更新才是未来,不仅可以修复Bug, 还可以进行不停机功能更新。


直到前两天,和一位同学再次聊起热更新。

我突然想到,进程级滚动更新有一个致命的弱点,他是有损的(当然在WEB领域是无损了,因为WEB服务器无状态)。某个进程在升级期间,会有短暂的服务不可用。

进程级滚动升级和停服升级相比,服务不可用的时间会大大降低。但是就临时修Bug而言,热更新可能还是更为合适。

那么,还是想办法改造一下热更新的实现吧。

我又重新思考了一下整个程序的构成,程序是由模块组成的,而模块与模块的交互是通过模块的导出函数进行的。

这样如果我们以模块为粒度进行热更新,就不太会造成模块撕裂的现象。比如下面代码:

--M1.lua
local M1 = {}
local function foobar()
        print("1")
end
function M1.foo()
        foobar()
        print("foo1")
end

function M1.bar()
        foobar()
        print("bar1")
end
return M1
--M2.lua
local M2 = {}
local functin foobar()
        print("2")
end
local function M2.foo()
        foobar()
        print("foo2")
end
local function M2.bar()
        foobar()
        print("bar1")
end
return M2

如果仅仅只使用M2.foo去更新M1.foo, 就会造成新的M1.foo调用的foobar和旧的M1.bar调用的foobar不是同一个函数。

但是以模块为粒度进行更新就不会有这种问题,我很快便实现了第二版

随后发现,第二版除了解决了模块撕裂问题外,和第一版没有本质区别。第一版存在的问题在第二版依然存在。

问题并不是解决不了,但是这些方案都代表着超高的代价,我认为在生产环境这是不可接受的。

case表中引用函数问题,可以递归遍历上值中的Table来解决。但这里有一个潜在的风险,不小心引用了一个超大Table, 可能会有遍历整个LuaVM的风险。

一个模块持有其他模块引用的函数问题, 可以通过遍历整个LuaVM中函数的上值来修正。


种种迹象表明,不可能存在一种方案,即智能又高效又正确。

即然如此,只好采用一种更灵活,但是复杂的方案了。虽然很复杂,但是能解决问题

这个复杂的方案就是:不管是以函数为单位还是模块为单位,都不再实现一键热更功能。取而代之的是,每次Bug的修复,都需要重新编写修复脚本

修复脚本中,我们可以使用Lua原生的debug.upvaluejoin来正确的将修复函数引用到被修复的函数的上值,然后使用修复函数替换被修复函数

针对一个模块持有另一个模块的导出函数引用的情况,我们也可以使用debug.setupvalue来进行修正。

与此同时。我观察到,模块撕裂在某种程度上是不会有副作用的。

比如下面代码:

M.foo和M.bar虽然调用的不是同一个foobar函数,但是由于foobar1和foobar2逻辑相同,并且共享上值。这种情况下,模块撕裂现象虽然会增加内存,但却并不会有副作用。

local M = {}
local a, b = 0,0
local function foobar1()
    a = a + 1
    b = b + 1
end
local function foobar2()
    a = a + 1
    b = b + 1
end
local function M.foo()
        foobar1()
        print("foo2")
end
local function M.bar()
        foobar2()
        print("bar1")
end
return M

修复脚本的方案下,修复逻辑可以是千变万化。但是一个共通点,我们在编写修复脚本时总是需要先定位到一个函数,然后对两个或两组函数进行上值的join。

在这种思路下,我实现了第三版热更新。共提供三个接口:

  • sys.patch.create 用于创建一个上下文, 用于在随后的修复操作中做一些必要的缓存。
  • sys.patch.collectupval(f_or_t)用于收集一个或一组函数的上值信息, 该函数会递归遍历所有上值为函数的上值。
  • sys.patch.join(f1, f2)/sys.patch.join(f1, up1, f2, up2)用于将f1函数的上值以上值名字为关联信息引用f2的上值, 如果f1的某个上值名字不存在于f2的上值列表中,则这个上值的路径将会被存储到路径列表中, sys.patch.join在执行完之后, 总是会返回一个路径列表, 即使这个列表为空。

我们应该如何利用上面这组接口定位一个函数呢。

通过观察,我们可以得知一个事实。函数与函数的引用关系其实是一张有向有环图。这也就意味着不管我们想定位哪一个函数,它一定就在某一个函数的上值或者上值的Table中。

因此,sys.patch.collectupval收集来的上值,不但可以辅助我们做新旧函数的上值引用,还可以辅助定位我们需要更新的函数。

最后以一个简单的热更新例子来结束:

--run.lua
local M = {}
local a, b = 1, 1
function M.foo()
    a = a + 1
end
function M.bar()
    b = b + 1
end
function M.dump()
    return a, b
end
return M
--fix.lua
local M = {}
step = 3
local a, b = 1, 1
function M.foo()
    step = 4
    a = a + step
    b = b + step
end
function M.bar()
    b = b + 1
end
function M.dump()
    return a, b
end
return M
--修复脚本 将使用fix.lua来修复run.lua中的逻辑
local patch = require "sys.patch"
local run = require "run"
local ENV = setmetatable({}, {__index = _ENV})
local fix = loadfile("fix.lua", "bt", ENV)()
local P = patch:create()
local up1 = P:collectupval(run)
local up2 = P:collectupval(fix)
local absent = P:join(up2, up1)
print(absent[1], absent[2])
debug.upvaluejoin(up2.foo.val, up2.foo.upvals.b.idx, up2.dump.val, up2.dump.upvals.b.idx)
print("hotfix ok")
for k, v in pairs(fix) do
    run[k] = v
end
for k, v in pairs(ENV) do
    if type(v) == "function" or not _ENV[k] then
        _ENV[k] = v
    end
end

在这个例子中,我们可以看到fix.lua中的M.foo函数引用的上值b, 是不能被正确引用的。因为run.lua中的M.foo函数没有上值b, 这时我们就需要手动调用debug.upvaluejoin来修正fix.lua中的M.foo函数的上值b的引用。

关于游戏服务器的服务拆分

先阐明一下观点,可以使用单体(单线程)应用程序解决的问题,都不应该使用分布式系统来解决,因为分布式真的很复杂。

在游戏服务器中,我们做服务拆分,大部分情况下都是为了可伸缩,而不是为了高可用(这里暂不考虑那些使用WEB模式实现游戏服务器的思路。我认为这种思路,逻辑的复杂度和实时性都不能保证,而且还需要处理并发问题。)

以前我就说过,游戏服务器的开发更像是在开发数据服务

现在,我觉得可以更明确一点。

游戏服务器的开发,其实就是针对某种业务逻辑开发的专用数据库。 而玩家的客户端就真的是我们开发的数据库的客户端,来进行“增删改查"。

之所以我认为游戏服务器开发过程中,使用分布式不是为了高可用。是因为,在整个游戏服务器中,每个服务都是单点不可替代的。如果某个服务挂了,在它还没有被启动起来之前,所有与之相关系的业务都会出现异常。除非每个服务都会有对应的候补进程,然后将数据实时冗余在候补进程中。

如果使用“最终一致性”,冗余就会有同步延时。而在游戏服务器这种实时“数据库”领域,有延时就代表崩溃时会有数据丢失,这也谈不上高可用了。

如果使用“强一致性”, 虽然同步没有延时,但是会出现网络请求链路过长,性能和请求的实时性不能保证。

因此,可伸缩往往也是大多数游戏服务器最终的目的。虽然我们一般不要求高可用,但是我们在部分服务Crash的情况下,也要保证不能产生错误的结果(可以产生异常,而终止某条逻辑)。

虽然说“如无必要,勿增实体”。然而,我们在开发之初往往很难评估到我们的单体应用是否可以满足“策划”们突如其来的需求。

因此,除非极其明确的游戏类型,否则往往在设计之始,都不得不预留一些分布式设计。以免增加某个需求之后,需要大规模重构代码。


我目前的认知,一个通用分布式游戏服务器框架,最多可以帮助业务程序员解决服务发现服务依赖RPC机制集群健康监控等一些服务级别的管理。

而最重要的一环服务拆分,则留给了我们人类来做。

在服务拆分过程中, 我们往往需要关注服务间的数据依赖关系服务的内聚性服务间的交互频率每个客户端请求所经过的链路长度等指标。

同时,遵循“如无必要,勿增实体”原则,服务的拆分应该尽可能的少,这不但会减少链路长度,同时还会降低整个分布式系统出现故障的概率。

这是因为,每个服务都是单点。如果某个服务异常可能导致其余所有服务都产生异常。因此整个分布式系统出现故障的概率,是所有服务出现故障的概率之而不是积。


事实上, 当单体应用程序变成分布式之后,整个逻辑的复杂度都会有相当程度上的提升,尤其在数据一致性上。

在关系型数据库,如Mysql中,有一项功能叫“外键约束”,用于保证数据完整性。然而随着各种Mysql分布式方案的出现,这项功能被越来越少的使用。其原因就是因为在分布式系统中,“外键约束”很难实现,需要应用逻辑自己来保证。

在我们游戏服务设计中,也存在同样的问题。

假如有一个“联盟服务”,有一个“城池服务”。联盟可以借助占有的城池成为国家,“城池”服务则管理着城池相关的归属问题,比如复杂的领土争夺。如果城池丢失,则国家需要变回联盟。

这时,一般会有两种实现方案:

1) “城池服务”在城池丢失时,直接推送给“联盟服务”进行处理, 并不在意“联盟服务”是否收到消息。
2) “城池服务”在城池丢失时,通过RPC请求等待“联盟服务”处理完“国家变联盟”逻辑之后, 再修改城池归属。

即使在不考虑网络问题的情况下,这两种方案也会存在数据不一致的情况。

比如方案1,在“城池服务”发送给“联盟服务”消息之后,“联盟服务”Crash掉。

比如方案2,在"城池服务”收到“联盟服务”的成功返回后,“城池服务”还没有写入数据库,就Crash掉。

借用数据库的理论,如果需强的一致性就需要2PC,3PC来解决,然而就算2PC,3PC也不能完全解决个别极端情况。

因此,在服务启动时,必须要检查数据约束是否满足,并修正不满足约束的数据。

由于我们需要在启动时进行“数据溯源”(即A需要依赖B来检查约束,B需要依赖C来检查逻辑约束)来修正约束,就势必会产生“服务间依赖”,这时最好不要有循环依赖。

如果我们在拆分服务时,服务的内聚性不够好(比如将联盟和国家数据拆分成“联盟服务”和“国家服务”。由于国家是依托联盟而成存在,如果联盟不存在了,则国家必然不存在了),则会产生更复杂的依赖链,同时会加大数据不一致的概率。


解决了“服务的内聚性”,我们可能会迎来一个新的矛盾“服务间交互频率”。 而且极大概率,这两者是相互冲突的。这需要我们做出取舍,软件设计就是这样,按下葫芦起了瓢,我们永远需要做trade-off。

举个例子, 比如我们“城池服务”中的逻辑和国家数据耦合很紧密,如果我们把联盟和国家数据都放在“联盟服务”中。有可能会导致每处理一条客户端请求,“城池服务”和“联盟服务”之间要通信十数次。这会大大增大调用链的长度。

调用链的变长,会导致浪费很多CPU在网络处理和协议序列化上。同时也会降低系统的稳定性,增加客户端请求的RTT。

如果这个开销在整个系统中难以承受。我们就需要考虑,违反“服务内聚”原则将国家数据,挪到“城池服务”中,即使这会使“城池服务”和“联盟服务”变成循环引用。

至于什么程度是“难以承受”, 又到底要怎么变通,这取决于个人的经验以及对整个业务系统的认知程度。


上面一直在说分布式的复杂性, 还没有提到如何做到“高可伸缩”。并不是拆成分布式系统之后,就一定能做到高可伸缩。

先来描述一个简化的业务场景。

整个世界是由数百万个正方形格子无缝拼接而成。玩家出生后,会随机分配一个空闲格子作为出生点。

玩家在整个世界的主要任务就是打格子,然后形成势力,并最终占领整个服务器。

每个玩家拥有有限个英雄和10支队伍,每支队伍可以上阵三个英雄。

玩家以队伍为单位去占领格子,队伍的出发点,永远是玩家的出生点。

队伍从出发点经过有限时间后到达目标点,经历战斗并最终占领格子。

队伍出发之后到达目标之前称为行军中。行军中的队伍,如果会路过当前客户端显示的世界区域,则会将路线显示出来。

行军中的队伍在到达目标点之前,不会再参与任何逻辑计算。

只要目标点周围两圏范围内有自己的格子,就可以直接行军过去占领,与行军所经过的路线无关。

最初我认为,这样的业务场景,应该会有Role,League,World,Scene这4种服务。

Role用于处理玩家英雄相关数据和请求,可以水平部署多份,有伸缩性
League用于处理联盟相关数据和请求,全服只有一份,无伸缩性
World用于管理队伍和格子数据,及队伍相关请求,全服只有一份, 无伸缩性
Scene用于镜像World的格子数据,供客户端只读拉取,可以水平部署多份,无伸缩性

为League服务增加可伸缩性并不难。因此随着数据规模的增加,整个分布式系统最终的瓶颈将是World服务。整个分布式系统的伸缩性都将被World的伸缩性所限制。

而我之所以这么分,是因为我对整个业务场景没有清晰的认知,而且有点性能强迫症所致。

当时的思路是这样的:

队伍和格子数据关系很密切,因此需要将队伍和格子数据放在一个服务中处理。这样,客户端来一个请求“占领某格子”,队伍的“出发”,“到达”,“战斗”,“占领”,“返回” 全都在一个服务中搞定了,可以极大的减少服务间的交互。

当我把队伍相关数据放在World服务之后,限制就出现了。

如果我把World服务按地图区域切分,水平部署多份,那么相关的队伍信息,到底应该以怎样的方式分布在相关的World服务中呢,无解。

如果World无法水平部署,那么怎么分摊客户端不停拖屏,所带来的查询压力呢。

只有增加Scene只读服务,用于实时镜像World服务中的格子数据和队伍的行军路线。供客户端拖屏查询使用。

现在重新看待这个问题,我认为应该分为Role,League,World这3种服务。

Role用于处理玩家英雄和队伍的相关数据和请求,可以水平部署多份,有伸缩性
League用于处理联盟相关数据和请求,全服只有一份,无伸缩性
World用于管理格子数据,及战斗规则实现,按区域切分,可以水平部署多份, 有伸缩性

当我们把队伍相关逻辑放入Role服务之后,很多逻辑都会变得更清晰,代价是会多出几次低频率的RPC请求。大概流程如下:

客户端发送“占领某格子”的请求时,Role服务向World服务发出RPC请求,校验目标地的合法性。

World服务返回合法,Role服务为当前操作的队伍设置到达定时器。并再次通过RPC请求路线相关的World服务,设置行军路线供客户端查询使用。

队伍到达目标点之后,Role服务向World服务发出RPC请求,进行战斗并占领行为。World服务向Role服务返回战斗成功。

Role服务再次为队伍设置返回定时器。

队伍到达出生点之后,Role服务通过RPC请求路线相关的World服务,取消行军路线。

从上面流程可以看到,虽然增加了5次RPC请求,但是瞬时RPC请求数量并不高。

虽然在设置和取消行军路线时,需要消耗CPU来计算行军路线会经过哪些World服务,但是这些消耗是常量,随着服务的水平伸缩,很快就被抵消了。

同时还会有两处额外的开销,也需要能通过水平伸缩来抵消。

1) 在客户端拉取当前屏幕地块信息时,有可能需要收集1个以上的World服务中的地块信息
2)在客户端拉取行军路线的队伍信息时,也需要向1个以上Role服务拉取相关的队伍信息

但是不管怎样,整个分布式系统都是以常量的开销, 获得了高可伸缩的能力。

我使用这两个方案进行对比,是想说明分布式系统中服务的拆分,非常依赖于个人对整个业务模式理解,这一点真的很难。


再说一些细节问题。

在设计分布式系统之初, 我为了减少“服务间的交互”, 常常使用缓存技术。

经过一段时间的思考,我认为这是不正确的,因为冗余数据往往是滋生Bug的温床。少量的RPC交互并不会产生性能热点。

如果已经确定了某些交互频率确实过高影响性能。应该首先尝试将交互过多的数据放在同一个服务中,如果确定这样做会产生bad taste,再考虑缓存技术。

在实时游戏服务器中,服务间会经常产生消息推送。在我们不使用缓存技术的情况下,某些业务逻辑会产生竞争问题。

还是以联盟立国为例,客户端调用“联盟服务”选定国都C1进行立国,“联盟服务”通过RPC调用“城池服务”检查是否为自己城池。

“城池服务”收到这条消息,返回消息M1,告知当前城池还是属于此联盟。之后城池突然别被的联盟打掉,然后“城池服务”给“联盟服务”推送了一条消息M2,告知当前城池所有者已经变为了另一个联盟。

由于“联盟服务”调用“城池服务”使用的链接和“城池服务”向“联盟服务”推送的链接不是同一条,所以M1和M2会有一个竞争问题。

如果M2先于M1到达,则“联盟服务”必须要抛弃M1的结果,因为M1是不准确的。

如果M2后于M1到达,则“联盟服务”可以正常处理M1,因为稍后到来的M2再次校正结果。

同样,虽然缓存技术容易滋生Bug, 但是他可以解决上述竞争问题。


9月1日补充。

之所以有这篇文章有出现。其实是因为我想梳理一下思路,从框架层面解决M1和M2的消息竞争问题。

经过几天的思考,我认为框架没有能力也不应该解决这类问题。这类问题可以广义的归纳为异步问题。

比如,我打掉了一块格子, 我需要花钱让他升级。当我们调用rpc:submoney(uid, 100)返回时,有可能这块地已经被别人打掉了,我需要在rpc:submoney回来之后,重新检查这块格子是不是我的。

总的来说,服务间通信,异步是常态,这需要业务程序员自己去做约束。

ps. 分布式真的很复杂D:

ECS的初步实现

从我开始研究ECS算起, 到现在已经将近20天了。

第一版ECS库终于实现完成了。先不论性能如何,基本功能都实现了。

在我的理解中,ECS中最复杂的地方是EC部分的管理和查询。而S部分的复杂度主要是依赖关系的问题,这会取决于具体的项目。

因此,在这个ECS库中主要解决EC的问题,关于S的部分并没有提供。这也是我称为库而不是框架的原因。


在整个实现过程中,由于我还没能完全克服性能强迫症,导致我的心路历程非常坎坷(每次实现到一半,总会因为这样或那样的原因,让我推倒重来)。

最开始,我认为守望先锋的ECS之所以那么复杂,是因为他们使用了C++这种强类型语言。为了解决动态组合(动态添加和删除C)的问题,不得不在API上做出一些让步。

如果拿Lua来实现,语言本身就支持动态组合,那添加/删除Component的行为,可以退化为添加/删除“标签”功能。

每个System只需要过滤出含有特定“标签”组的Entity, 然后加以处理就行了。

很快我放弃了这一想法,主要原因是我认为作为一个合格的框架或库,它应该提供一些限制。可以让我们写出符合ECS原则,更易读的代码。

在上面的设计中,客户程序员很容易就违反了ECS原则,他完全可以只过滤某一个ComponentA, 然后去修改这个Entity中的ComponentB, 甚至删掉ComponentB但是并不会删除ComponentB的标签。这会导致一些很奇怪的Bug。而且从代码的易读性上来讲也没有好处。

在后续的设计中,我又陆续纠结了,Eid的分配问题, Component的存储问题,同一个Entity中的Component的关联问题。

在经过陆陆续续几次推倒重来之后,直到今天才实现完第一个版本。

在这不断的推倒重来中,我总是在是否“需要暴露Eid给客户程序”之间摇摆不定。最终,我认为是需要的。

我们总是需要在程序的某处去New出一个个的Entity。同样我们也总会需要在程序的某处,去修改某个特定Entity的某个Component数据。

在我看来,整个ECS的运行机制很像一个巨大的“粉碎机”。 我们总是在某一个入口投入足量的Entity, 然后ECS库或框架将这些Entity粉碎成各种Component,供System查询并操作。

因此在这一版的ECS库的实现中,我把Component作为主角来实现的。Entity的作用在这里,将一组Component进行关联,以方便Component查询和生命周期的管理。


先简单介绍一下API:

--创建一个名为Admin的world对象。使用相同名字多次调用ECS.fetch_world, 返回的是同一个world对象
local world = ECS.fetch_world("Admin")

--注册Component类型。 其中world.register的第二个参数是为了方便建立Component缓存池和Debug阶段检查一些Component的合法性(暂时还没有实现)。
world:register("vector2", {x = 0, y = 0})
world:register("vector3", {x = 0, y = 0, z = 0})

--创建一个Entity, 这个Entity只含有一个"vector2"的Component
local eid = world:new { vector2 = {x = 2, y = 2}}

--向eid所代表的Entity上添加一个"vector3"的Component
world:add(eid, "vector3", {x = 3, y = 3, z = 3})

--向eid所代表的Entity上删除一个"vector3"的Component
world:remove(eid, "vector3")

--查询world中的所有类型为"vector2"的Component
for v2 in world:match("all", "vector2") do
    w:touch(v2) --将Component v2置为脏标记
end

--查询world中所有被w:touch过的类型为"vector2"的Component
for v2 in world:match("dirty", "vector2") do
end

--查询world中所有已经死亡的类型为"vector2"的Component
for v2 in world:match("dead", "vector2") do

end

--删除Entity
world:del(eid)

--执行清理操作,每调一次为一个逻辑帧
world:update()

整个设计大概是这样的:

每个Component类型都有一个数字id称为tid。每个Component实例都有一个数字id称为cid。我们总是可以根据tid和cid来找到某一个具体的Component实例。

在相同的Component类型中,新创建的Component的cid总是比旧的Component的cid要大。在world:update时所有Component的cid会进行重排,但是依然满足这一约束。这会提供一个便利,在我们使用for遍历world:match时,依然可以不受限制的添加任何Compoent实例。

当某个Component实例被删除时,仅将其挂在“dead”链表上,并不做其他操作。如果已经在“dead”链表上,则不做任何处理。这会产生一个限制,刚对某个Entity删除了一个Component之后,不可以立马添加一个同类型的Component

当某个Component实例被touch时,仅将其挂在“dirty”链表上。

当某个Entity被删除时,将此Entity下的所有Component标记为"dead", 然后将Entity挂在"dead"链表,不做任何处理。

在执行world:update时会产生以下行为:

1. 释放所有的Entity及其eid(以备后面复用)
2. 释放所有标记为“dead"的Component, 并整理存活的Component的cid
3. 清除"dead"链表
4. 清除"dirty"链表

总的来讲,所有的添加都是立即生效,所有的释放都会延迟到world:update中执行。

ps. 在这次纠结的过程中,在一定程度上治愈了我的性能强迫症。

再谈分布式服务架构

在两年前,我曾经设计过一版,高可伸缩服务器架构, 但只进行了理论推演,并没有使用具体业务逻辑验证过。以这两年的经验来看,这个架构不具备可实施性。

在之前的架构中,我只考虑了Gate和其服务之间的交互。没有考虑其他服务之间也会有基于"玩家"的交互,还可能有基于"联盟"等其他对象的交互。

按这套模型继续演化下去,为了解决"任意两服务"之间的交互问题,势必需要所有服务都有一份相同的Agent对象。这样一来,整个架构的复杂性就大大提升了。

这两年里,我一直在思考,游戏服务器和WEB服务器最本质的区别是什么?为什么WEB可以很轻松的做伸缩, 而游戏服务器想要做对就很难。

现在,我想我有了答案。

WEB的经典模式其实是"逻辑"服务和"数据"服务分离。

每一次WEB请求都是一次全新的上下文来处理。如果需要延续之前的状态,就从"数据"服务获取之前的状态之后再处理。

而由于"逻辑"没有状态,因此几乎可以无限扩展。

如果事情到这里就完美解决,这个世界就不会有“理想情况”和“现实情况”之分了。

这个世界是平衡的,看似WEB可以轻松获取高可伸缩的能力,但其实高可伸缩的压力全落到了"数据"服务(大部分情况下,数据服务都是由数据库来完成)。

由于可能会有任意多个"逻辑"服务,同时访问和修改同一个数据。为了解决并发问题,数据库需要提供ACID功能。

当数据量和读写并发量上来以后,数据库就需要进行"分片"来提供可伸缩性。然而,数据库分片之后,ACID的功能就大打折扣。分布式情况下ACID的难度要远高于单机ACID, 而且性能也得不到保证。这也是为什么后来会有很多NOSQL的出现。NOSQL其实更应该叫NO ACID才对。

为了尽可能的不分片,人们总是倾向于先极限压榨"单机""数据"服务的吞吐量,比如设计一主多从机制来改善查询压力(大部分情况下,你看到网页过3秒跳转的,都是利用了这种机制。主机和从机之间的同步一般在1s以内,延迟3秒就是为了能确保在从机上查询出刚刚在主机上写入的数据)。甚至还会在数据库之前再加Redis或Memcached等缓存系统来改善数据库的压力,一般这种缓存数据服务都是NOSQL,其目的就是为了避免ACID和硬盘数据结构所带来的性能影响

因此可以得出一个结论,其实WEB的可伸缩性的限制并不是不存在,而是主要落在"数据"端。

而"数据"端的可伸缩设计,由于各种现实环境的掣肘,即使是到了现在,也依然没有一个银弹方案。可以认为依然是一地鸡毛。

而游戏服务由于性能原因,不太可能像WEB的工作流程那样,每一次对数据的查询和修改都直接穿透到"数据"服务。

大部分常见的做法是,在服务器启动和玩家登陆时,加载必要的数据到"逻辑"进程的内存中。

在接收到请求之后,直接在内存中处理相关数据,然后在合适的时间节点(比如每个请求处理之后,每个定时器事件处理之后,每隔一段时间之后)对脏数据进行落地。

这么比较下来,其实我们的游戏服务,更像是WEB的"数据"服务而不是"逻辑"服务。而从WEB"数据"服务的可伸缩设计历史来看,游戏服务的高可伸缩性也并不是这么容易就可以实现的。


由于我们需要操作的必要数据都已经在内存中了,我们也不可能再借助一主多从或redis/memcached等机制提升性能了。

我们可以提升性能的惟一手段就是伸缩,而伸缩性的惟一的手段就是“分片”(把玩家的数据,全服数据分摊到不同的游戏服务中)。

然而,由于现代分布式数据库领域都还没有特别完美的ACID解决方案。我们的游戏服务之间的交互更不可能提供ACID功能了。

幸运的是,这个世界是平衡的。在我们需要极高性能的同时,对错误的容忍度也相应提高了。

在此之前,先定义两个术语:

"错误" -> 是指产生了不可预料/推理的结果,比如并发过程中,两个线程同时对一个变量进行自增(没有使用原子操作指令)。这种结果是无法预料了,就算出了错了,你也很难推理出正确结果。

"异常" -> 是指产生了一些异常的,但是可推理出正确结果的操作。比如两个线程同时去对一个变量自增, 一个线程在自增前对此变量加锁,在锁定过程中,另一个线程尝试获取锁(非阻塞),如果获取失败,他直接打了一行log即算完成。在这种情况下,即使变量的最终值是不正确的,但是我们借助log是可以还原出这个变量最终值的。

我认为在游戏服务器的分布式领域中,我们只要阻止错误的发生就可以了。至于异常是避免不了的(比如超时)。

基于这个原则和我两年前的架构设计,我重新抽象了整个分布式架构

在这次的设计中,我不再为玩家设计Agent。而是为每一个服务设计Agent.

当一个服务SA会被其它服务调用时,SA需要提供一段Agent代码来暴露他的能力。

依赖SA的服务仅通过这段Agent代码来间接与SA交互。

当然SA可能会被水平扩展多份,但是具体这次请求会被路由到哪一个SA的实例上,则由相应的Agent代码来决定,这样从调用方的角度来看,就像集群中永远只有一个SA实例存在一样。

这次的抽象和上次一样,我并不企图抹平分布式的事实,仅仅只是为了抹平同一个服务会被水平布署多份的事实。


在设计完这个抽象之后,一个自然而然的事实,摆在我面前。

假如,有三个服务A,B,C。每一个服务都有一段对应的Agent代码agent.A, agent.B, agent.C。

因此,在每个服务中,每一份Agent代码都各有一份实例:
A::agent.B,A::agent.C
B::agent.C,B::agent.A,
C::agent.A,C::agent.B

我们需要保证所有的X::agent.A,X::agent.B, X::agent.C实例中的对相应服务的连接是一致的。

讲道理,用配置文件是最简单,也是最可靠的,但是服务间的启动依赖需要自己实现代码来管理。

索性将服务发现一起实现了吧(坏处就是,即然是服务发现,就意味着节点在挂掉和重启之间,是有可能进行地址变更的)。

在WEB领域,一般服务发现典型的做法是,使用etcd或zookeeper。

但是对照一下上面对WEB的概述就会发现,他们的服务发现,一般是用于"逻辑"的服务发现,而不是"数据"的服务发现。

我们的游戏服务与WEB的"数据"服务相似。每一个实例都负责惟一的一份数据,而且我们游戏服务并没并有提供ACID。

试想一下,如果我们用’etcd’来做服务发现会发生什么情况。

我们有一个role_1服务负责处理uid: 1000-2000的玩家数据,由于某种原因,它失联了。我们认为他挂了,又启动了一个实例role_1_new来负责处理uid: 1000-2000的玩家数据。但其实role_1只是与集群中的部分节点失联而已。这时就会出现,role_1和role_1_new同时操作着1000-2000的玩家数据, 这种就属于"错误"而不是"异常"。因为这种操作不可推理,也不可逆。

一旦出现这种情况后,我们的期望应该是"异常"而不是"错误"。


为了解决这个问题,我们必须自己设计“服务发现”机制。

在这次设计中,我将所有实例分为两类,master和worker。

master用于协调所有worker之间的互联,以及提供某种一致性,不参与业务逻辑,全局只有一份。

worker节点则真正用于处理业务逻辑,worker可以再细分为不同种类的服务(比如auth, gate, role),具体由业务逻辑来决定。每个服务可以水平布署多份实例。

为了确保不会出上述两个服务实例处理同一份数据的情况。我将一个实例成功加入集群的过程分为两个阶段:"up", "run"。

在"up’阶段,worker携带服务名向master申请加入集群,master检查此服务是否全部健在(即已经有足够多的处于"up"或"run"状态的服务存在),如果全部健在,则向此worker返回加入失败,worker收到失败后自动退出。如果此服务有空缺(比如需要5个实例,但现在只有4个健康实例。master会定期检查所有worker的心跳状态,如果有worker心跳超时,则将其标记为"down"状态,但是并不通知集群,直到有新的worker替换它才会通知集群,这么做是为了防止过载误判。)则master向所有worker广播当前集群信息(所有的Worker的信息)。worker收到广播之后,如果发现某个实例被替换,则关闭掉相应的rpc连接,并返回成功。在所有worker都返回成功之后,master向申请的worker节点返回成功。

worker在"up"阶段完成之后,会拿到当前分配的slotid和当前服务被计划布署实例的个数capacity。其中slotid即为(1~capacity)中的一个值,一般用于做数据分片。

worker根据slotid和capacity开始从数据库加载所需数据。

加载完成之后,worker再向master申请接管消息处理。此时master会再次将集群信息广播给所有worker, 待所有worker都返回成功之后master返回成功。在worker收到广播消息之后,发现有新的“run”节点,则创建与其对应的rpc连接。

至此,新加入的实例,处于一致状态。

API设计类似如下:

----master实例
local capacity = {
        ['auth'] = 1,
        ['gate'] = 2,
        ['role'] = 2,
    }
    local ok, err = master.start {
        listen = addr,
        capacity = capacity
    }
---worker实例
    worker.up {
        type = "auth",
        listen = listen,
        master = master,
        proto = require "proto.cluster",
        agents = {
            ["gate"] = gate,
        }
    }
    worker.run(MSG)

其中在worker.up时需要提供关心的服务列表,提供其相应的agent对象。每个服务在新建立rpc连接时,都会回调其对应的agent.join,示例代码如下:

function M.join(conns, count)
    if not gate_count then
        gate_count = count
    else
        assert(gate_count == count)
    end
    for i = 1, count do
        local rpc = conns[i]
        if rpc then
            gate_rpc[i] = rpc
        end
    end
end

BTW, 这种组网机制还可以提供一些时序保证(就是上面提到的一致性),如果在worker.up或worker.run返回之后,Agent对象中还没有建立相应的rpc连接,那么这个Agent对象所对应的服务一定在此之后才能成功加入集群。

游戏上线一个月后的反思

大约在1个月前,游戏终于上线了,在这一个月以来,服务器竟然crash了5+次,还有几次严重Bug.

除了觉得测试力度不够之外,我也在想到底有那些环节是我能做而没做好的。

仔细思考下来,出Bug的原因大概有两种典型情况。


写代码时,逻辑思维不严谨,并且函数之间耦合性过强,导致随便一个很平常的改动,都会产生新的Bug, 甚至是crash. 比如我经常写出类似下面这种函数之间耦合性过强代码。

struct foo {
    bool isfinish;
    time_t rsttime;
    //...
};
static viod try_reset(struct foo *f) {
    if (f->rsttime < time(NULL)) {
        //reset foo fields
    }
}
static int bar(struct foo *f) {
    if (f->isfinish)
        return -1;
    try_reset(f);
    //....do more things
}

这上面这个代码里,bar和try_reset耦合性实在是太强了,强到甚至我调换一下try_reset的调用顺序,都会导致整个逻辑出错。这次出的Bug中有好几个都是这种原因,原始逻辑是1年前写的,上线前调整了一下,结果没测到,然后就出问题了。

我仔细回忆了一下,这种强耦合的实现方式,是我最近两年才开始出现的情况。

之所以会出现这种情况,一方面是因为代码估算做多了之后,我对一条两条指令的开销同样很敏感,以至于很多时候把控不住优化的尺度,把设计弄糟。另一方面,我一直坚持设计正确,不需额外检查这一原则。对于上面的代码,本来设计try_reset就是需要调用方保证,一定在foo.finish为false时才调用,所以try_reset没有理由去检查foo.finish字段。

事实上,这并不是我第一次发现这个问题,以前也发生过几次这种问题。每次发生之后,总是自嘲一句“过早的优化是万恶之源”,然后改正了事。

但是连续几次的Bug, 让我头脑清醒了很多,我觉得我有必要重新审视一下这个问题,这应该不仅仅是过早优化的问题。

这段代码相关的需求大概是这样:如果foo在开始之后,需要完成若干操作,如果在开始之后的规定时间段内没有完成,则重置完成进度,重新完成。

为了“削峰填谷”,我并没有为每一个foo设置一个定时器(即使是固定时间轮),只是在每次获取foo结构时,尝试重置foo的进度。

经过重新审视了整个需求发现,其实“try_reset"的前置条件有两个:一个是没有完成,一个是过期时间。

但是在实现过程中, 把条件1放在了调用方,条件2放在了被调用方(也就是try_reset)。这样try_reset执行结果是否正确,竟然需要调用方来保证。以致于所有调用try_reset的函数,都会与try_reset有强耦合。这其实很像是“契约式编程”,但是除非有语言层面支持,不然只是靠人脑来保证契约前置条件是不靠谱的,所以我个人其实也不是很相信“契约式编程”。

将try_reset函数更改如下,其实可以避免很多Bug, 尤其是多年之后需要修改更时如此:

static viod try_reset(struct foo *f) {
    if (f->isfinish == false && f->rsttime < time(NULL)) {
        //reset foo fields
    }
}

那么上面问题的答案来了,是“过早优化么”?不是。“设计正确不需要检查”这个原则正确么,我依然认为是正确的。因为这种类型的Bug,其根本原因是没有设计好。

根据历史的设计经验,我一般都会有意识的将class/module设计为自成闭环。但是在一个class/module内部函数级实现时,几乎没有仔细思考抽象过,都是实现调用方函数的过程中做的,顺手提取出一个子函数供调用方调用,因此大部分情况下,内部函数和调用方之间都有很强的耦合性。当一个模块过大时,这种耦合性会呈几何倍数增加。

现在回过头来看,其实每一个函数在提取时,都值得仔细抽象来和调用方解偶,即使现在它只有一个调用方。据我过去的经验,你最开始抽象出来的函数,往往会随着业务逻辑的衍变,产生多个调用方。


上面的问题,大部分情况下只是会产生逻辑bug, 一般不太会诱发crash.

据最近几次crash的经验来看,主要原因就是在对于个unordered_map在for循环时,删除了其中的一些元素。比如下面代码:

struct st {
    int id;
    int progress;
    //other fields
};
std::unordered_map<int, struct st> DB;

bool bar(struct st &x)
{
    ++x.progress;
    if (x.progress >= MAX_PROCESS) {
        database_delete_by_id(x.id);
        DB.erase(x.id);
        return false;
    }
    //process x
    return true;
}

void foo(std::vector<int> &l)
{
    l.reserve(DB.size())
    for (auto &iter:DB) {
        auto &x = iter.second;
        if (bar(x)) {
            l.emplace_back(x.f1);
        }
    }
}

这种Bug最为讨厌,只要bar函数删除一个元素,就会导致整个迭代器失效,并且不是必崩的。所以这种Bug即使在测试足够的情况下,也很容易逃逸到线上。一般我都会将代码改成如下:

struct st {
    int id;
    int progress;
    //other fields
};
std::unordered_map<int, struct st> DB;

static std::unordered_map<int, struct st>::iterator
check(std::unordered_map<int, struct st>::iterator iter, bool &clear)
{
    auto &x = iter->second;
    if (x.progress >= MAX_PROCESS) {
        database_delete_by_id(x.id);
        clear = true;
        return DB.erase(iter);
    } 
    clear = false;
    return ++iter;
}

static std::unordered_map<int, struct st>::iterator
bar(std::unordered_map<int, struct st>::iterator iter, bool &clear)
{
    bool clear;
    auto &x = iter->second;
    ++x.progress;
    iter = check(iter, clear);
    if (clear == false) {
        //process x
    }
    return iter;
}

void foo(std::vector<int> &l)
{
    bool clear;
    l.reserve(DB.size())
    for (auto iter = DB.begin(); iter != DB.end(); ) {
        bar(iter, clear);
        if (clear == false) {
            l.emplace_back(x.f1);
        }
    }
}

事实上,我个人很不喜欢这种改动,一股bad taste扑面而来。我也很不喜欢unordered_map的这个限制,他会让for循环方和被调用方强耦合在一起。

for循环方必须保证被调用方不会删除unordered_map中的元素才可以调用。即使是第二种写法,也有一种浓浓的耦合味道。

最近重新审视这种代码时,我发现也许我们可以使用“变换式编程(《程序员修炼之道(第二版)》P149)来解决这种问题。

struct st {
    int id;
    int progress;
    //other fields
};

std::unordered_map<int, struct st> DB;

static std::unordered_map<int, struct st>::iterator
check(std::unordered_map<int, struct st>::iterator iter)
{
    auto &x = iter->second;
    if (x.progress >= MAX_PROCESS) {
        database_delete_by_id(x.id);
        return DB.erase(iter);
    } else {
        return ++iter;
    }
}

static void
batch_check()
{
    auto iter = DB.begin();
    while (iter != DB.end()) {
        iter = check(iter);
    }
}

static void
batch_process()
{
    for (auto &iter:DB)
        ++iter->second.progress;
}

void foo(std::vector<int> &l)
{
    bool clear;
    l.reserve(DB.size())
    batch_process();
    batch_check();
    for (auto &iter:DB) {
        l.emplace_back(iter.second.f1);
    }
}

当然这样的改动会增加一点点性能开销,但是我认为这点开销不会成为热点的致因。


在研究上述问题的同时,我也在想,除了良好的抽象之外,我还能为代码的可靠性做些什么。

是的,答案只有一个,为代码写测试代码。

我查了很多关于单元测试理论性的书籍,他们都告诉我,单元测试就是要把被测试的类通过mock类隔离掉,转而只测试这一个类。

但是游戏服务器的业务太复杂了,以至于会有少量循环引用的情况。这样我实现的mock类到最后,除了可以设置假数据之外,几乎和真实的类的功能一模一样了。

而且我看到书上的很多例子,为了可测试性,会对被测试类的接口加以修改。这种侵入式的测试,我很难适应。

而且游戏服务器还有三大不可控因素: 时间,配表,数据库

时间 – 在业务逻辑中,有大量时间相关的逻辑,比如多久回复多少资源, 执行一次操作后有多久的CD。与此相对的是,测试代码需要尽可能快的跑完。

配表 – 在业务逻辑中,有大量逻辑是根据配置来,因此测试代码并不能写死,需要根据配置自适应。而且升级配表之后,很难保证测试代码的正确性。

数据库 – 在业务逻辑中,玩家所有的操作造成的影响都会被存入数据库,因此测试不具有可重复性。

其实我很早以前就想为我写过的业务逻辑编写测试代码,但是基于以上种种原因,尝试了几次均以失败而告终。

由于最近出Bug的概率太高了,我下定决心想去解决游戏服务器的单元测式。

即然书上的理论在我这里行不通,那么我去看一些开源项目对单元测试是如何落地的。

我分别考察了Lua和Redis,发现他们都没有做大量侵入式的单元测试。而是通过他们“特有的方式”来触发相关的代码执行。

比如Lua是通过执行Lua代码来测试Lua虚拟机的各种角落,而Redis则是通过网络协议来测试。

我研究了一下这种方式,发现对游戏服务器同样可行。

其实我们根本不需要mock类。理论上我们可以通过网络协议来操作模块处理指定状态。然后再发协议操作某个特定模块,以验证这个模块的正确性。只要我们为服务器的每一个模块编写一个对应的测试操作模块,就可以大幅度减少测试代码, 大大提高了测试的可行性。

比如account操作模块提供一个account.eusure_create_role_whith_money(name, passwd, money)来保证创建一个名字为name, 密码为passwd, 初始金币为money的账号。

解决了mock类的问题之后,再来看看时间问题。

为了让测试代码尽可能快的运行,我们只有一种选择,那就是调时间。但是频繁去调开发机的时间,会造成一些很麻烦的后果,而且每个单元测试之后,都要把时间调回来。

好在,当时为了减少time()函数造成的系统调用,我封装了一个time.cpp静态类,所有的时间都是通过time::get()函数来获取的。我设计了一条GM指令,可以调整时间偏移量。为了避免测试相关的GM指令逃逸到线上去。我在本地Makefile加入了一个TEST宏,以确保只有test版本才会有这些指令。如此,时间的问题解决了。事实上不仅仅是调时间,我还加了少部分用于控制特殊逻辑的GM开关,以方便测试代码执行(就像Lua同样在源码里留了一些桩点用于测试使用),这些控制逻辑同样是被TEST控制。

配置表的问题,咋一看很麻烦,但是静下心来想想,其实我们的代码依赖的并不是表中的内容。而是表结构。所以在写完测试代码之后,除非功能有变化,不然根本没有理由去更改表的内容。惟一麻烦的是,有时候我们需要通过改表来隔绝其他模块造成的干扰。这种情况下,我们就需要为这引单元测试代码定制一份配置表出来。这也意味着我们的单元测试框架需要有,能为不同单元测试代码指定不同配置的能力。

数据库的问题,其实在我们编写测试操作模块时就已经解决了。我们只需要像Redis一样,在执行每个单元测试之前,把所有测试进程杀死,并且清空数据库即可。每个单元测试自己负责构造自己所需要的数据。由于我们的测试操作模块提供的都是很上层的操作。因此创建数据部分,并不会花费太多代码。

基于以上事实,我实现了一个简易的单元测试框架,并配合valgrindt和GCC的ASan,可以同时进行逻辑测试和内存问题测试。

这个框架有部分项目相关性,而且代码并不算太多,因此并没有开源。

ps. 在编辑测试框架过程中,发现一个有意思的问题。当可执行程序在运行过程中,替换其所依赖的so文件会造成进程崩溃。研究了一下发现,这是因为so文件的代码部分是通过mmap到进程内存空间直接执行的。而mmap的特性就是,对映射内存的修改,直接反映到文件,对文件的修改直接反应到mmap的进程内存地址。而cp命的执行步骤一般是,将文件大小截断为0,然后再写入新文件内容。当文件大小截断为0这个步骤,一下子就把so的代码部分破块了,崩溃也就成了必然。

谈谈随机数的使用

在日常开发中,伪随机函数几乎是必不可少的一个函数。

大部分我们在使用这个函数时,就自然而然拿来用了,很少去思考用的对不对,反正他是随机的,并且也很难去验证(需要各种大量数据统计)。

所以即使概率看起来不太对,也可以安慰自己说,其实是统计的数据量不够。但有时候真的是因为我们误用了随机函数。

在《计算机程序设计艺术》卷2中,详细介绍了线性同余序列的生成算法。
下面就以线性同余算法为例,来分析一下,为什么随机函数还有可能被误用,他原本不就是随机的么?

在游戏开发中,一般都会设计有开宝箱环节,假设每个宝箱每次开出A的概率是30%,开出B的概率是70%,宝箱可以重复开。

我们的代码可能会这么写

    int open_box(box *b)
    {
        int n = rand() % 1000;

        return  n < 300 ? b->a : b->b;
    }

是的,这段代码就是开宝箱存在“垫刀”的根本原因。

我们来看一下线性同余(LCG)伪随机算法的定义:

Nj+1 = (A*Nj + B) (mod M)(j, j+1为下标)

其中A,B,M为线性同余序列生成常数。

LCG周期为M,A,B,M的关系限定如下:

  1. B,M互质
  2. M的所有质因子都能整除A-1
  3. 若M是4的倍数,则A-1 也是
  4. A, B, N0都比M小
  5. A,B是正整数

通俗点来讲就是,线性同余生成的[0,M)个数在统计学意义上,是等概率出现的。也就是说在足够多次随机以后,他们出现的次数是相同的。

咋一看,感觉上面的代码好像没啥问题。因为[0,M)是等概率出现的,因此rand()%1000之后的值,也是等概率出现的。

但是!我们忽略了一个事实,这段代码意味着。所有人的所有宝箱(甚至还有其他系统)共用了一个伪随机序列。

假设rand()%1000的伪随机序列是这样的:

900,1,300, 500, 299, 785, 556 …

我们来模拟一下多个宝箱交替打开的行为:

开宝箱1,rand()%1000返回的是900, 因此开出来的是B

开宝箱2,rand()%1000返回的是1, 因此开出来的是A

开宝箱1,rand()%1000返回的是300, 因此开出来的是B

开宝箱1,rand()%1000返回的是500, 因此开出来的是B

开宝箱2, rand()%1000返回的是299, 因此开出来的是A

如果宝箱1和宝箱2一直在以类似的顺序交替打开。即使开再多次,你也很难拍着胸脯说,宝箱1和宝箱2开出来的A,B概率分布是符合预期的。

毕竟你亲口告诉玩家,每个宝箱都有30%的概率开出来的是A,但是宝箱1却从来开不出A。

事情之所以会演变成这样。根本原因是,除了有一个伪随机序列之外,还有一个真随机事件,即玩家开宝箱的时机选择。

用软件工程的话来说,宝箱1和宝箱2通过一个全局变量(同一个线性同余序列)耦合在一起了,他们不是正交的。因此,开一个宝箱势必会影响另一个,所以它必然是错的。

还有很多类似的情况,比如一个技能的触发概率。我们本来告诉玩家的是每个技能以某种特定的概率触发,但是我们很可能做成了,以某种概率释放了某个技能。

在我们用随机函数之前,一定要先问问自己,所有使用rand()函数的地方其实是共用了同一个伪随机序列,这样真的没问题么?


2021/11/16补充:

经过这一年多来的观察, 除了"垫刀"的问题之外, 我们还习惯使用如1000, 10000之类的数字来代替100%的概率. 这就会产生一个现象, 就是1000或10000很可能和LCG公式中的M是有公约数的, 这会导致LCG的周期缩短, 其影响远大于"垫刀"产生的不良影响。在我们将接近1000或10000的质数作为100%概率的代表时,随机数的分布有明显改善。

Lua中的函数式编程

最近在用Lua实现Websocket协议时,碰到了一个直击我的思维惯性的弱点的Bug。代码大约如下(实际实现较为复杂,比如还支持wss协议,因此定位到问题也着实花费了一些功夫,毕竟GC的执行是异步的.):

--websocket.lua
local M = {}
local mt = {
__index = M,
__gc = function(sock)
    close_via_c_layer(sock[1])
end}
function M:connect(url)
    local ip,port = parse from url
    local fd = connect_via_c_layer(ip,port);
    local sock = setmetatable({fd}, mt)
    return sock
end
....
return M
--foo.lua
local ws = require "websocket"
local sock = ws:connect("ws://127.0.0.1")
print(sock)

--main.lua
require "foo"
while true do
    collectgarbage("step", 1024)
    sleep for a while
end

起初,我发现foo.lua中建立的链接会被莫名关闭,各种排查websocket的实现。最后才发现竟然是sock对象的__gc函数被触发了。

查到的问题后,我足足想了有5分钟才明白过来为什么sock会被GC掉。

因为潜意识中,foo.lua类似于下面C代码,其中sock变量是与整个C代码的生命周期一致的。而在C语言中,代码是不会被回收的。因此sock是作用域有限的全局变量。

#include "websocket"
static websocket sock;
void exec()
{
    sock = websocket::connect("ws://127.0.0.1:8001");
    print(sock);
}

那为什么在Lua中sock变量会被GC掉,就要从Lua的基本规则说起:

在Lua中,一共有8种基本类型: nil、boolean、number、string、function、userdata、 thread 和 table。

其中’string,function,userdata,thread,table’等需要额外分配内存的数据类型均受Lua中的GC管理。

而require "foo" 的本质工作(如果你没有修改packaeg.preload的话)是在合适的路径找到foo.lua,并将其编译为一个chunk(一个拥有不定参数的匿名函数),然后执行这个chunk来获取返回值,并将返回值赋给package.loaded["foo"]。

在这个chunk被执行之后,整个LuaVM再无一处引用着此chunk. 因而此chunk可以被GC掉,而顺带着,被chunk引用的sock变量也一并被GC掉(因为sock变量仅被此chunk引用)。

一切都是这么的自然和谐,惟一不和谐的就是,我犯了这个错误。

以往在研究GC时,就单纯的研究GC,在一张图上经过若干步骤进行mark,再进行若干步骤进行sweep。

在编写Lua代码时,却往往根据以往的c/c++经验来判断变量的生命周期, 毕竟就算在如java,C#这些带GC的面向对象语言中,这些经验依然适用。

也因此,在我面向对象编程范式(也许叫‘基于对象’更合适,毕竟我极少使用继承)的思维惯性下,潜意识竟然将这两个紧密相关的部分,强行割裂开来。

以往写Lua代码时,我一直以为Lua是“原型对象”编程范式,然而这个“大跟头”让我发现,原来Lua的底层基石竟然是“函数式编程”范式(非纯函数式编程语言,Lua中的函数有副作用)。


在我们写代码之初,就被人谆谆教导:“程序=算法+数据结构”。

过一段时间(也许很久),我们又被教导各种编程范式,如:“面向对象编程范式,函数式编程范式”。

接着你就会问:“什么是函数式编程,什么是面向对象编程?”

会有很多人告诉你:“在函数式编程语言中,函数是一等公民。在面向对象编程中,万物皆对象”。

然后你(主要是我自己)就开始似懂非懂的用这些概念去“忽悠”其他人。

却从来没在意过,整个编程范式中,数据的生命周期是以何种方式被管理着,以及数据在以何种方式进行转换和通信。

借着这个Bug的契机,我从数据的视角来重新审视了一下这些话,有了一些意想不到的发现。这次终于打破了以往的范式惯性(上次学Lua时,我也是自信满满的认为我懂了函数式编程,结果摔了个大跟头)。

先来大致看看面向对象的哲学。

在纯面向对象编程语言中(C++显然不算),所有的逻辑交互均是在对象之间产生的,不允许变量产生在对象之外。

即使他们在努力的模仿函数式编程,比如所谓的委托,匿名函数。然而这些函数背后却总是逃不开this指针的影子。比如下面代码:

using System;
using System.IO;
namespace test {
class TestClass {
    public string a;
    public delegate int foo_t();
    public int bar() {
        foo_t func = ()=>{Console.Write(a + "\n"); return 0;};
        func();
        return 1;
    }
};
class Program {
    static void Main(string[] args)
    {
        TestClass tc = new  TestClass();
        tc.a = "foo";
        TestClass.foo_t cb = tc.bar;
        cb();
    }
}}

再来看看函数式编程范式中一等公民的定义:"如果一个语言支持将函数作为参数传入其他函数,将其作为值从其他函数中返回,并且将它们向变量赋值或将他们存储在数据结构中,就在这门语言中,函数是一等公民。

我认为对于有C/C++背景的人来讲,这不足以解释函数式编程的特点。

因为在C/C++语言中,函数指针同样可以做到上述所有的事情。

惟一的区别就是函数式编程语言中的函数其实是闭包(所需要的上下文+指令码(也许是CPU指令,也许是VM的OPCODE)),而C语言中的函数就真的是一段CPU指令。这两种函数有着本质上的区别。

类比面向对象是万物皆对象,函数式编程就应该是万物皆函数。

而实现万物皆函数,闭包是函数式编程必不可少的条件(这里不讨论纯函数式编程范式,连LISP都不是纯函数式编程语言)。

在函数式编程范式中,所有的逻辑交互均是以函数(闭包)为主体来运行。

每一个函数会携带自身所需的环境变量,以便在任何需要执行的地方执行。

自身的GC机制会保证,在函数(闭包)没有被回收前,其携带的环境变量永远有效。

在Lua的require和chunk的机制中我摔的跟头充分验证了这一点。

最后让我绞尽脑汁举一个不太恰当的例子来收尾吧(毕竟我也是刚刚(自以为)重新认识了函数式编程):

local function travel(tbl, process)
    return function()
        for k, v in pairs(tbl) do
            process(k,v)
        end
    end
end

local function printx(title)
    return function(x, y)
        print(title, x, '=>', y)
    end
end
local tbl = {"foo", "bar"}
local func = travel(tbl, printx("index:"))
tbl = {"newfoo", "newbar"}
----other logic
func()

重构登录逻辑

终于又甩掉了一个包袱, 这是我重构完第一句想说的话。

从我入职开始, 就因为这套登录逻辑的复杂性而略为不满, 其间还坑过我一次,但是因为一直勉强能用,所以也没有理由对它动手。

这一次终于因为不能够满足新需求,而让我有理由可以重构这段代码了。


旧的登录流程大致如下:

    $UST=(uid,session,token)
    Client-------------AuthServer---------------------GameServer-----------DBProxy
     *--"账号密码认证" ---> * 
                     检查账号密码,
                     生成session和token
                          *----------发送$UST----------->*
                                                     记录$UST
                          *<----------成功--------------*
    *<------$UST----------*
    *-------使用$UST登陆-------------------------------->*
                                                     校验$UST是否匹配
                                                        *------请求加载------->*
                                                                      加载玩家数据
                                                        *<---返回玩家数据------*
    *<---------------------------返回登陆成功------------*

每一个认证流程都会生成一个惟一session,类似tcp连接. 甚至加载玩家数据也会带着同样的session请求。如果此次数据加载与当前登陆session不符,则拒绝使用,直接抛弃。

session的存在使的整个“认证-登陆”流程可以做到精确无比。

然而,这种精确会带来其他问题,例如客户端出于某种bug多发了一次认证,就会导致整个登陆流程失败,并且会导致重复加载玩家数据(事实上我真的被这个问题坑过,查了好久才发现问题)。


经过这次重构后,我去掉了session机制,并将GameServer的整个登陆流程切分成三块相对独立的逻辑,即“认证逻辑”,“加载逻辑”,“登陆逻辑”。

  1. 当AuthServer检查账号密码正确后,即颁发token, 将uid,token发往GameServer。
  2. 当GameServer的“认证逻辑”到消息uid,token之后,记下token并随即调用“加载逻辑”进行数据加载。
  3. 当GameServer的“登陆逻辑”收到协议后会检查uid,token是否匹配,如果匹配则等待数据加载完成,然后向客户端返回登陆成功。

“认证逻辑”在收到AuthServer的消息后,仅简单的记录每一个要登陆的uid对应的最新的token,并踢掉当前在线的链接。

“加载逻辑”则需要对加载请求进行过滤,防止重复加载。当收到加载请求后,如果数据已经加载途中或已经加载完成,则直接扔掉加载请求。为了防止DBProxy挂掉,而造成加载卡死。每一个玩家会有一个30秒的加载超时时间(即超过30s后即使DBProxy还没有返回数据,下次收到加载数据请求,依然会再次向DBProxy请求)。

加载数据返回后,总是检查当前内存中是否已经有加载成功的数据,如果没有才使用当前DBProxy返回的玩家数据,如果已经有数据,则直接抛弃。

之所以这样做,是为了数据一致性问题。来看一下如下操作序列。

GameServer视角:加载数据请求A—〉等待30秒—>加载数据请求B—->加载数据请求A返回—>玩家开始操作,修改玩家数据—>向DBProxy更新数据—>另载数据请求B返回。

DBProxy视角: 加载数据A->返回加载数据A->加载数据B->返回加载数据B->修改数据

如果此时应用数据B,则在A返回和B返回对玩家数据的修改则全部会丢失。而旧的登陆逻辑,之所以在加载数据时依然带着session,应该也是为了防止数据一致性问题,但是我们玩家数据都是采用Write-Through方式来将一个玩家的所有数据都cache内存之后才进行修改的。因此我认为只取第一份成功加载的数据就可以解决一致性问题。

重构之后的登陆流程大概如下:

    $UT=(uid,token)
    Client-------------AuthServer---------------------GameServer-----------DBProxy
     *--"账号密码认证" ---> * 
                     检查账号密码,
                     生成session和token
                          *----------发送$UT----------->*
                                                     记录$UT
                          *<----------成功--------------*--------请求加载----->*
    *<------$UT----------*                                         加载玩家数据
                                                         | <-----返回玩家数据--*
    *-------使用$UST登陆--------------------------------> |
                                                         |
    *<------返回登陆成功----------------------------------* 

本来“加载逻辑”还要负责登陆超时的淘汰操作,即数据加载之后,客户端迟迟不向GameServer发送登陆请求,这时需要将加载出来的数据从GameServer淘汰掉。

但是由于我们专为手游实现了半弱联网机制。在socket断掉之后,N分钟以内会保留玩家数据。这可以保证在地铁或电梯等信号不好处链接断掉后,可以在信号良好时快速登陆。

因此“加载逻辑”将数据加载完成之后,直接交给原本的数据淘汰逻辑即可。


除了登陆流程以外,这次重构我还修改了"登陆"的语义。

重构前, 对AuthServer发起认证时,会先尝试对账号进行注册,再发送登陆协议。虽然这样可以避免在新号时连着发送三条认证相关的协议。但总感觉有点bad taste。

重构后,我将"登陆"定义为"如果账号不存在则先执行注册, 然后执行登陆逻辑"。

重构前,对GameServer发起登陆时,如果uid还没有对应的游戏数据,同样会返回错误。然后客户端根据错误码去发初始化消息进行初始化, 在初始化的过程中,如果由于某种原因连续多发,同样会出现问题。

重构后,当GameServer收到$UT准备加载数据前,会先检查uid是否被初始化过数据,如果没有,则直接向DBProxy请求创建。DBProxy创建玩家数据与DBProxy加载玩家数据具有相同的返回。

这样整个"认证-登陆"逻辑相关的协议,由4条("注册","认证","创建","登陆")降为2条("认证","登陆")。

这里会有一个问题,刚开服时,可能有很多玩家只注册完就卸载走人了。这样会浪费我们的服务器资源。因为他只要发一条"认证",我们就帮他把数据创建好了。

幸运的是,我们新实现里, 恰好提前实现了删号功能。即一个低级玩家多久没登陆过之后,过一段时间会自动被清理。

ps.重构之后,个人觉得对于整个流程,服务端和客户端都各种更内聚了,因为整个登陆流程的判断都被挪到了服务端。