GS 中的 ACID 一致性

前段时间又和同事讨论到 GS 中的 ACID 一致性,在这里唠叨几句。ACID 一致性即系统内部的数据一致性,而非分布式系统对外体现的一致性(CAP 中的 C)。

假设我们有一个业务逻辑叫做行军,在单线程下,其逻辑如下:

1
2
3
4
5
6
7
8
9
10
if !check_money(cost) {
return error_money_not_enough
}

if !check_march(troopId) {
return error_troop_can_not_march
}

deduct_money(cost)
start_march(troopId)

如果现在我们由于性能问题,将 Play(玩家数据逻辑) 和 Map(大地图玩法) 分为了两个 Actor (如goroutine,线程,节点),玩家金币由 Play Actor 管理,部队数据由 Map Actor 管理,那么我们现在的逻辑变成了分布式中最常见的 Check-Do 模型:

Play Map
check_money check_march
deduct_money start_march

现在我们讨论如何在这种情形下尽可能提升数据一致性,假设 Play 和 Map 以异步消息的方式交互,然后我们来考虑如下执行流:

执行流A:

Steps Play Map
1 check_money
2 deduct_money
3 check_march
4 start_march

该执行流的异步交互少(理想情况下只需要一次),但问题显而易见,当执行到check_march检查失败时,需要回滚扣除的金币,即再向 Play 发消息将扣除的钱加回来。如果考虑到回滚操作(业务逻辑的 Check 是很可能失败的),该模型并不简单,并且可能如对玩家可能带来一些不好的体验(比如看着钱扣除了又增加了)。

为了减少数据回滚的可能性,我们需要遵循第一条 Rule: 先 Check 再 Do,

执行流B:

Steps Play Map
1 check_money
2 check_march
3 start_march
4 deduct_money

这个执行流稍微要复杂一些,但通过先 check 再 do 的方式避免了逻辑检查(check_march)导致需要回滚的问题。但异步交互本身的不一致问题仍然存在,比如如果在行军逻辑之后,Play 立马收到了一条购买消息,然后在 Step 1-4之间,Play 处理了这条消息,扣除了金币,导致行军扣除金币时,金币不够了,此时就麻烦了: 玩家做了事,但没扣(够)钱,回滚行军的代价也可能很大(体力回滚,广播,任务统计等等),因此,我们可以梳理出第二条 Rule: 先 Deduct 再 Do。

执行流C:

Steps Play Map
1 check_money
2 check_march
3 deduct_money
4 start_march

现在这个执行流异步交互最复杂,如果 Step 1,3 发生不一致,Step 3失败,行军逻辑无法继续。但如果 Step 2,4 发生不一致,Step 4失败,此时金币已经扣除,可以通过 Step 5 发消息给 Play 把金币加回来,也可以通过日志手动 Fix(当逻辑回滚比较复杂时)。

到目前为止,我们来理理前面提到的:

  1. 先 Check 再 Do,避免逻辑数据检查导致的不一致
  2. 先 Deduct 再 Do,保证数据安全性(如玩家刷道具)以及回滚的可行性
  3. 简单的数据不一致可以逻辑回滚,复杂的数据不一致通过日志来手动修复

下面是你可能会问的几个问题:

Q1. 为什么不通过事务来保证一致性?

在分布式中,常见的2PC,3PC都不能完全解决分布式一致性问题,2PC 其实和我们的执行流C有点类似,都是先询问各个参与者(Play, Map)是否可以提交(CanCommit),再执行提交(DoCommit)。事实上,2PC,3PC 都不能完全解决分布式中的一致性问题,并且会引入更多的复杂度,如协调者单点,超时机制,锁等。随着业务逻辑变动频繁,并发实体不断增多,想要保证任何操作的事务性是非常困难且难以维护的。因此在游戏服务器中,通常我们认为可用性大于一致性,对于重要的逻辑,如充值,购买,可以做尝试逻辑回滚,但对于大部分的普通逻辑,我们是选择忍受这部分不一致性的。

Q2. 为什么不用同步调用?

为了保证check_moneydeduct_money,以及check_marchstart_march的不一致性,我们可以让 Map check_march 后,直接同步调用 Play 的deduct_money,然后根据扣除是否成功执行后续操作。这样就像写同步代码一样避免了不一致性。然而同步调用可能会带来更多的问题,我在GS中的异步编程中有详细讨论。

Q3. 关于异步超时?

考虑这样一种情况,当执行流C Step3 deduct_money之后,Map 因为各种原因(网络波动,甚至节点挂掉)没有处理到 start_march 这条消息,然后整个执行流就断掉了,就没有下文了(这也是2PC 协调者单独存在的意义)。那么我们是否应该给异步调用一个超时,让发起者可以对对端无响应有所感知加以处理?我们目前的处理方式是,节点内的 Actor 交互无需超时机制,节点间的异步交互通过 gRPC 超时来做。我认为超时机制适用于 RPC 这种机制,而不适用于异步消息,因为异步消息本身就不包含调用上下文语义,同样的消息可能在不同的场景对超时有不同的需求(或者不需要超时)。

Q4. 通过更细粒度的 Actor 化异步为同步?

由于所有玩家都跑在 Play 中,Play 的任何外部 IO(网络IO,DB,与 Map 的交互等)都必须是非阻塞的,因此必然会产生很多异步逻辑,那么换一种思路: 如果我们进一步地将每个玩家都跑在一个单独的 Actor 中,这样玩家 Actor 就可以将一些原本 Play 需要异步的操作变成同步(阻塞一个玩家 Actor 是可接受的),可以简化部分逻辑(比如登录状态机),但也会带来玩家与玩家之间的异步交互,对于 SLG 这类玩家数据交互频繁的游戏来说,这未必是简化。通常我们做拆分都是基于性能瓶颈或扩展性,性能拆分要考虑到整体瓶颈,最好是基于实测,比如我们的例子中可能实际的瓶颈是 Map 而不是 Play,扩展性是指 Actor 之间的交互边界是否清晰,不清晰的交互边界会放大系统的数据不一致性,环形阻塞等问题。