使用 Amazon DynamoDB 对游戏玩家数据建模

模块 4:设计游戏访问模式

利用稀疏索引技术使用全局二级索引寻找可加入的游戏

概述

对于刚接触 DynamoDB 和 NoSQL 的用户来说,最需要适应的是如何建立数据模型,以便在整个数据集中进行筛选。例如,在我们的游戏中,我们需要找到有空位的游戏会话,以便向用户显示他们可以加入哪个游戏会话。

如果是在关系数据库中,您需要编写一些 SQL 来查询数据。

SELECT * FROM games
	WHERE status = “OPEN”

DynamoDB 可以在查询或扫描操作中过滤结果,但 DynamoDB 的运作方式与关系数据库不同。在查询或扫描操作初步筛选出数据项后,DynamoDB 过滤器才会应用。过滤器可以减小从 DynamoDB 服务发送的数据量,但最初检索到的数据项条数仍受限于 DynamoDB 容量限制

值得庆幸的是,您可以通过多种方式在 DynamoDB 中对数据集实现过滤查询。要在 DynamoDB 表中有效地应用过滤器,您需要从构建数据模型的初期就开始考虑过滤器的设置。回想一下我们在本教程的第二模块中学到的:首先考虑您的访问模式,再去设计您的数据表。

在下面的步骤中,我们将使用全局二级索引寻找可加入的游戏。具体来说,我们将使用 稀疏索引技术来处理这种访问模式。

  时长

40 分钟

 使用的服务

