游戏服务器中常见的数据一致性问题分析

什么是一致性问题

在游戏服务器的开发中,我们经常会碰到所谓“一致性”问题,以及碰到各种为了解决这种问题所做的“方案”,那么,什么是一致性问题呢?其实非常简单,就是有两个客户端进程,都需要修改同一个数据,造成的问题。

譬如服务器上有一个怪物,玩家 A 释放了一个火球,根据业务逻辑,火球会扣减 10% 的最大 HP 值作为伤害;玩家 B 对怪物砍了一刀,扣减怪物的 HP 需要计算玩家 B 的攻击力和怪物的防御力。那么一般我们编写程序的时候,就会先从“怪物”和“玩家”读取其数值,包括“攻击力”,“最大 HP”,“防御力”,“现存 HP”这些数据,然后根据“火球术”和“刀砍”进行伤害计算,然后算出怪物遭受的伤害,以及受伤后应该剩下多少 HP。按上面的方法,就会有包含了 2 次“读”数据和“写”数据的过程。

如果这两次“先读后写”的操作,在并行的两个线程中执行,那么就会出现所谓“一致性问题”:先读了同一份数据,导致最终的操作互相覆盖了。下图是一个“增加数值”的一致性问题的描述,“203”是需要修改的数据的名字,这个数据的值一开始 100,又叫 key,A 进程试图进行的是“增加 10”这个操作,B 进程试图进行的是“增加 20”这个操作,但是如果同时执行,可能结果会是 110,或者是 120,但正确的结果应该是 130。

以上的问题,在一个进程内的多个线程中可能出现,在一个集群中的多个互相通信的进程也可能出现。为了解决以上的问题,人们想了很多方法,但是大多数可以分为两类:

  1. 悲观锁
  2. 乐观锁

下面具体说说这两类思路的实际常见的表现形式。

悲观锁

多线程同步锁

在 Java 语言中,有一个关键字叫 synchronized ,这个关键字可以加用括号来表示“锁”住的对象。下面的写法,表示执行下面 { ... } 的代码块时,必须尝试获得 obj 对象的“锁”,如果其他线程正在使用 obj 对象这个锁,则必须等待。

synchronized(obj) {
  int hp = obj.GetHp();
  hp += 10;
  obj.SetHp(hp);
}

对于 Java 来说,直接拿任何一个“对象”作为“锁”的标记都可以。这种做法,实际上是让多个线程,在执行某些代码的时候,“依次排队”执行,以避免“一致性问题”。在 Linux C 的 pthread 库里面,同样也有类似的 API 实现锁,都是针对多线程处理的。

异步模型

后来出现了以 Epoll 为代表的异步编程方式,这对于主要是网络 IO 造成阻塞的游戏服务器开发,带来了新的解决“一致性问题”的手段。由于不需要为每个 TCP 连接开一个线程,所以可以整个服务器就一个线程,依次处理每个到达服务器的网络数据请求。在这种编程模式下,由于来源的数据请求,本身就被 epoll 的处理方式,转换成一种“依次排队”的执行方式了,所以可以说是天然的上了一个锁,所有的需要并行处理的逻辑,都自动变成了串行处理。

元语

有一些团队,会喜欢使用 Redis 来处理一致性问题。尽管 Redis 自己也是单线程异步模式运行的,但如果仅仅使用其 get 和 set 命令,还是会造成同样的一致性问题。幸好 Redis 有一系列的“数据类型”,譬如:

  1. List 这个类型,就提供了 lpush 这个命令作为“插入队列”,这个命令本身,就是一种需要“先读后写”的任务,因为需要先读取队列的“头/尾”,然后写入数据。
  2. Zset 这个类型,提供的带排序功能的插入 zadd 命令,会先读数值,然后按写入位置,也是一种“先读后写”的操作。

这种处理方式,又可以被称为一种叫“元语”的方式。也就是说,把需要读写的多个操作,打包成一个命令来执行。如前文所说的“增加10”,“增加20”的操作,就可以设计成“+=”这样的一种元语。由于最终执行命令的程序,是一个单线程的模式,所以元语们,也被“依次排队”的执行了。

游戏服务器处理

在游戏服务器领域,这个方法更是一种“基本模型”:

  1. 我们会把游戏运行时所需要的数据,设计成存放在一个个游戏服务器进程的内存里
  2. 我们会设计很多所谓“ SS 协议”,也就是服务器进程之间的协议,每个 SS 协议,都是一种“元语”,这种协议的处理过程,往往都带有很复杂的,对数据的读写运算
  3. 当有业务逻辑需要处理的时候,我们把处理命令,以 SS 协议的方式,发送到“数据所在的进程”
  4. 数据所在进程,以单线程的方式,“依次排队”的处理所有的 SS 协议,实现了避免一致性问题

队列处理

有一些业务系统,会使用“消息队列”这种中间件,让处理的请求,天然的就以“队列”这种形态存在,这样以单线程“依次排队”消费队列里的消息,就会非常的自然。类似的消息队列中间件,在开源产品里也有很多,譬如 ActiveMQ,kafaka 等等。

在对数据持久化的情况下,为了同样的一致性问题,很多开发者也会专门编写一个类似 MySQL Proxy 之类的独立进程,专门把数据持久化操作,以队列的形式“依次排队”处理,尽管这样往往需要一些额外的开发,为逻辑上认定不会互相影响的数据,建立多个处理队列,以避免由于等待一个存储连接,导致严重的性能下降。实际上,在 MySQL 内部,也会有防止多个 SQL (在不同连接上)进行并发修改,而设计的“锁”,如古老的 MyISAM 表结构就是“表锁”,新的 InnoDB 表结构是“行锁”

