关于游戏场景中,状态一致性那些破事

目录

 

事件和状态

什么是状态?

状态就是历史事件的影响累加,在某一个时间点上的数据快照。

由于一系列的事件的触发,这些事件按照序列(发生时间、先后顺序)影响并改变了数据当前的结果(快照)。

这些事件对数据的影响可能是局部的(比如用户的操作,可能影响范围只被限定在这个玩家的数据边界内),也有可能是全局的(比如系统的操作、规则的变化,影响范围可能就是所有的玩家)。

无论如何,状态不过是从初始值,不断的应用事件,逐步演变到某一个事件序列的快照,他是那个序列点的计算结果。

一般我们谈论状态的时候,如果没有特别指明,那个时间序列点就是现实世界中的现在进行时,比如当我打字写到这段话的时候(2019年09月21日04:18:23),那么我目前所能观察到的所有状态,都是基于宇宙大爆炸到现在为止无数事件累计影响的结果。

历史无法改变,一眨眼,瞬息之间,天地万物已然与前一刻有所不同,无论如何反抗,都无法重来。

我时常在想,现实宇宙是多么的强大,能够容纳不计其数的原子、粒子进行不断的组合和运动。

我并不是一名物理学家,如果把整个宇宙当做是一个容器,这个容器内的某一个(空间)点上,原子、粒子相互运动产生影响,随着(时间)的推移,逐步影响扩散到整个容器范围内的所有元素运动轨迹。

换句话来讲,在上面的比方中,当宇宙中的某一点,在某一个时刻产生了一个事件,时间会把它的影响扩散到整个宇宙,更不用说,每时每刻,在每一个点上都在产生这种影响。

所以我们不可能预测到下一个时间点,它们的状态和现在的状态究竟有什么不同,短时间内的宏观预测是相对准确的,这种准确的来源,其实就是短时间内,它所带来的影响还没有扩散到我们无法想象的程度,也是我们从小到大,成长给我们带来的“经验”,但是随着时间范围的拉大,更多的事件参与其中,我们便再也无法得知到那时究竟会怎样?这就是所谓的蝴蝶效应。

 

一致性

在上面的比喻中,我讲到,每个人都是从婴儿、小孩逐步成长到现在,我们相信成长给我们带来的“经验”,我们相信这些经验给我们带来的感觉,那种感觉使我们能够在我们的观察中,从容应对,以及预测短时间内所要发生的一系列事情(状态预测、基于目前的快照)。

想象一下,如果我们正在吃饭,一闭眼,一睁眼,发现自己突然出现在了珠穆朗玛峰的峰顶,物理精神病患者(脑部物理损伤,傻子)可能会吹着刺骨的寒风,远眺喜马拉雅山脉。但作为正常人的我们来讲,可能都会认为自己是在做梦,毕竟他不符合我们的感觉,也就是不符合我们的认知(一致性)。

所以究竟什么是一致性?它是对历史的总结,对经验的累积,对训练的结果产生一致的期待。

一致性使我们的生活更加的平滑、简单。

就好比是做UI交互设计,为什么风格、元素以及交互的模式都要保持相对的统一?首先它减少了重复的工作量,最为重要的是,用户在使用一段时间过后,它就能够适应你的模式,对你的模式产生了一致性的认知,无论我们进行怎样的组合,哪怕没有教用户怎么使用一个新的UI界面,他们也能从之前的经验中得到可能的答案。

所以,一致性使人们能够对这些模式产生总结,使人们能够更加轻松的,而更加高效的使用我们的产品。

再来讲状态的一致性,我们就知道它所要求的具体是什么了,那就是某一个时间点的状态(快照),它必须符合我们的预期,这个预期的来源是我们相对于上一个时间点的状态观察,执行了新的操作(事件),所预测的合理结果。

在这里,时间序列是其中非常重要的一个因素,当然我们也可以理解为先后顺序,我们不希望在下一秒突然站在珠穆朗玛峰的峰顶,自然也不希望,我的钱在下一秒就突然消失不见了。

 

一致性级别

大多数时候,我们并不是在造火箭,出于性能的考虑以及业务上天然的规则和要求,对于一致性的要求也是不尽相同。

我们可以简单的将一致性级别划分为两个大类,“强一致”和“最终一致”,那么它们之间有什么区别呢?

强一致

我们首先说强一致,很多用过关系型数据库系统(RDBMS)的同学都知道,基本上绝大多数(如MySQL,取决于存储引擎)数据库都会提供事务来保证ACID,我们单独来看一下ACID的说明:

  • (Atomicity)原子性:一个事务内,可能修改了多张表的多行数据,在事务提交的时候,要么全部成功,要么全部失败,不存在中间状态(数据被破坏)。
  • (Consistency)一致性:这东西一般用于数据库校验数据的状态,比如外键、约束(唯一、主键),但是数据是否一致,基本上是依赖于我们的代码是否正确的应用了数据所代表的业务规则,也可以理解为,一致性就是我们需要在应用程序内需要进行保障的,数据库的一致性仅仅只适用于数据库系统本身对数据的一些校验。
  • (Isolation)隔离性:隔离多个事务之间相互的影响,主要是为了进行并发控制,将每一个事务所访问的资源进行隔离,不同的隔离级别说明了业务规则所需要的并发控制策略。听起来有点晕对不对?没关系,我们只需要知道,隔离性一般用于读写竞争资源的时候,数据库如何进行处理。而且,隔离级别的使用,取决于我们是否希望操作到其他事务也可能操作的数据,也就是说,这玩意儿跟业务有关系,业务规则如果清晰,我们就能进行良好的定义,费这么多事是因为不同的隔离级别对于性能的影响,对于业务的需要是存在差异的。如果仍然感觉有点头晕,简单点,那就是一句话,隔离性用于并发控制。
  • (Durability)持久性:这个没啥好说的,事务一旦提交,除非后续事务再次提交,否则对当前的状态快照永久有效。

