在游戏服务器的开发中,我们经常会碰到所谓“一致性”问题,以及碰到各种为了解决这种问题所做的“方案”,那么,什么是一致性问题呢?其实非常简单,就是有两个客户端进程,都需要修改同一个数据,造成的问题。
譬如服务器上有一个怪物,玩家 A 释放了一个火球,根据业务逻辑,火球会扣减 10% 的最大 HP 值作为伤害;玩家 B 对怪物砍了一刀,扣减怪物的 HP 需要计算玩家 B 的攻击力和怪物的防御力。那么一般我们编写程序的时候,就会先从“怪物”和“玩家”读取其数值,包括“攻击力”,“最大 HP”,“防御力”,“现存 HP”这些数据,然后根据“火球术”和“刀砍”进行伤害计算,然后算出怪物遭受的伤害,以及受伤后应该剩下多少 HP。按上面的方法,就会有包含了 2 次“读”数据和“写”数据的过程。
如果这两次“先读后写”的操作,在并行的两个线程中执行,那么就会出现所谓“一致性问题”:先读了同一份数据,导致最终的操作互相覆盖了。下图是一个“增加数值”的一致性问题的描述,“203”是需要修改的数据的名字,这个数据的值一开始 100,又叫 key,A 进程试图进行的是“增加 10”这个操作,B 进程试图进行的是“增加 20”这个操作,但是如果同时执行,可能结果会是 110,或者是 120,但正确的结果应该是 130。
以上的问题,在一个进程内的多个线程中可能出现,在一个集群中的多个互相通信的进程也可能出现。为了解决以上的问题,人们想了很多方法,但是大多数可以分为两类:
下面具体说说这两类思路的实际常见的表现形式。
在 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 有一系列的“数据类型”,譬如:
这种处理方式,又可以被称为一种叫“元语”的方式。也就是说,把需要读写的多个操作,打包成一个命令来执行。如前文所说的“增加10”,“增加20”的操作,就可以设计成“+=”这样的一种元语。由于最终执行命令的程序,是一个单线程的模式,所以元语们,也被“依次排队”的执行了。
在游戏服务器领域,这个方法更是一种“基本模型”:
有一些业务系统,会使用“消息队列”这种中间件,让处理的请求,天然的就以“队列”这种形态存在,这样以单线程“依次排队”消费队列里的消息,就会非常的自然。类似的消息队列中间件,在开源产品里也有很多,譬如 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 方法的调用,就有可能返回错误,然后需要业务逻辑自己处理。如此,我们就可以通过一种编程模型,统一乐观锁和悲观锁两种数据一致性问题处理方法:
最后的问题,就是如何实现一个“远程对象的方法调用”,这里给出几个需要重点处理的问题: