在我所见过的常规跨服玩法中(跨服相关数据由跨服进程管理),通常或多或少都会包含以下几种典型的工作模式:
- 跨服在特定时机(如玩家某个操作之后、每日 0 点、赛季结束)需要根据当前状态进行结算,并向游戏服发放奖励。
- 玩家的请求在游戏服处理后得到一个结果,该结果需要同步到跨服(一个非常典型的例子是跨服排行榜)。
- 玩家在执行某个跨服操作(比如攻击玩家)成功时,需要从背包中扣除相关资源(战斗次数、体力、道具等)。
对于模式 1 和 2,实现数据一致性虽然需要花点功夫,但并不算困难。
只要做好消息重发机制,游戏服加上去重逻辑,基本就能实现。
而对于模式 3,我曾经很长一段时间都没有找到比较好的解法,因为它确实非常复杂。
下面来看一下,当游戏服收到玩家请求后,常见的两种处理策略:
-
策略一:先扣资源,再请求跨服处理。这种方式面临以下几种情况:
-
跨服返回成功,游戏服也成功收到,皆大欢喜。
-
跨服返回失败,游戏服补偿玩家资源,也没问题。
-
跨服消息因各种原因(如链接断开、服务器重启等)丢失,类似
timeout
的场景:如果这条丢失的消息是失败的,那我们就无法对玩家进行资源补偿,导致玩家莫名承担了不应有的损失。
-
-
策略二:先请求跨服处理,收到成功后再扣除资源。这同样存在问题:
-
在跨服消息返回之前,客户端连续发起多次请求。当跨服陆续返回成功消息时,玩家资源已不足,会形成恶意刷资源漏洞。
这时,通常需要对该模块实现一个逻辑锁,确保所有消耗资源的操作不会并行执行。
即便如此,如果资源是金币这类公共资源,也很难确保跨服消息返回时玩家还有足够的余额。
因此还需要加上预转换逻辑:即在跨服处理之前先扣除玩家资源,并发放模块内临时道具,等跨服结果回来后再正式扣除这些道具。
这样只要有逻辑锁的存在,我们就可以保证,玩家在跨服消息返回之后,可以成功扣除资源。
-
如果跨服消息最终丢失了,我们也失去了扣除资源的机会。
-
尽管以上两种策略都有缺陷,我们通常还是倾向于采用先请求跨服处理,再扣除资源
的方式。
如果跨服消息的丢失是我们自身系统的问题,那就当这次操作白送了。只要这个策略没有被恶意利用的漏洞,仍在可接受范围内。
在我过去的一些分布式事务相关的文章评论区里,经常有人推荐我使用 消息队列中间件
。但我认为它并不适用于这个场景。
消息队列中间件
的主要作用是削峰填谷,其代价是消息延迟显著增加。在 Web 场景里延迟几秒可能没什么,但在游戏中,即便是卡牌游戏,几秒也难以接受。
而且游戏服务器的负载本身就高度集中,比如玩家通常会在每天的 0 点、12 点、18 点、22 点等时间段集中上线,刷怪、打 Boss、做任务等操作。
本着“良好抽象可以减少开发工作量、降低 Bug 数量”的原则,我对上述三种模式做了归纳,并抽象成了三个通用组件(它们都是在原有的 TCP 链接上进行,不会单独建立新的链接, 这也便于我们充分利用 TCP 的 FIFO 特性)。
先来看看模式 1的场景。
在这种场景下,奖励是由跨服主动发起的,而不是由玩家触发。既然不是玩家主动操作,那就算有点延迟,玩家也是感知不到的。
更关键的是,这类消息往往比较单纯,与功能内的其他消息不存在顺序相关性。
因此我借鉴了 消息队列中间件
的思路,设计了一个叫做 Mq
的组件。
Mq
会保证消息按顺序送达 —— 即必须在收到 Msg1
的回包之后,才会开始发送 Msg2
。
当然,Mq
只能负责重发和顺序性,它无法确保消息只送达一次。
因此,在游戏服这边,我们需要针对不同的协议自行实现去重逻辑。
好在由于 Mq
保证了消息的顺序性,我们只需要在消息中增加一个 ID 字段,并确保 ID 单调递增,就可以轻松实现去重。
当然,我们也可以在游戏服中实现一个通用的 消息去重
组件,进一步减轻业务层的负担。但目前我还没想到一个真正通用且完美的方案。
不同于模式 1 的维度是“服务器”,模式 2 的维度是“玩家”。
同一条客户端消息(如 pve_battle
),玩家 1 的整个请求链可以与玩家 2 的请求链交错执行。
如果此时仍然使用 Mq
组件,就会出现玩家 1 的请求执行速度影响玩家 2 的响应速度的情况。最终可能导致:CPU 负载很低,但响应延迟却很高。
这类消息大多是由玩家在客户端主动触发,因此他们对处理延迟非常敏感。你玩游戏的时候也不希望点一下按钮后,要等 3 秒才看到结果吧 ^_^!
针对这种场景,我抽象并实现了 UserSync
组件。
与 Mq
不同,UserSync
对每个玩家的每个 cmd
仅保留最新的一条消息内容。
来看一个最简单的时序示例:
-
玩家 1 向游戏服发送
pve_battle
请求,游戏服计算后认为积分为100
,向跨服排行榜发送消息:
sync_rank{uid:1, score:100}
。 -
此时跨服链接断开,游戏服没有收到回包。
-
玩家 1 继续进行下一场
pve_battle
,积分变为200
,游戏服再次发送:
sync_rank{uid:1, score:200}
。 -
玩家 1 再次进行战斗,积分变为
300
,游戏服发送:
sync_rank{uid:1, score:300}
。 -
当跨服链接恢复时,
UserSync
会只重试最新的一条消息:
sync_rank{uid:1, score:300}
,忽略之前的历史消息。
之所以这样设计,是因为这种消息既不能丢,又必须做到尽可能低延迟。
因此在调用 UserSync.Send(uid, msg)
时,UserSync
会立即发送消息给跨服,并更新玩家当前消息内容。
这个“立即”非常重要。根据我过往的实践经验,在打完 pve
战斗后,客户端的回包中通常需要包含当前排行榜排名。
也就是说,在我调用完:UserSync.Send(uid, sync_rank{uid:1, score:300})
之后,往往会立即跟一个 query_myrank
请求,用于查询当前排名。
这个排名必须是在我更新积分之后的排名。
由于 UserSync
总是在第一时间尝试发送消息,而且 TCP
链接具有 FIFO
(先进先出)特性,在一切正常的情况下,query_myrank
总是会返回最新积分更新之后的排名。
即便遇到链接异常,也能保证:玩家的积分更新不会丢失。
场景 3 是最复杂的,它通常发生在跨服 PVP
类玩法中,本质上是一个分布式事务,事务的两端分别是游戏服与跨服。
事实上,我已经被这个场景困扰了很多年。我曾多次尝试借鉴 Saga 模式
来解决,但都以失败告终。
直到最近,我才突然意识到:借助 Mq
和 UserSync
两个组件,其实可以在保持较低复杂度的前提下,解决跨服扣资源的问题。
当玩家发起一场跨服 PVP
战斗时,流程如下:
-
游戏服先扣除资源,然后生成结构:
consume{uuid: <递增的 ID>, resource: <扣除的资源>}
-
将
consume
嵌入到pvp_battle
消息中,通过UserSync
发送给跨服。由于
UserSync
对同一个玩家、同一个cmd
只保留最新消息,在游戏服执行资源扣除之前,必须先检查该cmd
是否还有未完成的同步。如果有,就需提示玩家稍后再试 —— 这个逻辑本质上就是一个业务逻辑锁。
跨服收到pvp_battle
消息后,流程如下:
-
对消息进行去重。
-
然后根据当前跨服状态进行计算。
-
如果一切正常:
pvp_battle
中的consume
就可以直接忽略。 -
如果出现异常(例如对手已死亡、活动已过期等),跨服可以生成如下结构,并通过
Mq
发送回游戏服:consume_rollback{consume: consume}
-
游戏服收到 consume_rollback
消息后,流程如下:
-
根据其中的
consume.uuid
执行去重处理。 -
将
consume.resource
中的资源返还给玩家。
至此,我们实现了一个最终一致性的分布式柔性事务。
一些旁支末节:
-
consume_rollback
的处理逻辑在游戏服只需要实现一次,之后所有跨服玩法都可以共用这一套流程。 -
关于去重逻辑,其实有一个小技巧:
只要每个服务器的
consume.uuid
是连续递增的,我们就可以下面结构来保存所有已处理的uuid
。{start: int, end: int, fragments: array
} 由于这些消息都有时间相关性,最终一致达成之后,
fragments
数组的体积一般不会太大。
相信这三种模式基本可以覆盖绝大多数跨服业务场景了。
ps. Mq
和UserSync
中的数据都会进行数据库落地。