可能很多人瞟一眼上面的ACID说明列表,就直接关掉了,哈哈。

不过说实在的,这东西每隔一段时间看一遍也确实烦人,关键是看了这么多遍,如果还没有理解ACID,那就会越来越烦,这样,当我们又一次看到这种东西的时候,直接扭头关浏览器了,非常影响心情。

言归正传,ACID当中,个人认为最重要的就是原子性隔离性

原子性保证了状态在任何时候,任何一个快照中,都是正确的。这个正确,是指状态在被提交的时候,经过了应用程序业务代码的校验,是符合业务规则预期的,也就是一致的。

试想一下,如果没有原子性,当事务提交后,一部分数据修改了,另一部分数据还没有修改,接下来的事务如果访问了还未修改的数据,并以此产生了新的错误的数据,那就直接破坏了数据的一致性要求。

隔离性则保证了,在事务对这些状态进行操作(读或写)的时候,确保不会读取了我们不希望读取的数据。

什么意思呢?举个例子,当并发的时候,两个事务对同一个状态进行访问、修改,如何确保一个事务的访问和修改不会影响到另一个事务?

答案并不是统一的,这取决于设置的隔离级别,一些业务场景可能允许访问另一个事务已修改,但是还未提交的状态,这就是读未提交(Read uncommitted),一般应用于日志、或者应用程序的设计确保了数据的边界(数据被天然的区分开来了)不会发生此类问题。

而大多数情况下,我们都需要确保在事务的操作过程中,不会被其他的事务所影响,比如可重复读(Repeatable reads),或者读已提交(Read committed)的隔离级别更能保证数据在事务操作过程中,不会引入不一致的数据,从而造成当前事务的错乱。

另一个比较极端的隔离级别是串行化,也有称序列化(Serializable),一般是用悲观锁,好的系统设计不会使用这个隔离级别,原因就是并发能力不行,锁的开销比较大,一旦检测到多个事务存在写冲突,那么只有一个事务能允许被提交。

我们来总结一下,强一致是为了解决什么问题而存在的?

强一致为我们提供了一种通用的技术模型,那就是事务,无论我们是什么业务,终究离不开对状态的管理,也就是数据,RDBMS中的ACID事务是一种通用的解决方案,它对大多数的应用场景提供了不同的隔离级别,用以控制并发的时候,怎么样才能保证数据的一致性。

隔离级别的选取,关系到并发能力,现如今的硬件发展已经足以应对大规模的并发挑战,但终究单机的容量有限的,是存在天花板的。或许你可以说,我们可以使用分布式事务啊,比如JTA,它一样提供了ACID的保证。

朋友,且听我慢慢道来,为什么不推荐使用分布式事务?

我时常在一些技术群里,见到很多同学在问,如何在两个数据库之间使用事务,我也是从新手一步一步走过来的,因此非常能够理解,这个问题背后所隐含的知识面,究竟有多么的宽广,如果没有对这些知识面有一个大致的了解,出现这个问题也很常见,当然也很频繁。

如果我们能够换一个角度来看待这个问题,就能够发现,究竟是哪里出问题了?

首先,当我们使用单机ACID事务的时候,很Happy,不用担心业务的变化而导致数据一致性的问题,这是因为这项技术是通用的,可以适用于任何的业务。

当我们从单机变为多机的时候,也就是跨数据库的时候,为什么问题就出现了呢?此时我们仍然可以使用支持XA接口的数据库来进行分布式事务的管理(JTA),久而久之,我们可能会发现,事务并发能力并没有因为多个数据库存在而提高了效率,反而比单机还要低,这让我们终于发现了,并发能力依赖的并不是你有多少个数据库。

问题在于,数据库通信需要耗时,网络也是不稳定的,多个数据库协调所占据的网络IO消耗了大量的时间。

那么,有没有其他的办法可以解决呢?

朋友,还没有意识到问题所在吗?我们尝试把一切都交给数据库,期望数据库能够达到我们的预期,期望能够使用一套通用的技术模型来实现我们的要求,但是老铁,数据库哪有这么聪明?

对于数据的存储和结构关系的设计,如果我们没有进行规划,没有进行业务上的分区,没有一个全面的掌握……如果我们自己都不了解我们的数据有哪些特征和要求,我们又如何期望数据库能够理解这些数据的存取要求并达到最大化的利用呢?

当然,在开发期间,业务原型还没有确定的时候,我们想怎么玩都可以,不进行设计也是很正常的。

我们不能把所有的东西都交给数据库,让数据库来解决一切事情,至少在产品已经相对稳定,数据规模已经开始有所上升的时候,我们就得思考一下这些该怎么存储这些数据才能最大化的保障业务的发展。

以上就是强一致描述,接下来,再来看另一类一致性,那就是最终一致,由我们自己来设计数据的一致性,并实现它的要求。

 

最终一致

让我们来思考一下,状态一致性它究竟解决的是什么问题? 我们在上面讨论过关于一致性它到底是什么,但是在咱们日常编程进行状态维护的过程中,它究竟有什么用?

如果说一致性它本质上是为了保证符合预期,那么这个预期我们可以分为两个层面:

  • 逻辑层面:对于业务逻辑,我们编写代码对输入(请求)进行一系列的校验、转换。成功之后,我们就会进行输出(状态存储),并且状态的完整性和一致性都符合咱们的预期。
  • 技术层面:对于需要修改的状态,我们需要保证这些状态不会因为并发问题而造成数据的不一致。比如两个输入同时对一个状态进行操作,造成了资源竞争,其中一个输入肯定是要失败的,因为提交的时候只有一个会成功。

我们所面临的问题就在于并发,并发对同一个资源进行修改,注意,是“同一个资源”,无论如何结局肯定是只有一个会成功,其余的都将会失败。

