对跨服玩法中的分布式一致性问题进行简单抽象

在我所见过的常规跨服玩法中(跨服相关数据由跨服进程管理),通常或多或少都会包含以下几种典型的工作模式:

  1. 跨服在特定时机(如玩家某个操作之后、每日 0 点、赛季结束)需要根据当前状态进行结算,并向游戏服发放奖励。
  2. 玩家的请求在游戏服处理后得到一个结果,该结果需要同步到跨服(一个非常典型的例子是跨服排行榜)。
  3. 玩家在执行某个跨服操作(比如攻击玩家)成功时,需要从背包中扣除相关资源(战斗次数、体力、道具等)。

对于模式 1 和 2,实现数据一致性虽然需要花点功夫,但并不算困难。

只要做好消息重发机制,游戏服加上去重逻辑,基本就能实现。

而对于模式 3,我曾经很长一段时间都没有找到比较好的解法,因为它确实非常复杂。

下面来看一下,当游戏服收到玩家请求后,常见的两种处理策略:

  • 策略一:先扣资源,再请求跨服处理。这种方式面临以下几种情况:

    1. 跨服返回成功,游戏服也成功收到,皆大欢喜。

    2. 跨服返回失败,游戏服补偿玩家资源,也没问题。

    3. 跨服消息因各种原因(如链接断开、服务器重启等)丢失,类似 timeout 的场景:

      如果这条丢失的消息是失败的,那我们就无法对玩家进行资源补偿,导致玩家莫名承担了不应有的损失。

  • 策略二:先请求跨服处理,收到成功后再扣除资源。这同样存在问题:

    1. 在跨服消息返回之前,客户端连续发起多次请求。当跨服陆续返回成功消息时,玩家资源已不足,会形成恶意刷资源漏洞。

      这时,通常需要对该模块实现一个逻辑锁,确保所有消耗资源的操作不会并行执行。

      即便如此,如果资源是金币这类公共资源,也很难确保跨服消息返回时玩家还有足够的余额。

      因此还需要加上预转换逻辑:即在跨服处理之前先扣除玩家资源,并发放模块内临时道具,等跨服结果回来后再正式扣除这些道具。

      这样只要有逻辑锁的存在,我们就可以保证,玩家在跨服消息返回之后,可以成功扣除资源。

    2. 如果跨服消息最终丢失了,我们也失去了扣除资源的机会。

尽管以上两种策略都有缺陷,我们通常还是倾向于采用先请求跨服处理,再扣除资源的方式。

如果跨服消息的丢失是我们自身系统的问题,那就当这次操作白送了。只要这个策略没有被恶意利用的漏洞,仍在可接受范围内。

在我过去的一些分布式事务相关的文章评论区里,经常有人推荐我使用 消息队列中间件。但我认为它并不适用于这个场景。

消息队列中间件的主要作用是削峰填谷,其代价是消息延迟显著增加。在 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. 玩家 1 向游戏服发送 pve_battle 请求,游戏服计算后认为积分为 100,向跨服排行榜发送消息:
    sync_rank{uid:1, score:100}

  2. 此时跨服链接断开,游戏服没有收到回包。

  3. 玩家 1 继续进行下一场 pve_battle,积分变为 200,游戏服再次发送:
    sync_rank{uid:1, score:200}

  4. 玩家 1 再次进行战斗,积分变为 300,游戏服发送:
    sync_rank{uid:1, score:300}

  5. 当跨服链接恢复时,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 模式 来解决,但都以失败告终。

直到最近,我才突然意识到:借助 MqUserSync 两个组件,其实可以在保持较低复杂度的前提下,解决跨服扣资源的问题。


当玩家发起一场跨服 PVP 战斗时,流程如下:

  1. 游戏服先扣除资源,然后生成结构:

    consume{uuid: <递增的 ID>, resource: <扣除的资源>}
  2. consume 嵌入到 pvp_battle 消息中,通过 UserSync 发送给跨服。

    由于 UserSync 对同一个玩家、同一个 cmd 只保留最新消息,在游戏服执行资源扣除之前,必须先检查该 cmd 是否还有未完成的同步。

    如果有,就需提示玩家稍后再试 —— 这个逻辑本质上就是一个业务逻辑锁

跨服收到pvp_battle 消息后,流程如下:

  1. 对消息进行去重。

  2. 然后根据当前跨服状态进行计算。

    • 如果一切正常:pvp_battle 中的 consume 就可以直接忽略。

    • 如果出现异常(例如对手已死亡、活动已过期等),跨服可以生成如下结构,并通过Mq发送回游戏服:

      consume_rollback{consume: consume}

游戏服收到 consume_rollback 消息后,流程如下:

  1. 根据其中的 consume.uuid 执行去重处理。

  2. consume.resource 中的资源返还给玩家。

至此,我们实现了一个最终一致性的分布式柔性事务。

一些旁支末节:

  • consume_rollback 的处理逻辑在游戏服只需要实现一次,之后所有跨服玩法都可以共用这一套流程。

  • 关于去重逻辑,其实有一个小技巧:

    只要每个服务器的 consume.uuid 是连续递增的,我们就可以下面结构来保存所有已处理的 uuid

    {start: int, end: int, fragments: array}

    由于这些消息都有时间相关性,最终一致达成之后,fragments 数组的体积一般不会太大。

相信这三种模式基本可以覆盖绝大多数跨服业务场景了。

ps. MqUserSync中的数据都会进行数据库落地。

发表评论

− six = two