使用 Amazon DynamoDB 处理高并发场景中的条件写入错误

我们很高兴 地宣布, 亚马逊 Dynamo DB 推出 了一项新功能,该功能通过简化 Con ditionalCheckFailedException 的处理来增强开发者体验。 用于单次写入操作的新 Ret urnValuesOnConditionCheck Failure 参数允许您按写入尝试失败期间的原样返回项目的副本,从而减少了在想要调查失败原因并重试请求时对读取请求的需求。

条件检查 一直是 DynamoDB 中的一个强大工具,它允许您在执行项目修改(例如放置、更新或删除操作)时应用条件。但是,到目前为止,接收 Condition alCheckFailedException 只能提供最少的信息,需要额外的读取操作才能了解失败的具体原因。

现在,有了新的 ReturnValuesOnConditionCheckFailur e 参数,你可以选择在响应消息中按失败时的状态 返回商品。此参数在几个用例中可以提供帮助。在这篇文章中,我们将更深入地探讨如何将其与计数器一起使用以提高处理并发更新时的效率。

条件检查概述

具有大量并发更新的环境需要一种机制来确保记录不会因来自多个用户的无序更新而被更改。带有 条件写入 的计数器 可以有效地提供版本控制信息,以确保只有有效的写入才能成功。

我们看到在 DynamoDB 中为各种规模和 行业的客户实施了 计数器 。常见的实现方式是在 DynamoDB 中使用 带 条件检查 乐观锁定 作为并发控制机制来处理表中项目的并发更新。并发控制机制的一个例子是乐观锁定,它仅允许在多个客户端尝试更改同一项目时应用预期的更改,从而帮助防止冲突和维护数据完整性。使用乐观锁定时,DynamoDB 中的每个项目都有一个版本属性,通常是数值。在更新项目之前,客户端会检索当前版本值。发出更新请求时,客户端包含一个条件,即版本必须与先前检索到的值相匹配。如果版本匹配,表明项目未更改,则更新可能会继续,版本会增加。但是,如果版本不匹配,则表明另一个客户端已经更改了项目,更新请求被拒绝并返回 condition alCheckFailedException ,要求客户端通过读取项目并重新尝试更新来获取最新版本。这充当验证检查,以确保自获得版本以来该项目没有被其他客户端更改。

现在,为了最大限度地缩短时间并节省 读取容量单位 ,开发人员可以使用 R eturnValuesOnConditionCheckFailur e 参数,该参数使 DynamoDB 能够在条件检查失败 时返回项目的状态。此功能使您能够迅速评估状态失败的原因,并就是否继续进行预期更新或更改方法做出明智的决定。

返回值条件检查失败正在运行

想象一下,我们正在对一款电子游戏进行建模,其中有两支队伍,每支队伍有100名玩家并肩竞争。每支队伍都需要制作不同的三明治作为挑战。你生产的三明治越多,挑战出现的速度就越快。有些玩家将成为厨师,而其他大多数玩家将收集制作三明治所需的食材。 例如,我们需要两片面包、两片生菜叶、两片番茄片和两条培根条来制作 BL T。

因为我们有几位厨师,他们会在制作三明治时消耗食材。当团队没有足够的食材来制作三明治时,游戏需要向团队展示他们缺少什么食物,这样收藏家才能得到它。游戏计算团队分数,因此他们可以与排行榜上的其他队伍竞争,但它还提供个人分数来确定谁是团队中最好的球员。

因为每个玩家都有获得最高分的动力,所以他们倾向于抓住所有三明治订单或获得厨师需要的所有食材。每个三明治都由一个三明治 ID 来识别,这对团队来说是独一无二的,这使得两支队伍无法制作同一个三明治。当一个团队收到确认后,三明治就制作好了,其他厨师就不可能做三明治了。

在我们的烹饪游戏示例中,我们将使用名为 “ 单桌设计” 的 DynamoDB 设计概念将不同的实体存储在一起。 在单表设计中,多个实体存在于同一个表中,并由其独特的分区键结构进行标识。例如,我们将使用 GID# 来唯一化每个游戏,其中 GID# 是表中每个项目的静态前缀,代表 “游戏标识符”。 然后,将前缀与哈希 ( # ) 符号和代表哈希 (# ) 的二十一个字符的 n anoid 唯一字符串连接在一起。

我们还将使用 物品集合 来高效查询收集的产品和每个团队制作的三明治。 我们可以使用排序键来实现这种设计模式,其中 TID# 是一个静态前缀,表示与哈希符号和 连接在一起的 “团队标识符”,它也是一个八个字符 nanoid 唯一字符串 (TID#)。 因此,每个分区键 ( GID# ) 将在其下分组多个相关的项目,从而使我们能够更有效地查询给定游戏的所有物品。