有点像咱们输出日志的时候,如果同时输出多个日志内容到同一个文件或者标准输出,那么日志里面的内容就有可能是混乱的,这造成了不一致。

事情总有个先后顺序,我们可以“同时对不同的”资源进行修改,但是却不能“同时对一个”资源进行修改。

现实生活中好像我们并不会遇到这种问题,例如,我可以跟我女朋友同时吃一块蛋糕。

“这完全是两码子事!”你的内心或许是这样想的,但是请让我打个比喻。

这里的一块蛋糕只是我们逻辑上认为的它是一块,但是我可以把它分为两块,当我把它分为两块之后,之前的那一块还存在吗?换句话来说,这个世界是连续的还是离散的?时间是连续的还是离散的?我们根本无法得知。

或许当我咬下去的那一瞬间,我的女朋友还没有咬下去,如何测量我跟我女朋友是同时咬下去的?要精确到哪个小数点后才能认定?“同时咬下去”这个问题对于我女朋友来讲,当然是无关紧要的,她在乎的是有没有吃到蛋糕,而不是谁先吃的。

再来,假如我是一个超人,我在我女朋友咬下去的那一瞬间,先把她的那一块给吃了,那么她当然只能吃空气了,这个时候她会认为不一致了。

所以不可能我吃了之后她还能吃到原本她能吃到的那一块蛋糕,只是我动作太快了,她的预期应该是可以吃到的。这个时候实际上就是并发冲突了,后果当然是等着被挨打,但是宇宙并不会因为这个冲突而崩溃。

所以在业务逻辑上认为的一致性,并不会去纠结这些小细节。

我们认定发生了不一致,只有在我们发现不符合我们的预期的时候,才能给它下一个定义,说它不一致了。

不一致反映出来的问题是什么?有可能是业务逻辑设计上的缺陷,也有可能是我们的编码问题而造成的,这是在两个层面上看待问题。

所以不同层面反映出的问题,它的解决方法是不一样的,我们没有办法通过技术去解决原本在逻辑上就有缺陷的问题。

在之前强一致小节中我曾说过,强一致试图使用一个统一的抽象概念“事务”来解决一切问题,但是具体应用到不同的场景上,并发性能并不乐观。

但是使用事务本身并没有错,错的是我们没有经过思考,没有经过设计就试图靠它来解决一切问题,它或许可以解决问题,但并不是最好的解决方案。

最好的解决方案往往取决于你的系统设计和业务模式是否做到了良好的匹配,就像我上面那个例子,如果发生了不一致该怎么办?如果我的女朋友性格好的话,她或许不会深究,我也会补偿给她一块新的蛋糕。

这就是最终一致,宇宙并不会因为她不愿意就崩溃,她的反应早已被上帝设计好,出现任何情况都能得到良好的补偿,只是世间万物发展的一个小插曲而已。

至于影响力有多大?可能由于这个事件以后,她跟我分道扬镳,在一段时间过后我也许也会跟她重新复合,但这谁又能知晓呢?

现实世界我们无法预测和预先设计,但是对于我们的业务系统来讲,我们是可以做到预先的设计和补偿处理的。接下来我们就来讲一讲,什么情况下?我们才会认为发生了不一致。

 

什么是不一致?

注意,我们讨论什么是不一致的时候,讨论的层面是在技术层面,而非业务层面。

业务层面的逻辑是否合理取决于各自的应用场景,是需要根据各自领域规则来进行抉择的,我在其中会举几个简单的例子,但是请勿对号入座,将它们直接拿来用。

在讨论什么是不一致的时候,我们需要牢记,这些问题都是处于并发情况下才会出现的,因为一致性问题主要就是由于并发操作所产生的状态错乱。

 

并发读取和写入之间的时间窗口重叠关系

现在,假设我有100块钱(好古典的例子),让我们来根据事件的先后顺序梳理一下发生了什么:

  1. A问我有多少钱,我告诉他我有100块,A走开了。
  2. B问我有多少钱,我告诉他我有100块,B也走开了。
  3. A走过来跟我说,就在刚才,我帮你办理了一个全套大宝剑业务,这是发票,你要付60块钱(我有答应吗?这么便宜的确定没问题吗?发票都有??),好的吧,现在我只剩下40块钱了。
  4. B走过来跟我说,就在刚才,我帮你办理了一个全套大宝剑业务,这是发票,你要付60块钱,我告诉他我坚决不会掏这60块钱,因为我只剩下了40块钱,怎么办?B只能拿着发票去办理退标手续,边走还编发牢骚,你明明刚才就有100块,搞得我现在回扣都拿不了。

其中1和2的步骤,我们可以理解为同时发生的,但是3和4,A早来一步,B只能去退标了。

这看起来没有什么问题,因为按照顺序,40块钱确实没有办法支付60块的账单,因为按照世界的运转规则,我不能负债(业务规则,钱不能小于0),所以B失败了,回滚了刚刚办理的业务操作,世界仍然是一致的,这很美好。

但是我们在编程的过程中,一般使用的是赋值,而不是现实生活中这么简单,比如A知道我有100块(做钱余额的校验)然后直接赋值给我40块,B也知道我有100块(做钱余额的校验,但在A赋值之前)然后也给我直接赋值了40块,这样就会产生一个问题,我只花了60块钱就办理了两个全套大宝剑业务,但实际上两个账单加起来应该要付120块的,类似下面的代码:

 1 function process(my) {
 2   if (my.money < 60) return;
 3   var receipt = dbj(my);
 4   try
 5     my.money = my.money - 60;
 6   } catch (error) {
 7     rollbackForDbj(receipt);
 8   }
 9 }
10 
11 var my = {money: 100};
12 // 定义setter,实现赋值逻辑。
13 defineSetter(my, 'money', function(newValue){
14   if (newValue < 0) throw error('value cannot less than 0')
15   this.value = newValue
16 });
17 A.ask(() => process(my)); // A
18 B.ask(() => process(my)); // B

 