操作步骤

  • 二级索引是 DynamoDB 中至关重要的数据建模工具。您可以利用二级索引重新构造数据,以适应不同的查询模式。要创建二级索引,您需要指定索引的主键,就像之前创建表一样。请注意,全局二级索引的主键在表中不需要唯一。然后,DynamoDB 会根据指定的属性将数据项复制到索引中,您可以像查询表一样查询索引。

    使用稀疏二级索引是 DynamoDB 的高级策略。使用二级索引时,DynamoDB 只会从原始表中复制具有二级索引主键的数据项。不具有主键元素的数据项不会被复制,这就是为什么这些二级索引被称为“稀疏”。

    下面我们来看看如何利用它。您可能还记得,我们有两种查找可加入的游戏的访问模式:

    • 查找可加入的游戏(
    • 通过地图查找可加入的游戏(

    我们可以创建一个复合主键的二级索引,其中 HASH 键是游戏的映射属性,RANGE 键是游戏的 open_timestamp 属性,表示游戏开放的时间。

    而关键在于,当游戏满员时,open_timestamp 属性将被删除。该属性被删除后,将导致 RANGE 属性值空缺,满员的游戏就会从二级索引中被移除。这就是我们的索引能保持稀疏的原因:索引中只包括具有 open_timestamp 属性的可加入的游戏。

    下一步,我们创建二级索引。

  • 在此步骤中,我们将为可加入(未满员)的游戏创建稀疏二级索引。

    创建二级索引与创建表类似。在您下载的代码中,scripts/ 目录下有一个名为 add_secondary_index.py 的脚本文件。该文件的内容如下所示。

    import boto3
    
    dynamodb = boto3.client('dynamodb')
    
    try:
        dynamodb.update_table(
            TableName='battle-royale',
            AttributeDefinitions=[
                {
                    "AttributeName": "map",
                    "AttributeType": "S"
                },
                {
                    "AttributeName": "open_timestamp",
                    "AttributeType": "S"
                }
            ],
            GlobalSecondaryIndexUpdates=[
                {
                    "Create": {
                        "IndexName": "OpenGamesIndex",
                        "KeySchema": [
                            {
                                "AttributeName": "map",
                                "KeyType": "HASH"
                            },
                            {
                                "AttributeName": "open_timestamp",
                                "KeyType": "RANGE"
                            }
                        ],
                        "Projection": {
                            "ProjectionType": "ALL"
                        },
                        "ProvisionedThroughput": {
                            "ReadCapacityUnits": 1,
                            "WriteCapacityUnits": 1
                        }
                    }
                }
            ],
        )
        print("Table updated successfully.")
    except Exception as e:
        print("Could not update table. Error:")
        print(e)

    在表或二级索引的主键中使用属性时,必须在 AttributeDefinitions 中定义这些属性。然后,我们在 GlobalSecondaryIndexUpdates 属性中 创建 一个新的二级索引。对于该二级索引,我们指定了索引名称、主键 Schema(模式)、预配吞吐量以及需要映射的属性

    请注意,我们无需指定我们的二级索引是否要用作稀疏索引。这完全取决于您输入的数据。如果向表中写入的数据项不具备二级索引所需的属性,这些数据项就不会出现在二级索引中。

    运行以下命令创建二级索引。

    python scripts/add_secondary_index.py

    您应该能在控制台中看到以下提示信息:Table updated successfully.(表更新成功。)

    在下一步中,我们将使用稀疏索引按地图查找可加入的游戏。

  • 配置好二级索引后,我们现在可以用它来满足特定的访问模式了。

    使用二级索引时,您可以选择两种 API 调用方式:Query(查询)和 Scan(扫描)。使用 Query 时,必须指定 HASH 键,并返回目标结果。使用 Scan 时,无需指定 HASH 键,该操作将遍历整个表。在 DynamoDB 中,除特定情况外,通常不建议进行 Scan 操作,因为它会访问数据库中的每个数据项。如果表中含有大量数据,进行扫描可能会花费很长时间。在下一步中,我们将向您展示为什么在使用稀疏索引时扫描可以成为一个强大的工具。

    我们可以针对上一步创建的二级索引使用 Query API,按地图名称查找所有可加入的游戏。二级索引是按地图名称分区的,因此我们可以进行有针对性的查询来查找可加入的游戏。

    在您下载的代码中,application/ 目录下有一个 find_open_games_by_map.py 文件。脚本内容如下所示:

    import boto3
    
    from entities import Game
    
    dynamodb = boto3.client('dynamodb')
    
    def find_open_games_by_map(map_name):
        resp = dynamodb.query(
            TableName='battle-royale',
            IndexName="OpenGamesIndex",
            KeyConditionExpression="#map = :map",
            ExpressionAttributeNames={
                "#map": "map"
            },
            ExpressionAttributeValues={
                ":map": { "S": map_name },
            },
            ScanIndexForward=True
        )
    
        games = [Game(item) for item in resp['Items']]
    
        return games
    
    games = find_open_games_by_map("Green Grasslands")
    for game in games:
        print(game)

    在上述脚本中,find_open_games_by_map 函数与应用程序中需要实现的函数类似。该函数接受一个地图名称,并根据 OpenGamesIndex 进行查询,以找到该地图的所有可加入的游戏。然后,它将返回的实体组合成可在应用程序中使用的 Game 对象。

    在终端运行以下命令,测试该脚本。

    python application/find_open_games_by_map.py

    终端将显示以下输出,其中包含 Green Grasslands(绿色草原)地图的四个可加入的游戏。

    Open games for Green Grasslands:
    Game<14c7f97e-8354-4ddf-985f-074970818215 -- Green Grasslands>
    Game<3d4285f0-e52b-401a-a59b-112b38c4a26b -- Green Grasslands>
    Game<683680f0-02b0-4e5e-a36a-be4e00fc93f3 -- Green Grasslands>
    Game<0ab37cf1-fc60-4d93-b72b-89335f759581 -- Green Grasslands>
    sudo cp -r wordpress/* /var/www/html/

    下一步,我们使用 Scan API 扫描稀疏二级索引。

  • 在上一步中,我们了解了如何查找特定地图的游戏。对于偏好特定地图的玩家来说,这一功能非常有用。而其他玩家可能对游戏所在地图没有特别的偏好。在本节中,我们将演示如何在应用程序中查找任何可加入的游戏,不限地图类型。为此,我们将使用 Scan API。

    一般来说,我们不推荐您在设计表时就依赖于 DynamoDB 的 Scan 操作,因为 DynamoDB 设计之初就是为了进行精确的查询,以便直接获取您所需的实体。Scan 操作会随机获取表中的实体集合,因此可能需要多次查询数据库才能找到所需的实体。

    然而,在某些情况下,Scan 也能派上用场。在上述情况中,我们使用的是一个稀疏二级索引,这意味着索引中的实体数量应该不多。此外,该索引仅包括那些可加入的游戏,这正符合我们的需求。

    对于这类使用场景,Scan 非常适用。我们来看看它是如何派上用场的。在您下载的代码中,application/ 目录下有一个 find_open_games.py 文件。此文件的内容如下所示。

    import boto3
    
    from entities import Game
    
    dynamodb = boto3.client('dynamodb')
    
    def find_open_games():
        resp = dynamodb.scan(
            TableName='battle-royale',
            IndexName="OpenGamesIndex",
        )
    
        games = [Game(item) for item in resp['Items']]
    
        return games
    
    games = find_open_games()
    print("Open games:")
    for game in games:
        print(game)

    这段代码与上一步中的代码类似。不过,我们没有在 DynamoDB 客户端使用 query() 方法,而是使用了 scan() 方法。因为我们使用的是 scan(),所以我们不需要像使用 query() 时那样指定任何关键条件。我们只是让 DynamoDB 返回一堆没有特定顺序的数据项。

    在终端使用以下命令运行该脚本。

    python application/find_open_games.py
    

    您的终端应该会输出一个列表,其中包含 9 个在不同地图上可加入的游戏。

    Open games:
    Game<c6f38a6a-d1c5-4bdf-8468-24692ccc4646 -- Urban Underground>
    Game<d06af94a-2363-441d-a69b-49e3f85e748a -- Dirty Desert>
    Game<873aaf13-0847-4661-ba26-21e0c66ebe64 -- Dirty Desert>
    Game<fe89e561-8a93-4e08-84d8-efa88bef383d -- Dirty Desert>
    Game<248dd9ef-6b17-42f0-9567-2cbd3dd63174 -- Juicy Jungle>
    Game<14c7f97e-8354-4ddf-985f-074970818215 -- Green Grasslands>
    Game<3d4285f0-e52b-401a-a59b-112b38c4a26b -- Green Grasslands>
    Game<683680f0-02b0-4e5e-a36a-be4e00fc93f3 -- Green Grasslands>
    Game<0ab37cf1-fc60-4d93-b72b-89335f759581 -- Green Grasslands>

    在此步骤中,我们演示了在特定条件下如何有效使用 Scan 操作。我们使用 Scan 从稀疏的二级索引中获取多个实体,向玩家显示可加入的游戏。

    在接下来的步骤中,我们将实现两种访问模式:

    • 用户加入游戏(
    • 开始游戏(

    DynamoDB 事务
    为了实现“用户加入游戏”的访问模式,我们将使用 DynamoDB 事务。事务在关系数据库系统中广泛用于同时处理多个数据元素的操作。例如,假设您正在经营一家银行。一位名叫 Alejandra 的顾客向另一位名叫 Ana 的顾客转账 100 美元。在记录这笔交易时,您需要使用事务确保两位顾客的余额都得到相应的调整,而不只是其中一位。

    对于在单个操作中更改多个数据项的应用程序,DynamoDB 事务使其构建更加轻松。通过事务,您可以在单个事务请求中对多达 10 个数据项进行操作。

    在调用 TransactWriteItem API 时,您可以执行以下操作:

    • Put(输入):用于插入或覆盖数据项。
    • Update(更新):用于更新数据项。
    • Delete(删除):用于删除数据项。
    • ConditionCheck(条件检查):用于对现有数据项进行条件判断,而不对数据项进行任何更改。

    在下一步中,我们将使用 DynamoDB 事务来添加新用户至游戏,同时确保游戏不会超过最大玩家数。

  • 我们在本模块中要实现的第一种访问模式是向游戏中添加新用户。

    在向游戏添加新用户时,我们需要:

    • 确认游戏中是否已经有 50 名玩家(每个游戏的最大玩家数为 50)。
    • 确认该用户尚未加入该游戏。
    • 创建一个新的 UserGameMapping 实体,将用户添加到游戏中。
    • Game 实体上增加 people 属性,用于跟踪游戏中的玩家人数。

    请注意,要完成所有这些操作,需要在现有的 Game 实体和新的 UserGameMapping 实体,并对每个实体应用条件逻辑。这种操作非常适合 DynamoDB 事务,因为您需要在同一个请求中处理多个实体,而且希望整个请求要么全部成功要么全部失败。

    在您下载的代码中,application/ 目录下有一个 join_game.py 脚本。脚本中的函数使用 DynamoDB 事务将用户添加到游戏中。

    该脚本的内容如下所示。

    import boto3
    
    from entities import Game, UserGameMapping
    
    dynamodb = boto3.client('dynamodb')
    
    GAME_ID = "c6f38a6a-d1c5-4bdf-8468-24692ccc4646"
    USERNAME = 'vlopez'
    
    
    def join_game_for_user(game_id, username):
        try:
            resp = dynamodb.transact_write_items(
                TransactItems=[
                    {
                        "Put": {
                            "TableName": "battle-royale",
                            "Item": {
                                "PK": {"S": "GAME#{}".format(game_id) },
                                "SK": {"S": "USER#{}".format(username) },
                                "game_id": {"S": game_id },
                                "username": {"S": username }
                            },
                            "ConditionExpression": "attribute_not_exists(SK)",
                            "ReturnValuesOnConditionCheckFailure": "ALL_OLD"
                        },
                    },
                    {
                        "Update": {
                            "TableName": "battle-royale",
                            "Key": {
                                "PK": { "S": "GAME#{}".format(game_id) },
                                "SK": { "S": "#METADATA#{}".format(game_id) },
                            },
                            "UpdateExpression": "SET people = people + :p",
                            "ConditionExpression": "people <= :limit",
                            "ExpressionAttributeValues": {
                                ":p": { "N": "1" },
                                ":limit": { "N": "50" }
                            },
                            "ReturnValuesOnConditionCheckFailure": "ALL_OLD"
                        }
                    }
                ]
            )
            print("Added {} to game {}".format(username, game_id))
            return True
        except Exception as e:
            print("Could not add user to game")
    
    join_game_for_user(GAME_ID, USERNAME)

    在该脚本的 join_game_for_user 函数中,transact_write_items() 方法执行了一个写事务。该事务包含两项操作。

    第一项操作是使用 Put 操作插入一个新的 UserGameMapping 实体。在进行该操作时,我们设定了一个条件,即该实体不应存在 SK 属性。这样可以确保表中不存在同时具有 PKSK 属性的实体。如果表中存在这样的实体,那就意味着这个用户已经加入了游戏。

    第二项操作是使用 Update 操作更新 Game 实体,增加 people 属性。在进行该操作时,我们添加了一个条件检查,即 people 的当前值小于等于 50。当达到 50 人加入时,游戏即视为满员并可以开始。

    在终端使用以下命令运行该脚本。

    python application/join_game.py
    

    终端输出应显示用户已加入游戏。

    Added vlopez to game c6f38a6a-d1c5-4bdf-8468-24692ccc4646
    

    请注意,如果您尝试再次运行该脚本,函数将失败。用户 vlopez 已经加入了游戏,因此再次尝试添加该用户并不符合我们所设定的条件。

    DynamoDB 事务的引入极大简化了此类复杂操作的处理流程。如果没有事务,就需要调用多个带有复杂条件的 API,并在发生冲突时手动回滚。现在,我们只需不到 50 行代码就能实现这样复杂的操作。

    下一步,我们将实现“开始游戏()”访问模式。

  • 当一个游戏有 50 个用户时,游戏的创建者就可以启动并开始游戏。在此步骤中,我们将演示如何处理这种访问模式。

    当应用程序后台收到启动游戏的请求时,我们需要检查三个条件:

    • 游戏中已加入 50 人。
    • 发起请求的用户是游戏的创建者。
    • 游戏尚未开始。

    我们可以在更新游戏的请求中用条件表达式来执行这些检查。如果所有这些检查都通过了,我们就需要通过以下方式更新实体:

    • 删除 open_timestamp 属性,这样它就不会在上一个模块的稀疏二级索引中显示为可加入的游戏。
    • 添加 start_time 属性,标记游戏开始的时间。

    在您下载的代码中,application/ 目录下有一个 start_game.py 脚本。此文件的内容如下所示。

    import datetime
    
    import boto3
    
    from entities import Game
    
    dynamodb = boto3.client('dynamodb')
    
    GAME_ID = "c6f38a6a-d1c5-4bdf-8468-24692ccc4646"
    CREATOR = "gstanley"
    
    def start_game(game_id, requesting_user, start_time):
        try:
            resp = dynamodb.update_item(
                TableName='battle-royale',
                Key={
                    "PK": { "S": "GAME#{}".format(game_id) },
                    "SK": { "S": "#METADATA#{}".format(game_id) }
                },
                UpdateExpression="REMOVE open_timestamp SET start_time = :time",
                ConditionExpression="people = :limit AND creator = :requesting_user AND attribute_not_exists(start_time)",
                ExpressionAttributeValues={
                    ":time": { "S": start_time.isoformat() },
                    ":limit": { "N": "50" },
                    ":requesting_user": { "S": requesting_user }
                },
                ReturnValues="ALL_NEW"
            )
            return Game(resp['Attributes'])
        except Exception as e:
            print('Could not start game')
            return False
    
    game = start_game(GAME_ID, CREATOR, datetime.datetime(2019, 4, 16, 10, 15, 35))
    
    if game:
        print("Started game: {}".format(game))

    在该脚本中,start_game 函数与应用程序中需要实现的函数类似。该函数接收 game_idrequesting_userstart_time,并运行请求更新 Game 实体以启动游戏。

    update_item() 调用中,ConditionExpression 参数指定了我们在之前列出的三项检查条件:游戏必须有 50 人;请求启动游戏的用户必须是游戏的创建者;游戏不能有 start_time 属性,因为该属性表明游戏已经启动。

    UpdateExpression 参数中,您可以看到我们要对实体进行的更改。首先,我们删除实体中的 open_timestamp 属性,然后将 start_time 属性设置为游戏的开始时间。

    在终端使用以下命令运行该脚本。

    python application/start_game.py
    

    您应当会在终端中看到游戏已成功启动的输出。

    Started game: Game<c6f38a6a-d1c5-4bdf-8468-24692ccc4646 -- Urban Underground>
    

    尝试在终端中再次运行脚本。这一次,您应该会看到一条错误信息,提示您无法启动游戏。这是因为您已经启动了游戏,所以 start_time 属性是存在的。因此,请求未能通过对实体的条件检查。

    反向索引模式
    您可能还记得,Game 实体与其关联的 User 实体之间存在多对多的关系,这种关系通过 UserGameMapping 实体表示。

    通常情况下,您需要查询关系的双方。通过主键设置,我们可以找到游戏中的所有用户实体。我们可以使用反向索引来查询用户的所有游戏实体。

    在 DynamoDB 中,反向索引是您的主键的逆向二级索引。RANGE 键和 HASH 键互换。这种模式可以颠倒表结构,查询多对多关系的另一方。

    在下面的步骤中,我们将向表中添加一个反转索引,并演示如何使用它来检索特定用户 (User) 的所有 Game 实体。

  • 在此步骤中,我们将为表添加一个反向索引。反向索引的创建与其他二级索引类似。

    在您下载的代码中,scripts/ 目录下有一个 add_inverted_index.py 脚本。这个 Python 脚本会在表中添加一个反向索引。

    该文件的内容如下所示。

    import boto3
    
    dynamodb = boto3.client('dynamodb')
    
    try:
        dynamodb.update_table(
            TableName='battle-royale',
            AttributeDefinitions=[
                {
                    "AttributeName": "PK",
                    "AttributeType": "S"
                },
                {
                    "AttributeName": "SK",
                    "AttributeType": "S"
                }
            ],
            GlobalSecondaryIndexUpdates=[
                {
                    "Create": {
                        "IndexName": "InvertedIndex",
                        "KeySchema": [
                            {
                                "AttributeName": "SK",
                                "KeyType": "HASH"
                            },
                            {
                                "AttributeName": "PK",
                                "KeyType": "RANGE"
                            }
                        ],
                        "Projection": {
                            "ProjectionType": "ALL"
                        },
                        "ProvisionedThroughput": {
                            "ReadCapacityUnits": 1,
                            "WriteCapacityUnits": 1
                        }
                    }
                }
            ],
        )
        print("Table updated successfully.")
    except Exception as e:
        print("Could not update table. Error:")
        print(e)

    在此脚本中,我们在 DynamoDB 客户端上调用 update_table() 方法。我们在这个方法中提供了要创建的二级索引的详细信息,包括索引的键模式 (KeySchema)、预配吞吐量和要映射到索引中的属性。

    在终端使用以下命令运行该脚本。

    python scripts/add_inverted_index.py
    

    终端将显示索引创建成功的输出结果。

    Table updated successfully.
    

    在下一步中,我们将使用反向索引检索特定 User(用户)的所有 Game 实体。

  • 既然我们已经创建了反向索引,那么就用它来检索用户玩过的游戏实体。为此,我们需要通过反向索引查询我们想要检索其 Game 实体的 User

    在您下载的代码中,application/ 目录下有一个 find_games_for_user.py 脚本。此文件的内容如下所示。

    import boto3
    
    from entities import UserGameMapping
    
    dynamodb = boto3.client('dynamodb')
    
    USERNAME = "carrpatrick"
    
    
    def find_games_for_user(username):
        try:
            resp = dynamodb.query(
                TableName='battle-royale',
                IndexName='InvertedIndex',
                KeyConditionExpression="SK = :sk",
                ExpressionAttributeValues={
                    ":sk": { "S": "USER#{}".format(username) }
                },
                ScanIndexForward=True
            )
        except Exception as e:
            print('Index is still backfilling. Please try again in a moment.')
            return None
    
        return [UserGameMapping(item) for item in resp['Items']]
    
    games = find_games_for_user(USERNAME)
    
    if games:
        print("Games played by {}:".format(USERNAME))
        for game in games:
            print(game)

    该脚本包含一个叫做 find_games_for_user() 的函数,它与游戏中需要实现的函数类似。该函数接收一个用户名,并返回该用户参与过的所有游戏。

    在终端使用以下命令运行该脚本。

    python application/find_games_for_user.py
    

    脚本应输出用户 carrpatrick 参与过的所有游戏。

    Games played by carrpatrick:
    UserGameMapping<25cec5bf-e498-483e-9a00-a5f93b9ea7c7 -- carrpatrick -- SILVER>
    UserGameMapping<c6f38a6a-d1c5-4bdf-8468-24692ccc4646 -- carrpatrick>
    UserGameMapping<c9c3917e-30f3-4ba4-82c4-2e9a0e4d1cfd -- carrpatrick>

总结

在本模块中,我们为表添加了二级索引,从而实现了两种额外的访问模式:

  • 通过地图查找可加入的游戏(读)
  • 查找可加入的游戏(读)

为了实现这一目标,我们使用了一个稀疏索引,该索引只包含不在游戏中的玩家可加入的游戏。然后,我们针对索引使用 QueryScan API 来查找可加入的游戏。

此外,我们还了解了如何在应用程序中实现两种高级写操作。首先,我们在实现用户加入游戏时使用了 DynamoDB 事务。通过事务,我们在单个请求中完成对多个实体的复杂条件写操作。

其次,我们实现了游戏创建者在游戏准备就绪时启动游戏的功能。在这种访问模式中,我们的更新操作需要检查三个属性的值并更新两个属性。通过条件表达式和更新表达式的强大功能,我们可以在单个请求中实现这种复杂的逻辑。

第三,我们通过检索 User(用户)参与过的所有 Game(游戏)实体来实现最后一种访问模式。为了实现这种访问模式,我们使用反向索引模式创建了二级索引,以便查询 User 实体和 Game 实体之间多对多关系的另一方。

在下一个模块中,我们将清理我们创建的资源。

清理资源和后续步骤