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

模块 3:核心用途:用户画像和游戏

为 DynamoDB 表设计主键并实现核心访问模式

概述

在上一模块中,我们定义了游戏应用程序的所有访问模式。这个模块中,我们将设计 DynamoDB 表的主键和实现核心访问模式。

在为 DynamoDB 表设计主键时,请遵循以下最佳实践:

  • 区分表中的不同实体。如果您要在单个表中存储多种不同类型的数据,例如员工、部门、客户和订单,请确保您设置的主键能明确标识每个实体,并能对某个单项进行核心操作。
  • 使用前缀区分实体类型。使用前缀区分实体类型可以防止冲突,并有助于查询。例如,如果同一个表中有客户和员工信息,那么客户实体类型的主键可以是 CUSTOMER#,而员工实体类型的主键可以是 EMPLOYEE#。
  • 对单数据项的操作是重点,并尽可能支持对多数据项的操作。设置主键时,需要保证您可以调用以下针对单项操作的 API 接口来实现单项上的读写操作:GetItem、PutItem、UpdateItem 和 DeleteItem。您也可以通过使用 Query API 接口,根据主键实现多项读取模式。如果主键设计不能满足以上需求,可以添加二级索引,通过二级索引来实现 Query 操作。

我们根据这些最佳实践,为游戏应用程序的表设计主键并执行一些基本操作。

 时长

20 分钟

 使用的服务

操作步骤

  • 我们需要按照前面的介绍来设计不同的实体。在示例游戏中,我们有以下实体:

    • User
    • Game
    • UserGameMapping

    UserGameMapping是表示用户加入游戏的记录。User 和 Game 之间存在多对多的关系。

    多对多映射通常表示需要满足两种 Query 模式,这个游戏也不例外。我们有一种访问模式需要查找所有已加入游戏的用户,还有一种访问模式需要查找某个用户玩过的所有游戏。

    如果您的数据模型中有多个实体,并且这些实体之间存在关系,您通常需要使用一个同时具有 HASH 和 RANGE 值的复合主键。复合主键为我们提供了基于 HASH 键的 Query 功能,满足我们所需的一种查询模式。在 DynamoDB 文档中,分区键被称为 HASH,排序键被称为 RANGE。在本指南中,我们交替使用 API 术语,尤其是在讨论代码或 DynamoDB JSON Wire 协议格式时。

    另外两个数据实体 User 和 Game 没有 RANGE 值的自然属性,因为 User 或 Game 的访问模式是键值查找。由于需要 RANGE 值,我们可以为 RANGE 键设置一个填充值。

    考虑到这一点,我们需要为每种实体类型的 HASH 和 RANGE 值设置以下模式。

    实体 HASH RANGE
    User USER#<USERNAME> #METADATA#<USERNAME>
    Game GAME#<GAME_ID> #METADATA#<GAME_ID>
    UserGameMapping GAME#<GAME_ID> USER#<USERNAME>

    我们来看一下上表。

    对于 User 实体,HASH 值是 USER#<USERNAME>。请注意,我们使用了一个前缀来标识实体,防止实体类型之间可能出现的任何冲突。

    对于 User 实体上的 RANGE 值,我们使用的静态前缀是 #METADATA#,紧接这个前缀的是 USERNAME 的值。对于 RANGE 值,我们必须要有一个已知的值,例如 USERNAME。这样就可以执行单项操作,如 GetItem、PutItem 和 DeleteItem。

    但是,我们还希望 RANGE 在不同的用户实体上具有不同的值,以便在将此列用作索引的 HASH 键来实现均衡分区。为此,我们添加了 USERNAME。

    Game 实体的主键设计与 User 实体的设计类似。Game 实体的主键使用了不同的前缀 GAME# 加 GAME_ID 而不是 USERNAME,但原理是一样的。

    最后,UserGameMapping 实例使用了与 Game 实体相同的 HASH 键。这样,我们不仅能在一次查询中获取游戏的元数据,还能获取游戏中的所有用户信息。然后,我们将 User 实体用于 UserGameMapping 上的 RANGE 键,用于识别哪个用户加入了特定游戏。

    下一步,我们将使用这种主键设计创建一个表。

  • 现在我们已经设计好了主键,让我们创建一个表。

    在模块 1 的步骤 3 中下载的代码中,包含一个 Python 脚本,位于 scripts/ 目录中,名为 create_table.py。Python 脚本的内容如下:

    import boto3
    
    dynamodb = boto3.client('dynamodb')
    
    try:
        dynamodb.create_table(
            TableName='battle-royale',
            AttributeDefinitions=[
                {
                    "AttributeName": "PK",
                    "AttributeType": "S"
                },
                {
                    "AttributeName": "SK",
                    "AttributeType": "S"
                }
            ],
            KeySchema=[
                {
                    "AttributeName": "PK",
                    "KeyType": "HASH"
                },
                {
                    "AttributeName": "SK",
                    "KeyType": "RANGE"
                }
            ],
            ProvisionedThroughput={
                "ReadCapacityUnits": 1,
                "WriteCapacityUnits": 1
            }
        )
        print("Table created successfully.")
    except Exception as e:
        print("Could not create table. Error:")
        print(e)

    以上脚本用于通过 AWS Python SDK Boto 3 调用 CreateTable 操作。该操作声明了两个属性定义,它们是将用于主键的特定类型属性。虽然 DynamoDB 是无模式,但您必须声明用于主键的属性名称和类型。属性必须包含在写入表的每个数据项中,因此必须在创建表格时指定。

    由于我们要在一个表中存储不同的实体,因此不能使用主键属性名称,如 UserId。根据所存储实体的类型,属性的含义会有所不同。例如,用户的主键可能是 USERNAME,而游戏的主键可能是 GAMEID。因此,我们为属性设置通用名称,如 PK (表示分区键)和 SK (表示排序键)。

    在键模式中配置属性后,我们需要为表指定预置吞吐量。DynamoDB 有两种容量配置模式:预置和按需。采用预置容量模式时,您需要声明想预置的读写吞吐量。无论使用与否,您都要为预置的容量付费。

    在 DynamoDB 按需容量模式下,您可以按请求付费。每个请求的成本略高于完全使用预配吞吐量的成本,但你不必花时间去做容量规划,也不必担心被节流。按需模式非常适合突发性或不可预测的工作负载。我们在本实验室中使用预配容量模式,因为 DynamoDB 免费资源额度 可用于预配容量。

    使用以下命令运行 Python 脚本创建表格。

    python scripts/create_table.py

    创建成功,则返回:“Table created successfully.” (表创建成功)。

    下一步,我们将向表中批量加载一些示例数据。

  • 在这一步中,我们将把一些数据批量加载到上一步创建的 DynamoDB 表中。在后续步骤中,我们将使用这些示例数据。

    在 scripts/ 目录中,找到名为 items.json 的文件。该文件中包含为本实验室随机生成的 835 个示例数据项。这些数据项包括 User、Game 和 UserGameMapping 实体。打开该文件便可查看示例数据项。

    scripts/ 目录中还有一个名为 bulk_load_table.py 的脚本文件,用于读取 items.json 文件中的数据项并将它们批量写入 DynamoDB 表中。该文件中的内容如下:

    import json
    
    import boto3
    
    dynamodb = boto3.resource('dynamodb')
    table = dynamodb.Table('battle-royale')
    
    items = []
    
    with open('scripts/items.json', 'r') as f:
        for row in f:
            items.append(json.loads(row))
    
    with table.batch_writer() as batch:
        for item in items:
            batch.put_item(Item=item)

    在这个脚本中,我们没有在 Boto 3 中使用低端客户端,而是使用了更高级的 资源对象。资源对象为使用 AWS API 提供了一个更简单的接口。资源对象在这种情况下非常有用,因为它可以批量处理我们的请求。BatchWriteItem 操作可在单个请求中写入多达 25 个数据项。资源对象会帮助我们处理批量请求,我们无需将数据分成 25 个或更少的数据项。

    在终端运行执行以下命令,运行 bulk_load_table.py 脚本将数据载入表中。

    python scripts/bulk_load_table.py

    为确认是否所有数据都已加载到表中,可以执行 scan 操作并获取计数。

    aws dynamodb scan \
     --table-name battle-royale \
     --select COUNT

    返回结果应如下所示:

    {
        "Count": 835, 
        "ScannedCount": 835, 
        "ConsumedCapacity": null
    }

    返回结果中,Count 值为 835,表示所有数据项都已成功加载。

    下一步,我们将展示如何在单个请求中检索多个实体类型。这种检索方法可以减少应用程序中的网络请求总数,并提高应用程序性能。

  • 正如我们在上一个模块中所说的,您应该根据 DynamoDB 表收到的请求数量对其进行优化。我们还介绍了,DynamoDB 不支持关系型数据库的连接 (join) 功能。因此,在设计表时应考虑到请求中所需的类似连接的行为。

    在这一步中,我们将展示如何通过单个请求检索多个实体类型。在游戏中,我们需要获取游戏会话的详细信息。这些详细信息包括游戏本身的信息,如开始时间、结束时间、游戏开局者以及参与游戏的所有玩家的详细信息。

    该请求跨越两个实体类型: Game 实体和 UserGameMapping 实体。不过,这并不意味着我们需要发起多个请求。

    在您下载的代码中,application/ 目录下有一个 fetch_game_and_players.py 脚本。该脚本展示了如何构建通过一次请求同时获取游戏的 Game 实体和 UserGameMapping 实体的代码。

    fetch_game_and_players.py 脚本代码如下:

    import boto3
    
    from entities import Game, UserGameMapping
    
    dynamodb = boto3.client('dynamodb')
    
    GAME_ID = "3d4285f0-e52b-401a-a59b-112b38c4a26b"
    
    
    def fetch_game_and_users(game_id):
        resp = dynamodb.query(
            TableName='battle-royale',
            KeyConditionExpression="PK = :pk AND SK BETWEEN :metadata AND :users",
            ExpressionAttributeValues={
                ":pk": { "S": "GAME#{}".format(game_id) },
                ":metadata": { "S": "#METADATA#{}".format(game_id) },
                ":users": { "S": "USER$" },
            },
            ScanIndexForward=True
        )
    
        game = Game(resp['Items'][0])
        game.users = [UserGameMapping(item) for item in resp['Items'][1:]]
    
        return game
    
    
    game = fetch_game_and_users(GAME_ID)
    
    print(game)
    for user in game.users:
        print(user)

    在脚本的开头部分用于导入 Boto 3 库和一些简单的类,这些类在应用程序代码中指定对象。您可以在 application/entities.py 文件中看到这些实体的定义。

    脚本的主要能力是 fetch_game_and_users 函数定义模块。该函数类似于在应用程序中定义的函数,供任何需要这些数据的端点使用。

    fetch_game_and_users 函数主要完成以下操作。首先,它会向 DynamoDB 发出 Query 请求。这个 Query 请求中,使用 GAME#<GameId> 的 PK。然后,它会检索排序键在 #METADATA#<GameId> 和 USER$ 之间的实体。这将抓取排序键为 #METADATA#<GameId> 的 Game 实体,以及键以 USER# 开头的所有 UserGameMapping 实体。字符串类型的排序键按 ASCII 字符编码排序。在 ASCII 中,美元符号 ($) 直接位于磅号 (#) 之后,因此这确保了我们将获得 UserGameMappings 实体中的所有映射。

    收到响应后,我们会将数据实体组合成应用程序已知的对象。我们知道返回的第一个实体是 Game 实体,因此我们从该实体中创建一个 Game 对象。对于其余实体,我们为每个实体创建一个 UserGameMapping 对象,然后将用户数组附加到 Game 对象。

    脚本的结尾部分用于显示函数的使用情况,并打印出结果对象。在终端执行以下命令运行该脚本:

    python application/fetch_game_and_players.py

    该脚本执行成功后,会将 Game 对象和所有 UserGameMapping 对象打印到控制台

    Game<3d4285f0-e52b-401a-a59b-112b38c4a26b -- Green Grasslands>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- branchmichael>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- deanmcclure>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- emccoy>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- emma83>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- iherrera>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- jeremyjohnson>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- lisabaker>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- maryharris>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- mayrebecca>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- meghanhernandez>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- nruiz>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- pboyd>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- richardbowman>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- roberthill>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- robertwood>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- victoriapatrick>
    UserGameMapping<3d4285f0-e52b-401a-a59b-112b38c4a26b -- waltervargas>

    此脚本展示了如何为表建模并编写查询,通过单个 DynamoDB 请求中检索多个实体类型。在关系型数据库中,您可以使用连接功能通过单个请求从不同表中检索多个实体类型。在 DynamoDB 中,您可以对数据进行特定建模,使您要同时访问的实体存在表中相邻的位置。这种方法取代了典型关系型数据库中的连接需要,并能在扩展时保持应用程序的高性能。

总结

在本模块中,我们设计了一个主键并创建了一个表。然后,我们将数据批量加载到表中。我们试验了如何在单个请求中查询多个实体类型。

通过当前的主键设计,我们可以满足以下访问模式:

  • 创建用户画像(写)
  • 更新用户画像(写)
  • 获取用户画像(读)
  • 创建游戏(写)
  • 查看游戏(读)
  • 用户加入游戏(写)
  • 开始游戏(写)
  • 为用户更新游戏(写)
  • 更新游戏(写)

在下一个模块中,我们将学习添加二级索引,并学习稀疏索引技术。通过二级索引,您可以在 DynamoDB 表上支持更多访问模式。

设计游戏访问模式