将 Spanner 用作游戏数据库的最佳实践

本文档介绍了将 Spanner 用作游戏状态存储的主要后端数据库的最佳做法。您可以使用 Spanner 代替存储玩家身份验证数据和背包数据的常用数据库。本文档面向从事长期状态存储的游戏后端工程师,以及为这些系统提供支持并希望在 Google Cloud 上托管其后端数据库的游戏基础架构操作员和管理员。

多人游戏和在线游戏的发展需要越来越复杂的数据库结构来跟踪玩家权益、状态和背包数据。不断增长的玩家群以及不断增加的游戏复杂性导致数据库解决方案在扩缩和管理方面存在难度,经常需要使用分片聚簇。跟踪有价值的游戏内道具或关键玩家进度通常需要事务,并且在许多类型的分布式数据库中进行操作时难度较大。

Spanner 是为云端打造的首款具备高度一致性且可扩缩的全球分布式企业级数据库服务,融合了关系型数据库结构与非关系型数据库横向扩缩能力的优势。许多游戏公司都发现它非常适合代替生产规模系统中的游戏状态和身份验证数据库。您可以使用 Google Cloud 控制台添加节点来进行扩容,从而获得额外的性能或存储空间。Spanner 可以透明地处理全局复制,同时保持高度一致性,无需管理区域副本。

本最佳做法文档讨论以下内容:

  • 重要的 Spanner 概念以及 Spanner 与游戏中常用数据库的差异。
  • Spanner 适合作为游戏数据库的情况。
  • 将 Spanner 用于游戏时应该避免的模式。
  • 在将 Spanner 用作游戏的数据库的情况下,设计数据库操作。
  • 使用 Spanner 对数据建模并创建架构以获得最佳性能。

术语

权益
属于玩家的游戏、扩展内容或应用内购买。
个人身份信息 (PII)
在游戏中,通常包括电子邮件地址和付款账号信息的信息,例如信用卡号和账单邮寄地址。在某些市场中,此信息可能包括身份证号码。
游戏数据库(游戏 DB)
保存玩家进度和游戏道具的数据库。
身份验证数据库(身份验证 DB)
包括玩家权益和玩家在购买时使用的个人身份信息的数据库。身份验证数据库也称为账号数据库或玩家数据库。此数据库有时与游戏数据库结合使用,但拥有多个作品的工作室或发行商通常将它们分开使用。
事务
数据库事务 - 一组具有原子性的写入操作。要么事务成功,并且所有更新都生效,要么数据库返回到不包含任何属于该事务的更新的状态。在游戏中,数据库事务在处理付款和分配有价值的游戏内背包或货币的所有权时最为重要。
关系型数据库管理系统 (RDBMS)
基于相互引用的表和行的数据库系统。在游戏中使用的关系型数据库的示例有 SQL Server、MySQL 和 Oracle®(不太常见)。这些数据库经常被使用,因为它们能够提供熟悉的方法和强有力的事务保证
NoSQL 数据库 (NoSQL DB)
没有关系型结构的数据库。这些数据库在游戏领域变得越来越热门,因为它们在数据模型发生变化时具有很大的灵活性。NoSQL 数据库包含 MongoDB 和 Cassandra。
主键
通常是包含与商品目录商品、玩家帐号和购买交易对应的唯一 ID 的列。
实例
单个数据库。例如,一个集群运行数据库软件的多个副本,但在游戏后端显示为单个实例。
节点
在本文档中,运行数据库软件副本的一台机器。
副本
数据库的第二个副本。副本经常用于实现数据恢复和高可用性,或帮助增加读取吞吐量。
集群
在多台机器上运行的多个软件副本,在游戏后端中一起显示为单个实例。聚簇用于提高可扩缩性和可用性。
分片 (shard)
数据库的实例。许多游戏工作室运行多个同构数据库实例,每个实例保存一部分游戏数据。每个实例通常称为“分片”(shard)。分片通常是为了提高性能或可扩缩性,但增加了应用的复杂性,牺牲了管理效率。这在 Spanner 中是使用分片 (split) 实现的
拆分
Spanner 会将数据划分为称作分片的区块,各个分片可以彼此独立移动并被分配给不同的服务器。分片是指顶级(即非交错)表中某个范围内的行,这些行按主键进行排序。这一范围的开始和结束键称为“分片边界”。Spanner 会自动添加和移除分片边界,这样做会改变数据库中的分片数量。Spanner 基于负载对数据进行分片:当检测到某个分片中许多键之间的读取或写入负载较高时,Cloud Spanner 就会自动添加分片边界。
热点
Spanner 等分布式数据库收到的查询中,涉及的很大一部分记录都位于单个分片中的情况。这种情况应该避免,因为它会降低性能。