代码是什么语言就不要去纠结了哈,仅仅表明意图,其中的A和B我们可以理解为两个不同的主体(线程),并发执行了。

当执行到第5行的时候,问题就出现了,这是由于读和写的过程当中并不是按照顺序来的,也就是说,B不是等A执行完之后才去执行。

 

理想状态下,我们可能想要的是,当B读取的时候,能够得知我只有40块,那样的话就直接返回了,有的同学可能会说,我们可以加锁。

但是我们不可能涉及到读取的时候就加锁,因为也有可能会有C、D来读取从而去办理其他的什么鬼业务去了,如果每一次读取的时候就加锁阻塞的话,效率未免也太低了。

这种问题出现的原因就是:并发读取和写入之间存在一个时间窗口,这个时间窗口可能每次都不一样,有时候能够一致,有时候不一致,当不一致出现的时候,实际上就重叠了。

也有同学可能会说,我们先把钱拿走,然后再去办理业务,在逻辑上这样处理可能是一种更好的办法,但是细想这样也会存在问题。

现实中我只能把钱交给一个人,因为钱只有一张。但是编写程序并发执行的时候,A和B都可以拿到100块这个状态,然后给我进行“赋值”。

关键就在于并发写的时候造成了冲突,实际上读取的时候,我确实有100块,但这并不代表,你去办理业务或者找我拿钱的时候,我还有100块。

既然加锁并不是一个好主意(悲观锁),并且我们知道,只有当写入的时候我们才需要保证一致性,强一致的解决方案有更好的做法,那就是使用版本号来保证(乐观锁)。

大体思路就是,我需要有一个方法,当你赋值的时候,你必须提供你读取时候我给你的版本号,这样我会对比我目前版本号和你提供的是否一致,如果是一致的,那么才允许赋值,并且更新我的版本号。

请注意,在这里,赋值和更新版本号是一个原子操作,要么同时成功,要么同时失败,不存在赋值过后版本号还没有更新的问题。

那么代码就会变为如下的形式:

 1 function process(my) {
 2   var read = my.moneyWithVersion
 3   if (read.value < 60) return;
 4   var receipt = dbj(my);
 5   try {
 6     my.moneyWithVersion = {value: read.value - 60, version: read.version};
 7   } catch (error) {
 8     rollbackForDbj(receipt);
 9   }  
10 }
11 
12 var my = {moneyWithVersion: {value: 100, version: 0}};
13 // 定义setter,实现赋值逻辑。
14 defineSetter(my, 'moneyWithVersion', function(newValue){
15   if (newValue.value < 0) throw error('value cannot less than 0')
16   // 原子操作
17   atom(() => {
18     if (newValue.version == this.version) {
19       this.version = this.version + 1;
20       this.value = newValue.value;
21     } else {
22       throw error('Version is not match');
23     }
24   });
25 });
26 
27 A.ask(() => process(my)) // A
28 B.ask(() => process(my)) // B

 

由于atom这个神奇的函数确保执行的时候是原子操作,我先不去关心如何实现atom函数,当我们这样做了之后,无论有多少个ABCD,当他们尝试写入状态的时候,我们都会进行版本的确认,防止错误的写入造成数据的不一致。

读取的时候大家尽管读取,因为我们关注的点在于并发写入的时候可能会造成不一致的问题,所以这种做法被称之为乐观锁,我们乐观的认为,读取不会对状态有任何影响,只有当写入的时候才做必要的校验。

注意到我们的代码抛出了一个错误,这个错误就是由于并发写造成的,我们检测到版本号不一致了,就可以确定目前的写操作是冲突的。

我们必须要通知赋值者,你不能这样做,好让他妥善处理接下来的事宜(拿着发票凭证去退标,回滚操作)。

乐观锁适用的场景请根据自己的需要来进行适用,如果频繁的读取总是会修改数据,那么乐观锁可能反而比悲观锁的效率还要低。

事实就是如此,业务的场景决定了我们到底该使用什么样的技术,这也是为什么老生常谈,没有最好的,只有最合适的。

面对现实而不要去钻牛角尖才是处理问题的良好心态,如果业务允许,我们或许会进行延迟扣款,或许会有其他的业务流程来保证就算你钱不够,仍然可以通过后续的还款来保证业务逻辑的一致性,这种最终一致的处理流程可能更加灵活,但是也更加的复杂。

 

多版本并发控制(MVCC)

这个技术一般被用于各种数据库的并发控制实现,能够提高数据库系统的存取效率,它的思想就是我们并发读取和修改都不会被加锁,只有当我们提交的时候才会进行必要的检查。

MVCC不会修改已有的状态,相反,它创建一个新版本的状态来替代旧版本的状态,这样旧版本的事务仍然读取的是之前的状态,实现了可重复读的隔离级别。

有时间我会写一篇专门的文章来讨论MVCC的具体细节,实际上它并没有一个标准,它只是一种控制并发情况下状态变更的方法,每种实现都有可能根据自己系统的特性进行适配和概念转换。

它跟乐观锁有异曲同工之妙,只不过它对应的还有一个事务的概念。

这里提一下是因为我们如果需要自己去实现软内存事务(STM)的话,使用MVCC来实现就能达到一个很好的效果。

因为在游戏场景类(终于提到游戏了),很多时候我们对状态的变更,并不会直接影响到底层存储,而是对其状态进行缓存,避免由于IO耗时而造成请求响应处理不及时。

而游戏的各模块实现上,为了实现跨模块调用链上的某一个模块由于校验失败而造成这个链之前的调用已经对部分状态产生的变更的情况下进行回滚,我们就需要使用事务来进行管理。

打个比方,假如说现在有一个业务操作,需要对模块A,B,C进行调用,例如扣除100金币(钱包模块),然后在玩家的背包内增加一个道具库存项(背包库存模块),最后增加玩家角色10点经验值(角色模块)。