单表设计方法也允许我们在表中存储其他实体的数据。 例如,通过为每个 TID # 创建分区键,所有团队成员的信息(我们之前将其用作排序键)也可以成为自己的实体。通过创建 # 排序键,我们可以对团队中的每个角色执行相同的操作,例如 “厨师” 或 “采集者” 角色。这种分区键和排序键的组合使我们能够使用 查询操作 高效地检索团队中的所有用户 。使用 “begins with” 键条件 表达式可以获得更精细的结果并仅检索身为厨师的团队成员。

最后,在我们的示例中,我们还使用了三明治实体,它将由 SID# 表示 为分区键和排序键,其中 SID# 是静态前缀,表示 “三明治标识符”,与哈希符号和 sand wich _id 连接在一起 ,后者是一个八个字符的纳米样唯一字符串。 这个实体将包含我们制作三明治所需的食材。

假设我们是一个扮演厨师角色的玩家,我们正要做一个 BLT 三明治。游戏会告诉我们制作每个三明治需要多少以及需要哪些食材。我们需要首先阅读团队收集的食材,以验证我们是否有使用 GetItem 操作生产三明治所需的食物。下图描绘了游戏作为物品存储在我们的桌子中的示意图,包括我们可用的食材。

这些信息会显示给厨师玩家,但他们仍然需要按下 Make 按钮。这就是并发进入画面的地方。厨师在阅读时很有可能得到相同的信息,但只有第一位厨师才能完成三明治。

在这种情况下,我们需要确保只有在有足够的原料的情况下才能制作三明治。这就是条件更新出现的地方,条件是只有满足条件表达式我们才会更新项目。以下是用于此条件检查的 Python 片段:

import boto3
import botocore.exceptions

#Constants
TABLE_NAME = "chef-royale"
GAME_ID = "NZzPTNp3XKig5ZnxeyFfQ" # NanoID - Unique game identifier
TEAM_ID = "GA5NPQ9g"              # Team's unique identifier
USER_ID = "h3PkZ3pek88ezIhK3ORvl" # NanoID - Unique user identifier
SANDWICH_ID = "mVeOsKX_"          # Unique identifier per sandwitch

#Init resources
table = boto3.resource("dynamodb").Table(TABLE_NAME)
primary_key = {"PK": f"GID#{GAME_ID}", "SK": f"TID#{TEAM_ID}"}

# Obtain how many items does the team has
current = table.get_item(Key=primary_key)

# Try to make the sandiwch
try:
    response = table.update_item(
        Key=primary_key,
        UpdateExpression="ADD sandwiches :this_sandwich SET bacon = bacon + :minus_two, bread_slice = bread_slice + :minus_two, lettuce = lettuce + :minus_two, tomato = tomato + :minus_two",
        ConditionExpression="bacon >= :two AND bread_slice >= :two AND lettuce >= :two AND tomato >= :two AND not contains(sandwiches, :sandwich_id)",
        ExpressionAttributeValues={
            ":sandwich_id": SANDWICH_ID,
            ":minus_two": -2,
            ":two": 2,
            ":this_sandwich": set([SANDWICH_ID])
        },
        ReturnValues="UPDATED_NEW",
    )
    if response["ResponseMetadata"]["HTTPStatusCode"] == 200:
        print("Success!")
        print(f"Updated values {response['Attributes']}")
    
except botocore.exceptions.ClientError as error:
    if error.response["Error"]["Code"] == "ConditionalCheckFailedException":
        print("The conditional expression is not met")
        current_value = error.response.get("Item")
        print(f"Current Value: {current_value}")
        print(f"Detail: {error.response}")
    else:
        print(f"There has been an error. Detail {error.response}")

使用此代码,当满足条件时,我们会移除三明治所需的食材量,但也会将当前的 sundwich _ id 添加到 字符串集 中。当游戏结束时,这个操作可以作为计数器(通过测量集合长度,你可以定义球队的分数),也可以确保不会重复计算。条件部分确保有足够的物品来制作三明治,第二个条件验证同一个团队中没有人已经做过那个三明治。这样可以确保操作的一致性和原子性,没有人想要出现 10 个玩家可以从同一个三明治中获得积分的错误!

当我们第一次运行这段代码时,我们确实会制作三明治,因为我们的物品含有必需的食材,我们应该会看到如下结果:

{'bacon': Decimal('4'), 'lettuce': Decimal('0'), 'SK': 'TID#GA5NPQ9g', 'PK': 'GID#NZzPTNp3XKig5ZnxeyFfQ', 'tomato': Decimal('2'), 'bread_slice': Decimal('1'), 'sandwiches': {'8MPyZK63', 'mVeOsKX_'}}