将 Spanner 用于游戏

在大多数情况下,如果考虑为您的游戏使用 RDBMS,则 Spanner 是一种适当的选择,因为它可以有效替代游戏数据库或身份验证数据库,或者在许多情况下可以同时替代这两者。

游戏数据库

Spanner 可以作为一个全局事务性授权方,因此非常适合用于游戏背包系统。任何可以交易、出售、赠送或以其他方式从一个玩家转移到另一个玩家的游戏内货币或商品都为大规模游戏后端带来了难度。通常,游戏的流行速度很快,因而传统数据库无法在单节点数据库中处理所有事务。数据库可能会遇到处理玩家负载所需的操作数以及存储数据量方面的难题,具体取决于游戏类型。这通常会导致游戏开发者对其数据库进行分片以获得额外的性能,或存储不断增加的表。此类解决方案会导致操作复杂性和高维护开销。

为帮助减轻这种复杂性,一种常见策略是运行完全分开的游戏区域,这些区域之间无法移动数据。在此情况下,商品和货币无法在不同游戏区域中的玩家之间进行交易,因为每个区域中的产品目录都被隔离为单独的数据库。但是,这种设置为了方便开发者和运行,牺牲了首选的玩家体验。

另一方面,您可以在地理上分片的数据库中允许跨区域交易,但通常代价是复杂性较高。此设置需要事务跨多个数据库实例,从而导致应用端逻辑复杂、容易出错。尝试在多个数据库上获取事务锁定可能会对性能产生重大影响。此外,无法依赖原子化事务可能导致游戏内货币或商品复制等玩家漏洞,这会损害游戏的生态系统和社区。

Spanner 可以简化您的背包和货币事务的方法。即使使用 Spanner 保存您在全球范围内的所有游戏数据,它也能够提供具有优于传统数据库的原子性、一致性、隔离性和持久性 (ACID) 属性的读写事务。Spanner 具有可扩缩性,这意味着在需要更多的性能或存储空间时,无需将数据分片为单独的数据库实例;您只需添加更多节点。此外,游戏经常对数据库进行聚类从而实现高可用性和数据弹性的操作由 Spanner 通过透明方式处理,无需额外的设置或管理。

身份验证数据库

Spanner 还能很好地为身份验证数据库提供服务,当您希望在工作室或发布商级层对单个 RDBMS 实现标准化时尤其如此。虽然用于游戏的身份验证数据库通常并不需要 Spanner 的可扩缩性,但 Spanner 的事务保证和高数据可用性使其很有吸引力。Spanner 中的数据复制是透明、同步和内置的。Spanner 的配置提供 99.99%(“四个九”)或 99.999%(“五个九”)的可用性,其中“五个九”相当于一年内的不可用时间不到五分三十秒。由于具有这样的高可用性,因此 Spanner 是每个玩家会话开始时所需的关键身份验证路径的不错选择。

最佳实践

本部分提供有关如何在游戏设计中使用 Spanner 的建议。请务必在为您的游戏数据建模时,考虑如何利用 Spanner 提供的独特功能。虽然您可以使用关系型数据库语义访问 Spanner,但某些架构设计点可帮助您提高性能。Spanner 文档提供了详细架构设计建议供您查看,但以下部分是适用于游戏数据库的一些最佳做法。

本文档中的做法基于客户使用情况和案例中的经验。

将 UUID 用作玩家和角色 ID

玩家表中,每个玩家及其游戏内货币、进度或其他无法轻松映射到离散的产品目录表行的数据通常都具有一行。如果您的游戏允许玩家为多个角色分别保存进度(例如许多大型持久性多人游戏),则此表会改为每个角色通常具有一行,模式的其他方面保持不变。

我们建议使用全局唯一角色或玩家标识符(角色 ID)作为角色表的主键。我们还推荐使用通用唯一标识符 (UUID) v4,因为它可以跨数据库节点分布玩家数据,并且有助于通过 Spanner 提升性能。

将交错用于背包表

背包表通常保存游戏道具,例如角色装备、卡片或器件。通常情况下,单个玩家的背包中包含多个道具。每个道具由表中的一行表示。