对ABC三个模块的调用,产生了3个状态的变更,在变更之前,肯定是会有一系列的校验操作的,比如钱包里面的钱够不够,背包库存有没有满。

假如说在增加背包库存的时候,由于玩家的背包已经满了,无法再容纳更多的物品,此时背包模块就会抛出一个异常,但是我们已经扣了100个金币了(钱包校验通过,正常执行)。

这个业务操作我们可以看做为一次事务操作,事务需要保证原子性,那么B模块失败后,已经对A进行的操作应该被回滚(返还100金币)。

可能有的小伙伴会说,我们可以在业务A里面先去判断所有的校验条件是否满足,然后再去执行相应的操作,比如先判断钱包余额够不够,在判断背包是否还有库存容量。

我们先不讲这样做了之后,会不会存在什么问题,就单单为每一个业务去判断每一个模块是否满足特定的条件就已经很伤脑筋了,会存在大量的校验代码和额外的模块方法,破坏了模块本身自己的业务逻辑封装。

而且,对于稍微大一点的业务操作,比如十连抽这种循环操作,进行十次我们上面讲到的业务,如果在第九次由于校验失败而终止了,这种情况我们是不是也需要提前将循环次数代入我们的校验计算呢?

最致命的是,如果存在并发,那么这种提前校验是没有任何意义的,有可能成功,也有可能失败,原因是因为并发本身就是不确定的一件事情。

如果使用锁来实现,那么又回到了我上面所讲的那个例子的情况了,而如果这次你学到了,使用乐观锁,那么将会存在大量的回滚代码,对于每一个状态操作,可能都会存在一个对应的undo(撤销)操作,而且在撤销操作期间,也有可能会存在并发情况,导致中间状态被读取,从而产生了不一致的数据。

面对无法确定的、大量业务交叉调用多个模块且在并发的情况下,为了保证一致性,我们只能够使用事务来实现,而如果使用数据库的事务的话,就会存在网络IO。

所以面对这种高规格一致性和速度响应比较敏感的场景,我们别无选择,只能够采用软内存事务(STM),将状态保持在进程内才能达到我们的响应需求。

我们这里假设的需求,规格级别算是很高的了,也有点极端,如果是非实时交互或者只是简单的,对一致性要求并不是那么严格的场景,可以省略这一步。

顺便提一下,很多游戏后端采用Redis的事务也能很好的工作,速度都还不错,而且支持持久化,只是还是会存在网络IO、序列化/反序列化等操作,不过这已经足够满足需求了,当然这取决于游戏类型。

你看我讲了这么多,看来好像很复杂,难道就没有一个比较简单但可以用的框架可以给我使用吗?

据我所知,函数式编程在这方面有天然的优势,由于不变性,状态是无法被修改,这样我们管理状态的时候就更加的方便直观,例如Erlang,甚至有一个内建的分布式数据库。

但这并不代表他们不需要关心此类问题,量变导致质变,事实上对于咱们做技术的来讲,除了实现需求以外,我们始终也要去关心业务需求,因为只有我们自己弄清楚业务以后,我们才能给出更好的建议以及更好的软件适配。

这里没有银弹。

 

一致性级别对应的场景

最后,总结一下游戏内不同的状态类型对应于一致性级别所使用的场景,可能列出的具体场景并不是很全面,取决于业务需求:

  • 资产状态:这种类型的状态,如果发生不一致,玩家可以明显的察觉到,比如背包内的物品,钱包里面的货币数量,任务的完成条件,角色的等级与技能等等,对于这种状态,我们必须要保证他们的一致性,否则出现问题可能会面临业务投诉。
  • 场景状态:这种类型的状态,比如MMORPG游戏一般都会有地图场景,状态的变更相对频繁。比如玩家的位置坐标、BUFF、NPC、怪物等等,一般取决于最后一个事件的影响,状态的时效性也比较短。这种状态一般短暂的不一致是可以容忍的。
  • 日志状态:这种类型的状态,比如聊天、公告,玩家的操作日志等等,就算丢失也没什么太大的问题,几乎不存在一致性的要求,只需要如实的进行持久和广播同步。

我们可以发现,对于不同的状态类型,他们的一致性级别需求是不一样的,有的不允许出现不一致,有的却对此没有特别要求。

我们讨论了这么多,对于一致性总算是有一个比较深入的认识了,要知道,一致性是为了解决并发问题,只要不存在并发,我们的任务就会轻松很多。

但是很明显,为了系统可用性,并发又不得不存在。幸运的是,我们可以进一步去分析,并发与状态之间的关系。

通过分析,我们能够更加深入的了解需求,从而在某些方面给予反馈,通过合作沟通的方式,我们可以在保证需求得以实现的同时,提高系统一致性和并发能力。

接下来,我们就来讨论一下并发和状态之间的关系,只有当我们梳理好状态,并对其进行隔离,才能最大化的提升并发能力。

 

 

并发以及隔离

并发,更准确一点的说应该叫并行,只不过前者表示逻辑上的(1人做多件任务),后者表示物理上的(多人做多件任务)。

我并不想去争论它们的区别是否有意义,当我们编程的时候,可能会在多核CPU上面跑,也可能只在单核CPU上来跑。

当我们编写使用多线程、多进程甚至是不同主机上运行的程序的时候,它们都有一个共同点,那就是它们的运行顺序并不受我们的控制,或者说,我们并不关心先后顺序,它们都是分布式的。

我们无法说A一定要运行在B之前,B也一定要运行在C之前,除非我们有明确的意图需要这么做,才需要引入额外的控制机制去编排执行流程。

但大多数时候,我们创建了一个线程去处理一个任务,是希望即将要处理的任务可以被分离出去,而不必阻塞当前的流程,这样我们就可以继续处理其他的任务。

 

为什么需要并发?

为什么要有并发?本质上是为了提高系统的可用性,合理的分配资源,利用多核CPU甚至多个服务器来水平拓展我们的处理能力。