我们现在已经做了两个三明治,而且我们已经食用了制作其中一个所需的食材

假设现在我们还有另一位玩家仍然拥有相同的 “读取” 信息,他选择了 Cook 按钮。从我们的剧本角度来看,这意味着他们正在尝试制作同样的三明治。这次,脚本产生了以下错误:

The conditional expression is not met
Detail: {'Error': {'Message': 'The conditional request failed', 'Code': 'ConditionalCheckFailedException'}, 'ResponseMetadata': {'RequestId': '1G1HBHH898VK5KK8QHPP8J4SGFVV4KQNSO5AEMVJF66Q9ASUAAJG', 'HTTPStatusCode': 400, 'HTTPHeaders': {'server': 'Server', 'date': 'Thu, 22 Jun 2023 20:39:33 GMT', 'content-type': 'application/x-amz-json-1.0', 'content-length': '120', 'connection': 'keep-alive', 'x-amzn-requestid': '1G1HBHH898VK5KK8QHPP8J4SGFVV4KQNSO5AEMVJF66Q9ASUAAJG', 'x-amz-crc32': '396270901'}, 'RetryAttempts': 0}, 'message': 'The conditional request failed'}

我们知道玩家不符合制作三明治的要求,但是失败的玩家是什么条件呢?我们缺少生菜吗?有人吃了面包吗?还是培根?以前可能有人做过三明治吗?唯一的知道方法是再次发出 GetItem 操作,因为玩家几秒钟前才读取的信息现在已经过时。

随着 ReturnValuesOnConditionCheck Failure 功能的引入,现在我们可以验证失败的原因,因为我们得到的是存储在 DynamoDB 表中的当前项目的值。在同样的异常逻辑处理中,我们现在可以访问当前项目的值,我们可以检查哪个条件失败了。以下更新的代码片段允许我们查看属性的值:

try:
    response = table.update_item(
        Key=primary_key,
        UpdateExpression="ADD sandwiches :this_sandwich SET bacon = bacon + :minus_two, bread_slice = bread_slice + :minus_two, lettuce = lettuce + :minus_two, tomato = tomato + :minus_two",
        ConditionExpression="bacon >= :two AND bread_slice >= :two AND lettuce >= :two AND tomato >= :two AND not contains(sandwiches, :sandwich_id)",
        ExpressionAttributeValues={
            ":sandwich_id": SANDWICH_ID,
            ":minus_two": -2,
            ":two": 2,
            ":this_sandwich": set([SANDWICH_ID])
        },
        ReturnValues="UPDATED_NEW",
        ReturnValuesOnConditionCheckFailure="ALL_OLD"
    )
    if response["ResponseMetadata"]["HTTPStatusCode"] == 200:
        print("Success!")
        print(f"Updated values {response['Attributes']}")
    
except botocore.exceptions.ClientError as error:
    if error.response["Error"]["Code"] == "ConditionalCheckFailedException":
        print("The conditional expression is not met")
        current_value = error.response.get("Item")
        print(f"Current Value: {current_value}")
        print(f"Detail: {error.response}")
    else:
        print(f"There has been an error. Detail {error.response}")

运行时,我们会看到如下错误,我们可以理解生菜用完了,面包缺了,有人在请求之前做了那个三明治:

The conditional expression is not met
Current Value: {'bacon': {'N': '4'}, 'lettuce': {'N': '0'}, 'SK': {'S': 'TID#GA5NPQ9g'}, 'PK': {'S': 'GID#NZzPTNp3XKig5ZnxeyFfQ'}, 'tomato': {'N': '2'}, 'bread_slice': {'N': '1'}, 'sandwiches': {'SS': ['8MPyZK63', 'mVeOsKX_']}}
Detail: {'Error': {'Message': 'The conditional request failed', 'Code': 'ConditionalCheckFailedException'}, 'ResponseMetadata': {'RequestId': 'ICRV52MCH3P3TH0OGTOOM2PICRVV4KQNSO5AEMVJF66Q9ASUAAJG', 'HTTPStatusCode': 400, 'HTTPHeaders': {'server': 'Server', 'date': 'Thu, 22 Jun 2023 21:12:25 GMT', 'content-type': 'application/x-amz-json-1.0', 'content-length': '319', 'connection': 'keep-alive', 'x-amzn-requestid': 'ICRV52MCH3P3TH0OGTOOM2PICRVV4KQNSO5AEMVJF66Q9ASUAAJG', 'x-amz-crc32': '933435066'}, 'RetryAttempts': 0}, 'Item': {'bacon': {'N': '4'}, 'lettuce': {'N': '0'}, 'SK': {'S': 'TID#GA5NPQ9g'}, 'PK': {'S': 'GID#NZzPTNp3XKig5ZnxeyFfQ'}, 'tomato': {'N': '2'}, 'bread_slice': {'N': '1'}, 'sandwiches': {'SS': ['8MPyZK63', 'mVeOsKX_']}}}

