前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >游戏服务器中常见的数据一致性问题分析

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

作者头像
韩伟
发布2021-03-15 20:05:07
1.7K0
发布2021-03-15 20:05:07
举报
文章被收录于专栏:韩伟的专栏

什么是一致性问题

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

譬如服务器上有一个怪物,玩家 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 对象这个锁,则必须等待。

代码语言:javascript
复制
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() 方法调用时,都必须“锁”住此对象,以便多个线程对此方法的调用,保证是“依次排队”处理的:

代码语言:javascript
复制
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 (也是访问地址)给调用者。
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-03-03,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 韩大 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 什么是一致性问题
  • 悲观锁
    • 多线程同步锁
      • 异步模型
        • 元语
          • 游戏服务器处理
            • 队列处理
              • 总结
              • 乐观锁
              • 远程对象系统
              相关产品与服务
              云服务器
              云服务器(Cloud Virtual Machine,CVM)提供安全可靠的弹性计算服务。 您可以实时扩展或缩减计算资源,适应变化的业务需求,并只需按实际使用的资源计费。使用 CVM 可以极大降低您的软硬件采购成本,简化 IT 运维工作。
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档