如果我们的程序只有一个主线程,把所有的任务按顺序依次执行,也就是说,同一时刻只能处理一件事情,那么我们的处理能力取决于单核CPU的核心频率到底有多快。

不仅如此,当我们在任务中访问了IO资源,比如磁盘、网络,那么处理能力还将会受到这些因素的影响,也就是说有相当多的时间耗费在了阻塞等待的过程中。

必须要阻塞等待的原因是因为我们的代码书写都是按照先后顺序同步执行的,接下来的代码依赖于前面执行的上下文(变量、状态)。

比如访问数据库,只有当我们拿到数据之后,接下来的代码才能够去处理,所以必须等待数据库的响应。

在单线程模型中,这是最为致命的,我们有可能计算并没有占用多少CPU资源,但是等待IO资源却耗费了绝大多数的时间。

 

并发有什么问题?

如果我们的程序只运行在单核CPU上,并发的优势并没有那么明显,但也绝对比单线程模型要好得多。

当我们等待阻塞的时候,当前线程的上下文得以保存,从而可以把时间用于执行其他的线程,等外部资源响应的时候,再切换回来,加载之前的上下文,继续处理。

但是线程的切换并非没有开销,如果在单核CPU上面频繁的切换线程,在进行不涉及IO阻塞的程序上,使用单线程反而速度要更快,操作系统在进程级别的管理上可以减少此类的切换开销。

当然了,现如今硬件的提升,我们实际上很少会遇到这种情况。

在20年前,硬件资源匮乏的年代,一块小小的硬盘能够存储几个T的数据,在那个时候是想都不敢想的事情。

以前的内存也少的可怜,我们在进行开发时候必须要规划好内存的使用,考虑各种不同的情况,因为内存很容易被填满而导致意外的错误。

操作系统抽象出来的虚拟内存可以缓解一部分问题,将一部分内存交换到空间更大的磁盘上,使我们的编程任务更加简单,但这也是没有办法的事情,因为程序要求的内存使用量已经无法再精简了。

而现如今,如果出现了内存磁盘交换的情况,我们会把它当做为一个异常,考虑是不是程序出现了内存泄露的问题?因为这样会导致访问速度变得很慢,而内存也不是非常珍贵的资源了。

云主机的出现再一次降低了我们的成本,可以根据使用量来计费,用多少花多少,没有浪费,进一步提高了资源的利用。

回归主题,当我们将程序代码并行运行,进行分布式开发的时候,会遇到哪些问题?

由于我们在进行代码编写的时候,往往是按照先后顺序的思维模式来组织的,常常忽略了在分布式开发中可能会遇到的一些问题。

这些问题带来了进一步的挑战,往往使我们的代码变得异常复杂,也无法很容易的像以前一样从上看到下就能在脑海里模拟出大致的结果。

以前我们使用单线程进行编码的编程模型被称之为同步编程,而多线程则被称之为异步编程(在异步编程中,使用声明式的编程模型可能会更直观)。

所以异步编程如果没有一个良好的编程模型来组织我们的代码的话,就会很容易发生各种各样的错误,异步编程不仅使我们的代码复杂度变高,最关键的,也让我们的心智活动变得更加的复杂,所以往往会遗漏。

那么具体有哪些问题需要我们注意呢,我列出了一个列表:

  • 状态管理:由于并发,多个线程、进程试图对同一个状态进行修改,从而导致的一致性问题。
  • 任务编排:由于并发,多个线程、进程可能会同时处理同一个任务,导致状态的冲突,因此我们需要对任务进行编排,使其同时只能在一个线程、进程上执行。由于不同场景的需求存在差异,所以任务编排取决于业务上的需要。
  • 负载分布:为了资源能够被合理的利用,使其分布节点的负载差异减少,不至于出现某些服务负载高,而某些服务负载小,我们要对不同任务的资源进行合理的调度。这是因为资源如果没有被合理的使用,就失去了并发的好处,我们始终希望能够最大化的利用好资源,在成本范围内提升处理能力,避免浪费,在分布式程序架构中,有不同维度的策略来组织资源的规划。
  • 编程模型:我们需要一种清晰简单的编程模型来应对异步编程的挑战,需要组织代码结构保持项目的可维护性。
  • 架构调整:我不建议在项目之初,就把架构搞得很复杂,近几年微服务备受关注,但是我见过的很多项目就是因为在项目之初就引入了大量没有用到的(或者说没有必要用到)技术,从而造成了项目依赖和复杂程度变得很高,开发和调试都是举步维艰,最后不得不合并为单体架构,重新从业务角度出发去审视项目的需求规格。在我看来,如果仅仅只是技术层面“微服务”了,那只能算是一种部署架构,没有匹配到业务的组织划分,带来的结果可能就是返工。

在本文中,我只会讨论“状态管理”的问题,其他的问题超出了本文的范畴,有兴趣的小伙伴可自行搜索相关资料文献,以后有机会我可能也会写几篇文章对应不同的主题进行讨论。

 

并发与状态的关系

如果说支持并发是为了提高系统的可用性(处理能力),那么在并发处理期间,可能会对某一个状态同时进行修改,这完全取决于用户或者前端的操作。

对于不同的状态,并发进行操作是相互不受影响的,假如有100个用户同时请求操作自己的某些状态,这是完全没有问题的,因为他们的状态本身就是被隔离开的,不存在竞争关系。

但如果这些用户请求的操作,还涉及到另一个相同状态的修改,那么就会产生竞争关系。

例如用户创建了一个订单,我们要将对应商品的库存减少,以避免超卖。假如这是因为一个需求,当创建订单的时候,如果库存不足,订单无法被创建。

我们该怎么来处理这个需求呢?如何控制并发对商品的修改不会导致一致性问题?