与其他关系型数据库类似,Spanner 中的背包表具有主键,主键是该道具的全局唯一标识符,如下表所示。

itemID type playerID
7c14887e-8d45 1 6f1ede3b-25e2
8ca83609-bb93 40 6f1ede3b-25e2
33fedada-3400 1 5fa0aa7d-16da
e4714487-075e 23 5fa0aa7d-16da
d4fbfb92-a8bd 14 5fa0aa7d-16da
31b7067b-42ec 3 26a38c2c-123a

示例背包表中截断了 itemIDplayerID 以提高可读性。实际的背包表还包含该示例中未包含的许多其他列。

在 RDBMS 中跟踪道具所有权的典型方法是使用列作为保存当前所有者的玩家 ID 的外键。此列是单独的数据库表的主键。在 Spanner 中,您可以使用交错来存储相关玩家表行附近的背包行以获得更好的性能。使用交错表时,请注意以下几点:

  • 您需要将玩家行及其所有后代商品目录行中的数据总量保持在 ~4 GiB 以下。此限制通常不属于适当的数据模型设计问题,
  • 您无法生成没有所有者的对象。您可以在游戏设计中避免无所有者的对象,前提是提前知道该限制。

设计索引以避免热点

许多游戏开发者会在许多背包字段中实现索引以优化某些查询。在 Spanner 中,使用该索引中的数据创建或更新某行会产生额外的写入负载,负载量与已编写索引的列数成正比。您可以通过消除不经常使用的索引,或通过以不影响数据库性能的其他方式实现这些索引来提高 Spanner 的性能。

以下示例中有一个长期玩家最高得分记录表:

CREATE TABLE Ranking (
        PlayerID STRING(36) NOT NULL,
        GameMode INT64 NOT NULL,
        Score INT64 NOT NULL
) PRIMARY KEY (PlayerID, GameMode)

此表包含玩家 ID (UUIDv4)、一个表示游戏模式、关卡级数或季数的数字以及玩家的得分。

为了加快筛选游戏模式的查询,请考虑使用以下索引:

CREATE INDEX idx_score_ranking ON Ranking (
        GameMode,
        Score DESC
)

如果每个人都使用相同的游戏模式 1,则此索引会引发一个 GameMode=1 的热点。如果您希望获取此游戏模式的排名,则索引将仅扫描包含 GameMode=1 的行,快速返回排名。

如果更改上一个索引的顺序,则可以解决此热点问题:

CREATE INDEX idx_score_ranking ON Ranking (
        Score DESC,
        GameMode
)

此索引不会在玩家处于同一游戏模式下竞争时创建显著热点,前提是其得分跨可能的范围分布。但是,获取得分的速度不会比使用上一个索引快,因为查询会扫描所有模式中的所有得分,以确定是否 GameMode=1

因此,重新排序的索引解决了上一个游戏模式热点问题,但仍有改进的空间,如以下设计所示。

CREATE TABLE GameMode1Ranking (
        PlayerID STRING(36) NOT NULL,
        Score INT64 NOT NULL
) PRIMARY KEY (PlayerID)

CREATE INDEX idx_score_ranking ON Ranking (
        Score DESC
)

我们建议将游戏模式从表架构中移出,如果可能的话,每个模式使用一个表。通过使用此方法,在检索某个模式下的得分时,您只需查询包含该模式得分的表。该表可以通过得分来编制索引以快速检索得分范围,避免显著的热点危险(前提是得分均匀分布)。截至本文档编写时,Spanner 中每个数据库的最大表数为 2560,这对于大多数游戏来说已经足够。

每个租户具有单独的数据库

与建议使用不同主键值在 Spanner 中设计多租户的其他工作负载不同,对于游戏数据,我们建议使用每个租户具有单独的数据库的更传统的方法。新游戏功能在实时服务游戏中发布时,架构更改很常见,在数据库级层隔离租户可以简化架构更新。此策略还可以优化备份或恢复租户数据所需的时间,因为这些操作会同时对整个数据库执行。

避免增量架构更新

与一些传统的关系型数据库不同,Spanner 在架构更新期间仍可运作。针对旧架构的所有查询都将返回(虽然它们的返回速度可能不及平常),并且针对新架构的查询在可用时也将返回。您可以设计更新流程,以便在 Spanner 上运行时,在架构更新期间继续运行游戏,前提是遵循上述限制条件。