得益于这项新功能,应用程序开发人员将节省时间,因为他们不必执行了解当前值所需的额外 GetItem 操作,从而减少了读取操作的次数,还减少了运行此场景所需的应用程序逻辑。

访问控制

根据提供对 亚马逊云科技 资源的最低权限访问权限的 亚马逊云科技 最佳实践,DynamoDB 中的 R eturnValuesOnConditionCheckFailure 功能遵循相同的方法。 通过聚焦 R eturn Values 参数的范围,此功能允许对权限进行精细控制。通过仔细配置与 Return Values 相关的权限 ,管理员可以选择允许或拒绝 DynamoDB 在条件检查失败时将项目按原样返回。

应用最低权限原则可确保仅向用户和应用程序授予执行所需操作所需的权限。在 ReturnValuesOnConditionCheck Failure 的背景下,这意味着管理员可以在条件检查失败时限制对项目先前状态的访问,从而提供额外的安全保护并防止未经授权查看敏感数据。

要配置 ReturnValuesOnConditionCheck Failure 的权限 ,管理员可以将 IAM 条件 dynamoDB: ReturnValues 设置为 , 这反过 来意味着它不会返回值 ,或者 将允许将值返回给用户的 ALL_OLD。 以下示例 IAM 策略拒绝在特定表的 PutIte m 或 u pdateItem 上出现条件失败时退回该项目:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "DenyReturnValues",
            "Effect": "Allow",
            "Action": [
                "dynamodb:PutItem",
                "dynamodb:UpdateItem"
            ],
            "Resource": "arn:aws:dynamodb:us-east-1:555555555555:table/ExampleTable",
            "Condition": {
                "StringEqualsIfExists": {
                    "dynamodb:ReturnValues": [
                        "NONE"
                    ]
                }
            }
        }
    ]
}

其他资源

要成功运行这篇文章中指定的代码,你需要使用 Python SDK 版本 1.26.158。如果你正在使用任何其他 SDK,请记得下载最新的 API 版本,这样你就可以利用 ReturnValuesO nConditionCheckFailur e 了。所有编程语言都会将该项目作为异常正文的一部分返回。有关此功能的更多信息,请参阅 UpdateItem API 参考文档。

结论

通过合并 ReturnValuesOnConditionCheck Failur e 参数,可以减少额外的读取操作并简化错误处理。现在,当出现 Condition alCheckFailedException 时,您可以直接从服务器端检 索详细信息,从而提高效率并改进决策。 首先,请将新参数添加到您的 putItem、 u pdateItem 或 D eleteItem 操作 中,并将该 值设置为 ALL_OLD。 你可以在我们的 入门指南 中使用你最喜欢的编程语言 。


作者简介

Esteban Serna 是一名资深的 DynamoDB 专家解决方案架构师。在过去的15年中,Esteban一直在研究数据库,帮助客户选择合适的架构来满足他们的需求。刚从大学毕业的他开始部署支持分布式地点的联络中心所需的基础设施。当引入 NoSQL 数据库时,他爱上了它们,并决定专注于它们,因为集中计算已不再是常态。如今,Esteban 专注于使用 DynamoDB 帮助客户设计需要一位数毫秒延迟的分布式大规模应用程序。有人说他是一本开放的书,他喜欢与他人分享自己的知识。

李·汉尼根 是一名资深的 DynamoDB 专家解决方案架构师。Lee 在过去的 4 年里一直是 DynamoDB 专家,在大数据技术方面有着深厚的背景。凭借与创新型初创公司合作获得的宝贵见解,Lee 为他在欧洲、中东和非洲的 亚马逊云科技 客户带来了丰富的知识。他热衷于帮助 亚马逊云科技 客户扩展其应用程序,他的专长在于使用 DynamoDB 和无服务器技术来实现最佳性能和效率。通过提供量身定制的解决方案和指导,Lee 成功地帮助数百家组织充分发挥 DynamoDB 的潜力并采用无服务器架构。凭借以客户为中心的方法和对 亚马逊云科技 服务的深刻理解,Lee 致力于帮助企业在云计算世界中蓬勃发展。

凯文·威利斯 是 DynamoDB 团队的高级产品经理。他专注于提升开发者体验。凭借在关系 OLTP 系统方面十多年的经验,他热衷于帮助具有类似背景的人快速大规模使用 Amazon DynamoDB。


*前述特定亚马逊云科技生成式人工智能相关的服务仅在亚马逊云科技海外区域可用,亚马逊云科技中国仅为帮助您发展海外业务和/或了解行业前沿技术选择推荐该服务。