我们可以分析一下:

  • 首先用户创建订单操作的是自己的数据,每一个用户都是隔离开的,相互不受影响。
  • 其次用户创建订单这个操作,可能还操作了另一个相同的数据,那就是某一个商品的库存,这一部分数据可能会有多个用户同时并发进行请求,可能存在并发问题。注意我说了好几遍“可能”,可能存在,可能不存在,例如某些商品它的订单量比较低,在同一时刻几乎不会超过一个用户同时创建订单,这就为我们用于控制并发提供了不同级别的实现策略。
  • 要求是,当订单的库存不足,用户创建订单这个操作就会失败。

 

最最简单的,当然是把对商品的修改这个操作也参与进订单的创建事务中,这样当我们发现商品的库存不足时,抛出异常,回滚事务,订单也将不会被创建。

在项目前期,为了快速实现需求原型,这样做是最为简单的方便的,将用户的订单表、商品表、可能还有账户表(钱包)全部划分到一个数据库中。

当项目后期,运营的影响以及用户量和商品数量上升,可能会导致这个系统的可用性达不到要求,这是非常常见的,项目不会一成不变,为了快速的适应市场的需求,我们必须要想办法来提高系统的可用性。

请注意,如何观察系统已经达到瓶颈,以及如何解决瓶颈取决于业务的需要和规模的性质。

当系统达到瓶颈之后,最明显的现象就是会存在大量的请求阻塞,原因可能来自于数据库(IO),也有可能来自于CPU的负载(计算)。

我们会分析是由于IO出现问题还是计算出现问题,一般最先出现问题的是IO,我们可以通过观察系统的资源,来分析和判断具体的原因。

如果是由于IO过高,增加处理节点其实并不会提高系统的可用性,此时我们应该解决的单点问题在于数据库。

当然最简单的办法仍然是增加数据库的资源,比如购买更好的硬件来提升数据库的处理能力,在应急的时候此方案是首选,以最小的成本以及影响来提升系统的可用性。

在出现几次这样的提升后,我们就需要根据运营数据的分析,得到系统增长的周期指数报表。我们观察这个周期指数报表,就能够相对准确的来预测未来在某一个时间点可能会再次出现可用性的问题。

当发现硬件已经无法满足扩充能力的时候(大多数项目还没有到达这一步就消失了),我们就需要提前对整个系统的架构设计进行一次优化了,同时也恭喜你们的产品已经相当成功了。

当单体架构已经无法满足需求和规模的时候,我们就必须得进行拆分,可以是纯技术层面的优化,当然前提是业务层面已经相对的稳定定型,否则有可能出现的情况就是,可用性问题仍然无法得到解决。

不要低估硬件的能力,除非确实是因为成本的问题或者硬件已经达到极限,比如系统规模上升的速度超过了摩尔定律,我们才需要换一种手段来进行优化。

最简单的拆分,不牵扯业务层面的调整,那就是对数据库进行分库分表水平扩容,唯一需要注意的就是数据的负载分布问题,考验我们拆分的是否有效,起决定因素的就是数据的分布。

只要我们掌握了数据的分布特征,等于就掌握了水平扩容的核心因素,是可以做到无限扩容的。

我们来分析一下在上述例子中,数据的分布特征有哪些:

  • 用户订单:我们分析过,每一个用户的订单相对于其他的用户都是隔离开的,相互不受影响的,因此我们可以把用户标识当做分布特征之一。
  • 商品库存:我们分析过,一个商品可能会被所有的用户购买,如果我们已经根据用户进行分布了,理论上来讲,商品库存也需要一起被分布,这样才能保证他们在同一个事务管理中,不会发生一致性的问题。

看起来我们遇到了一个问题,由于用户订单可能存在于多个数据库中的多个表中,他们被物理隔离了。但是可能会购买同一个商品,这些商品是唯一的,就会出现跨库的问题,跨库之后由于事务管理不在同一个作用域内,提交和回滚操作都无法得到一致的保证。

那么如何解决这个问题呢?如果我们使用分布式事务,那么瓶颈仍然会存在,由于商品是单点的,如果某一个商品很畅销,导致大量的用户购买,仍然需要面对性能低下的问题,但相比之前的架构,情况已经好很多,至少用户订单已经被均匀的负载分布到了不同的资源上。

如果我们使用分布式事务,那么商品库存表就没有必要和用户订单表挤在一个数据库之内了,他们是两个不相关的主体,我们已经根据用户主体将订单进行了分布,那么商品库存表无论放到哪一个分布节点内好像都不合适。

此时我们的应用程序仍然是单体架构,只不过是将数据进行了分布,用户创建订单的时候,根据用户主体标识路由到不同的数据库和表上面,然后使用分布式事务同步操作另一个商品库存数据库,在这里,商品库存数据库是否需要分库分表取决于以下两个因素:

  1. 如果按照数据量来看,由于商品数量一般比用户数量少,一个数据库足以容纳所有的商品库存信息。
  2. 如果按照修改数据的频繁程度来看,由于所有的用户在创建订单的时候都会访问和修改商品库存,所以一个数据库可能无法承受过高的请求负载。

问题已经逐步清晰了,我们发现始终绕不过去的一个点就是商品的库存修改,因为所有的用户创建订单都需要去修改它,除非我们将商品库存也进行分库分表(因为第2个因素),这样一个订单创建的操作就会被分布到不同的资源上面。

现在又出现了另一个问题,我们已经假设使用了分布式事务,所以先不用考虑分布式事务的可靠性问题,重点在于,我们的商品数量还远远没有达到需要分库分表的程度,一个数据库完全可以存储和索引所有的商品库存,但是由于修改的并发负载,我们又不得不进行这样的分割,造成了资源使用上的浪费。那么有没有其他比较好的方法来解决这个问题呢?

我列出了几个方案:

  1. 强一致:商品库存预分配,我们可以引入商品库存预分配机制,根据后台运营数据,实时调整商品的库存分配到用户订单数据库内。
  2. 最终一致:引入订单创建状态,使用额外的中间步骤来达成最终一致。