但是,如果您在当前正在处理某个架构更改时请求另一个架构更改,则新的更新将排队,直至所有先前的架构更新完成后,才发生新的更新。您可以通过计划较大的架构更新(而不是在短时间内发出大量增量架构更新)来避免此情况。如需详细了解架构更新(包括如何执行需要进行数据验证的架构更新),请参阅 Spanner 架构更新文档

考虑数据库访问和大小

在开发游戏服务器和平台服务以使用 Spanner 时,请考虑游戏如何访问数据库以及如何调整数据库大小以避免产生不必要的费用。

使用原生驱动程序和库

在针对 Spanner 进行开发时,请考虑代码如何与数据库连接。Spanner 提供多种常用语言版本的原生客户端库,它们通常具有丰富的功能和高性能。Spanner 还提供 JDBC 驱动程序,它支持数据操纵语言 (DML) 和数据定义语言 (DDL) 语句。如果 Spanner 用于新的开发,我们建议为 Spanner 使用 Cloud 客户端库。虽然典型的游戏引擎集成在语言选择方面没有太大的灵活性,但对于访问 Spanner 的平台服务,存在游戏客户使用 Java 或 Go 的情况。对于高吞吐量应用,请选择一个可为多个连续请求使用同一 Spanner 客户端的库。

根据测试和生产需求调整数据库的大小

在开发期间,单节点 Spanner 实例对于大多数活动(包括功能测试)可能已足够。

针对生产评估 Spanner 需求

在从开发到测试再到生产的过程中,请务必重新评估 Spanner 的需求以确保游戏能够处理实时玩家流量。

在进入生产之前,负载测试对于验证后端是否能够在生产过程中处理负载至关重要。我们建议使用比预期的生产环境负载多一倍的负载来运行负载测试,为使用量高峰和游戏比预期更受欢迎的情况做准备。

使用真实数据运行负载测试

使用合成数据运行负载测试是不够的。您还应该使用尽可能接近生产中的预期情况的数据和访问模式运行负载测试。合成数据可能无法检测 Spanner 架构设计中的潜在热点。最好的方法是通过真实玩家运行 Beta 版测试(开放或封闭)来验证 Spanner 如何处理真实数据。

下图是游戏工作室的一个示例玩家表架构,说明了使用 Beta 版测试进行负载测试的重要性。

参与负载测试的玩家姓名及特性列表。

该工作室根据他们运营了几年的以前的一个游戏的趋势准备了这些数据。公司希望架构能够很好地表示新游戏中的数据。

每条玩家记录都包含一些用于跟踪玩家在游戏中的进度的相关数值特性(例如排名和游戏时间)。对于上表中使用的示例特性,新玩家的起始值为 50,随着玩家游戏的推进,此值会变为 1 到 100 之间的值。

该工作室希望对此特性编制索引,以便在游戏过程中加快重要查询的速度。

根据这些数据,该工作室创建了以下 Spanner 表,使用 PlayerID 作为主键,并对 Attribute 编制二级索引。

CREATE TABLE Player (
        PlayerID STRING(36) NOT NULL,
        Attribute INT64 NOT NULL
) PRIMARY KEY (PlayerID)

CREATE INDEX idx_attribute ON Player(Attribute)

对该索引进行查询,找出最多 10 个 Attribute=23 的玩家,如下所示:

SELECT PlayerID
        FROM Player@{force_index=idx_attribute}
        WHERE Attribute = 23
        LIMIT 10

根据关于优化架构设计的文档,Spanner 以与表相同的方式存储索引数据,每个索引条目占用一行。在负载测试中,此模型可以较好地跨多个 Spanner 分片分布二级索引读写负载,如下图所示。

玩家按其特性分布在 Spanner 分片上。

虽然负载测试中使用的合成数据类似于游戏的最终稳定状态(其中,Attribute 值均匀分布),但游戏设计规定所有玩家都从 Attribute=50 开始。由于每个新玩家都从 Attribute=50 开始,因此当新玩家加入时,它们会被插入到 idx_attribute 二级索引的同一部分。这意味着更新会路由到同一个 Spanner 分片,从而在游戏的发布窗口中引发热点。这种使用 Spanner 的做法效率低下。

发布时具有相同特性的玩家在单个 Spanner 分片中创建一个热点。

在下图中,发布后向架构中添加 IndexPartition 列解决了热点问题,玩家在可用的 Spanner 分片中均匀分布。用于创建表和索引的更新命令如下所示:

CREATE TABLE Player (
        PlayerID STRING(36) NOT NULL,
        IndexPartition INT64 NOT NULL
        Attribute INT64 NOT NULL
) PRIMARY KEY (PlayerID)

CREATE INDEX idx_attribute ON Player(IndexPartition,Attribute)

向架构添加 IndexPartition 列会在发布时均匀分布玩家。

IndexPartition 值的范围需要有限,才能高效查询,但其范围应至少是分屏数量的两倍,以便高效分布。

在本示例中,工作室会在游戏应用中手动为每个玩家分配一个 IndexPartition(介于 16 之间)。

另一种方法是为每个玩家分配一个随机号码,或者分配一个从 PlayerID 值的哈希派生的值。如需了解更多应用级分片策略,请参阅有关 Spanner 的 DBA 须知事项,第 1 部分:键和索引

更新上一个查询以使用这个经过改进的索引,如下所示:

SELECT PlayerID
        FROM Player@{force_index=idx_attribute}
        WHERE IndexPartition BETWEEN 1 and 6
        AND Attribute = 23
        LIMIT 10

由于没有运行 Beta 版测试,工作室并未意识到他们正在使用具有错误假设的数据进行测试。虽然合成负载测试是验证实例可处理的每秒查询次数 (QPS) 的一种不错方法,但通过真实玩家进行 Beta 版测试是验证架构和准备成功发布所必不可少的。

调整生产环境的规模以预测高峰需求

大型游戏在发布时经常会出现流量高峰。构建可扩缩的后端不仅适用于平台服务和专用游戏服务器,还适用于数据库。使用 App Engine 等 Google Cloud 解决方案,您可以构建可快速纵向扩容的前端 API 服务。虽然 Spanner 提供了在线添加和移除节点的灵活性,但它并不是一种自动扩缩数据库。您需要预配足够的节点来处理发布时的流量高峰。

根据您在负载测试期间或通过任何公开 Beta 版测试收集的数据,您可以估算在发布时处理请求所需的节点数。最好添加几个节点作为缓冲区,以应对玩家数量超过预期的情况。您应始终遵循平均 CPU 使用量不超过 65% 的原则调整数据库的大小。

在游戏发布前预热数据库

在发布游戏之前,我们建议您预热数据库,以利用 Spanner 并行化功能。如需了解详情,请参阅在应用发布之前预热数据库

监控并了解性能

任何生产数据库都需要全面的监控和性能指标。Spanner 附带了 Cloud Monitoring 中的内置指标。在可能的情况下,我们建议将提供的 gRPC 库合并到游戏后端进程中,因为这些库包含 OpenCensus 跟踪。通过 OpenCensus 跟踪,您可在 Cloud Trace 和其他受支持的开源跟踪工具中查看查询跟踪记录。

在 Cloud Monitoring 中,您可以查看有关 Spanner 使用情况的详细信息,包括数据存储和 CPU 使用情况。对于大多数情况,我们建议您根据此 CPU 使用情况指标或观察到的延迟时间来决定 Spanner 扩缩。如需详细了解针对性能优化建议的 CPU 使用,请参阅最佳实践

Spanner 提供了查询执行计划。您可以在 Google Cloud 控制台中查看这些计划,如果您在了解查询性能方面需要帮助,请与支持团队联系。

在评估性能时,请将短周期测试维持在最低程度,因为 Spanner 会在后台透明地拆分您的数据以根据您的数据访问模式优化性能。您应使用持续、真实的查询负载来评估性能。

移除数据时,删除行而不是重新创建表

使用 Spanner 时,新创建的表还没有机会进行基于负载或基于大小的拆分以提高性能。如果通过删除并重新创建表来删除数据,Spanner 需要数据、查询和时间来确定表的正确分片。如果您计划使用相同类型的数据重新填充表(例如,运行连续性能测试时),可以对包含您不再需要的数据的行运行 DELETE 查询。出于同样的原因,架构更新应使用提供的 Cloud Spanner API,并且应避免手动策略,例如创建新表和从其他表或备份文件复制数据。

选择数据存放区域以满足合规性要求

许多游戏在全球范围内必须遵守 GDPR 等数据存放区域法律。如需帮助以支持您的 GDPR 需求,请参阅 Google Cloud 和 GDPR 白皮书,并选择正确的 Spanner 区域配置

后续步骤