总结

悲观锁的本质就是队列,也就是“依次排队”执行,不管这个队列,是由于多线程同步锁形成,还是异步 IO 系统内部实现的,还是专门设计的队列处理流程,都是一样的思想。

由于需要排队执行,所以如果没有认真规划那些一定要排队的操作,很容易造成性能的浪费,譬如多个线程在等一个锁,多个进程在等一个队列处理。而且,对于“队列”本身的处理,也会耗费额外的通信和协调的资源。异步编程模型,就是要求程序员,必须很清楚那些可能存在“等待”的操作,然后用回调或者事件查询的方式,来手工编程的切分开,但是这样也对程序员提出了更高的要求,毕竟每个函数、方法的调用,都必须知道这个调用是否会堵塞。

对于使用原语的系统,用什么方式定义原语是一个重要的问题,如 redis,天然提供了依附于某些数据结构的原语,但如果这些命令还满足的不了需求,就需要提供一种手段,让使用者自己定义这些原语,于是 redis 就开始支持 lua 脚本,编写自定义的命令。而对于游戏服务器开发,开发者们天天都在编写这种原语,其处理代码和业务逻辑本身就是一份代码。

乐观锁

乐观锁的基本处理方法,就是给每一次的读、写操作,都带上一个额外的数据:版本号。这个版本号,代表着数据被修改的次数。这样就能辨识出在某次写操作之前,此数据是否已经被其他线程/进程修改过。

这种处理方案,在每次写入操作的时候,会返回“是否成功”的结果,需要业务逻辑处理。一般来说,如果发生写入错误,就需要重新再读取数据,然后再处理后写入。这个“重试”的过程一般来说不复杂,但是,如果在特别频繁变化的数据上,这种“重试”多次都有可能会失败。幸好游戏服务一般都是“有损服务”,对于很多数据,是容忍一定程度上的失败和丢失的。

由于乐观锁提供了一种“通用”的一致性问题解决方案,所以特别适合在某些数据库、缓存中间件提供。但是缺点也很明显,就是需要使用者清醒的认识到,每一次写入都可能失败,需要预备失败的处理。对于特别复杂的逻辑来说,可能存在上百个需要修改的数据,编写这样的代码就会特别费劲。所以乐观锁也不应该用在“所有”的数据和处理逻辑上。

大部分的开发者,都还是比较倾向,对大多数比较方便进行分割的数据,分别存放在不同的进程上,然后用以“悲观锁”的策略进行处理。而对于不变分割的数据,采用乐观锁的策略进行处理。

远程对象系统

悲观锁在开发上的表现形式有很多种,但是基本上都离不开需要锁的“数据”和操作数据的“方法”,这和面向对象概念中的“对象”,“方法”不谋而合。正如 Java 语言,可以使用以下方式对方法加锁,表示任何一个线程,在对一个 Cat 类对象的 Eat() 方法调用时,都必须“锁”住此对象,以便多个线程对此方法的调用,保证是“依次排队”处理的:

class Cat {
  private int hp_ = 100;
  public Player() {
  }

  synchronized public void Eat(int eng) {
    hp_ += eng;
  }
}

其实任何游戏服务器中的对象,都可以类似的形式进行加锁——如果我们的处理逻辑是单线程的,那么所有的“方法”都会是“依次排队”执行的。如果我们能自动把 SS 协议原语,映射到特定对象的方法上,那么就可以非常自然的把悲观锁实现成“对远程对象的方法调用”这种形态了。

尽管上述方法,用“对象的方法”包装了悲观锁的概念,但是如果需要修改的数据无法被定位在一个进程内,那么可能需要使用乐观锁的概念,来实现另外一种更通用的数据修改方法。同样,我们可以采用“对象”的模型来包装:getter/setter——对于对象属性的存取器方法。我们可以让所有的存取器的都自动的带上“乐观锁”的特性,让远程方法自动处理。

基于乐观锁的设计,对于 setter 方法的调用,就有可能返回错误,然后需要业务逻辑自己处理。如此,我们就可以通过一种编程模型,统一乐观锁和悲观锁两种数据一致性问题处理方法:

  1. 定义一般的远程方法,会以悲观锁的方式执行
  2. 定义特殊的属性存储器,以乐观锁的方式执行

最后的问题,就是如何实现一个“远程对象的方法调用”,这里给出几个需要重点处理的问题:

  1. 远程对象如何在集群中(一批进程)中表示。这种表示方式也是远程调用的地址。我们可以通过一个 32/64 位的整数来表达,也可以通过设计某种容量更大的数据结构。这个地址都需要集群系统懂得如何快速的路由到对应的进程上。
  2. 远程对象的建立和销毁应该如何处置。
    1. 一种方法是先定义一个“远程函数”的系统,先通过服务器进程 ID 的表达,然后通过这种远程函数进行对象建立/销毁。
    2. 另外一种方法,是预先以某种配置方式,自动建立对象。任何一个客户端进程,都可以向集群任何节点发起“建立对象”的请求,然后集群自动根据预定义规则建立对象,返回对象 ID (也是访问地址)给调用者。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值