还有很多其他的方案,实现方式可能有几十种,但终究绕不开一致性并发问题,所有的解决方案都是围绕一致性进行的。

我们先说第一个方案,我们可以在用户创建订单的时候,先在同一个库内查找该商品的库存,这些库存是被预先分配的,这样做的好处在于,我们不需要分布式事务就能完成创建订单的操作。

如果该商品库存不存在,那么动态请求系统的另一个分配服务,将该商品的剩余库存分配一下,然后尝试创建订单的逻辑,这种是被动式的分配,主动式的就策略就很多了,有可能是上架商品的时候就预先规划了,也有可能是根据以往用户创建订单的运营分析,利用一系列算法,将商品的库存进行预先分配,由于各自业务流程的偏好不同,实现方案千差万别,我不打算尝试详述,因为这超出了本文的范围。

这种实现方式有点类似于令牌桶,它限制每一个节点的并发程度,当然我们的目的不一样,在这里的作用恰恰相反,是用于提升系统的可用性。

如果该商品库存不够,也会去动态请求分配服务,分配服务可能会抛出一个异常,或者返回一个响应,告诉你该商品已经卖完了,那么此时就可以据此提醒用户创建订单失败,因为已经断货了。

因为商品如果已经被分配完,不涉及修改操作,我们就可以把该数据完全交给分配服务进行缓存控制,当商品发生变更,只需要确保分配服务处于流程上。

 

我们再来看最终一致的方案,这种方案的实现的可选项就更多了,因为是异步的,用户创建订单可能会在一个LandingPage(着陆页、中间页,一般用于耗时操作的状态变更监控通知)等待异步通知,也可以离开等到系统发出结果通知。

在这里,由于用户可以接受耗时等待,所以我们的系统设计相对灵活,实时性要求可能就没有那么高,可以队列依次进行处理。

但是这样做唯一的问题就是需要提前和业务方进行沟通,如果得到业务方理解,在不影响用户体验(仁者见仁、智者见智)的情况下,比如耗时一般控制在5秒内等等,我们就能够得到比较大的空间去设计系统。

最终一致保证数据一致性所用到的技术一般有:

  • 事务关联ID串联不同的资源处理步骤。
  • 重试/幂等用于保证数据不会被重复处理。
  • 补偿/回滚用于保证某些不可变规则因素导致的事务失败,需要回滚其他步骤所产生的影响。
  • 定时校验事务的处理情况,在适当的时候发出重试、补偿等操作,恢复意外中断的步骤。

当我们决定使用哪一种方案的时候,无论是强一致还是最终一致,最好最好事先将我们面临的情况如实与业务方进行沟通,只有业务方同意调整涉及到流程问题的解决方案,我们才能进行下一步的落实,否则由于业务的变更,最终实现出来的产品并不符合业务方的需求,从而导致返工就得不偿失了。

我们再进一步,为了将已经稳定的业务模块分离出来,单独作为一个独立的服务存在,这样我们在部署和修改服务而不影响外部调用的情况下可能更加方便,也更加通用和便于管理,当然前提仍然是,业务已经经受考验,相对稳定,且抽象出来的接口相对精简。

 

“容器”管理

就像上节我们讲述的那个例子一样,用户和商品分别是俩个不同主体,这些不同的主体通过订单进行关联存在,而主体本身就像是一个个容器,无论这些容器有多少,我们都可以对其进行管理和水平扩容。

不同的容器代表了他们之间存在业务边界,只要我们严格遵守这些边界,就能够针对性的对其进行调整,合理的利用资源来提升系统的可用性。

回忆一下之前我们讲的那句话:我们可以在同时修改不同的状态(容器),但是无法在同时修改同一个状态。

容器可以包含多个状态,状态与状态之间的依赖必须要通过容器来进行隔离,如果随意使用的话可能会造成难以管理的复杂度。

如果说你会某一样技术,证明你拥有这项技能,但是如果你会思考,会从业务角度出发去观察技术所要服务的通用模式,我个人觉得这样的能力更难得可贵。

技术只是工具,用于实现业务目的,但如果能够了解到现如今的基础底层架构和业务之间的关系映射,会让我们的能力和学习的方向更有针对性。

 

总结

本文断断续续写了将近十天左右的时间,因为牵扯的知识面太过于广阔,一开始,我本来只想写一下关于游戏相关的内容,但是写到后面我发现,不仅仅是游戏,就算是企业级的开发,仍然也会遵循相同的原理,区别只在于使用场景以及业务的抽象程度所造成的差异到底有多大。

只不过游戏的开发在某些场景中更加极端,所以我仍想借此标题来写一写隐藏在问题背后的那些本质原因。

我常常在想,游戏客户端有很多的引擎和框架技术来帮助人们创造难以置信的体验和效果,这些技术大大减少了开发游戏所要面临的基础工作,但是在后端却很少有相关的资料和通用的技术来实现游戏服务端,不过在写完本文之后我似乎有一些明白了,和前端不同的是,后端只要能够提供这些看不见的能力,实现方法可能数千种,每一个团队喜欢用的技术都可以满足他们的需要。

但是我们仍然需要抽象出通用的模式,来帮助实现各种各样的需求场景,这样当我们面临一个已经被解决的问题的时候,就能够减少宝贵的研发时间,从而将更多的时间放在用户的反馈体验、改进产品的服务上面。

一定要站在巨人的肩膀上,我们遇到的问题99%都已经被解决了,只是我们没有找到,没有组织好我们的需求,难以进行检索。

最后,本文之中所讲述的很多地方肯定会存在很多的疑点,甚至是错误的,我不能保证我目前所理解的都是正确的,但我会想办法去改进,在这一方面离不开各位看官的反馈,本人在此先谢谢诸位。

posted @ 2019-09-21 05:27 XingxueLiao 阅读(...) 评论(...) 编辑 收藏